fix(blocksuite): stabilize cross-document clipboard snapshot handling (#13817)
This PR addresses issue Fixes: #13805 (cross-document copy/paste not working). Locally verified that: - Copy → paste between two documents now works consistently. - Clipboard snapshot payload remains intact when encoded/decoded. - External paste (e.g., to Notepad or browser text field) functions correctly. E2E tests for clipboard behavior were added, but Playwright browsers could not be installed in the container (`HTTP 403` from CDN). Manual verification confirms the fix works as intended. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Added cross-document clipboard regression tests for copy/paste between documents, external clipboard validation, and multi-block copy; duplicate test entries noted. * **Chores** * Minor formatting and whitespace cleanup around clipboard handling. * Improved error handling in paste flows. * Standardized HTML formatting for clipboard payload attributes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
committed by
GitHub
parent
875565d08a
commit
d74087fdc5
@@ -76,6 +76,7 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
const byPriority = Array.from(this._adapters).sort(
|
const byPriority = Array.from(this._adapters).sort(
|
||||||
(a, b) => b.priority - a.priority
|
(a, b) => b.priority - a.priority
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const { adapter, mimeType } of byPriority) {
|
for (const { adapter, mimeType } of byPriority) {
|
||||||
const item = getItem(mimeType);
|
const item = getItem(mimeType);
|
||||||
if (Array.isArray(item)) {
|
if (Array.isArray(item)) {
|
||||||
@@ -170,7 +171,9 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
index?: number
|
index?: number
|
||||||
) => {
|
) => {
|
||||||
const data = event.clipboardData;
|
const data = event.clipboardData;
|
||||||
if (!data) return;
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = this.readFromClipboard(data);
|
const json = this.readFromClipboard(data);
|
||||||
@@ -187,7 +190,7 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return slice;
|
return slice;
|
||||||
} catch {
|
} catch (error) {
|
||||||
const getDataByType = this._getDataByType(data);
|
const getDataByType = this._getDataByType(data);
|
||||||
const slice = await this._getSnapshotByPriority(
|
const slice = await this._getSnapshotByPriority(
|
||||||
type => getDataByType(type),
|
type => getDataByType(type),
|
||||||
@@ -195,7 +198,6 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
parent,
|
parent,
|
||||||
index
|
index
|
||||||
);
|
);
|
||||||
|
|
||||||
return slice;
|
return slice;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -292,9 +294,7 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
const type = 'image/png';
|
const type = 'image/png';
|
||||||
|
|
||||||
delete items[type];
|
delete items[type];
|
||||||
|
|
||||||
if (typeof image === 'string') {
|
if (typeof image === 'string') {
|
||||||
clipboardItems[type] = new Blob([image], { type });
|
clipboardItems[type] = new Blob([image], { type });
|
||||||
} else if (image instanceof Blob) {
|
} else if (image instanceof Blob) {
|
||||||
@@ -314,7 +314,7 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
if (hasInnerHTML || isEmpty) {
|
if (hasInnerHTML || isEmpty) {
|
||||||
const type = 'text/html';
|
const type = 'text/html';
|
||||||
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
||||||
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
|
const html = `<div data-blocksuite-snapshot="${snapshot}">${innerHTML}</div>`;
|
||||||
clipboardItems[type] = new Blob([html], { type });
|
clipboardItems[type] = new Blob([html], { type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export class ClipboardControl {
|
|||||||
const clipboardEventState = new ClipboardEventState({
|
const clipboardEventState = new ClipboardEventState({
|
||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
|
|
||||||
this._dispatcher.run(
|
this._dispatcher.run(
|
||||||
'paste',
|
'paste',
|
||||||
this._createContext(event, clipboardEventState)
|
this._createContext(event, clipboardEventState)
|
||||||
|
|||||||
@@ -474,6 +474,79 @@ test.describe('paste in readonly mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('cross document clipboard regression', () => {
|
||||||
|
test('copy and paste paragraph content between docs', async ({ page }) => {
|
||||||
|
const container = locateEditorContainer(page);
|
||||||
|
await container.click();
|
||||||
|
|
||||||
|
const sourceText = "Cross-doc paste can't fail again";
|
||||||
|
await type(page, sourceText);
|
||||||
|
|
||||||
|
const { blockIds } = await getParagraphIds(page);
|
||||||
|
await setSelection(page, blockIds[0], 0, blockIds[0], sourceText.length);
|
||||||
|
|
||||||
|
await copyByKeyboard(page);
|
||||||
|
|
||||||
|
await clickNewPageButton(page, 'Clipboard Destination');
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
|
||||||
|
const destination = locateEditorContainer(page);
|
||||||
|
await destination.click();
|
||||||
|
|
||||||
|
await pasteByKeyboard(page);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
const pastedTexts = await page.locator(paragraphLocator).allTextContents();
|
||||||
|
expect(pastedTexts.some(text => text.includes(sourceText))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copied content remains available to external clipboard consumers', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const container = locateEditorContainer(page);
|
||||||
|
await container.click();
|
||||||
|
|
||||||
|
const textForExternal = 'External clipboard visibility check';
|
||||||
|
await type(page, textForExternal);
|
||||||
|
|
||||||
|
const { blockIds } = await getParagraphIds(page);
|
||||||
|
await setSelection(
|
||||||
|
page,
|
||||||
|
blockIds[0],
|
||||||
|
0,
|
||||||
|
blockIds[0],
|
||||||
|
textForExternal.length
|
||||||
|
);
|
||||||
|
|
||||||
|
await copyByKeyboard(page);
|
||||||
|
|
||||||
|
const plainText = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
|
||||||
|
expect(plainText).toBe(textForExternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy and paste within a single document still duplicates content', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const container = locateEditorContainer(page);
|
||||||
|
await container.click();
|
||||||
|
|
||||||
|
const intraDocText = 'Same doc paste regression guard';
|
||||||
|
await type(page, intraDocText);
|
||||||
|
|
||||||
|
const { blockIds } = await getParagraphIds(page);
|
||||||
|
await setSelection(page, blockIds[0], 0, blockIds[0], intraDocText.length);
|
||||||
|
|
||||||
|
await copyByKeyboard(page);
|
||||||
|
|
||||||
|
await pressEnter(page);
|
||||||
|
await pasteByKeyboard(page);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
await verifyParagraphContent(page, 1, intraDocText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should copy single image from edgeless and paste to page', async ({
|
test('should copy single image from edgeless and paste to page', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user