feat(editor): clipboard config extensions (#11171)

This commit is contained in:
Saul-Mirone
2025-03-25 12:09:24 +00:00
parent abe560082d
commit a4033f5596
5 changed files with 409 additions and 370 deletions

View File

@@ -0,0 +1,33 @@
import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
import type {
BaseAdapter,
ExtensionType,
Transformer,
} from '@blocksuite/store';
type AdapterConstructor = new (
job: Transformer,
provider: ServiceProvider
) => BaseAdapter;
export interface ClipboardAdapterConfig {
mimeType: string;
priority: number;
adapter: AdapterConstructor;
}
export const ClipboardAdapterConfigIdentifier =
createIdentifier<ClipboardAdapterConfig>('clipboard-adapter-config');
export function ClipboardAdapterConfigExtension(
config: ClipboardAdapterConfig
): ExtensionType {
return {
setup: di => {
di.addImpl(
ClipboardAdapterConfigIdentifier(config.mimeType),
() => config
);
},
};
}

View File

@@ -0,0 +1,311 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type {
BlockSnapshot,
Slice,
Store,
TransformerMiddleware,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import * as lz from 'lz-string';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import { LifeCycleWatcher } from '../extension/index.js';
import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js';
import { onlyContainImgElement } from './utils.js';
export class Clipboard extends LifeCycleWatcher {
static override readonly key = 'clipboard';
private get _adapters() {
const adapterConfigs = this.std.provider.getAll(
ClipboardAdapterConfigIdentifier
);
return Array.from(adapterConfigs.values());
}
// Need to be cloned to a map for later use
private readonly _getDataByType = (clipboardData: DataTransfer) => {
const data = new Map<string, string | File[]>();
for (const type of clipboardData.types) {
if (type === 'Files') {
data.set(type, Array.from(clipboardData.files));
} else {
data.set(type, clipboardData.getData(type));
}
}
if (data.get('Files') && data.get('text/html')) {
const htmlAst = unified()
.use(rehypeParse)
.parse(data.get('text/html') as string);
const isImgOnly =
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe') === 'yes';
if (isImgOnly) {
data.delete('text/html');
}
}
return (type: string) => {
const item = data.get(type);
if (item) {
return item;
}
const files = (data.get('Files') ?? []) as File[];
if (files.length > 0) {
return files;
}
return '';
};
};
private readonly _getSnapshotByPriority = async (
getItem: (type: string) => string | File[],
doc: Store,
parent?: string,
index?: number
) => {
const byPriority = Array.from(this._adapters).sort(
(a, b) => b.priority - a.priority
);
for (const { adapter, mimeType } of byPriority) {
const item = getItem(mimeType);
if (Array.isArray(item)) {
if (item.length === 0) {
continue;
}
if (
// if all files are not the same target type, fallback to */*
!item
.map(f => f.type === mimeType || mimeType === '*/*')
.reduce((a, b) => a && b, true)
) {
continue;
}
}
if (item) {
const job = this._getJob();
const adapterInstance = new adapter(job, this.std.provider);
const payload = {
file: item,
assets: job.assetsManager,
workspaceId: doc.workspace.id,
pageId: doc.id,
};
const result = await adapterInstance.toSlice(
payload,
doc,
parent,
index
);
if (result) {
return result;
}
}
}
return null;
};
private _jobMiddlewares: TransformerMiddleware[] = [];
copy = async (slice: Slice) => {
return this.copySlice(slice);
};
// Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
copySlice = async (slice: Slice) => {
const adapterKeys = this._adapters.map(adapter => adapter.mimeType);
await this.writeToClipboard(async _items => {
const items = { ..._items };
await Promise.all(
adapterKeys.map(async type => {
const item = await this._getClipboardItem(slice, type);
if (typeof item === 'string') {
items[type] = item;
}
})
);
return items;
});
};
duplicateSlice = async (
slice: Slice,
doc: Store,
parent?: string,
index?: number,
type = 'BLOCKSUITE/SNAPSHOT'
) => {
const items = {
[type]: await this._getClipboardItem(slice, type),
};
await this._getSnapshotByPriority(
type => (items[type] as string | File[]) ?? '',
doc,
parent,
index
);
};
paste = async (
event: ClipboardEvent,
doc: Store,
parent?: string,
index?: number
) => {
const data = event.clipboardData;
if (!data) return;
try {
const json = this.readFromClipboard(data);
const slice = await this._getSnapshotByPriority(
type => json[type],
doc,
parent,
index
);
if (!slice) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'No snapshot found'
);
}
return slice;
} catch {
const getDataByType = this._getDataByType(data);
const slice = await this._getSnapshotByPriority(
type => getDataByType(type),
doc,
parent,
index
);
return slice;
}
};
pasteBlockSnapshot = async (
snapshot: BlockSnapshot,
doc: Store,
parent?: string,
index?: number
) => {
return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
};
unuse = (middleware: TransformerMiddleware) => {
this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
};
use = (middleware: TransformerMiddleware) => {
this._jobMiddlewares.push(middleware);
};
get configs() {
return this._getJob().adapterConfigs;
}
private async _getClipboardItem(slice: Slice, type: string) {
const job = this._getJob();
const adapterItem = this.std.getOptional(
ClipboardAdapterConfigIdentifier(type)
);
if (!adapterItem) {
return;
}
const { adapter } = adapterItem;
const adapterInstance = new adapter(job, this.std.provider);
const result = await adapterInstance.fromSlice(slice);
if (!result) {
return;
}
return result.file;
}
private _getJob() {
return this.std.store.getTransformer(this._jobMiddlewares);
}
readFromClipboard(clipboardData: DataTransfer) {
const items = clipboardData.getData('text/html');
const sanitizedItems = DOMPurify.sanitize(items);
const domParser = new DOMParser();
const doc = domParser.parseFromString(sanitizedItems, 'text/html');
const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
if (!dom) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'No snapshot found'
);
}
const json = JSON.parse(
lz.decompressFromEncodedURIComponent(
dom.dataset.blocksuiteSnapshot as string
)
);
return json;
}
sliceToSnapshot(slice: Slice) {
const job = this._getJob();
return job.sliceToSnapshot(slice);
}
async writeToClipboard(
updateItems: (
items: Record<string, unknown>
) => Promise<Record<string, unknown>> | Record<string, unknown>
) {
const _items = {
'text/plain': '',
'text/html': '',
'image/png': '',
};
const items = await updateItems(_items);
const text = items['text/plain'] as string;
const innerHTML = items['text/html'] as string;
const png = items['image/png'] as string | Blob;
delete items['text/plain'];
delete items['text/html'];
delete items['image/png'];
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
const htmlBlob = new Blob([html], {
type: 'text/html',
});
const clipboardItems: Record<string, Blob> = {
'text/html': htmlBlob,
};
if (text.length > 0) {
const textBlob = new Blob([text], {
type: 'text/plain',
});
clipboardItems['text/plain'] = textBlob;
}
if (png instanceof Blob) {
clipboardItems['image/png'] = png;
} else if (png.length > 0) {
const pngBlob = new Blob([png], {
type: 'image/png',
});
clipboardItems['image/png'] = pngBlob;
}
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
}
}

