diff --git a/blocksuite/blocks/src/root-block/clipboard/page-clipboard.ts b/blocksuite/blocks/src/root-block/clipboard/page-clipboard.ts index 6d803a453d..3ea40af695 100644 --- a/blocksuite/blocks/src/root-block/clipboard/page-clipboard.ts +++ b/blocksuite/blocks/src/root-block/clipboard/page-clipboard.ts @@ -61,8 +61,20 @@ export class PageClipboard extends ReadOnlyClipboard { this._std.store.captureSync(); this._std.command .chain() - .try(cmd => [ - cmd.pipe(getTextSelectionCommand), + .try<{}>(cmd => [ + cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => { + const { currentTextSelection } = ctx; + if (!currentTextSelection) { + return; + } + const { from, to } = currentTextSelection; + if (to && from.blockId !== to.blockId) { + this._std.command.exec(deleteTextCommand, { + currentTextSelection, + }); + } + return next(); + }), cmd .pipe(getSelectedModelsCommand) .pipe(clearAndSelectFirstModelCommand) diff --git a/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts b/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts new file mode 100644 index 0000000000..feb5c9acb8 --- /dev/null +++ b/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts @@ -0,0 +1,143 @@ +import { test } from '@affine-test/kit/playwright'; +import { pasteContent } from '@affine-test/kit/utils/clipboard'; +import { + copyByKeyboard, + pasteByKeyboard, + pressEnter, +} from '@affine-test/kit/utils/keyboard'; +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + clickNewPageButton, + type, + waitForEditorLoad, +} from '@affine-test/kit/utils/page-logic'; +import { setSelection } from '@affine-test/kit/utils/selection'; +import type { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph'; +import { expect, type Page } from '@playwright/test'; + +const paragraphLocator = 'affine-note affine-paragraph'; + +// Helper function to create paragraph blocks with text +async function createParagraphBlocks(page: Page, texts: string[]) { + for (const text of texts) { + await pressEnter(page); + await type(page, text); + } +} + +// Helper function to verify paragraph text content +async function verifyParagraphContent( + page: Page, + index: number, + expectedText: string +) { + expect( + await page + .locator(paragraphLocator) + .nth(index) + .evaluate( + (block: ParagraphBlockComponent, expected: string) => + block.model.type === 'text' && + block.model.text.toString() === expected, + expectedText + ) + ).toBeTruthy(); +} + +// Helper function to get paragraph block ids +async function getParagraphIds(page: Page) { + const paragraph = page.locator(paragraphLocator); + const paragraphIds = await paragraph.evaluateAll( + (blocks: ParagraphBlockComponent[]) => blocks.map(block => block.model.id) + ); + return { paragraphIds }; +} + +test.beforeEach(async ({ page }) => { + await openHomePage(page); + await clickNewPageButton(page, 'Clipboard Test'); + await waitForEditorLoad(page); +}); + +test.describe('paste in multiple blocks text selection', () => { + test('paste plain text', async ({ page }) => { + const texts = ['hello world', 'hello world', 'hello world']; + await createParagraphBlocks(page, texts); + + const { paragraphIds } = await getParagraphIds(page); + + /** + * select text cross the 3 blocks: + * hello |world + * hello world + * hello| world + */ + await setSelection(page, paragraphIds[0], 6, paragraphIds[2], 5); + + await pasteContent(page, { 'text/plain': 'test' }); + + /** + * after paste: + * hello test world + */ + await verifyParagraphContent(page, 0, 'hello test world'); + }); + + test('paste snapshot', async ({ page }) => { + // Create initial test blocks + await createParagraphBlocks(page, ['test', 'test']); + + // Create target blocks + await createParagraphBlocks(page, [ + 'hello world', + 'hello world', + 'hello world', + ]); + + /** + * before paste: + * test + * test + * hello world + * hello world + * hello world + */ + const { paragraphIds } = await getParagraphIds(page); + + /** + * select the first 2 blocks: + * |test + * test| + * hello world + * hello world + * hello world + */ + await setSelection(page, paragraphIds[0], 0, paragraphIds[1], 4); + // copy the first 2 blocks + await copyByKeyboard(page); + + /** + * select the last 3 blocks: + * test + * test + * hello |world + * hello world + * hello| world + */ + await setSelection(page, paragraphIds[2], 6, paragraphIds[4], 5); + + // paste the first 2 blocks + await pasteByKeyboard(page); + await page.waitForTimeout(100); + + /** + * after paste: + * test + * test + * hello test + * test world + */ + await verifyParagraphContent(page, 2, 'hello test'); + await verifyParagraphContent(page, 3, 'test world'); + }); +}); diff --git a/tests/kit/src/utils/clipboard.ts b/tests/kit/src/utils/clipboard.ts new file mode 100644 index 0000000000..d93da34684 --- /dev/null +++ b/tests/kit/src/utils/clipboard.ts @@ -0,0 +1,24 @@ +import type { Page } from '@playwright/test'; + +export async function pasteContent( + page: Page, + clipData: Record +) { + await page.evaluate( + ({ clipData }) => { + const e = new ClipboardEvent('paste', { + clipboardData: new DataTransfer(), + }); + Object.defineProperty(e, 'target', { + writable: false, + value: document, + }); + Object.keys(clipData).forEach(key => { + e.clipboardData?.setData(key, clipData[key] as string); + }); + document.dispatchEvent(e); + }, + { clipData } + ); + await page.waitForTimeout(100); +} diff --git a/tests/kit/src/utils/selection.ts b/tests/kit/src/utils/selection.ts new file mode 100644 index 0000000000..f58da8d889 --- /dev/null +++ b/tests/kit/src/utils/selection.ts @@ -0,0 +1,55 @@ +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import type { Page } from '@playwright/test'; + +export async function setSelection( + page: Page, + anchorBlockId: string, + anchorOffset: number, + focusBlockId: string, + focusOffset: number +) { + await page.evaluate( + ({ anchorBlockId, anchorOffset, focusBlockId, focusOffset }) => { + const editorHost = document.querySelector('editor-host'); + if (!editorHost) { + throw new Error('Cannot find editor host'); + } + + const anchorRichText = editorHost.querySelector( + `[data-block-id="${anchorBlockId}"] rich-text` + )!; + const anchorRichTextRange = anchorRichText.inlineEditor!.toDomRange({ + index: anchorOffset, + length: 0, + })!; + const focusRichText = editorHost.querySelector( + `[data-block-id="${focusBlockId}"] rich-text` + )!; + const focusRichTextRange = focusRichText.inlineEditor!.toDomRange({ + index: focusOffset, + length: 0, + })!; + + const sl = getSelection(); + if (!sl) throw new Error('Cannot get selection'); + + const range = document.createRange(); + range.setStart( + anchorRichTextRange.startContainer, + anchorRichTextRange.startOffset + ); + range.setEnd( + focusRichTextRange.startContainer, + focusRichTextRange.startOffset + ); + sl.removeAllRanges(); + sl.addRange(range); + }, + { + anchorBlockId, + anchorOffset, + focusBlockId, + focusOffset, + } + ); +}