From a4033f55968f79f40c2e95f12127796e8cfcc20e Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Tue, 25 Mar 2025 12:09:24 +0000 Subject: [PATCH] feat(editor): clipboard config extensions (#11171) --- .../src/clipboard/readonly-clipboard.ts | 101 +++--- .../block-root/src/common-specs/index.ts | 2 + .../src/clipboard/clipboard-adapter.ts | 33 ++ .../block-std/src/clipboard/clipboard.ts | 311 ++++++++++++++++ .../block-std/src/clipboard/index.ts | 332 +----------------- 5 files changed, 409 insertions(+), 370 deletions(-) create mode 100644 blocksuite/framework/block-std/src/clipboard/clipboard-adapter.ts create mode 100644 blocksuite/framework/block-std/src/clipboard/clipboard.ts diff --git a/blocksuite/affine/blocks/block-root/src/clipboard/readonly-clipboard.ts b/blocksuite/affine/blocks/block-root/src/clipboard/readonly-clipboard.ts index 3fbf46392c..aa9b7c08b9 100644 --- a/blocksuite/affine/blocks/block-root/src/clipboard/readonly-clipboard.ts +++ b/blocksuite/affine/blocks/block-root/src/clipboard/readonly-clipboard.ts @@ -14,8 +14,68 @@ import { draftSelectedModelsCommand, getSelectedModelsCommand, } from '@blocksuite/affine-shared/commands'; -import type { BlockComponent, UIEventHandler } from '@blocksuite/block-std'; +import { + type BlockComponent, + ClipboardAdapterConfigExtension, + type UIEventHandler, +} from '@blocksuite/block-std'; import { DisposableGroup } from '@blocksuite/global/disposable'; +import type { ExtensionType } from '@blocksuite/store'; + +const SnapshotClipboardConfig = ClipboardAdapterConfigExtension({ + mimeType: ClipboardAdapter.MIME, + adapter: ClipboardAdapter, + priority: 100, +}); + +const NotionClipboardConfig = ClipboardAdapterConfigExtension({ + mimeType: 'text/_notion-text-production', + adapter: NotionTextAdapter, + priority: 95, +}); + +const HtmlClipboardConfig = ClipboardAdapterConfigExtension({ + mimeType: 'text/html', + adapter: HtmlAdapter, + priority: 90, +}); + +const imageClipboardConfigs = [ + 'image/apng', + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', +].map(mimeType => { + return ClipboardAdapterConfigExtension({ + mimeType, + adapter: ImageAdapter, + priority: 80, + }); +}); + +const PlainTextClipboardConfig = ClipboardAdapterConfigExtension({ + mimeType: 'text/plain', + adapter: MixTextAdapter, + priority: 70, +}); + +const AttachmentClipboardConfig = ClipboardAdapterConfigExtension({ + mimeType: '*/*', + adapter: AttachmentAdapter, + priority: 60, +}); + +export const clipboardConfigs: ExtensionType[] = [ + SnapshotClipboardConfig, + NotionClipboardConfig, + HtmlClipboardConfig, + ...imageClipboardConfigs, + PlainTextClipboardConfig, + AttachmentClipboardConfig, +]; /** * ReadOnlyClipboard is a class that provides a read-only clipboard for the root block. @@ -34,30 +94,6 @@ export class ReadOnlyClipboard { protected _disposables = new DisposableGroup(); protected _initAdapters = () => { - this._std.clipboard.registerAdapter( - ClipboardAdapter.MIME, - ClipboardAdapter, - 100 - ); - this._std.clipboard.registerAdapter( - 'text/_notion-text-production', - NotionTextAdapter, - 95 - ); - this._std.clipboard.registerAdapter('text/html', HtmlAdapter, 90); - [ - 'image/apng', - 'image/avif', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/svg+xml', - 'image/webp', - ].forEach(type => - this._std.clipboard.registerAdapter(type, ImageAdapter, 80) - ); - this._std.clipboard.registerAdapter('text/plain', MixTextAdapter, 70); - this._std.clipboard.registerAdapter('*/*', AttachmentAdapter, 60); const copy = copyMiddleware(this._std); this._std.clipboard.use(copy); this._std.clipboard.use( @@ -67,19 +103,6 @@ export class ReadOnlyClipboard { this._disposables.add({ dispose: () => { - this._std.clipboard.unregisterAdapter(ClipboardAdapter.MIME); - this._std.clipboard.unregisterAdapter('text/plain'); - [ - 'image/apng', - 'image/avif', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/svg+xml', - 'image/webp', - ].forEach(type => this._std.clipboard.unregisterAdapter(type)); - this._std.clipboard.unregisterAdapter('text/html'); - this._std.clipboard.unregisterAdapter('*/*'); this._std.clipboard.unuse(copy); this._std.clipboard.unuse( titleMiddleware(this._std.store.workspace.meta.docMetas) @@ -120,5 +143,3 @@ export class ReadOnlyClipboard { this._disposables.dispose(); } } - -export { copyMiddleware }; diff --git a/blocksuite/affine/blocks/block-root/src/common-specs/index.ts b/blocksuite/affine/blocks/block-root/src/common-specs/index.ts index 69f2849169..ef0dc0312c 100644 --- a/blocksuite/affine/blocks/block-root/src/common-specs/index.ts +++ b/blocksuite/affine/blocks/block-root/src/common-specs/index.ts @@ -21,6 +21,7 @@ import { import type { ExtensionType } from '@blocksuite/store'; import { RootBlockAdapterExtensions } from '../adapters/extension'; +import { clipboardConfigs } from '../clipboard'; import { builtinToolbarConfig } from '../configs/toolbar'; import { innerModalWidget, @@ -39,6 +40,7 @@ export const CommonSpecs: ExtensionType[] = [ FileDropExtension, ToolbarRegistryExtension, ...RootBlockAdapterExtensions, + ...clipboardConfigs, modalWidget, innerModalWidget, diff --git a/blocksuite/framework/block-std/src/clipboard/clipboard-adapter.ts b/blocksuite/framework/block-std/src/clipboard/clipboard-adapter.ts new file mode 100644 index 0000000000..7149eda1de --- /dev/null +++ b/blocksuite/framework/block-std/src/clipboard/clipboard-adapter.ts @@ -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('clipboard-adapter-config'); + +export function ClipboardAdapterConfigExtension( + config: ClipboardAdapterConfig +): ExtensionType { + return { + setup: di => { + di.addImpl( + ClipboardAdapterConfigIdentifier(config.mimeType), + () => config + ); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/clipboard/clipboard.ts b/blocksuite/framework/block-std/src/clipboard/clipboard.ts new file mode 100644 index 0000000000..3f88439a55 --- /dev/null +++ b/blocksuite/framework/block-std/src/clipboard/clipboard.ts @@ -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(); + 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('[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)]); + } +} diff --git a/blocksuite/framework/block-std/src/clipboard/index.ts b/blocksuite/framework/block-std/src/clipboard/index.ts index 67f3a34b31..63e374d68b 100644 --- a/blocksuite/framework/block-std/src/clipboard/index.ts +++ b/blocksuite/framework/block-std/src/clipboard/index.ts @@ -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 = - | { new (job: Transformer): T } - | (new (job: Transformer, provider: ServiceProvider) => T); - -type AdapterMap = Map< - string, - { - adapter: AdapterConstructor; - 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(); - 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)]); - } -} +export * from './clipboard'; +export * from './clipboard-adapter';