View File

@@ -1,330 +1,2 @@
import type { ServiceProvider } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type {
BaseAdapter,
BlockSnapshot,
Slice,
Store,
Transformer,
TransformerMiddleware,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import * as lz from 'lz-string';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import { LifeCycleWatcher } from '../extension/index.js';
import { onlyContainImgElement } from './utils.js';
type AdapterConstructor<T extends BaseAdapter> =
| { new (job: Transformer): T }
| (new (job: Transformer, provider: ServiceProvider) => T);
type AdapterMap = Map<
string,
{
adapter: AdapterConstructor<BaseAdapter>;
priority: number;
}
>;
export class Clipboard extends LifeCycleWatcher {
static override readonly key = 'clipboard';
private readonly _adapterMap: AdapterMap = new Map();
// Need to be cloned to a map for later use
private readonly _getDataByType = (clipboardData: DataTransfer) => {
const data = new Map<string, string | File[]>();
for (const type of clipboardData.types) {
if (type === 'Files') {
data.set(type, Array.from(clipboardData.files));
} else {
data.set(type, clipboardData.getData(type));
}
}
if (data.get('Files') && data.get('text/html')) {
const htmlAst = unified()
.use(rehypeParse)
.parse(data.get('text/html') as string);
const isImgOnly =
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe') === 'yes';
if (isImgOnly) {
data.delete('text/html');
}
}
return (type: string) => {
const item = data.get(type);
if (item) {
return item;
}
const files = (data.get('Files') ?? []) as File[];
if (files.length > 0) {
return files;
}
return '';
};
};
private readonly _getSnapshotByPriority = async (
getItem: (type: string) => string | File[],
doc: Store,
parent?: string,
index?: number
) => {
const byPriority = Array.from(this._adapterMap.entries()).sort(
(a, b) => b[1].priority - a[1].priority
);
for (const [type, { adapter }] of byPriority) {
const item = getItem(type);
if (Array.isArray(item)) {
if (item.length === 0) {
continue;
}
if (
// if all files are not the same target type, fallback to */*
!item
.map(f => f.type === type || type === '*/*')
.reduce((a, b) => a && b, true)
) {
continue;
}
}
if (item) {
const job = this._getJob();
const adapterInstance = new adapter(job, this.std.provider);
const payload = {
file: item,
assets: job.assetsManager,
workspaceId: doc.workspace.id,
pageId: doc.id,
};
const result = await adapterInstance.toSlice(
payload,
doc,
parent,
index
);
if (result) {
return result;
}
}
}
return null;
};
private _jobMiddlewares: TransformerMiddleware[] = [];
copy = async (slice: Slice) => {
return this.copySlice(slice);
};
// Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
copySlice = async (slice: Slice) => {
const adapterKeys = Array.from(this._adapterMap.keys());
await this.writeToClipboard(async _items => {
const items = { ..._items };
await Promise.all(
adapterKeys.map(async type => {
const item = await this._getClipboardItem(slice, type);
if (typeof item === 'string') {
items[type] = item;
}
})
);
return items;
});
};
duplicateSlice = async (
slice: Slice,
doc: Store,
parent?: string,
index?: number,
type = 'BLOCKSUITE/SNAPSHOT'
) => {
const items = {
[type]: await this._getClipboardItem(slice, type),
};
await this._getSnapshotByPriority(
type => (items[type] as string | File[]) ?? '',
doc,
parent,
index
);
};
paste = async (
event: ClipboardEvent,
doc: Store,
parent?: string,
index?: number
) => {
const data = event.clipboardData;
if (!data) return;
try {
const json = this.readFromClipboard(data);
const slice = await this._getSnapshotByPriority(
type => json[type],
doc,
parent,
index
);
if (!slice) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'No snapshot found'
);
}
return slice;
} catch {
const getDataByType = this._getDataByType(data);
const slice = await this._getSnapshotByPriority(
type => getDataByType(type),
doc,
parent,
index
);
return slice;
}
};
pasteBlockSnapshot = async (
snapshot: BlockSnapshot,
doc: Store,
parent?: string,
index?: number
) => {
return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
};
registerAdapter = <T extends BaseAdapter>(
mimeType: string,
adapter: AdapterConstructor<T>,
priority = 0
) => {
this._adapterMap.set(mimeType, { adapter, priority });
};
unregisterAdapter = (mimeType: string) => {
this._adapterMap.delete(mimeType);
};
unuse = (middleware: TransformerMiddleware) => {
this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
};
use = (middleware: TransformerMiddleware) => {
this._jobMiddlewares.push(middleware);
};
get configs() {
return this._getJob().adapterConfigs;
}
private async _getClipboardItem(slice: Slice, type: string) {
const job = this._getJob();
const adapterItem = this._adapterMap.get(type);
if (!adapterItem) {
return;
}
const { adapter } = adapterItem;
const adapterInstance = new adapter(job, this.std.provider);
const result = await adapterInstance.fromSlice(slice);
if (!result) {
return;
}
return result.file;
}
private _getJob() {
return this.std.store.getTransformer(this._jobMiddlewares);
}
readFromClipboard(clipboardData: DataTransfer) {
const items = clipboardData.getData('text/html');
const sanitizedItems = DOMPurify.sanitize(items);
const domParser = new DOMParser();
const doc = domParser.parseFromString(sanitizedItems, 'text/html');
const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
if (!dom) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'No snapshot found'
);
}
const json = JSON.parse(
lz.decompressFromEncodedURIComponent(
dom.dataset.blocksuiteSnapshot as string
)
);
return json;
}
sliceToSnapshot(slice: Slice) {
const job = this._getJob();
return job.sliceToSnapshot(slice);
}
async writeToClipboard(
updateItems: (
items: Record<string, unknown>
) => Promise<Record<string, unknown>> | Record<string, unknown>
) {
const _items = {
'text/plain': '',
'text/html': '',
'image/png': '',
};
const items = await updateItems(_items);
const text = items['text/plain'] as string;
const innerHTML = items['text/html'] as string;
const png = items['image/png'] as string | Blob;
delete items['text/plain'];
delete items['text/html'];
delete items['image/png'];
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
const htmlBlob = new Blob([html], {
type: 'text/html',
});
const clipboardItems: Record<string, Blob> = {
'text/html': htmlBlob,
};
if (text.length > 0) {
const textBlob = new Blob([text], {
type: 'text/plain',
});
clipboardItems['text/plain'] = textBlob;
}
if (png instanceof Blob) {
clipboardItems['image/png'] = png;
} else if (png.length > 0) {
const pngBlob = new Blob([png], {
type: 'image/png',
});
clipboardItems['image/png'] = pngBlob;
}
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
}
}
export * from './clipboard';
export * from './clipboard-adapter';