diff --git a/blocksuite/affine/blocks/code/src/clipboard/index.ts b/blocksuite/affine/blocks/code/src/clipboard/index.ts new file mode 100644 index 0000000000..677ef941bf --- /dev/null +++ b/blocksuite/affine/blocks/code/src/clipboard/index.ts @@ -0,0 +1,173 @@ +import { deleteTextCommand } from '@blocksuite/affine-inline-preset'; +import { + HtmlAdapter, + pasteMiddleware, + PlainTextAdapter, +} from '@blocksuite/affine-shared/adapters'; +import { + getBlockIndexCommand, + getBlockSelectionsCommand, + getTextSelectionCommand, +} from '@blocksuite/affine-shared/commands'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { DisposableGroup } from '@blocksuite/global/disposable'; +import { + type BlockStdScope, + Clipboard, + type ClipboardAdapterConfig, + LifeCycleWatcher, + LifeCycleWatcherIdentifier, + StdIdentifier, + type UIEventHandler, +} from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; + +export const CodeClipboardAdapterConfigIdentifier = + createIdentifier('code-clipboard-adapter-config'); + +export function CodeClipboardAdapterConfigExtension( + config: ClipboardAdapterConfig +): ExtensionType { + return { + setup: di => { + di.addImpl( + CodeClipboardAdapterConfigIdentifier(config.mimeType), + () => config + ); + }, + }; +} + +const PlainTextClipboardConfig = CodeClipboardAdapterConfigExtension({ + mimeType: 'text/plain', + adapter: PlainTextAdapter, + priority: 90, +}); + +const HtmlClipboardConfig = CodeClipboardAdapterConfigExtension({ + mimeType: 'text/html', + adapter: HtmlAdapter, + priority: 80, +}); + +export class CodeBlockClipboard extends Clipboard { + static override readonly key = 'code-block-clipboard'; + + override get _adapters() { + const adapterConfigs = this.std.provider.getAll( + CodeClipboardAdapterConfigIdentifier + ); + return Array.from(adapterConfigs.values()); + } +} + +export class CodeBlockClipboardController extends LifeCycleWatcher { + static override key = 'code-block-clipboard-controller'; + + private readonly _disposables = new DisposableGroup(); + + constructor( + std: BlockStdScope, + readonly clipboard: CodeBlockClipboard + ) { + super(std); + } + + static override setup(di: Container) { + di.add( + this as unknown as { + new ( + std: BlockStdScope, + clipboard: CodeBlockClipboard + ): CodeBlockClipboardController; + }, + [StdIdentifier, CodeBlockClipboard] + ); + di.addImpl(LifeCycleWatcherIdentifier(this.key), provider => + provider.get(this) + ); + } + + protected _init = () => { + const paste = pasteMiddleware(this.std); + this.clipboard.use(paste); + + this._disposables.add({ + dispose: () => { + this.clipboard.unuse(paste); + }, + }); + }; + + onPaste: UIEventHandler = ctx => { + const e = ctx.get('clipboardState').raw; + e.preventDefault(); + + this.std.store.captureSync(); + this.std.command + .chain() + .try(cmd => [ + cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => { + const textSelection = ctx.currentTextSelection; + if (!textSelection) return; + const end = textSelection.to ?? textSelection.from; + next({ currentSelectionPath: end.blockId }); + }), + cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + if (!currentBlockSelections) return; + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) return; + next({ currentSelectionPath: blockSelection.blockId }); + }), + ]) + .pipe(getBlockIndexCommand) + .try(cmd => [cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand)]) + .pipe((ctx, next) => { + if (!ctx.parentBlock) { + return; + } + this.clipboard + .paste( + e, + this.std.store, + ctx.parentBlock.model.id, + ctx.blockIndex ? ctx.blockIndex + 1 : 1 + ) + .catch(console.error); + + return next(); + }) + .run(); + return true; + }; + + override mounted() { + this._init(); + + // add paste event listener for code block + const subscription = this.std.view.viewUpdated.subscribe( + ({ type, method, view }) => { + if (type !== 'block' || view.model.flavour !== 'affine:code') return; + + if (method === 'add') { + view.handleEvent('paste', this.onPaste); + } + } + ); + this._disposables.add(subscription); + } + + override unmounted() { + this._disposables.dispose(); + } +} + +export function getCodeClipboardExtensions(): ExtensionType[] { + return [ + PlainTextClipboardConfig, + HtmlClipboardConfig, + CodeBlockClipboard, + CodeBlockClipboardController, + ]; +} diff --git a/blocksuite/affine/blocks/code/src/code-block-spec.ts b/blocksuite/affine/blocks/code/src/code-block-spec.ts index fae649f674..1e74aba578 100644 --- a/blocksuite/affine/blocks/code/src/code-block-spec.ts +++ b/blocksuite/affine/blocks/code/src/code-block-spec.ts @@ -8,6 +8,7 @@ import type { ExtensionType } from '@blocksuite/store'; import { literal, unsafeStatic } from 'lit/static-html.js'; import { CodeBlockAdapterExtensions } from './adapters/extension.js'; +import { getCodeClipboardExtensions } from './clipboard/index.js'; import { CodeBlockInlineManagerExtension, CodeBlockUnitSpecExtension, @@ -33,4 +34,5 @@ export const CodeBlockSpec: ExtensionType[] = [ CodeBlockAdapterExtensions, SlashMenuConfigExtension('affine:code', codeSlashMenuConfig), CodeKeymapExtension, + ...getCodeClipboardExtensions(), ].flat(); diff --git a/blocksuite/affine/blocks/code/src/index.ts b/blocksuite/affine/blocks/code/src/index.ts index 4de4cf592b..af3de9858c 100644 --- a/blocksuite/affine/blocks/code/src/index.ts +++ b/blocksuite/affine/blocks/code/src/index.ts @@ -1,4 +1,5 @@ export * from './adapters'; +export * from './clipboard'; export * from './code-block'; export * from './code-block-config'; export * from './code-block-spec'; diff --git a/blocksuite/affine/blocks/root/src/clipboard/readonly-clipboard.ts b/blocksuite/affine/blocks/root/src/clipboard/readonly-clipboard.ts index 0f75d379fe..3e8e24ef10 100644 --- a/blocksuite/affine/blocks/root/src/clipboard/readonly-clipboard.ts +++ b/blocksuite/affine/blocks/root/src/clipboard/readonly-clipboard.ts @@ -34,6 +34,12 @@ const NotionClipboardConfig = ClipboardAdapterConfigExtension({ priority: 95, }); +const HtmlClipboardConfig = ClipboardAdapterConfigExtension({ + mimeType: 'text/html', + adapter: HtmlAdapter, + priority: 90, +}); + const imageClipboardConfigs = [ 'image/apng', 'image/avif', @@ -46,20 +52,14 @@ const imageClipboardConfigs = [ return ClipboardAdapterConfigExtension({ mimeType, adapter: ImageAdapter, - priority: 85, + priority: 80, }); }); const PlainTextClipboardConfig = ClipboardAdapterConfigExtension({ mimeType: 'text/plain', adapter: MixTextAdapter, - priority: 80, -}); - -const HtmlClipboardConfig = ClipboardAdapterConfigExtension({ - mimeType: 'text/html', - adapter: HtmlAdapter, - priority: 75, + priority: 70, }); const AttachmentClipboardConfig = ClipboardAdapterConfigExtension({ diff --git a/blocksuite/framework/std/src/clipboard/clipboard.ts b/blocksuite/framework/std/src/clipboard/clipboard.ts index 3f88439a55..8e71c5fbfb 100644 --- a/blocksuite/framework/std/src/clipboard/clipboard.ts +++ b/blocksuite/framework/std/src/clipboard/clipboard.ts @@ -15,9 +15,9 @@ import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js'; import { onlyContainImgElement } from './utils.js'; export class Clipboard extends LifeCycleWatcher { - static override readonly key = 'clipboard'; + static override key = 'clipboard'; - private get _adapters() { + protected get _adapters() { const adapterConfigs = this.std.provider.getAll( ClipboardAdapterConfigIdentifier ); diff --git a/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts b/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts index b8fa073e9d..04e328782a 100644 --- a/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts +++ b/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts @@ -397,4 +397,40 @@ test.describe('paste to code block', () => { // Verify the pasted code maintains indentation await verifyCodeBlockContent(page, 0, plainTextCode); }); + + test('should paste markdown text as plain text', async ({ page }) => { + await pressEnter(page); + await addCodeBlock(page); + + const markdownText = [ + '# Heading 1', + '', + '## Heading 2 with **bold** and *italic*', + '', + '### Lists:', + '- Item 1', + ' - Nested item with `inline code`', + ' - Another nested item', + '- Item 2 with [link](https://example.com)', + '', + '```typescript', + 'const code = "block";', + 'console.log(code);', + '```', + '', + '> This is a blockquote with **bold** text', + '> Multiple lines in blockquote', + '', + '| Table | Header |', + '|-------|--------|', + '| Cell 1 | Cell 2 |', + '$This is a inline latex$', + ].join('\n'); + + await pasteContent(page, { 'text/plain': markdownText }); + await page.waitForTimeout(100); + + // Verify the pasted code maintains indentation + await verifyCodeBlockContent(page, 0, markdownText); + }); });