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 type { RootContentMap } from 'hast'; import * as lz from 'lz-string'; import rehypeParse from 'rehype-parse'; import { unified } from 'unified'; import { LifeCycleWatcher } from '../extension/index.js'; type AdapterConstructor = | { new (job: Transformer): T } | (new (job: Transformer, provider: ServiceProvider) => T); type AdapterMap = Map< string, { adapter: AdapterConstructor; priority: number; } >; type HastUnionType< K extends keyof RootContentMap, V extends RootContentMap[K], > = V; export function onlyContainImgElement( ast: HastUnionType ): 'yes' | 'no' | 'maybe' { if (ast.type === 'element') { switch (ast.tagName) { case 'html': case 'body': return ast.children.map(onlyContainImgElement).reduce((a, b) => { if (a === 'no' || b === 'no') { return 'no'; } if (a === 'maybe' && b === 'maybe') { return 'maybe'; } return 'yes'; }, 'maybe'); case 'img': return 'yes'; case 'head': return 'maybe'; default: return 'no'; } } return 'maybe'; } 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(); 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 = ( mimeType: string, adapter: AdapterConstructor, 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('[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 ) => Promise> | Record ) { 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 = `
${innerHTML}
`; const htmlBlob = new Blob([html], { type: 'text/html', }); const clipboardItems: Record = { '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)]); } }