mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 04:04:27 +08:00
### TL;DR
Prevent pasting content when a document is in readonly mode.
### What changed?
Added a check in the `PageClipboard` class to prevent pasting operations when the document is in readonly mode. The function now returns early if `this.std.store.readonly` is true, preventing any clipboard operations from being executed.
### How to test?
1. Open a document and add some content
2. Set the document to readonly mode: `document.querySelector('affine-page-root')!.doc.readonly = true`
3. Try to paste content into the document using:
- Regular paste operation
- Keyboard shortcut (Ctrl+V/Cmd+V)
4. Verify that no content is pasted and the document remains unchanged
5. Set the document back to editable mode and confirm pasting works again
### Why make this change?
This change ensures that documents in readonly mode maintain their integrity by preventing unintended modifications through clipboard operations. This is consistent with the expected behavior of readonly documents, where users should not be able to modify content through any means.
473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
import { test } from '@affine-test/kit/playwright';
|
|
import { pasteContent } from '@affine-test/kit/utils/clipboard';
|
|
import {
|
|
clickEdgelessModeButton,
|
|
clickPageModeButton,
|
|
getCodeBlockIds,
|
|
getParagraphIds,
|
|
locateEditorContainer,
|
|
} from '@affine-test/kit/utils/editor';
|
|
import {
|
|
copyByKeyboard,
|
|
cutByKeyboard,
|
|
pasteByKeyboard,
|
|
pressEnter,
|
|
} from '@affine-test/kit/utils/keyboard';
|
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
|
import {
|
|
addCodeBlock,
|
|
clickNewPageButton,
|
|
type,
|
|
waitForEditorLoad,
|
|
} from '@affine-test/kit/utils/page-logic';
|
|
import { setSelection } from '@affine-test/kit/utils/selection';
|
|
import type { CodeBlockComponent } from '@blocksuite/affine-block-code';
|
|
import type { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
|
|
import type { PageRootBlockComponent } from '@blocksuite/affine-block-root';
|
|
import type { BlockComponent } from '@blocksuite/std';
|
|
import { expect, type Page } from '@playwright/test';
|
|
|
|
const paragraphLocator = 'affine-note affine-paragraph';
|
|
const codeBlockLocator = 'affine-note affine-code';
|
|
|
|
// 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 block text content
|
|
async function verifyBlockContent<T extends BlockComponent>(
|
|
page: Page,
|
|
selector: string,
|
|
index: number,
|
|
expectedText: string
|
|
) {
|
|
expect(
|
|
await page
|
|
.locator(selector)
|
|
.nth(index)
|
|
.evaluate((block: T, expected: string) => {
|
|
const model = block.model;
|
|
// Check if model has text property
|
|
if (!('text' in model)) return false;
|
|
const text = model.text;
|
|
// Check if text exists and has toString method
|
|
return text && text.toString() === expected;
|
|
}, expectedText)
|
|
).toBeTruthy();
|
|
}
|
|
|
|
// Helper functions using the generic verifyBlockContent
|
|
async function verifyParagraphContent(
|
|
page: Page,
|
|
index: number,
|
|
expectedText: string
|
|
) {
|
|
await verifyBlockContent<ParagraphBlockComponent>(
|
|
page,
|
|
paragraphLocator,
|
|
index,
|
|
expectedText
|
|
);
|
|
}
|
|
|
|
async function verifyCodeBlockContent(
|
|
page: Page,
|
|
index: number,
|
|
expectedText: string
|
|
) {
|
|
await verifyBlockContent<CodeBlockComponent>(
|
|
page,
|
|
codeBlockLocator,
|
|
index,
|
|
expectedText
|
|
);
|
|
}
|
|
|
|
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 { blockIds: 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 { blockIds: 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');
|
|
});
|
|
});
|
|
|
|
test.describe('surface-ref block', () => {
|
|
async function setupSurfaceRefBlock(page: Page) {
|
|
await clickEdgelessModeButton(page);
|
|
const container = locateEditorContainer(page);
|
|
await container.click();
|
|
|
|
// add a shape
|
|
await page.keyboard.press('s');
|
|
await container.click({ position: { x: 100, y: 300 } });
|
|
await page.waitForTimeout(50);
|
|
|
|
// add a frame
|
|
await page.keyboard.press('f');
|
|
await page.waitForTimeout(50);
|
|
|
|
// click on the frame title to trigger the change frame button toolbar
|
|
const frameTitle = page.locator('affine-frame-title');
|
|
await frameTitle.click();
|
|
await page.waitForTimeout(50);
|
|
|
|
const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
|
|
const insertIntoPageButton = toolbar.getByLabel('Insert into Page');
|
|
await insertIntoPageButton.click();
|
|
|
|
await clickPageModeButton(page);
|
|
await waitForEditorLoad(page);
|
|
await container.click();
|
|
|
|
return { container };
|
|
}
|
|
|
|
test('paste surface-ref block to another doc as embed-linked-doc block', async ({
|
|
page,
|
|
}) => {
|
|
await setupSurfaceRefBlock(page);
|
|
|
|
// copy surface-ref block
|
|
const surfaceRefBlock = page.locator('affine-surface-ref');
|
|
await surfaceRefBlock.click();
|
|
await page.waitForSelector('affine-surface-ref .focused');
|
|
await copyByKeyboard(page);
|
|
|
|
// paste to another doc
|
|
await clickNewPageButton(page, 'page2');
|
|
await pressEnter(page);
|
|
|
|
// paste the surface-ref block
|
|
await pasteByKeyboard(page);
|
|
await page.waitForTimeout(50);
|
|
|
|
const embedLinkedDocBlock = page.locator('affine-embed-linked-doc-block');
|
|
await expect(embedLinkedDocBlock).toBeVisible();
|
|
const embedLinkedDocBlockTitle = embedLinkedDocBlock.locator(
|
|
'.affine-embed-linked-doc-content-title-text'
|
|
);
|
|
await expect(embedLinkedDocBlockTitle).toHaveText('Clipboard Test');
|
|
});
|
|
|
|
test('cut and paste surface-ref block to same doc should remain surface-ref block', async ({
|
|
page,
|
|
}) => {
|
|
const { container } = await setupSurfaceRefBlock(page);
|
|
|
|
// cut surface-ref block
|
|
const surfaceRefBlock = page.locator('affine-surface-ref');
|
|
await surfaceRefBlock.click();
|
|
await page.waitForSelector('affine-surface-ref .focused');
|
|
await cutByKeyboard(page);
|
|
|
|
// focus on the editor
|
|
await container.click();
|
|
|
|
// paste the surface-ref block
|
|
await pasteByKeyboard(page);
|
|
await page.waitForTimeout(50);
|
|
await expect(surfaceRefBlock).toHaveCount(1);
|
|
await expect(surfaceRefBlock).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('paste to code block', () => {
|
|
test('should replace the selected text when pasting plain text', async ({
|
|
page,
|
|
}) => {
|
|
// press enter to focus on the first block of the editor
|
|
await pressEnter(page);
|
|
await addCodeBlock(page);
|
|
await type(page, 'hello world hello');
|
|
const { blockIds: codeBlockIds } = await getCodeBlockIds(page);
|
|
await setSelection(page, codeBlockIds[0], 6, codeBlockIds[0], 11);
|
|
await pasteContent(page, { 'text/plain': 'test' });
|
|
await verifyCodeBlockContent(page, 0, 'hello test hello');
|
|
});
|
|
|
|
test('should replace the selected text when pasting single snapshot', async ({
|
|
page,
|
|
}) => {
|
|
// Create initial test blocks
|
|
// add a paragraph block
|
|
await createParagraphBlocks(page, ['test']);
|
|
// add a code block
|
|
await pressEnter(page);
|
|
await addCodeBlock(page);
|
|
await type(page, 'hello world hello');
|
|
|
|
// select the paragraph content
|
|
const { blockIds: paragraphIds } = await getParagraphIds(page);
|
|
await setSelection(page, paragraphIds[0], 0, paragraphIds[0], 4);
|
|
// copy the paragraph content
|
|
await copyByKeyboard(page);
|
|
|
|
// select 'world' in the code block
|
|
const { blockIds: codeBlockIds } = await getCodeBlockIds(page);
|
|
await setSelection(page, codeBlockIds[0], 6, codeBlockIds[0], 11);
|
|
|
|
// paste to the code block
|
|
await pasteByKeyboard(page);
|
|
await page.waitForTimeout(100);
|
|
|
|
await verifyCodeBlockContent(page, 0, 'hello test hello');
|
|
});
|
|
|
|
test('should replace the selected text when pasting multiple snapshots', async ({
|
|
page,
|
|
}) => {
|
|
// Create initial test blocks
|
|
// add three paragraph blocks
|
|
await createParagraphBlocks(page, ['test', 'test', 'test']);
|
|
|
|
// add a code block
|
|
await pressEnter(page);
|
|
await addCodeBlock(page);
|
|
await type(page, 'hello world hello');
|
|
|
|
// select all paragraph content
|
|
const { blockIds: paragraphIds } = await getParagraphIds(page);
|
|
await setSelection(page, paragraphIds[0], 0, paragraphIds[2], 4);
|
|
// copy the paragraph content
|
|
await copyByKeyboard(page);
|
|
|
|
// select 'world' in the code block
|
|
const { blockIds: codeBlockIds } = await getCodeBlockIds(page);
|
|
await setSelection(page, codeBlockIds[0], 6, codeBlockIds[0], 11);
|
|
|
|
// paste to the code block
|
|
await pasteByKeyboard(page);
|
|
await page.waitForTimeout(100);
|
|
|
|
await verifyCodeBlockContent(page, 0, 'hello test\ntest\ntest hello');
|
|
});
|
|
|
|
test('should preserve indentation when pasting code with spaces into code block', async ({
|
|
page,
|
|
}) => {
|
|
await pressEnter(page);
|
|
await addCodeBlock(page);
|
|
|
|
// Sample code with proper indentation for text/plain
|
|
const plainTextCode = [
|
|
'const fibonacci = (n: number): number => {',
|
|
' if (n <= 1) return n;',
|
|
' return fibonacci(n - 1) + fibonacci(n - 2);',
|
|
'}',
|
|
'function generateSequence(length: number) {',
|
|
' const sequence = [];',
|
|
' for (let i = 0; i < length; i++) {',
|
|
' sequence.push(fibonacci(i));',
|
|
' }',
|
|
' return sequence;',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const htmlCode =
|
|
'<div><span>const</span><span> </span><span>fibonacci</span><span> </span><span>=</span><span> (n</span><span>:</span><span> </span><span>number</span><span>)</span><span>:</span><span> </span><span>number</span><span> </span><span>=></span><span> {</span></div><div><span> </span><span>if</span><span> (n </span><span><=</span><span> </span><span>1</span><span>) </span><span>return</span><span> n;</span></div><div><span> </span><span>return</span><span> </span><span>fibonacci</span><span>(n </span><span>-</span><span> </span><span>1</span><span>) </span><span>+</span><span> </span><span>fibonacci</span><span>(n </span><span>-</span><span> </span><span>2</span><span>);</span></div><div><span>}</span></div><div><span>function</span><span> </span><span>generateSequence</span><span>(length</span><span>:</span><span> </span><span>number</span><span>) {</span></div><div><span> </span><span>const</span><span> </span><span>sequence</span><span> </span><span>=</span><span> [];</span></div><div><span> </span><span>for</span><span> (</span><span>let</span><span> i </span><span>=</span><span> </span><span>0</span><span>; i </span><span><</span><span> length; i</span><span>++</span><span>) {</span></div><div><span> sequence.</span><span>push</span><span>(</span><span>fibonacci</span><span>(i));</span></div><div><span> }</span></div><div><span> </span><span>return</span><span> sequence;</span></div><div><span>}</span></div></div>';
|
|
|
|
await pasteContent(page, {
|
|
'text/plain': plainTextCode,
|
|
'text/html': htmlCode,
|
|
});
|
|
await page.waitForTimeout(100);
|
|
|
|
// Verify the pasted code maintains indentation
|
|
await verifyCodeBlockContent(page, 0, plainTextCode);
|
|
});
|
|
|
|
test('html tag should be treated as plain text when pasting', async ({
|
|
page,
|
|
}) => {
|
|
await pressEnter(page);
|
|
await addCodeBlock(page);
|
|
|
|
const textWithHtmlTags =
|
|
'<div><span>const</span><span> </span><span>fibonacci</span><span> </span><span>=</span><span> (n</span><span>:</span><span> </span><span>number</span><span>)</span><span>:</span><span> </span><span>number</span><span> </span><span>=></span><span> {</span></div><div><span> </span><span>if</span><span> (n </span><span><=</span><span> </span><span>1</span><span>) </span><span>return</span><span> n;</span></div><div><span> </span><span>return</span><span> </span><span>fibonacci</span><span>(n </span><span>-</span><span> </span><span>1</span><span>) </span><span>+</span><span> </span><span>fibonacci</span><span>(n </span><span>-</span><span> </span><span>2</span><span>);</span></div><div><span>}</span></div><div><span>function</span><span> </span><span>generateSequence</span><span>(length</span><span>:</span><span> </span><span>number</span><span>) {</span></div><div><span> </span><span>const</span><span> </span><span>sequence</span><span> </span><span>=</span><span> [];</span></div><div><span> </span><span>for</span><span> (</span><span>let</span><span> i </span><span>=</span><span> </span><span>0</span><span>; i </span><span><</span><span> length; i</span><span>++</span><span>) {</span></div><div><span> sequence.</span><span>push</span><span>(</span><span>fibonacci</span><span>(i));</span></div><div><span> }</span></div><div><span> </span><span>return</span><span> sequence;</span></div><div><span>}</span></div></div>';
|
|
|
|
await pasteContent(page, { 'text/plain': textWithHtmlTags });
|
|
await page.waitForTimeout(100);
|
|
|
|
// Verify the pasted code maintains indentation
|
|
await verifyCodeBlockContent(page, 0, textWithHtmlTags);
|
|
});
|
|
|
|
test('should not wrap line in brackets when pasting code', async ({
|
|
page,
|
|
}) => {
|
|
await pressEnter(page);
|
|
await addCodeBlock(page);
|
|
const plainTextCode = [
|
|
' model: anthropic("claude-3-7-sonnet-20250219"),',
|
|
' prompt: How many people will live in the world in 2040?',
|
|
' providerOptions: {',
|
|
' anthropic: {',
|
|
' thinking: { type: enabled, budgetTokens: 12000 },',
|
|
' } satisfies AnthropicProviderOptions,',
|
|
' },',
|
|
].join('\n');
|
|
|
|
await pasteContent(page, { 'text/plain': plainTextCode });
|
|
await page.waitForTimeout(100);
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
test.describe('paste in readonly mode', () => {
|
|
test('should not paste content when document is in readonly mode', async ({
|
|
page,
|
|
}) => {
|
|
await createParagraphBlocks(page, ['This is a test paragraph']);
|
|
const { blockIds } = await getParagraphIds(page);
|
|
const initialParagraphCount = blockIds.length;
|
|
|
|
await page.evaluate(() => {
|
|
const pageRoot = document.querySelector(
|
|
'affine-page-root'
|
|
) as PageRootBlockComponent;
|
|
pageRoot.doc.readonly = true;
|
|
});
|
|
|
|
await setSelection(page, blockIds[0], 0, blockIds[0], 4);
|
|
await pasteContent(page, {
|
|
'text/plain': ' - Added text that should not appear',
|
|
});
|
|
|
|
await verifyParagraphContent(page, 0, 'This is a test paragraph');
|
|
|
|
await pressEnter(page);
|
|
await pasteContent(page, { 'text/plain': 'This should not be pasted' });
|
|
|
|
const { blockIds: afterParagraphIds } = await getParagraphIds(page);
|
|
expect(afterParagraphIds.length).toBe(initialParagraphCount);
|
|
|
|
await setSelection(page, blockIds[0], 0, blockIds[0], 4);
|
|
await pasteByKeyboard(page);
|
|
|
|
await verifyParagraphContent(page, 0, 'This is a test paragraph');
|
|
});
|
|
});
|