diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index c6d34e549e316..54835030a3bd3 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -112,6 +112,36 @@ jobs:
yarn set version $(node -e "console.log(require('./package.json').packageManager.split('@')[1])")
git diff --exit-code
+ e2e-legacy-blocksuite-test:
+ name: Legacy Blocksuite E2E Test
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: ./.github/actions/setup-node
+ with:
+ playwright-install: true
+ electron-install: false
+ full-cache: true
+
+ - name: Run playground build
+ run: yarn workspace @blocksuite/playground build
+
+ - name: Run playwright tests
+ run: yarn workspace @blocksuite/legacy-e2e test --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
+
+ - name: Upload test results
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-e2e-legacy-bs-${{ matrix.shard }}
+ path: ./test-results
+ if-no-files-found: ignore
+
e2e-test:
name: E2E Test
runs-on: ubuntu-latest
@@ -185,6 +215,7 @@ jobs:
uses: ./.github/actions/setup-node
with:
electron-install: true
+ playwright-install: true
full-cache: true
- name: Download affine.linux-x64-gnu.node
@@ -767,6 +798,7 @@ jobs:
- lint
- check-yarn-binary
- e2e-test
+ - e2e-legacy-blocksuite-test
- e2e-mobile-test
- unit-test
- build-native
diff --git a/.prettierignore b/.prettierignore
index 6aa0948e1fdde..489bc0a065375 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -25,4 +25,6 @@ packages/frontend/templates/onboarding
packages/backend/native/index.d.ts
packages/frontend/native/index.d.ts
packages/frontend/native/index.js
-compose.yaml
\ No newline at end of file
+compose.yaml
+
+blocksuite/tests-legacy/snapshots
diff --git a/blocksuite/affine/all/vitest.config.ts b/blocksuite/affine/all/vitest.config.ts
index 0be7ff0fecb3c..700f600c649af 100644
--- a/blocksuite/affine/all/vitest.config.ts
+++ b/blocksuite/affine/all/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/block-embed/vitest.config.ts b/blocksuite/affine/block-embed/vitest.config.ts
index b86624acc9b42..c0330b2ab1951 100644
--- a/blocksuite/affine/block-embed/vitest.config.ts
+++ b/blocksuite/affine/block-embed/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/block-list/vitest.config.ts b/blocksuite/affine/block-list/vitest.config.ts
index b86624acc9b42..c0330b2ab1951 100644
--- a/blocksuite/affine/block-list/vitest.config.ts
+++ b/blocksuite/affine/block-list/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/block-paragraph/vitest.config.ts b/blocksuite/affine/block-paragraph/vitest.config.ts
index fb99961c008a2..b39a4f9ea873d 100644
--- a/blocksuite/affine/block-paragraph/vitest.config.ts
+++ b/blocksuite/affine/block-paragraph/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/block-surface/vitest.config.ts b/blocksuite/affine/block-surface/vitest.config.ts
index 3bb7c2cc2d0eb..a45195590e4f1 100644
--- a/blocksuite/affine/block-surface/vitest.config.ts
+++ b/blocksuite/affine/block-surface/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/components/vitest.config.ts b/blocksuite/affine/components/vitest.config.ts
index e2eab294b3642..3243b60ffb700 100644
--- a/blocksuite/affine/components/vitest.config.ts
+++ b/blocksuite/affine/components/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/data-view/vitest.config.ts b/blocksuite/affine/data-view/vitest.config.ts
index 1e76565bf5f7c..9ae9d1cc50554 100644
--- a/blocksuite/affine/data-view/vitest.config.ts
+++ b/blocksuite/affine/data-view/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../scripts/vitest-global.ts',
+ globalSetup: '../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/model/vitest.config.ts b/blocksuite/affine/model/vitest.config.ts
index a1bcb95c66e1c..9faa866c54031 100644
--- a/blocksuite/affine/model/vitest.config.ts
+++ b/blocksuite/affine/model/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../scripts/vitest-global.ts',
+ globalSetup: '../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/shared/vitest.config.ts b/blocksuite/affine/shared/vitest.config.ts
index a4b8d7fdcc7c0..9c1c45d368616 100644
--- a/blocksuite/affine/shared/vitest.config.ts
+++ b/blocksuite/affine/shared/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/affine/widget-scroll-anchoring/vitest.config.ts b/blocksuite/affine/widget-scroll-anchoring/vitest.config.ts
index 287861e3498de..04dcaa6f15a1d 100644
--- a/blocksuite/affine/widget-scroll-anchoring/vitest.config.ts
+++ b/blocksuite/affine/widget-scroll-anchoring/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../../scripts/vitest-global.ts',
+ globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/blocks/vitest.config.ts b/blocksuite/blocks/vitest.config.ts
index 235c0dbde91f2..8567a9f37a5fa 100644
--- a/blocksuite/blocks/vitest.config.ts
+++ b/blocksuite/blocks/vitest.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
target: 'es2018',
},
test: {
- globalSetup: '../../scripts/vitest-global.ts',
+ globalSetup: '../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
diff --git a/blocksuite/framework/block-std/package.json b/blocksuite/framework/block-std/package.json
index b8d8dcb064866..c1795ea3b0595 100644
--- a/blocksuite/framework/block-std/package.json
+++ b/blocksuite/framework/block-std/package.json
@@ -20,6 +20,7 @@
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@types/hast": "^3.0.4",
+ "dompurify": "^3.1.6",
"fractional-indexing": "^3.2.0",
"lib0": "^0.2.97",
"lit": "^3.2.0",
diff --git a/blocksuite/playground/package.json b/blocksuite/playground/package.json
index 6120359211115..47c397ffedc0c 100644
--- a/blocksuite/playground/package.json
+++ b/blocksuite/playground/package.json
@@ -5,7 +5,7 @@
"scripts": {
"dev": "vite --host",
"dev:hmr": "WC_HMR=1 vite",
- "build": "tsc && nx vite:build",
+ "build": "vite build",
"preview": "vite preview"
},
"dependencies": {
@@ -40,6 +40,8 @@
"@types/micromatch": "^4.0.9",
"graphql": "^16.9.0",
"magic-string": "^0.30.11",
+ "vite": "^6.0.3",
+ "vite-plugin-istanbul": "^6.0.2",
"vite-plugin-wasm": "^3.3.0",
"vite-plugin-web-components-hmr": "^0.1.3"
}
diff --git a/blocksuite/presets/src/__tests__/main/snapshot.spec.ts b/blocksuite/presets/src/__tests__/main/snapshot.spec.ts
index c5d802f23bd94..f286c8c0e8b99 100644
--- a/blocksuite/presets/src/__tests__/main/snapshot.spec.ts
+++ b/blocksuite/presets/src/__tests__/main/snapshot.spec.ts
@@ -89,11 +89,13 @@ beforeEach(async () => {
const xywhPattern = /\[(\s*-?\d+(\.\d+)?\s*,){3}(\s*-?\d+(\.\d+)?\s*)\]/;
-test('snapshot 1 importing', async () => {
+// FIXME: snapshot tests
+test.skip('snapshot 1 importing', async () => {
await snapshotTest('https://test.affineassets.com/test-snapshot-1.zip', 25);
});
-test('snapshot 2 importing', async () => {
+// FIXME: snapshot tests
+test.skip('snapshot 2 importing', async () => {
await snapshotTest(
'https://test.affineassets.com/test-snapshot-2%20(onboarding).zip',
174
diff --git a/blocksuite/tests-legacy/attachment.spec.ts b/blocksuite/tests-legacy/attachment.spec.ts
new file mode 100644
index 0000000000000..9de5a4509a16f
--- /dev/null
+++ b/blocksuite/tests-legacy/attachment.spec.ts
@@ -0,0 +1,769 @@
+import { sleep } from '@blocksuite/global/utils';
+import { expect, type Page } from '@playwright/test';
+import { switchEditorMode } from 'utils/actions/edgeless.js';
+
+import { dragBlockToPoint, popImageMoreMenu } from './utils/actions/drag.js';
+import {
+ pressArrowDown,
+ pressArrowUp,
+ pressBackspace,
+ pressEnter,
+ pressEscape,
+ pressShiftTab,
+ pressTab,
+ redoByKeyboard,
+ SHORT_KEY,
+ type,
+ undoByKeyboard,
+} from './utils/actions/keyboard.js';
+import {
+ captureHistory,
+ enterPlaygroundRoom,
+ focusRichText,
+ initEmptyEdgelessState,
+ initEmptyParagraphState,
+ resetHistory,
+ waitNextFrame,
+} from './utils/actions/misc.js';
+import {
+ assertBlockChildrenIds,
+ assertBlockCount,
+ assertBlockFlavour,
+ assertBlockSelections,
+ assertKeyboardWorkInInput,
+ assertParentBlockFlavour,
+ assertRichImage,
+ assertRichTextInlineRange,
+ assertStoreMatchJSX,
+} from './utils/asserts.js';
+import { test } from './utils/playwright.js';
+
+const FILE_NAME = 'test-card-1.png';
+const FILE_PATH = `../playground/public/${FILE_NAME}`;
+const FILE_ID = 'ejImogf-Tb7AuKY-v94uz1zuOJbClqK-tWBxVr_ksGA=';
+const FILE_SIZE = 45801;
+
+function getAttachment(page: Page) {
+ const attachment = page.locator('affine-attachment');
+ const loading = attachment.locator('.affine-attachment-card.loading');
+ const toolbar = page.locator('.affine-attachment-toolbar');
+ const switchViewButton = toolbar.getByRole('button', { name: 'Switch view' });
+ const renameBtn = toolbar.getByRole('button', { name: 'Rename' });
+ const renameInput = page.locator('.affine-attachment-rename-container input');
+
+ const insertAttachment = async () => {
+ await page.evaluate(() => {
+ // Force fallback to input[type=file] in tests
+ // See https://github.com/microsoft/playwright/issues/8850
+ window.showOpenFilePicker = undefined;
+ });
+
+ const slashMenu = page.locator(`.slash-menu`);
+ await waitNextFrame(page);
+ await type(page, '/');
+ await resetHistory(page);
+ await expect(slashMenu).toBeVisible();
+ await type(page, 'file', 100);
+ await expect(slashMenu).toBeVisible();
+
+ const fileChooser = page.waitForEvent('filechooser');
+ await pressEnter(page);
+ await sleep(100);
+ await (await fileChooser).setFiles(FILE_PATH);
+
+ // Try to break the undo redo test
+ await captureHistory(page);
+
+ await expect(attachment).toBeVisible();
+ };
+
+ const getName = () =>
+ attachment.locator('.affine-attachment-content-title-text').innerText();
+
+ return {
+ // locators
+ attachment,
+ toolbar,
+ switchViewButton,
+ renameBtn,
+ renameInput,
+
+ // actions
+ insertAttachment,
+ /**
+ * Wait for the attachment upload to finish
+ */
+ waitLoading: () => loading.waitFor({ state: 'hidden' }),
+ getName,
+ getSize: () =>
+ attachment.locator('.affine-attachment-content-info').innerText(),
+
+ turnToEmbed: async () => {
+ await expect(switchViewButton).toBeVisible();
+ await switchViewButton.click();
+ await page.getByRole('button', { name: 'Embed view' }).click();
+ await assertRichImage(page, 1);
+ },
+ rename: async (newName: string) => {
+ await attachment.hover();
+ await expect(toolbar).toBeVisible();
+ await renameBtn.click();
+ await page.keyboard.press(`${SHORT_KEY}+a`, { delay: 50 });
+ await pressBackspace(page);
+ await type(page, newName);
+ await pressEnter(page);
+ expect(await getName()).toContain(newName);
+ },
+
+ // external
+ turnImageToCard: async () => {
+ const { turnIntoCardButton } = await popImageMoreMenu(page);
+ await turnIntoCardButton.click();
+ await expect(attachment).toBeVisible();
+ },
+ };
+}
+
+test('can insert attachment from slash menu', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { noteId } = await initEmptyParagraphState(page);
+
+ const { insertAttachment, waitLoading, getName, getSize } =
+ getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ expect(await getName()).toBe(FILE_NAME);
+ expect(await getSize()).toBe('45.8 kB');
+
+ await assertStoreMatchJSX(
+ page,
+ `
+
+
+`,
+ noteId
+ );
+});
+
+test('should undo/redo works for attachment', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { noteId } = await initEmptyParagraphState(page);
+
+ const { insertAttachment, waitLoading } = getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await assertStoreMatchJSX(
+ page,
+ `
+
+`,
+ noteId
+ );
+
+ await undoByKeyboard(page);
+ await waitNextFrame(page);
+ // The loading/error state should not be restored after undo
+ await assertStoreMatchJSX(
+ page,
+ `
+
+
+`,
+ noteId
+ );
+
+ await redoByKeyboard(page);
+ await waitNextFrame(page);
+ await assertStoreMatchJSX(
+ page,
+ `
+
+
+`,
+ noteId
+ );
+});
+
+test('should rename attachment works', async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/4534',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ const {
+ attachment,
+ renameBtn,
+ renameInput,
+ insertAttachment,
+ waitLoading,
+ getName,
+ rename,
+ } = getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ expect(await getName()).toBe(FILE_NAME);
+
+ await attachment.hover();
+ await expect(renameBtn).toBeVisible();
+ await renameBtn.click();
+ await assertKeyboardWorkInInput(page, renameInput);
+ await pressEscape(page);
+ await expect(renameInput).not.toBeVisible();
+
+ await rename('new-name');
+ expect(await getName()).toBe('new-name.png');
+ await rename('');
+ expect(await getName()).toBe('.png');
+ await rename('abc');
+ expect(await getName()).toBe('abc');
+});
+
+test('should turn attachment to image works', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { noteId } = await initEmptyParagraphState(page);
+ const { insertAttachment, waitLoading, turnToEmbed, turnImageToCard } =
+ getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await turnToEmbed();
+
+ await assertStoreMatchJSX(
+ page,
+ `
+
+
+`,
+ noteId
+ );
+ await turnImageToCard();
+ await assertStoreMatchJSX(
+ page,
+ `
+
+
+`,
+ noteId
+ );
+});
+
+test('should attachment can be deleted', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { noteId } = await initEmptyParagraphState(page);
+ const { attachment, insertAttachment, waitLoading } = getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await attachment.click();
+ await pressBackspace(page);
+ await assertStoreMatchJSX(
+ page,
+ `
+
+
+`,
+ noteId
+ );
+});
+
+test.fixme(`support dragging attachment block directly`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { noteId } = await initEmptyParagraphState(page);
+
+ const { insertAttachment, waitLoading, getName, getSize } =
+ getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ expect(await getName()).toBe(FILE_NAME);
+ expect(await getSize()).toBe('45.8 kB');
+
+ await assertStoreMatchJSX(
+ page,
+ `
+
+`,
+ noteId
+ );
+
+ const attachmentBlock = page.locator('affine-attachment');
+ const rect = await attachmentBlock.boundingBox();
+ if (!rect) {
+ throw new Error('image not found');
+ }
+
+ // add new paragraph blocks
+ await page.mouse.click(rect.x + 20, rect.y + rect.height + 20);
+ await focusRichText(page);
+ await type(page, '111');
+ await page.waitForTimeout(200);
+ await pressEnter(page);
+
+ await type(page, '222');
+ await page.waitForTimeout(200);
+ await pressEnter(page);
+
+ await type(page, '333');
+ await page.waitForTimeout(200);
+
+ await page.waitForTimeout(200);
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+
+
+
+
+
+
+`
+ );
+
+ // drag bookmark block
+ await page.mouse.move(rect.x + 20, rect.y + 20);
+ await page.mouse.down();
+ await page.mouse.move(rect.x + 40, rect.y + rect.height + 80, { steps: 20 });
+ await page.mouse.up();
+
+ const rects = page.locator('affine-block-selection').locator('visible=true');
+ await expect(rects).toHaveCount(1);
+
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+
+
+
+
+
+
+`
+ );
+});
+
+test('press backspace after bookmark block can select bookmark block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ const { insertAttachment, waitLoading } = getAttachment(page);
+
+ await focusRichText(page);
+ await pressEnter(page);
+ await pressArrowUp(page);
+ await insertAttachment();
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await focusRichText(page);
+ await assertBlockCount(page, 'paragraph', 1);
+ await assertRichTextInlineRange(page, 0, 0);
+ await pressBackspace(page);
+ await assertBlockSelections(page, ['4']);
+ await assertBlockCount(page, 'paragraph', 0);
+});
+
+test('cancel file picker with input element resolves', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ const { attachment } = getAttachment(page);
+
+ await focusRichText(page);
+ await pressEnter(page);
+ await pressArrowUp(page);
+
+ await page.evaluate(() => {
+ // Force fallback to input[type=file]
+ window.showOpenFilePicker = undefined;
+ });
+
+ const slashMenu = page.locator(`.slash-menu`);
+ await waitNextFrame(page);
+ await type(page, '/file', 100);
+ await expect(slashMenu).toBeVisible();
+
+ const fileChooser = page.waitForEvent('filechooser');
+ await pressEnter(page);
+ const inputFile = page.locator("input[type='file']");
+ await expect(inputFile).toHaveCount(1);
+
+ // This does not trigger `cancel` event and,
+ // therefore, the test isn't representative.
+ // Waiting for https://github.com/microsoft/playwright/issues/27524
+ await (await fileChooser).setFiles([]);
+
+ await expect(attachment).toHaveCount(0);
+ await expect(inputFile).toHaveCount(0);
+});
+
+test('indent attachment block to paragraph', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ const { insertAttachment, waitLoading } = getAttachment(page);
+
+ await focusRichText(page);
+ await pressEnter(page);
+ await insertAttachment();
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await assertBlockChildrenIds(page, '1', ['2', '4']);
+ await assertBlockFlavour(page, '1', 'affine:note');
+ await assertBlockFlavour(page, '2', 'affine:paragraph');
+ await assertBlockFlavour(page, '4', 'affine:attachment');
+
+ await focusRichText(page);
+ await pressArrowDown(page);
+ await assertBlockSelections(page, ['4']);
+ await pressTab(page);
+ await assertBlockChildrenIds(page, '1', ['2']);
+ await assertBlockChildrenIds(page, '2', ['4']);
+
+ await pressShiftTab(page);
+ await assertBlockChildrenIds(page, '1', ['2', '4']);
+});
+
+test('indent attachment block to list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ const { insertAttachment, waitLoading } = getAttachment(page);
+
+ await focusRichText(page);
+ await type(page, '- a');
+ await pressEnter(page);
+ await insertAttachment();
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await assertBlockChildrenIds(page, '1', ['3', '5']);
+ await assertBlockFlavour(page, '1', 'affine:note');
+ await assertBlockFlavour(page, '3', 'affine:list');
+ await assertBlockFlavour(page, '5', 'affine:attachment');
+
+ await focusRichText(page);
+ await pressArrowDown(page);
+ await assertBlockSelections(page, ['5']);
+ await pressTab(page);
+ await assertBlockChildrenIds(page, '1', ['3']);
+ await assertBlockChildrenIds(page, '3', ['5']);
+
+ await pressShiftTab(page);
+ await assertBlockChildrenIds(page, '1', ['3', '5']);
+});
+
+test('attachment can be dragged from note to surface top level block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ const { insertAttachment, waitLoading } = getAttachment(page);
+
+ await focusRichText(page);
+ await insertAttachment();
+
+ // Wait for the attachment to be uploaded
+ await waitLoading();
+
+ await switchEditorMode(page);
+ await page.mouse.dblclick(450, 450);
+
+ await dragBlockToPoint(page, '4', { x: 200, y: 200 });
+
+ await waitNextFrame(page);
+ await assertParentBlockFlavour(page, '4', 'affine:surface');
+});
diff --git a/blocksuite/tests-legacy/basic.spec.ts b/blocksuite/tests-legacy/basic.spec.ts
new file mode 100644
index 0000000000000..eb089a68cf17f
--- /dev/null
+++ b/blocksuite/tests-legacy/basic.spec.ts
@@ -0,0 +1,590 @@
+import type { DeltaInsert } from '@inline/types.js';
+import { expect } from '@playwright/test';
+
+import {
+ addNoteByClick,
+ captureHistory,
+ click,
+ disconnectByClick,
+ enterPlaygroundRoom,
+ focusRichText,
+ focusTitle,
+ getCurrentEditorTheme,
+ getCurrentHTMLTheme,
+ getPageSnapshot,
+ initEmptyEdgelessState,
+ initEmptyParagraphState,
+ pressArrowLeft,
+ pressArrowRight,
+ pressBackspace,
+ pressEnter,
+ pressForwardDelete,
+ pressForwardDeleteWord,
+ pressShiftEnter,
+ redoByClick,
+ redoByKeyboard,
+ setSelection,
+ switchEditorMode,
+ toggleDarkMode,
+ type,
+ undoByClick,
+ undoByKeyboard,
+ waitDefaultPageLoaded,
+ waitNextFrame,
+} from './utils/actions/index.js';
+import {
+ assertBlockChildrenIds,
+ assertEmpty,
+ assertRichTextInlineDeltas,
+ assertRichTexts,
+ assertText,
+ assertTitle,
+} from './utils/asserts.js';
+import { scoped, test } from './utils/playwright.js';
+import { getFormatBar } from './utils/query.js';
+
+const BASIC_DEFAULT_SNAPSHOT = 'basic test default';
+
+test(scoped`basic input`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'hello');
+
+ await test.expect(page).toHaveTitle(/BlockSuite/);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${BASIC_DEFAULT_SNAPSHOT}.json`
+ );
+ await assertText(page, 'hello');
+});
+
+test(scoped`basic init with external text`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+
+ await page.evaluate(() => {
+ const { doc } = window;
+ const rootId = doc.addBlock('affine:page', {
+ title: new doc.Text('hello'),
+ });
+ const note = doc.addBlock('affine:note', {}, rootId);
+
+ const text = new doc.Text('world');
+ doc.addBlock('affine:paragraph', { text }, note);
+
+ const delta = [
+ { insert: 'foo ' },
+ { insert: 'bar', attributes: { bold: true } },
+ ];
+ doc.addBlock(
+ 'affine:paragraph',
+ {
+ text: new doc.Text(delta as DeltaInsert[]),
+ },
+ note
+ );
+ });
+
+ await assertTitle(page, 'hello');
+ await assertRichTexts(page, ['world', 'foo bar']);
+ await focusRichText(page);
+});
+
+test(scoped`basic multi user state`, async ({ context, page: pageA }) => {
+ const room = await enterPlaygroundRoom(pageA);
+ await initEmptyParagraphState(pageA);
+ await waitNextFrame(pageA);
+ await waitDefaultPageLoaded(pageA);
+ await focusTitle(pageA);
+ await type(pageA, 'hello');
+
+ const pageB = await context.newPage();
+ await enterPlaygroundRoom(pageB, {
+ flags: {},
+ room,
+ noInit: true,
+ });
+ await waitDefaultPageLoaded(pageB);
+ await focusTitle(pageB);
+ await assertTitle(pageB, 'hello');
+
+ await type(pageB, ' world');
+ await assertTitle(pageA, 'hello world');
+});
+
+test(
+ scoped`A open and edit, then joins B`,
+ async ({ context, page: pageA }) => {
+ const room = await enterPlaygroundRoom(pageA);
+ await initEmptyParagraphState(pageA);
+ await waitNextFrame(pageA);
+ await focusRichText(pageA);
+ await type(pageA, 'hello');
+
+ const pageB = await context.newPage();
+ await enterPlaygroundRoom(pageB, {
+ flags: {},
+ room,
+ noInit: true,
+ });
+
+ // wait until pageB content updated
+ await assertText(pageB, 'hello');
+ await Promise.all([
+ assertText(pageA, 'hello'),
+ expect(await getPageSnapshot(pageA, true)).toMatchSnapshot(
+ `${BASIC_DEFAULT_SNAPSHOT}.json`
+ ),
+ expect(await getPageSnapshot(pageB, true)).toMatchSnapshot(
+ `${BASIC_DEFAULT_SNAPSHOT}.json`
+ ),
+ assertBlockChildrenIds(pageA, '0', ['1']),
+ assertBlockChildrenIds(pageB, '0', ['1']),
+ ]);
+ }
+);
+
+test(scoped`A first open, B first edit`, async ({ context, page: pageA }) => {
+ const room = await enterPlaygroundRoom(pageA);
+ await initEmptyParagraphState(pageA);
+ await waitNextFrame(pageA);
+ await focusRichText(pageA);
+
+ const pageB = await context.newPage();
+ await enterPlaygroundRoom(pageB, {
+ room,
+ noInit: true,
+ });
+ await pageB.waitForTimeout(500);
+ await focusRichText(pageB);
+
+ await waitNextFrame(pageA);
+ await waitNextFrame(pageB);
+ await type(pageB, 'hello');
+ await pageA.waitForTimeout(500);
+
+ // wait until pageA content updated
+ await assertText(pageA, 'hello');
+ await assertText(pageB, 'hello');
+ await Promise.all([
+ expect(await getPageSnapshot(pageA, true)).toMatchSnapshot(
+ `${BASIC_DEFAULT_SNAPSHOT}.json`
+ ),
+ expect(await getPageSnapshot(pageB, true)).toMatchSnapshot(
+ `${BASIC_DEFAULT_SNAPSHOT}.json`
+ ),
+ ]);
+});
+
+test(
+ scoped`does not sync when disconnected`,
+ async ({ browser, page: pageA }) => {
+ test.fail();
+
+ const room = await enterPlaygroundRoom(pageA);
+ const pageB = await browser.newPage();
+ await enterPlaygroundRoom(pageB, { flags: {}, room });
+
+ await disconnectByClick(pageA);
+ await disconnectByClick(pageB);
+
+ // click together, both init with default id should lead to conflicts
+ await initEmptyParagraphState(pageA);
+ await initEmptyParagraphState(pageB);
+
+ await waitNextFrame(pageA);
+ await focusRichText(pageA);
+ await waitNextFrame(pageB);
+ await focusRichText(pageB);
+ await waitNextFrame(pageA);
+
+ await type(pageA, '');
+ await waitNextFrame(pageB);
+ await type(pageB, '');
+ await waitNextFrame(pageA);
+ await type(pageA, 'hello');
+ await waitNextFrame(pageB);
+
+ await assertText(pageB, 'hello');
+ await assertText(pageA, 'hello'); // actually '\n'
+ }
+);
+
+test(scoped`basic paired undo/redo`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'hello');
+
+ await assertText(page, 'hello');
+ await undoByClick(page);
+ await assertEmpty(page);
+ await redoByClick(page);
+ await assertText(page, 'hello');
+
+ await undoByClick(page);
+ await assertEmpty(page);
+ await redoByClick(page);
+ await assertText(page, 'hello');
+});
+
+test(scoped`undo/redo with keyboard`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'hello');
+
+ await assertText(page, 'hello');
+ await undoByKeyboard(page);
+ await assertEmpty(page);
+ await redoByClick(page);
+ await assertText(page, 'hello');
+});
+
+test(scoped`undo after adding block twice`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, 'hello');
+ await pressEnter(page);
+ await type(page, 'world');
+
+ await undoByKeyboard(page);
+ await assertRichTexts(page, ['hello']);
+ await redoByKeyboard(page);
+ await assertRichTexts(page, ['hello', 'world']);
+});
+
+test(scoped`undo/redo twice after adding block twice`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'hello');
+ await pressEnter(page);
+ await type(page, 'world');
+ await assertRichTexts(page, ['hello', 'world']);
+
+ await undoByKeyboard(page);
+ await assertRichTexts(page, ['hello']);
+
+ await undoByKeyboard(page);
+ await assertRichTexts(page, ['']);
+
+ await redoByClick(page);
+ await assertRichTexts(page, ['hello']);
+
+ await redoByKeyboard(page);
+ await assertRichTexts(page, ['hello', 'world']);
+});
+
+test(scoped`should undo/redo works on title`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await waitNextFrame(page);
+ await focusTitle(page);
+ await type(page, 'title');
+ await focusRichText(page);
+ await type(page, 'hello world');
+
+ await assertTitle(page, 'title');
+ await assertRichTexts(page, ['hello world']);
+
+ await captureHistory(page);
+ await pressBackspace(page, 5);
+ await captureHistory(page);
+ await focusTitle(page);
+ await type(page, ' something');
+
+ await assertTitle(page, 'title something');
+ await assertRichTexts(page, ['hello ']);
+
+ await focusRichText(page);
+ await undoByKeyboard(page);
+ await assertTitle(page, 'title');
+ await assertRichTexts(page, ['hello ']);
+ await undoByKeyboard(page);
+ await assertTitle(page, 'title');
+ await assertRichTexts(page, ['hello world']);
+
+ await redoByKeyboard(page);
+ await assertTitle(page, 'title');
+ await assertRichTexts(page, ['hello ']);
+ await redoByKeyboard(page);
+ await assertTitle(page, 'title something');
+ await assertRichTexts(page, ['hello ']);
+});
+
+test(scoped`undo multi notes`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await addNoteByClick(page);
+ await assertRichTexts(page, ['', '']);
+
+ await undoByClick(page);
+ await assertRichTexts(page, ['']);
+
+ await redoByClick(page);
+ await assertRichTexts(page, ['', '']);
+});
+
+test(scoped`change theme`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ const currentTheme = await getCurrentHTMLTheme(page);
+ await toggleDarkMode(page);
+ const expectNextTheme = currentTheme === 'light' ? 'dark' : 'light';
+ const nextHTMLTheme = await getCurrentHTMLTheme(page);
+ expect(nextHTMLTheme).toBe(expectNextTheme);
+
+ const nextEditorTheme = await getCurrentEditorTheme(page);
+ expect(nextEditorTheme).toBe(expectNextTheme);
+});
+
+test(
+ scoped`should be able to delete an emoji completely by pressing backspace once`,
+ async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2138',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '๐ท๐
โโ๏ธ๐ณ๏ธโ๐');
+ await pressBackspace(page);
+ await pressBackspace(page);
+ await pressBackspace(page);
+ await assertText(page, '');
+ }
+);
+
+test(scoped`delete emoji in the middle of the text`, async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2138',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '1๐ท1๐
โโ๏ธ1๐ณ๏ธโ๐1๐จโ๐ฉโ๐งโ๐ฆ1');
+ await pressArrowLeft(page, 1);
+ await pressBackspace(page);
+ await pressArrowLeft(page, 1);
+ await pressBackspace(page);
+ await pressArrowLeft(page, 1);
+ await pressBackspace(page);
+ await pressArrowLeft(page, 1);
+ await pressBackspace(page);
+ await assertText(page, '11111');
+});
+
+test(scoped`delete emoji forward`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '1๐ท1๐
โโ๏ธ1๐ณ๏ธโ๐1๐จโ๐ฉโ๐งโ๐ฆ1');
+ await pressArrowLeft(page, 8);
+ await pressForwardDelete(page);
+ await pressArrowRight(page, 1);
+ await pressForwardDelete(page);
+ await pressArrowRight(page, 1);
+ await pressForwardDelete(page);
+ await pressArrowRight(page, 1);
+ await pressForwardDelete(page);
+ await assertText(page, '11111');
+});
+
+test(
+ scoped`ZERO_WIDTH_SPACE should be counted by one cursor position`,
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await pressShiftEnter(page);
+ await type(page, 'asdfg');
+ await pressEnter(page);
+ await undoByKeyboard(page);
+ await page.waitForTimeout(300);
+ await pressBackspace(page);
+ await assertRichTexts(page, ['\nasdf']);
+ }
+);
+
+test('when no note block, click editing area auto add a new note block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+
+ await switchEditorMode(page);
+ await page.locator('affine-edgeless-note').click({ force: true });
+ await pressBackspace(page);
+ await switchEditorMode(page);
+ const edgelessNote = await page.evaluate(() => {
+ return document.querySelector('affine-edgeless-note');
+ });
+ expect(edgelessNote).toBeNull();
+ await click(page, { x: 200, y: 280 });
+
+ const pageNote = await page.evaluate(() => {
+ return document.querySelector('affine-note');
+ });
+ expect(pageNote).not.toBeNull();
+});
+
+test(scoped`automatic identify url text`, async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'abc https://google.com ');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+});
+
+test('ctrl+delete to delete one word forward', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'aaa bbb ccc');
+ await pressArrowLeft(page, 8);
+ await pressForwardDeleteWord(page);
+ await assertText(page, 'aaa ccc');
+});
+
+test('extended inline format', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'aaabbbaaa');
+
+ const { boldBtn, italicBtn, underlineBtn, strikeBtn, codeBtn } =
+ getFormatBar(page);
+ await setSelection(page, 0, 3, 0, 6);
+ await boldBtn.click();
+ await italicBtn.click();
+ await underlineBtn.click();
+ await strikeBtn.click();
+ await codeBtn.click();
+ await assertRichTextInlineDeltas(page, [
+ {
+ insert: 'aaa',
+ },
+ {
+ insert: 'bbb',
+ attributes: {
+ bold: true,
+ italic: true,
+ underline: true,
+ strike: true,
+ code: true,
+ },
+ },
+ {
+ insert: 'aaa',
+ },
+ ]);
+
+ // aaa|bbbccc
+ await setSelection(page, 2, 3, 2, 3);
+ await captureHistory(page);
+ await type(page, 'c');
+ await assertRichTextInlineDeltas(page, [
+ {
+ insert: 'aaac',
+ },
+ {
+ insert: 'bbb',
+ attributes: {
+ bold: true,
+ italic: true,
+ underline: true,
+ strike: true,
+ code: true,
+ },
+ },
+ {
+ insert: 'aaa',
+ },
+ ]);
+ await undoByKeyboard(page);
+
+ // aaab|bbccc
+ await setSelection(page, 2, 4, 2, 4);
+ await type(page, 'c');
+ await assertRichTextInlineDeltas(page, [
+ {
+ insert: 'aaa',
+ },
+ {
+ insert: 'bcbb',
+ attributes: {
+ bold: true,
+ italic: true,
+ underline: true,
+ strike: true,
+ code: true,
+ },
+ },
+ {
+ insert: 'aaa',
+ },
+ ]);
+ await undoByKeyboard(page);
+
+ // aaab|b|bccc
+ await setSelection(page, 2, 4, 2, 5);
+ await type(page, 'c');
+ await assertRichTextInlineDeltas(page, [
+ {
+ insert: 'aaa',
+ },
+ {
+ insert: 'bcb',
+ attributes: {
+ bold: true,
+ italic: true,
+ underline: true,
+ strike: true,
+ code: true,
+ },
+ },
+ {
+ insert: 'aaa',
+ },
+ ]);
+ await undoByKeyboard(page);
+
+ // aaabbb|ccc
+ await setSelection(page, 2, 6, 2, 6);
+ await type(page, 'c');
+ await assertRichTextInlineDeltas(page, [
+ {
+ insert: 'aaa',
+ },
+ {
+ insert: 'bbb',
+ attributes: {
+ bold: true,
+ italic: true,
+ underline: true,
+ strike: true,
+ code: true,
+ },
+ },
+ {
+ insert: 'c',
+ attributes: {
+ bold: true,
+ italic: true,
+ underline: true,
+ strike: true,
+ },
+ },
+ {
+ insert: 'aaa',
+ },
+ ]);
+});
diff --git a/blocksuite/tests-legacy/bookmark.spec.ts b/blocksuite/tests-legacy/bookmark.spec.ts
new file mode 100644
index 0000000000000..f7f7909872557
--- /dev/null
+++ b/blocksuite/tests-legacy/bookmark.spec.ts
@@ -0,0 +1,461 @@
+import './utils/declare-test-window.js';
+
+import type { Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { BlockSnapshot } from '@store/index.js';
+import { ignoreSnapshotId } from 'utils/ignore.js';
+import { getEmbedCardToolbar } from 'utils/query.js';
+
+import {
+ activeNoteInEdgeless,
+ copyByKeyboard,
+ dragBlockToPoint,
+ enterPlaygroundRoom,
+ expectConsoleMessage,
+ focusRichText,
+ getPageSnapshot,
+ initEmptyEdgelessState,
+ initEmptyParagraphState,
+ pasteByKeyboard,
+ pressArrowDown,
+ pressArrowRight,
+ pressArrowUp,
+ pressBackspace,
+ pressEnter,
+ pressShiftTab,
+ pressTab,
+ selectAllByKeyboard,
+ setInlineRangeInSelectedRichText,
+ SHORT_KEY,
+ switchEditorMode,
+ type,
+ waitForInlineEditorStateUpdated,
+ waitNextFrame,
+} from './utils/actions/index.js';
+import {
+ assertAlmostEqual,
+ assertBlockChildrenIds,
+ assertBlockCount,
+ assertBlockFlavour,
+ assertBlockSelections,
+ assertExists,
+ assertParentBlockFlavour,
+ assertRichTextInlineRange,
+} from './utils/asserts.js';
+import { scoped, test } from './utils/playwright.js';
+
+const LOCAL_HOST_URL = 'http://localhost';
+
+const YOUTUBE_URL = 'https://www.youtube.com/watch?v=fakeid';
+
+const FIGMA_URL = 'https://www.figma.com/design/JuXs6uOAICwf4I4tps0xKZ123';
+
+test.beforeEach(async ({ page }) => {
+ await page.route(
+ 'https://affine-worker.toeverything.workers.dev/api/worker/link-preview',
+ async route => {
+ await route.fulfill({
+ json: {},
+ });
+ }
+ );
+});
+
+const createBookmarkBlockBySlashMenu = async (
+ page: Page,
+ url = LOCAL_HOST_URL
+) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await page.waitForTimeout(100);
+ await type(page, '/link', 100);
+ await pressEnter(page);
+ await page.waitForTimeout(100);
+ await type(page, url);
+ await pressEnter(page);
+};
+
+test(scoped`create bookmark by slash menu`, async ({ page }, testInfo) => {
+ await createBookmarkBlockBySlashMenu(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+});
+
+test(scoped`covert bookmark block to link text`, async ({ page }, testInfo) => {
+ await createBookmarkBlockBySlashMenu(page);
+ const bookmark = page.locator('affine-bookmark');
+ await bookmark.click();
+ await page.waitForTimeout(100);
+ await page.getByRole('button', { name: 'Switch view' }).click();
+ await page.getByRole('button', { name: 'Inline view' }).click();
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+});
+
+test(
+ scoped`copy url to create bookmark in page mode`,
+ async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, LOCAL_HOST_URL);
+ await setInlineRangeInSelectedRichText(page, 0, LOCAL_HOST_URL.length);
+ await copyByKeyboard(page);
+ await focusRichText(page);
+ await type(page, '/link');
+ await pressEnter(page);
+ await page.keyboard.press(`${SHORT_KEY}+v`);
+ await pressEnter(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+ }
+);
+
+test(
+ scoped`copy url to create bookmark in edgeless mode`,
+ async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ const ids = await initEmptyEdgelessState(page);
+ await focusRichText(page);
+ await type(page, LOCAL_HOST_URL);
+
+ await switchEditorMode(page);
+
+ await activeNoteInEdgeless(page, ids.noteId);
+ await waitForInlineEditorStateUpdated(page);
+ await selectAllByKeyboard(page);
+ await copyByKeyboard(page);
+ await pressArrowRight(page);
+ await waitNextFrame(page);
+ await type(page, '/link', 100);
+ await pressEnter(page);
+ await page.waitForTimeout(100);
+ await waitNextFrame(page);
+ await page.keyboard.press(`${SHORT_KEY}+v`);
+ await pressEnter(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+ }
+);
+
+test.fixme(
+ scoped`support dragging bookmark block directly`,
+ async ({ page }, testInfo) => {
+ await createBookmarkBlockBySlashMenu(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_init.json`
+ );
+
+ const bookmark = page.locator('affine-bookmark');
+ const rect = await bookmark.boundingBox();
+ if (!rect) {
+ throw new Error('image not found');
+ }
+
+ // add new paragraph blocks
+ await page.mouse.click(rect.x + 20, rect.y + rect.height + 20);
+ await focusRichText(page);
+ await type(page, '111');
+ await page.waitForTimeout(200);
+ await pressEnter(page);
+
+ await type(page, '222');
+ await page.waitForTimeout(200);
+ await pressEnter(page);
+
+ await type(page, '333');
+ await page.waitForTimeout(200);
+
+ await page.waitForTimeout(200);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_after_add_paragraph.json`
+ );
+
+ // drag bookmark block
+ await page.mouse.move(rect.x + 20, rect.y + 20);
+ await page.mouse.down();
+ await page.waitForTimeout(200);
+
+ await page.mouse.move(rect.x + 40, rect.y + rect.height + 80, {
+ steps: 5,
+ });
+ await page.waitForTimeout(200);
+
+ await page.mouse.up();
+ await page.waitForTimeout(200);
+
+ const rects = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(rects).toHaveCount(1);
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_after_drag.json`
+ );
+ }
+);
+
+test('press backspace after bookmark block can select bookmark block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await pressEnter(page);
+ await pressArrowUp(page);
+ await type(page, '/link');
+ await pressEnter(page);
+ await page.waitForTimeout(100);
+ await type(page, LOCAL_HOST_URL);
+ await pressEnter(page);
+
+ await focusRichText(page);
+ await assertBlockCount(page, 'paragraph', 1);
+ await assertRichTextInlineRange(page, 0, 0);
+ await pressBackspace(page);
+ await assertBlockSelections(page, ['4']);
+ await assertBlockCount(page, 'paragraph', 0);
+});
+
+test.describe('embed card toolbar', () => {
+ async function showEmbedCardToolbar(page: Page) {
+ await createBookmarkBlockBySlashMenu(page);
+ const bookmark = page.locator('affine-bookmark');
+ await bookmark.click();
+ await page.waitForTimeout(100);
+ const { embedCardToolbar } = getEmbedCardToolbar(page);
+ await expect(embedCardToolbar).toBeVisible();
+ }
+
+ test('show toolbar when bookmark selected', async ({ page }) => {
+ await showEmbedCardToolbar(page);
+ });
+
+ test('copy bookmark url by copy button', async ({ page }, testInfo) => {
+ await showEmbedCardToolbar(page);
+ const { copyButton } = getEmbedCardToolbar(page);
+ await copyButton.click();
+ await page.mouse.click(600, 600);
+ await waitNextFrame(page);
+
+ await pasteByKeyboard(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+ });
+
+ test('change card style', async ({ page }) => {
+ await showEmbedCardToolbar(page);
+ const bookmark = page.locator('affine-bookmark');
+ const { openCardStyleMenu } = getEmbedCardToolbar(page);
+ await openCardStyleMenu();
+ const { cardStyleHorizontalButton, cardStyleListButton } =
+ getEmbedCardToolbar(page);
+ await cardStyleListButton.click();
+ await waitNextFrame(page);
+ const listStyleBookmarkBox = await bookmark.boundingBox();
+ assertExists(listStyleBookmarkBox);
+ assertAlmostEqual(listStyleBookmarkBox.width, 752, 2);
+ assertAlmostEqual(listStyleBookmarkBox.height, 46, 2);
+
+ await openCardStyleMenu();
+ await cardStyleHorizontalButton.click();
+ await waitNextFrame(page);
+ const horizontalStyleBookmarkBox = await bookmark.boundingBox();
+ assertExists(horizontalStyleBookmarkBox);
+ assertAlmostEqual(horizontalStyleBookmarkBox.width, 752, 2);
+ assertAlmostEqual(horizontalStyleBookmarkBox.height, 116, 2);
+ });
+});
+
+test('indent bookmark block to paragraph', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await pressEnter(page);
+ await type(page, '/link', 100);
+ await pressEnter(page);
+ await type(page, LOCAL_HOST_URL);
+ await pressEnter(page);
+
+ await assertBlockChildrenIds(page, '1', ['2', '4']);
+ await assertBlockFlavour(page, '1', 'affine:note');
+ await assertBlockFlavour(page, '2', 'affine:paragraph');
+ await assertBlockFlavour(page, '4', 'affine:bookmark');
+
+ await focusRichText(page);
+ await pressArrowDown(page);
+ await assertBlockSelections(page, ['4']);
+ await pressTab(page);
+ await assertBlockChildrenIds(page, '1', ['2']);
+ await assertBlockChildrenIds(page, '2', ['4']);
+
+ await pressShiftTab(page);
+ await assertBlockChildrenIds(page, '1', ['2', '4']);
+});
+
+test('indent bookmark block to list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '- a');
+ await pressEnter(page);
+ await type(page, '/link', 100);
+ await pressEnter(page);
+ await type(page, LOCAL_HOST_URL);
+ await pressEnter(page);
+
+ await assertBlockChildrenIds(page, '1', ['3', '5']);
+ await assertBlockFlavour(page, '1', 'affine:note');
+ await assertBlockFlavour(page, '3', 'affine:list');
+ await assertBlockFlavour(page, '5', 'affine:bookmark');
+
+ await focusRichText(page);
+ await pressArrowDown(page);
+ await assertBlockSelections(page, ['5']);
+ await pressTab(page);
+ await assertBlockChildrenIds(page, '1', ['3']);
+ await assertBlockChildrenIds(page, '3', ['5']);
+
+ await pressShiftTab(page);
+ await assertBlockChildrenIds(page, '1', ['3', '5']);
+});
+
+test('bookmark can be dragged from note to surface top level block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await focusRichText(page);
+ await page.waitForTimeout(100);
+ await type(page, '/link', 100);
+ await pressEnter(page);
+ await page.waitForTimeout(100);
+ await type(page, LOCAL_HOST_URL);
+ await pressEnter(page);
+
+ await switchEditorMode(page);
+ await page.mouse.dblclick(450, 450);
+
+ await dragBlockToPoint(page, '4', { x: 200, y: 200 });
+
+ await waitNextFrame(page);
+ await assertParentBlockFlavour(page, '4', 'affine:surface');
+});
+
+test.describe('embed youtube card', () => {
+ test(scoped`create youtube card by slash menu`, async ({ page }) => {
+ expectConsoleMessage(page, /Unrecognized feature/, 'warning');
+ expectConsoleMessage(page, /Failed to load resource/);
+ await createBookmarkBlockBySlashMenu(page, YOUTUBE_URL);
+ const snapshot = (await getPageSnapshot(page)) as BlockSnapshot;
+ expect(ignoreSnapshotId(snapshot)).toMatchSnapshot('embed-youtube.json');
+ });
+
+ test(scoped`change youtube card style`, async ({ page }) => {
+ expectConsoleMessage(page, /Unrecognized feature/, 'warning');
+ expectConsoleMessage(page, /Failed to load resource/);
+
+ await createBookmarkBlockBySlashMenu(page, YOUTUBE_URL);
+ const youtube = page.locator('affine-embed-youtube-block');
+ await youtube.click();
+ await page.waitForTimeout(100);
+
+ // change to card view
+ const embedToolbar = page.locator('affine-embed-card-toolbar');
+ await expect(embedToolbar).toBeVisible();
+ const embedView = page.locator('editor-menu-button', {
+ hasText: 'embed view',
+ });
+ await expect(embedView).toBeVisible();
+ await embedView.click();
+ const cardView = page.locator('editor-menu-action', {
+ hasText: 'card view',
+ });
+ await expect(cardView).toBeVisible();
+ await cardView.click();
+ const snapshot = (await getPageSnapshot(page)) as BlockSnapshot;
+ expect(ignoreSnapshotId(snapshot)).toMatchSnapshot(
+ 'horizontal-youtube.json'
+ );
+
+ // change to embed view
+ const bookmark = page.locator('affine-bookmark');
+ await bookmark.click();
+ await page.waitForTimeout(100);
+ const cardView2 = page.locator('editor-icon-button', {
+ hasText: 'card view',
+ });
+ await expect(cardView2).toBeVisible();
+ await cardView2.click();
+ const embedView2 = page.locator('editor-menu-action', {
+ hasText: 'embed view',
+ });
+ await expect(embedView2).toBeVisible();
+ await embedView2.click();
+ const snapshot2 = (await getPageSnapshot(page)) as BlockSnapshot;
+ expect(ignoreSnapshotId(snapshot2)).toMatchSnapshot('embed-youtube.json');
+ });
+});
+
+test.describe('embed figma card', () => {
+ test(scoped`create figma card by slash menu`, async ({ page }) => {
+ expectConsoleMessage(page, /Failed to load resource/);
+ expectConsoleMessage(page, /Refused to frame/);
+ await createBookmarkBlockBySlashMenu(page, FIGMA_URL);
+ const snapshot = (await getPageSnapshot(page)) as BlockSnapshot;
+ expect(ignoreSnapshotId(snapshot)).toMatchSnapshot('embed-figma.json');
+ });
+
+ test(scoped`change figma card style`, async ({ page }) => {
+ expectConsoleMessage(page, /Failed to load resource/);
+ expectConsoleMessage(page, /Refused to frame/);
+ expectConsoleMessage(page, /Running frontend commit/, 'log');
+ await createBookmarkBlockBySlashMenu(page, FIGMA_URL);
+ const youtube = page.locator('affine-embed-figma-block');
+ await youtube.click();
+ await page.waitForTimeout(100);
+
+ // change to card view
+ const embedToolbar = page.locator('affine-embed-card-toolbar');
+ await expect(embedToolbar).toBeVisible();
+ const embedView = page.locator('editor-menu-button', {
+ hasText: 'embed view',
+ });
+ await expect(embedView).toBeVisible();
+ await embedView.click();
+ const cardView = page.locator('editor-menu-action', {
+ hasText: 'card view',
+ });
+ await expect(cardView).toBeVisible();
+ await cardView.click();
+ const snapshot = (await getPageSnapshot(page)) as BlockSnapshot;
+ expect(ignoreSnapshotId(snapshot)).toMatchSnapshot('horizontal-figma.json');
+
+ // change to embed view
+ const bookmark = page.locator('affine-bookmark');
+ await bookmark.click();
+ await page.waitForTimeout(100);
+ const cardView2 = page.locator('editor-icon-button', {
+ hasText: 'card view',
+ });
+ await expect(cardView2).toBeVisible();
+ await cardView2.click();
+ const embedView2 = page.locator('editor-menu-action', {
+ hasText: 'embed view',
+ });
+ await expect(embedView2).toBeVisible();
+ await embedView2.click();
+ const snapshot2 = (await getPageSnapshot(page)) as BlockSnapshot;
+ expect(ignoreSnapshotId(snapshot2)).toMatchSnapshot('embed-figma.json');
+ });
+});
diff --git a/blocksuite/tests-legacy/clipboard/clipboard.spec.ts b/blocksuite/tests-legacy/clipboard/clipboard.spec.ts
new file mode 100644
index 0000000000000..f7519838c8d18
--- /dev/null
+++ b/blocksuite/tests-legacy/clipboard/clipboard.spec.ts
@@ -0,0 +1,409 @@
+import '../utils/declare-test-window.js';
+
+import { expect } from '@playwright/test';
+
+import {
+ captureHistory,
+ copyByKeyboard,
+ dragBetweenCoords,
+ dragOverTitle,
+ enterPlaygroundRoom,
+ focusRichText,
+ focusTitle,
+ getClipboardHTML,
+ getClipboardSnapshot,
+ getClipboardText,
+ getCurrentEditorDocId,
+ getEditorLocator,
+ getPageSnapshot,
+ initEmptyParagraphState,
+ mockParseDocUrlService,
+ pasteByKeyboard,
+ pasteContent,
+ pressEnter,
+ pressShiftTab,
+ pressTab,
+ resetHistory,
+ setInlineRangeInSelectedRichText,
+ setSelection,
+ SHORT_KEY,
+ type,
+ undoByClick,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import {
+ assertBlockTypes,
+ assertClipItems,
+ assertExists,
+ assertRichTexts,
+ assertText,
+ assertTitle,
+} from '../utils/asserts.js';
+import { scoped, test } from '../utils/playwright.js';
+
+test(scoped`clipboard copy paste`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, 'test');
+ await setInlineRangeInSelectedRichText(page, 0, 3);
+ await waitNextFrame(page);
+ await copyByKeyboard(page);
+ await focusRichText(page);
+ await page.keyboard.press(`${SHORT_KEY}+v`);
+ await assertText(page, 'testtes');
+});
+
+test(scoped`clipboard copy paste title`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusTitle(page);
+
+ await type(page, 'test');
+ await dragOverTitle(page);
+ await waitNextFrame(page);
+ await copyByKeyboard(page);
+ await focusTitle(page);
+ await page.keyboard.press(`${SHORT_KEY}+v`);
+ await assertTitle(page, 'testtest');
+});
+
+test(scoped`clipboard paste html`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ // set up clipboard data using html
+ const clipData = {
+ 'text/html': `aaabbbcccddd`,
+ };
+ await waitNextFrame(page);
+ await page.evaluate(
+ ({ clipData }) => {
+ const dT = new DataTransfer();
+ const e = new ClipboardEvent('paste', { clipboardData: dT });
+ Object.defineProperty(e, 'target', {
+ writable: false,
+ value: document,
+ });
+ e.clipboardData?.setData('text/html', clipData['text/html']);
+ document.dispatchEvent(e);
+ },
+ { clipData }
+ );
+ await assertText(page, 'aaabbbcccddd');
+});
+
+test(scoped`split block when paste`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await resetHistory(page);
+
+ const clipData = {
+ 'text/plain': `# text
+# h1
+`,
+ };
+ await type(page, 'abc');
+ await captureHistory(page);
+
+ await setInlineRangeInSelectedRichText(page, 1, 1);
+ await pasteContent(page, clipData);
+ await waitNextFrame(page);
+
+ await assertRichTexts(page, ['atext', 'h1c']);
+
+ await undoByClick(page);
+ await assertRichTexts(page, ['abc']);
+
+ await type(page, 'aa');
+ await pressEnter(page);
+ await type(page, 'bb');
+ const topLeft123 = await getEditorLocator(page)
+ .locator('[data-block-id="2"] .inline-editor')
+ .boundingBox();
+ const bottomRight789 = await getEditorLocator(page)
+ .locator('[data-block-id="4"] .inline-editor')
+ .boundingBox();
+ assertExists(topLeft123);
+ assertExists(bottomRight789);
+ await dragBetweenCoords(page, topLeft123, bottomRight789);
+
+ // FIXME see https://github.com/toeverything/blocksuite/pull/878
+ // await pasteContent(page, clipData);
+ // await assertRichTexts(page, ['aaa', 'bbc', 'text', 'h1']);
+});
+
+test(scoped`copy clipItems format`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await captureHistory(page);
+
+ const clipData = `
+- aa
+ - bb
+ - cc
+ - dd
+`;
+
+ await pasteContent(page, { 'text/plain': clipData });
+ await page.waitForTimeout(100);
+ await setSelection(page, 4, 1, 5, 1);
+ assertClipItems(page, 'text/plain', 'bc');
+ assertClipItems(page, 'text/html', '
');
+ await undoByClick(page);
+ await assertRichTexts(page, ['']);
+});
+
+test(scoped`copy partially selected text`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, '123 456 789');
+
+ // select 456
+ await setInlineRangeInSelectedRichText(page, 4, 3);
+ await copyByKeyboard(page);
+ assertClipItems(page, 'text/plain', '456');
+
+ // move to line end
+ await setInlineRangeInSelectedRichText(page, 11, 0);
+ await pressEnter(page);
+ await pasteByKeyboard(page);
+ await waitNextFrame(page);
+
+ await assertRichTexts(page, ['123 456 789', '456']);
+});
+
+test(scoped`copy & paste outside editor`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await page.evaluate(() => {
+ const input = document.createElement('input');
+ input.setAttribute('id', 'input-test');
+ input.value = '123';
+ document.body.querySelector('#app')?.append(input);
+ });
+ await page.focus('#input-test');
+ await page.dblclick('#input-test');
+ await copyByKeyboard(page);
+ await focusRichText(page);
+ await pasteByKeyboard(page);
+ await waitNextFrame(page);
+ await assertRichTexts(page, ['123']);
+});
+
+test('should keep first line format when pasted into a new line', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ const clipData = `
+- [ ] aaa
+`;
+
+ await pasteContent(page, { 'text/plain': clipData });
+ await waitNextFrame(page);
+ await assertRichTexts(page, ['aaa']);
+ await assertBlockTypes(page, ['todo']);
+});
+
+test(scoped`auto identify url`, async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ // set up clipboard data using html
+ const clipData = {
+ 'text/plain': `test https://www.google.com`,
+ };
+ await waitNextFrame(page);
+ await page.evaluate(
+ ({ clipData }) => {
+ const dT = new DataTransfer();
+ const e = new ClipboardEvent('paste', { clipboardData: dT });
+ Object.defineProperty(e, 'target', {
+ writable: false,
+ value: document,
+ });
+ e.clipboardData?.setData('text/plain', clipData['text/plain']);
+ document.dispatchEvent(e);
+ },
+ { clipData }
+ );
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+});
+
+test(scoped`pasting internal url`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusTitle(page);
+ await type(page, 'test page');
+
+ await focusRichText(page);
+ const docId = await getCurrentEditorDocId(page);
+ await mockParseDocUrlService(page, {
+ 'http://workspace/doc-id': docId,
+ });
+ await pasteContent(page, {
+ 'text/plain': 'http://workspace/doc-id',
+ });
+ await expect(page.locator('affine-reference')).toContainText('test page');
+});
+
+test(scoped`pasting internal url with params`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusTitle(page);
+ await type(page, 'test page');
+
+ await focusRichText(page);
+ const docId = await getCurrentEditorDocId(page);
+ await mockParseDocUrlService(page, {
+ 'http://workspace/doc-id?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_': docId,
+ });
+ await pasteContent(page, {
+ 'text/plain':
+ 'http://workspace/doc-id?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_',
+ });
+ await expect(page.locator('affine-reference')).toContainText('test page');
+});
+
+test(
+ scoped`pasting an external URL from clipboard to automatically creating a link from selection`,
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusTitle(page);
+ await type(page, 'test page');
+
+ await focusRichText(page);
+ await type(page, 'title alias');
+ await setSelection(page, 1, 6, 1, 11);
+
+ await pasteContent(page, {
+ 'text/plain': 'https://affine.pro/',
+ });
+ await expect(page.locator('affine-link')).toContainText('alias');
+ }
+);
+
+test(
+ scoped`pasting an internal URL from clipboard to automatically creating a link from selection`,
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusTitle(page);
+ await type(page, 'test page');
+
+ await focusRichText(page);
+ await type(page, 'title alias');
+ await setSelection(page, 1, 6, 1, 11);
+
+ const docId = await getCurrentEditorDocId(page);
+ await mockParseDocUrlService(page, {
+ 'http://workspace/doc-id': docId,
+ });
+ await pasteContent(page, {
+ 'text/plain': 'http://workspace/doc-id',
+ });
+ await expect(page.locator('affine-reference')).toContainText('alias');
+ }
+);
+
+test(scoped`paste parent block`, async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/3153',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'This is parent');
+ await page.keyboard.press('Enter');
+ await page.keyboard.press('Tab');
+ await type(page, 'This is child 1');
+ await page.keyboard.press('Enter');
+ await page.keyboard.press('Tab');
+ await type(page, 'This is child 2');
+ await setInlineRangeInSelectedRichText(page, 0, 3);
+ await copyByKeyboard(page);
+ await focusRichText(page, 2);
+ await page.keyboard.press(`${SHORT_KEY}+v`);
+ await assertRichTexts(page, [
+ 'This is parent',
+ 'This is child 1',
+ 'This is child 2Thi',
+ ]);
+});
+
+test(scoped`clipboard copy multi selection`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, 'abc');
+ await pressEnter(page);
+ await type(page, 'def');
+ await setSelection(page, 2, 1, 3, 1);
+ await waitNextFrame(page);
+ await copyByKeyboard(page);
+ await waitNextFrame(page);
+ await focusRichText(page, 1);
+ await pasteByKeyboard(page);
+ await waitNextFrame(page);
+ await type(page, 'cursor');
+ await waitNextFrame(page);
+ await assertRichTexts(page, ['abc', 'defbc', 'dcursor']);
+});
+
+test(scoped`clipboard copy nested items`, async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, 'abc');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, 'def');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, 'ghi');
+ await pressEnter(page);
+ await pressShiftTab(page);
+ await pressShiftTab(page);
+ await type(page, 'jkl');
+ await setSelection(page, 2, 1, 3, 1);
+ await waitNextFrame(page);
+ await copyByKeyboard(page);
+
+ const text = await getClipboardText(page);
+ const html = await getClipboardHTML(page);
+ const snapshot = await getClipboardSnapshot(page);
+ expect(text).toMatchSnapshot(`${testInfo.title}-clipboard.md`);
+ expect(JSON.stringify(snapshot.snapshot.content, null, 2)).toMatchSnapshot(
+ `${testInfo.title}-clipboard.json`
+ );
+ expect(html).toMatchSnapshot(`${testInfo.title}-clipboard.html`);
+
+ await setSelection(page, 4, 1, 5, 1);
+ await waitNextFrame(page);
+ await copyByKeyboard(page);
+
+ const text2 = await getClipboardText(page);
+ const html2 = await getClipboardHTML(page);
+ const snapshot2 = await getClipboardSnapshot(page);
+ expect(text2).toMatchSnapshot(`${testInfo.title}-clipboard2.md`);
+ expect(JSON.stringify(snapshot2.snapshot.content, null, 2)).toMatchSnapshot(
+ `${testInfo.title}-clipboard2.json`
+ );
+ expect(html2).toMatchSnapshot(`${testInfo.title}-clipboard2.html`);
+});
diff --git a/blocksuite/tests-legacy/clipboard/image.spec.ts b/blocksuite/tests-legacy/clipboard/image.spec.ts
new file mode 100644
index 0000000000000..05ff21d442ac2
--- /dev/null
+++ b/blocksuite/tests-legacy/clipboard/image.spec.ts
@@ -0,0 +1,58 @@
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ initEmptyParagraphState,
+ pasteContent,
+ pressArrowDown,
+ pressArrowUp,
+ pressEscape,
+ waitEmbedLoaded,
+} from '../utils/actions/index.js';
+import { assertRichImage, assertText } from '../utils/asserts.js';
+import { scoped, test } from '../utils/playwright.js';
+
+test(
+ scoped`clipboard paste end with image, the cursor should be controlled by up/down keys`,
+ async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/3639',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ // set up clipboard data using html
+ const clipData = {
+ 'text/html': `Lorem Ipsum placeholder text.
+
+ `,
+ };
+ await page.evaluate(
+ ({ clipData }) => {
+ const dT = new DataTransfer();
+ const e = new ClipboardEvent('paste', { clipboardData: dT });
+ Object.defineProperty(e, 'target', {
+ writable: false,
+ value: document,
+ });
+ e.clipboardData?.setData('text/html', clipData['text/html']);
+ document.dispatchEvent(e);
+ },
+ { clipData }
+ );
+ const str = 'Lorem Ipsum placeholder text.';
+ await waitEmbedLoaded(page);
+ await assertRichImage(page, 1);
+ await pressEscape(page);
+ await pressArrowUp(page, 1);
+ await pasteContent(page, clipData);
+ await assertRichImage(page, 2);
+ await assertText(page, str + str);
+ await pressArrowDown(page, 1);
+ await pressEscape(page);
+ await pasteContent(page, clipData);
+ await assertRichImage(page, 3);
+ await assertText(page, 'Lorem Ipsum placeholder text.', 1);
+ }
+);
diff --git a/blocksuite/tests-legacy/clipboard/list.spec.ts b/blocksuite/tests-legacy/clipboard/list.spec.ts
new file mode 100644
index 0000000000000..ab8ee5c56cf8a
--- /dev/null
+++ b/blocksuite/tests-legacy/clipboard/list.spec.ts
@@ -0,0 +1,714 @@
+import { expect } from '@playwright/test';
+
+import { initDatabaseColumn } from '../database/actions.js';
+import {
+ activeNoteInEdgeless,
+ changeEdgelessNoteBackground,
+ copyByKeyboard,
+ createShapeElement,
+ cutByKeyboard,
+ dragBetweenCoords,
+ enterPlaygroundRoom,
+ focusRichText,
+ getAllNoteIds,
+ getClipboardHTML,
+ getClipboardSnapshot,
+ getClipboardText,
+ getEdgelessSelectedRectModel,
+ getInlineSelectionIndex,
+ getInlineSelectionText,
+ getPageSnapshot,
+ getRichTextBoundingBox,
+ initDatabaseDynamicRowWithData,
+ initEmptyDatabaseWithParagraphState,
+ initEmptyEdgelessState,
+ initEmptyParagraphState,
+ initThreeParagraphs,
+ pasteByKeyboard,
+ pasteContent,
+ pressArrowLeft,
+ pressArrowRight,
+ pressEnter,
+ pressEscape,
+ pressShiftTab,
+ pressSpace,
+ pressTab,
+ selectAllByKeyboard,
+ selectNoteInEdgeless,
+ setInlineRangeInSelectedRichText,
+ SHORT_KEY,
+ switchEditorMode,
+ toViewCoord,
+ triggerComponentToolbarAction,
+ type,
+ undoByKeyboard,
+ waitForInlineEditorStateUpdated,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import {
+ assertBlockTypes,
+ assertEdgelessNoteBackground,
+ assertEdgelessSelectedModelRect,
+ assertExists,
+ assertRichTextModelType,
+ assertRichTexts,
+ assertStoreMatchJSX,
+ assertText,
+} from '../utils/asserts.js';
+import { scoped, test } from '../utils/playwright.js';
+
+test('paste a non-nested list to a non-nested list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ const clipData = {
+ 'text/plain': `
+- a
+`,
+ };
+ await type(page, '-');
+ await pressSpace(page);
+ await type(page, '123');
+ await page.keyboard.press('Control+ArrowLeft');
+
+ // paste on start
+ await waitNextFrame(page);
+ await pasteContent(page, clipData);
+ await pressArrowLeft(page);
+ await assertRichTexts(page, ['a123']);
+
+ // paste in middle
+ await pressArrowRight(page, 2);
+ await pasteContent(page, clipData);
+ await pressArrowRight(page);
+ await assertRichTexts(page, ['a1a23']);
+
+ // paste on end
+ await pressArrowRight(page);
+ await pasteContent(page, clipData);
+ await waitNextFrame(page);
+ await assertRichTexts(page, ['a1a23a']);
+
+ await assertBlockTypes(page, ['bulleted']);
+});
+
+test('copy a nested list by clicking button, the clipboard data should be complete', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ const clipData = {
+ 'text/plain': `
+- aaa
+ - bbb
+ - ccc
+`,
+ };
+ await pasteContent(page, clipData);
+
+ const rootListBound = await page.locator('affine-list').first().boundingBox();
+ assertExists(rootListBound);
+
+ // use drag element to test.
+ await dragBetweenCoords(
+ page,
+ { x: rootListBound.x + 1, y: rootListBound.y - 1 },
+ { x: rootListBound.x + 1, y: rootListBound.y + rootListBound.height - 1 }
+ );
+ await copyByKeyboard(page);
+
+ const text = await getClipboardText(page);
+ const html = await getClipboardHTML(page);
+ const snapshot = await getClipboardSnapshot(page);
+ expect(text).toMatchSnapshot(`${testInfo.title}-clipboard.md`);
+ expect(JSON.stringify(snapshot.snapshot.content, null, 2)).toMatchSnapshot(
+ `${testInfo.title}-clipboard.json`
+ );
+ expect(html).toMatchSnapshot(`${testInfo.title}-clipboard.html`);
+});
+
+test('paste a nested list to a nested list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ const clipData = {
+ 'text/plain': `
+- aaa
+ - bbb
+ - ccc
+`,
+ };
+ await pasteContent(page, clipData);
+ await focusRichText(page, 1);
+
+ // paste on start
+ await page.keyboard.press('Control+ArrowLeft');
+
+ /**
+ * - aaa
+ * - |bbb
+ * - ccc
+ */
+ await pasteContent(page, clipData);
+ /**
+ * - aaa
+ * - aaa
+ * - bbb
+ * - ccc|bbb
+ * -ccc
+ */
+
+ await assertRichTexts(page, ['aaa', 'aaa', 'bbb', 'cccbbb', 'ccc']);
+ expect(await getInlineSelectionText(page)).toEqual('cccbbb');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+
+ // paste in middle
+ await undoByKeyboard(page);
+ await pressArrowRight(page);
+
+ /**
+ * - aaa
+ * - b|bb
+ * - ccc
+ */
+ await pasteContent(page, clipData);
+ /**
+ * - aaa
+ * - baaa
+ * - bbb
+ * - ccc|bb
+ * - ccc
+ */
+
+ await assertRichTexts(page, ['aaa', 'baaa', 'bbb', 'cccbb', 'ccc']);
+ expect(await getInlineSelectionText(page)).toEqual('cccbb');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+
+ // paste on end
+ await undoByKeyboard(page);
+ await page.keyboard.press('Control+ArrowRight');
+
+ /**
+ * - aaa
+ * - bbb|
+ * - ccc
+ */
+ await pasteContent(page, clipData);
+ /**
+ * - aaa
+ * - bbbaaa
+ * - bbb
+ * - ccc|
+ * - ccc
+ */
+
+ await assertRichTexts(page, ['aaa', 'bbbaaa', 'bbb', 'ccc', 'ccc']);
+ expect(await getInlineSelectionText(page)).toEqual('ccc');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+});
+
+test('paste nested lists to a nested list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ const clipData = {
+ 'text/plain': `
+- aaa
+ - bbb
+ - ccc
+`,
+ };
+ await pasteContent(page, clipData);
+ await focusRichText(page, 1);
+
+ const clipData2 = {
+ 'text/plain': `
+- 111
+ - 222
+- 111
+ - 222
+`,
+ };
+
+ // paste on start
+ await page.keyboard.press('Control+ArrowLeft');
+
+ /**
+ * - aaa
+ * - |bbb
+ * - ccc
+ */
+ await pasteContent(page, clipData2);
+ /**
+ * - aaa
+ * - 111
+ * - 222
+ * - 111
+ * - 222|bbb
+ * - ccc
+ */
+
+ await assertRichTexts(page, ['aaa', '111', '222', '111', '222bbb', 'ccc']);
+ expect(await getInlineSelectionText(page)).toEqual('222bbb');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+
+ // paste in middle
+ await undoByKeyboard(page);
+ await pressArrowRight(page);
+
+ /**
+ * - aaa
+ * - b|bb
+ * - ccc
+ */
+ await pasteContent(page, clipData2);
+ /**
+ * - aaa
+ * - b111
+ * - 222
+ * - 111
+ * - 222|bb
+ * - ccc
+ */
+
+ await assertRichTexts(page, ['aaa', 'b111', '222', '111', '222bb', 'ccc']);
+ expect(await getInlineSelectionText(page)).toEqual('222bb');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+
+ // paste on end
+ await undoByKeyboard(page);
+ await page.keyboard.press('Control+ArrowRight');
+
+ /**
+ * - aaa
+ * - bbb|
+ * - ccc
+ */
+ await pasteContent(page, clipData2);
+ /**
+ * - aaa
+ * - bbb111
+ * - 222
+ * - 111
+ * - 222|
+ * - ccc
+ */
+
+ await assertRichTexts(page, ['aaa', 'bbb111', '222', '111', '222', 'ccc']);
+ expect(await getInlineSelectionText(page)).toEqual('222');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+});
+
+test('paste non-nested lists to a nested list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ const clipData = {
+ 'text/plain': `
+- aaa
+ - bbb
+`,
+ };
+ await pasteContent(page, clipData);
+ await focusRichText(page, 0);
+
+ const clipData2 = {
+ 'text/plain': `
+- 123
+- 456
+`,
+ };
+
+ // paste on start
+ await page.keyboard.press('Control+ArrowLeft');
+
+ /**
+ * - |aaa
+ * - bbb
+ */
+ await pasteContent(page, clipData2);
+ /**
+ * - 123
+ * - 456|aaa
+ * - bbb
+ */
+
+ await assertRichTexts(page, ['123', '456aaa', 'bbb']);
+ expect(await getInlineSelectionText(page)).toEqual('456aaa');
+ expect(await getInlineSelectionIndex(page)).toEqual(3);
+});
+
+test(scoped`cut should work for multi-block selection`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, 'a');
+ await pressEnter(page);
+ await type(page, 'b');
+ await pressEnter(page);
+ await type(page, 'c');
+ await selectAllByKeyboard(page);
+ await selectAllByKeyboard(page);
+ await selectAllByKeyboard(page);
+ await cutByKeyboard(page);
+ await page.locator('.affine-page-viewport').click();
+ await waitNextFrame(page);
+ await assertText(page, '');
+});
+
+test(
+ scoped`pasting into empty list should not convert the list into paragraph`,
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'test');
+ await setInlineRangeInSelectedRichText(page, 0, 4);
+ await copyByKeyboard(page);
+ await type(page, '- ');
+ await page.keyboard.press(`${SHORT_KEY}+v`);
+ await assertRichTexts(page, ['test']);
+ await assertRichTextModelType(page, 'bulleted');
+ }
+);
+
+test('cut will delete all content, and copy will reappear content', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '-');
+ await pressSpace(page);
+ await type(page, '1');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, '2');
+ await pressEnter(page);
+ await type(page, '3');
+ await pressEnter(page);
+ await pressShiftTab(page);
+ await type(page, '4');
+
+ const box123 = await getRichTextBoundingBox(page, '1');
+ const inside123 = { x: box123.left + 1, y: box123.top + 1 };
+
+ const box789 = await getRichTextBoundingBox(page, '6');
+ const inside789 = { x: box789.right - 1, y: box789.bottom - 1 };
+ // from top to bottom
+ await dragBetweenCoords(page, inside123, inside789);
+
+ await cutByKeyboard(page);
+ await waitNextFrame(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_after-cut.json`
+ );
+ await waitNextFrame(page);
+ await focusRichText(page);
+
+ await pasteByKeyboard(page);
+ await waitNextFrame(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_after-paste.json`
+ );
+});
+
+test(scoped`should copy and paste of database work`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseWithParagraphState(page);
+
+ // init database columns and rows
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, 'abc', true);
+ await pressEscape(page);
+ await focusRichText(page, 1);
+ await selectAllByKeyboard(page);
+ await selectAllByKeyboard(page);
+ await copyByKeyboard(page);
+ await pressEnter(page);
+ await pasteByKeyboard(page);
+ await page.waitForTimeout(100);
+
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+
+
+
+
+
+
+
+
+
+
+`
+ );
+
+ await undoByKeyboard(page);
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+
+
+
+
+
+
+
+`
+ );
+});
+
+test(`copy canvas element and text note in edgeless mode`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await initThreeParagraphs(page);
+ await createShapeElement(page, [0, 0], [100, 100]);
+ await selectAllByKeyboard(page);
+ const bound = await getEdgelessSelectedRectModel(page);
+ await copyByKeyboard(page);
+ const coord = await toViewCoord(page, [
+ bound[0] + bound[2] / 2,
+ bound[1] + bound[3] / 2 + 200,
+ ]);
+ await page.mouse.move(coord[0], coord[1]);
+ await page.waitForTimeout(300);
+ await pasteByKeyboard(page, false);
+ bound[1] = bound[1] + 200;
+ await assertEdgelessSelectedModelRect(page, bound);
+});
+
+test(scoped`copy when text note active in edgeless`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const ids = await initEmptyEdgelessState(page);
+ await focusRichText(page);
+ await type(page, '1234');
+
+ await switchEditorMode(page);
+
+ await activeNoteInEdgeless(page, ids.noteId);
+ await waitForInlineEditorStateUpdated(page);
+ await setInlineRangeInSelectedRichText(page, 0, 4);
+ await copyByKeyboard(page);
+ await pressArrowRight(page);
+ await type(page, '555');
+ await pasteByKeyboard(page, false);
+ await assertText(page, '12345551234');
+});
+
+test(scoped`paste note block with background`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const ids = await initEmptyEdgelessState(page);
+ await focusRichText(page);
+ await type(page, '1234');
+
+ await switchEditorMode(page);
+ await selectNoteInEdgeless(page, ids.noteId);
+
+ await triggerComponentToolbarAction(page, 'changeNoteColor');
+ const color = '--affine-note-background-grey';
+ await changeEdgelessNoteBackground(page, color);
+ await assertEdgelessNoteBackground(page, ids.noteId, color);
+
+ await copyByKeyboard(page);
+
+ await page.mouse.move(0, 0);
+ await pasteByKeyboard(page, false);
+ const noteIds = await getAllNoteIds(page);
+ for (const noteId of noteIds) {
+ await assertEdgelessNoteBackground(page, noteId, color);
+ }
+});
+
+test(scoped`copy and paste to selection block selection`, async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2265',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '1234');
+
+ await selectAllByKeyboard(page);
+ await copyByKeyboard(page);
+ await pressArrowRight(page);
+ await pasteByKeyboard(page, false);
+ await waitNextFrame(page);
+ await assertRichTexts(page, ['12341234']);
+});
+
+test(
+ scoped`should keep paragraph block's type when pasting at the start of empty paragraph block except type text`,
+ async ({ page }, testInfo) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2336',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await focusRichText(page);
+ await type(page, '>');
+ await page.keyboard.press('Space', { delay: 50 });
+
+ await page.evaluate(() => {
+ const input = document.createElement('input');
+ input.setAttribute('id', 'input-test');
+ input.value = '123';
+ document.body.querySelector('#app')?.append(input);
+ });
+ await page.focus('#input-test');
+ await page.dblclick('#input-test');
+ await copyByKeyboard(page);
+ await focusRichText(page);
+ await pasteByKeyboard(page);
+ await waitNextFrame(page);
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_after-paste-1.json`
+ );
+
+ await pressEnter(page);
+ await waitNextFrame(page);
+ await pressEnter(page);
+ await waitNextFrame(page);
+ await pasteByKeyboard(page, false);
+ await waitNextFrame(page);
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_after-paste-2.json`
+ );
+ }
+);
+
+test(scoped`paste from FeiShu list format`, async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2438',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ // set up clipboard data using html
+ const clipData = {
+ 'text/html': ``,
+ };
+ await waitNextFrame(page);
+ await page.evaluate(
+ ({ clipData }) => {
+ const dT = new DataTransfer();
+ const e = new ClipboardEvent('paste', { clipboardData: dT });
+ Object.defineProperty(e, 'target', {
+ writable: false,
+ value: document,
+ });
+ e.clipboardData?.setData('text/html', clipData['text/html']);
+ document.dispatchEvent(e);
+ },
+ { clipData }
+ );
+ await assertText(page, 'aaaa');
+ await assertBlockTypes(page, ['bulleted']);
+});
+
+test(scoped`paste in list format`, async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2281',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await type(page, '- test');
+ await focusRichText(page);
+
+ const clipData = {
+ 'text/html': ``,
+ };
+ await waitNextFrame(page);
+ await page.evaluate(
+ ({ clipData }) => {
+ const dT = new DataTransfer();
+ const e = new ClipboardEvent('paste', { clipboardData: dT });
+ Object.defineProperty(e, 'target', {
+ writable: false,
+ value: document,
+ });
+ e.clipboardData?.setData('text/html', clipData['text/html']);
+ document.dispatchEvent(e);
+ },
+ { clipData }
+ );
+ await assertRichTexts(page, ['test111', '222']);
+});
diff --git a/blocksuite/tests-legacy/clipboard/markdown.spec.ts b/blocksuite/tests-legacy/clipboard/markdown.spec.ts
new file mode 100644
index 0000000000000..1484fdb6ae5e6
--- /dev/null
+++ b/blocksuite/tests-legacy/clipboard/markdown.spec.ts
@@ -0,0 +1,170 @@
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ initEmptyParagraphState,
+ pasteContent,
+ resetHistory,
+ undoByClick,
+ waitEmbedLoaded,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import {
+ assertBlockTypes,
+ assertRichImage,
+ assertRichTexts,
+ assertTextFormats,
+} from '../utils/asserts.js';
+import { scoped, test } from '../utils/playwright.js';
+
+test(scoped`markdown format parse`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await resetHistory(page);
+
+ let clipData = {
+ 'text/plain': `# h1
+
+## h2
+
+### h3
+
+#### h4
+
+##### h5
+
+###### h6
+
+- [ ] todo
+
+- [ ] todo
+
+- [x] todo
+
+* bulleted
+
+- bulleted
+
+1. numbered
+
+> quote
+`,
+ };
+ await waitNextFrame(page);
+ await pasteContent(page, clipData);
+ await page.waitForTimeout(200);
+ await assertBlockTypes(page, [
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'todo',
+ 'todo',
+ 'todo',
+ 'bulleted',
+ 'bulleted',
+ 'numbered',
+ 'quote',
+ ]);
+ await assertRichTexts(page, [
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'todo',
+ 'todo',
+ 'todo',
+ 'bulleted',
+ 'bulleted',
+ 'numbered',
+ 'quote',
+ ]);
+ await undoByClick(page);
+ await assertRichTexts(page, ['']);
+ await focusRichText(page);
+
+ clipData = {
+ 'text/plain': `# ***bolditalic***
+# **bold**
+
+*italic*
+
+~~strikethrough~~
+
+[link](linktest)
+
+\`code\`
+`,
+ };
+ await waitNextFrame(page);
+ await pasteContent(page, clipData);
+ await page.waitForTimeout(200);
+ await assertTextFormats(page, [
+ { bold: true, italic: true },
+ { bold: true },
+ { italic: true },
+ { strike: true },
+ { link: 'linktest' },
+ { code: true },
+ ]);
+ await undoByClick(page);
+ await assertRichTexts(page, ['']);
+});
+
+test(scoped`import markdown`, async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await resetHistory(page);
+ const clipData = `# text
+# h1
+`;
+ await pasteContent(page, { 'text/plain': clipData });
+ await page.waitForTimeout(100);
+ await assertRichTexts(page, ['text', 'h1']);
+ await undoByClick(page);
+ await assertRichTexts(page, ['']);
+});
+
+test(
+ scoped`clipboard paste HTML containing markdown syntax code and image `,
+ async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2855',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ // set up clipboard data using html
+ const clipData = {
+ 'text/html': `็ฌฆๅ Markdown ๆ ผๅผ็ URL ๆพๅฐ็ฌ่ฎฐไธญ๏ผๆญคๆถ้่ฆ็ๆ ผๅผๅฆไธ๏ผ
+ md [ไปปๅก็ฎก็่ฟไปถไบ - ๅฐๆฐๆดพ](https://sspai.com/post/61092)
+ ๏ผๅฐไธๆฎตๆๅญๅ
่ฃนๅจ[[]]
ไธญ๏ผๆญคๆถ้่ฆ็ๆ ผๅผๅฆไธ๏ผ
+
+ ไธๅพไธญ๏ผๅฝๆไปฌๅคๅจ Obsidian ็ใ้ข่งๆจกๅผใๆถ๏ผ็นๅป่ฟไธชใๅๅ้พๆฅใ
+ `,
+ };
+ await page.evaluate(
+ ({ clipData }) => {
+ const dT = new DataTransfer();
+ const e = new ClipboardEvent('paste', { clipboardData: dT });
+ Object.defineProperty(e, 'target', {
+ writable: false,
+ value: document,
+ });
+ e.clipboardData?.setData('text/html', clipData['text/html']);
+ document.dispatchEvent(e);
+ },
+ { clipData }
+ );
+ await waitEmbedLoaded(page);
+ // await page.waitForTimeout(500);
+ await assertRichImage(page, 1);
+ }
+);
diff --git a/blocksuite/tests-legacy/code/copy-paste.spec.ts b/blocksuite/tests-legacy/code/copy-paste.spec.ts
new file mode 100644
index 0000000000000..9e7a9cd3d46d9
--- /dev/null
+++ b/blocksuite/tests-legacy/code/copy-paste.spec.ts
@@ -0,0 +1,149 @@
+import { expect } from '@playwright/test';
+
+import {
+ copyByKeyboard,
+ pasteByKeyboard,
+ pressArrowLeft,
+ pressEnter,
+ pressEnterWithShortkey,
+ selectAllByKeyboard,
+ type,
+} from '../utils/actions/keyboard.js';
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ getInlineSelectionText,
+ getPageSnapshot,
+ initEmptyCodeBlockState,
+ setSelection,
+} from '../utils/actions/misc.js';
+import { assertRichTextInlineRange } from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+import { getCodeBlock } from './utils.js';
+
+test('keyboard selection and copy paste', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'use');
+ await page.keyboard.down('Shift');
+ await pressArrowLeft(page, 'use'.length);
+ await page.keyboard.up('Shift');
+ await copyByKeyboard(page);
+ await pressArrowLeft(page, 1);
+ await pasteByKeyboard(page);
+
+ const content = await getInlineSelectionText(page);
+ expect(content).toBe('useuse');
+
+ await assertRichTextInlineRange(page, 0, 3, 0);
+});
+
+test('paste with more than one continuous breakline should remain in code block, ', async ({
+ page,
+}) => {
+ await page.setContent(`use super::*;
+use fern::{
+ colors::{Color, ColoredLevelConfig},
+ Dispatch,
+};
+
+#[inline]
`);
+ await page.focus('div');
+ await selectAllByKeyboard(page);
+ await copyByKeyboard(page);
+
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+ await pasteByKeyboard(page);
+
+ const locator = page.locator('affine-paragraph');
+ await expect(locator).toBeHidden();
+});
+
+test('drag copy paste', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'use');
+
+ await setSelection(page, 2, 0, 2, 3);
+ await copyByKeyboard(page);
+ await pressArrowLeft(page);
+ await pasteByKeyboard(page);
+
+ const content = await getInlineSelectionText(page);
+ expect(content).toBe('useuse');
+
+ await assertRichTextInlineRange(page, 0, 3, 0);
+});
+
+test.skip('use keyboard copy inside code block copy', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'use');
+ await page.keyboard.down('Shift');
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
+ for (let i = 0; i < 'use'.length; i++) {
+ await page.keyboard.press('ArrowLeft');
+ }
+ await page.keyboard.up('Shift');
+ await copyByKeyboard(page);
+ await page.keyboard.press('ArrowRight');
+ await pressEnter(page);
+ await pressEnter(page);
+ await pasteByKeyboard(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_pasted.json`
+ );
+});
+
+test('code block has content, click code block copy menu, copy whole code block', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page, { language: 'javascript' });
+ await focusRichText(page);
+ await page.keyboard.type('use');
+ await pressEnterWithShortkey(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+
+ await expect(codeBlockController.copyButton).toBeVisible();
+ await codeBlockController.copyButton.click();
+
+ await focusRichText(page, 1);
+ await pasteByKeyboard(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_pasted.json`
+ );
+});
+
+test('code block is empty, click code block copy menu, copy the empty code block', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page, { language: 'javascript' });
+ await focusRichText(page);
+
+ await pressEnterWithShortkey(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+ await expect(codeBlockController.copyButton).toBeVisible();
+ await codeBlockController.copyButton.click();
+
+ await focusRichText(page, 1);
+ await pasteByKeyboard(page);
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_pasted.json`
+ );
+});
diff --git a/blocksuite/tests-legacy/code/crud.spec.ts b/blocksuite/tests-legacy/code/crud.spec.ts
new file mode 100644
index 0000000000000..fc8cc5a5a04a9
--- /dev/null
+++ b/blocksuite/tests-legacy/code/crud.spec.ts
@@ -0,0 +1,668 @@
+import { expect } from '@playwright/test';
+import { dragBetweenIndices } from 'utils/actions/drag.js';
+import { getFormatBar } from 'utils/query.js';
+
+import { updateBlockType } from '../utils/actions/block.js';
+import {
+ createCodeBlock,
+ pressArrowLeft,
+ pressArrowUp,
+ pressBackspace,
+ pressEnter,
+ pressEscape,
+ pressShiftTab,
+ pressTab,
+ redoByKeyboard,
+ type,
+ undoByKeyboard,
+} from '../utils/actions/keyboard.js';
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ focusRichTextEnd,
+ getPageSnapshot,
+ initEmptyCodeBlockState,
+ initEmptyParagraphState,
+ setSelection,
+ waitNextFrame,
+} from '../utils/actions/misc.js';
+import {
+ assertBlockCount,
+ assertRichTexts,
+ assertStoreMatchJSX,
+ assertTitle,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+import { getCodeBlock } from './utils.js';
+
+test('use debug menu can create code block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await updateBlockType(page, 'affine:code');
+
+ const locator = page.locator('affine-code');
+ await expect(locator).toBeVisible();
+});
+
+test('use markdown syntax can create code block', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, 'aaa');
+ await pressEnter(page);
+ await type(page, 'bbb');
+ await pressTab(page);
+ await pressEnter(page);
+ await type(page, 'ccc');
+ await pressTab(page);
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_init.json`
+ );
+
+ await setSelection(page, 2, 0, 2, 0);
+ // |aaa
+ // bbb
+ // ccc
+
+ await type(page, '``` ');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_markdown_syntax.json`
+ );
+});
+
+test('use markdown syntax with trailing characters can create code block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '```JavaScript');
+ await type(page, ' ');
+
+ const locator = page.locator('affine-code');
+ await expect(locator).toBeVisible();
+});
+
+test('support ```[lang] to add code block with language', async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/1314',
+ });
+
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '```ts');
+ await type(page, ' ');
+
+ const codeBlockController = getCodeBlock(page);
+ const codeLocator = codeBlockController.codeBlock;
+ await expect(codeLocator).toBeVisible();
+
+ const codeRect = await codeLocator.boundingBox();
+ if (!codeRect) {
+ throw new Error('Failed to get bounding box of code block.');
+ }
+ const position = {
+ x: codeRect.x + codeRect.width / 2,
+ y: codeRect.y + codeRect.height / 2,
+ };
+ await page.mouse.move(position.x, position.y);
+
+ const languageButton = codeBlockController.languageButton;
+ await expect(languageButton).toBeVisible();
+ await expect(languageButton).toHaveText('TypeScript');
+});
+
+test('use more than three backticks can not create code block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '`````');
+ await type(page, ' ');
+
+ const codeBlockLocator = page.locator('affine-code');
+ await expect(codeBlockLocator).toBeHidden();
+ const inlineCodelocator = page.getByText('```');
+ await expect(inlineCodelocator).toBeVisible();
+ expect(await inlineCodelocator.count()).toEqual(1);
+});
+
+test('use shortcut can create code block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await createCodeBlock(page);
+
+ const locator = page.locator('affine-code');
+ await expect(locator).toBeVisible();
+});
+
+test('change code language can work', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { codeBlockId } = await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+ await codeBlockController.clickLanguageButton();
+ const locator = codeBlockController.langList;
+ await expect(locator).toBeVisible();
+
+ await type(page, 'rust');
+ await page.click(
+ '.affine-filterable-list > .items-container > icon-button:nth-child(1)'
+ );
+ await expect(locator).toBeHidden();
+
+ await codeBlockController.codeBlock.hover();
+ await expect(codeBlockController.languageButton).toHaveText('Rust');
+
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+ await undoByKeyboard(page);
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+
+ // Can switch to another language
+ await codeBlockController.clickLanguageButton();
+ await type(page, 'ty');
+ await pressEnter(page);
+ await expect(locator).toBeHidden();
+ await expect(codeBlockController.languageButton).toHaveText('TypeScript');
+});
+
+test('duplicate code block', async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page, { language: 'javascript' });
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+
+ // change language
+ await codeBlockController.clickLanguageButton();
+ const langLocator = codeBlockController.langList;
+ await expect(langLocator).toBeVisible();
+ await type(page, 'rust');
+ await page.click(
+ '.affine-filterable-list > .items-container > icon-button:nth-child(1)'
+ );
+
+ // add text
+ await focusRichTextEnd(page);
+ await type(page, 'let a: u8 = 7');
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+
+ // add a caption
+ await codeBlockController.codeBlock.hover();
+ await codeBlockController.captionButton.click();
+ await type(page, 'BlockSuite');
+ await pressEnter(page);
+ await pressBackspace(page); // remove paragraph
+ await waitNextFrame(page, 100);
+
+ // turn on wrap
+ await codeBlockController.codeBlock.hover();
+ await (await codeBlockController.openMore()).wrapButton.click();
+
+ // duplicate
+ await codeBlockController.codeBlock.hover();
+ await (await codeBlockController.openMore()).duplicateButton.click();
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+});
+
+test('delete code block in more menu', async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page, { language: 'javascript' });
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+ const moreMenu = await codeBlockController.openMore();
+
+ await expect(moreMenu.menu).toBeVisible();
+ await moreMenu.deleteButton.click();
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_final.json`
+ );
+});
+
+test('undo and redo works in code block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'const a = 10;');
+ await assertRichTexts(page, ['const a = 10;']);
+ await undoByKeyboard(page);
+ await assertRichTexts(page, ['']);
+
+ await redoByKeyboard(page);
+ await assertRichTexts(page, ['const a = 10;']);
+});
+
+test('toggle code block wrap can work', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { codeBlockId } = await initEmptyCodeBlockState(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+
+ await codeBlockController.codeBlock.hover();
+ await (await codeBlockController.openMore()).wrapButton.click();
+
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+
+ await codeBlockController.codeBlock.hover();
+ await (await codeBlockController.openMore()).cancelWrapButton.click();
+
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+});
+
+test('add caption works', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { codeBlockId } = await initEmptyCodeBlockState(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+ await codeBlockController.captionButton.click();
+ await type(page, 'BlockSuite');
+ await pressEnter(page);
+ await waitNextFrame(page, 100);
+
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+});
+
+test('undo code block wrap can work', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ const { codeBlockId } = await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+
+ await codeBlockController.codeBlock.hover();
+ await (await codeBlockController.openMore()).wrapButton.click();
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+
+ await focusRichText(page);
+ await undoByKeyboard(page);
+ await assertStoreMatchJSX(
+ page,
+ /*xml*/ `
+`,
+ codeBlockId
+ );
+});
+
+test('code block toolbar widget can appear and disappear during mousemove', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const position = await page.locator('affine-code').boundingBox();
+ if (!position) throw new Error('Failed to get affine code position');
+ await page.mouse.move(position.x, position.y);
+
+ const locator = page.locator('.code-toolbar-container');
+ const toolbarPosition = await locator.boundingBox();
+ if (!toolbarPosition) throw new Error('Failed to get option position');
+ await page.mouse.move(toolbarPosition.x, toolbarPosition.y);
+ await expect(locator).toBeVisible();
+ await page.mouse.move(position.x - 10, position.y - 10);
+ await expect(locator).toBeHidden();
+});
+
+test('should tab works in code block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'const a = 10;');
+ await assertRichTexts(page, ['const a = 10;']);
+ await page.keyboard.press('Tab', { delay: 50 });
+ await assertRichTexts(page, [' const a = 10;']);
+ await page.keyboard.press(`Shift+Tab`, { delay: 50 });
+ await assertRichTexts(page, ['const a = 10;']);
+
+ await page.keyboard.press('Enter', { delay: 50 });
+ await type(page, 'const b = "NothingToSay');
+ await page.keyboard.press('ArrowUp', { delay: 50 });
+ await page.keyboard.press('Enter', { delay: 50 });
+ await page.keyboard.press('Tab', { delay: 50 });
+ await assertRichTexts(page, ['const a = 10;\n \nconst b = "NothingToSay"']);
+});
+
+test('should open more menu and close on selecting', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+ await expect(codeBlockController.codeToolbar).toBeVisible();
+ const moreMenu = await codeBlockController.openMore();
+
+ await expect(moreMenu.menu).toBeVisible();
+ await moreMenu.wrapButton.click();
+ await expect(moreMenu.menu).toBeHidden();
+});
+
+test('should code block lang input supports alias', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const codeBlockController = getCodeBlock(page);
+ const codeBlock = codeBlockController.codeBlock;
+ await codeBlock.hover();
+ await codeBlockController.clickLanguageButton();
+ await expect(codeBlockController.langList).toBeVisible();
+ await type(page, 'ๆ่จ');
+ await pressEnter(page);
+ await expect(codeBlockController.languageButton).toHaveText('Wenyan');
+});
+
+test('multi-line indent', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'aaa');
+ await pressEnter(page);
+
+ await type(page, 'bbb');
+ await pressEnter(page);
+
+ await type(page, 'ccc');
+
+ await page.keyboard.down('Shift');
+ await pressArrowUp(page, 2);
+ await page.keyboard.up('Shift');
+
+ await pressTab(page);
+
+ await assertRichTexts(page, [' aaa\n bbb\n ccc']);
+
+ await pressShiftTab(page);
+
+ await assertRichTexts(page, ['aaa\nbbb\nccc']);
+
+ await pressShiftTab(page);
+
+ await assertRichTexts(page, ['aaa\nbbb\nccc']);
+});
+
+test('should bracket complete works in code block', async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/1800',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'const a = "');
+ await assertRichTexts(page, ['const a = ""']);
+
+ await type(page, 'str');
+ await assertRichTexts(page, ['const a = "str"']);
+ await type(page, '(');
+ await assertRichTexts(page, ['const a = "str()"']);
+ await type(page, ']');
+ await assertRichTexts(page, ['const a = "str(])"']);
+});
+
+test('auto scroll horizontally when typing', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '``` ');
+
+ for (let i = 0; i < 100; i++) {
+ await type(page, String(i));
+ }
+
+ const richTextScrollLeft1 = await page.evaluate(() => {
+ const richText = document.querySelector('affine-code rich-text');
+ if (!richText) {
+ throw new Error('Failed to get rich text');
+ }
+
+ return richText.scrollLeft;
+ });
+ expect(richTextScrollLeft1).toBeGreaterThan(200);
+
+ await pressArrowLeft(page, 5);
+ await type(page, 'aa');
+
+ const richTextScrollLeft2 = await page.evaluate(() => {
+ const richText = document.querySelector('affine-code rich-text');
+ if (!richText) {
+ throw new Error('Failed to get rich text');
+ }
+
+ return richText.scrollLeft;
+ });
+
+ expect(richTextScrollLeft2).toEqual(richTextScrollLeft1);
+});
+
+test('code hotkey should not effect in global', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+
+ await pressEnter(page);
+ await type(page, '``` ');
+
+ await assertTitle(page, '');
+ await assertBlockCount(page, 'paragraph', 1);
+ await assertBlockCount(page, 'code', 1);
+
+ await pressArrowUp(page);
+ await pressBackspace(page);
+ await type(page, 'aaa');
+
+ await assertTitle(page, 'aaa');
+ await assertBlockCount(page, 'paragraph', 0);
+ await assertBlockCount(page, 'code', 1);
+});
+
+test('language selection list should not close when hovering out of code block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page, { language: 'javascript' });
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+
+ await codeBlockController.clickLanguageButton();
+ const langLocator = codeBlockController.langList;
+ await expect(langLocator).toBeVisible();
+
+ const bBox = await codeBlockController.codeBlock.boundingBox();
+ if (!bBox) throw new Error('Expected bounding box');
+
+ const { x, y, width, height } = bBox;
+
+ // hovering inside the code block should keep the list open
+ await page.mouse.move(x + width / 2, y + height / 2);
+ await expect(langLocator).toBeVisible();
+
+ // hovering out should not close the list
+ await page.mouse.move(x - 10, y - 10);
+ await waitNextFrame(page);
+ await expect(langLocator).toBeVisible();
+});
+
+test('language selection list should not change when hovering over its elements', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+
+ const codeBlockController = getCodeBlock(page);
+ await codeBlockController.codeBlock.hover();
+ await codeBlockController.clickLanguageButton();
+ await waitNextFrame(page, 100);
+
+ const langListLocator = codeBlockController.langList;
+ const langItemsLocator = langListLocator.locator('icon-button');
+
+ // checking first 4 language list items
+ for (let i = 0; i < 3; i++) {
+ const item = langItemsLocator.nth(i); // current item in language list
+ const nextItem = langItemsLocator.nth(i + 1); // next item in language list
+
+ await item.hover();
+
+ const initialItemText = await item.textContent();
+ const initialNextItemText = await nextItem.textContent();
+
+ await nextItem.hover();
+
+ const currentItemText = await item.textContent();
+ const currentNextItemText = await nextItem.textContent();
+
+ // text content should remain unchanged after next item receives focus
+ expect(initialItemText).toBe(currentItemText);
+ expect(initialNextItemText).toBe(currentNextItemText);
+ }
+});
+
+test('format text in code block', async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '```ts ');
+ await waitNextFrame(page, 100);
+ await type(page, 'const aaa = 1000;');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_init.json`
+ );
+
+ const line = page.locator('affine-code rich-text v-line > div');
+ expect(await line.innerText()).toBe('const aaa = 1000;');
+
+ const { boldBtn, linkBtn } = getFormatBar(page);
+
+ await dragBetweenIndices(page, [0, 1], [0, 2]);
+ await boldBtn.click();
+ expect(await line.innerText()).toBe('const aaa = 1000;');
+ await dragBetweenIndices(page, [0, 4], [0, 7]);
+ await boldBtn.click();
+ expect(await line.innerText()).toBe('const aaa = 1000;');
+ await dragBetweenIndices(page, [0, 8], [0, 16]);
+ await boldBtn.click();
+ expect(await line.innerText()).toBe('const aaa = 1000;');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_format.json`
+ );
+
+ await dragBetweenIndices(page, [0, 4], [0, 10]);
+ await linkBtn.click();
+ await type(page, 'https://www.baidu.com');
+ await pressEnter(page);
+
+ expect(await line.innerText()).toBe('const aaa = 1000;');
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_link.json`
+ );
+});
diff --git a/blocksuite/tests-legacy/code/readonly.spec.ts b/blocksuite/tests-legacy/code/readonly.spec.ts
new file mode 100644
index 0000000000000..aada3d552f6e4
--- /dev/null
+++ b/blocksuite/tests-legacy/code/readonly.spec.ts
@@ -0,0 +1,59 @@
+import { expect } from '@playwright/test';
+
+import { switchReadonly } from '../utils/actions/click.js';
+import {
+ pressBackspace,
+ pressEnter,
+ pressTab,
+ type,
+} from '../utils/actions/keyboard.js';
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ focusRichTextEnd,
+ initEmptyCodeBlockState,
+} from '../utils/actions/misc.js';
+import { assertRichTexts } from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+import { getCodeBlock } from './utils.js';
+
+test('should code block widget be disabled in read only mode', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichTextEnd(page);
+
+ await page.waitForTimeout(300);
+ await switchReadonly(page);
+
+ const codeBlockController = getCodeBlock(page);
+ const codeBlock = codeBlockController.codeBlock;
+ await codeBlock.hover();
+ await codeBlockController.clickLanguageButton();
+ await expect(codeBlockController.langList).toBeHidden();
+
+ await codeBlock.hover();
+ await expect(codeBlockController.codeToolbar).toBeVisible();
+ await expect(codeBlockController.moreButton).toHaveAttribute('disabled');
+
+ await expect(codeBlockController.copyButton).toBeVisible();
+ await expect(codeBlockController.moreMenu).toBeHidden();
+});
+
+test('should not be able to modify code block in readonly mode', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'const a = 10;');
+ await assertRichTexts(page, ['const a = 10;']);
+
+ await switchReadonly(page);
+ await pressBackspace(page, 3);
+ await pressTab(page, 3);
+ await pressEnter(page, 2);
+ await assertRichTexts(page, ['const a = 10;']);
+});
diff --git a/blocksuite/tests-legacy/code/selections.spec.ts b/blocksuite/tests-legacy/code/selections.spec.ts
new file mode 100644
index 0000000000000..3a6960a17de04
--- /dev/null
+++ b/blocksuite/tests-legacy/code/selections.spec.ts
@@ -0,0 +1,195 @@
+import { expect } from '@playwright/test';
+
+import { dragBetweenCoords } from '../utils/actions/drag.js';
+import {
+ pressArrowLeft,
+ pressBackspace,
+ pressEnter,
+ pressEnterWithShortkey,
+ redoByKeyboard,
+ type,
+ undoByKeyboard,
+} from '../utils/actions/keyboard.js';
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ getInlineSelectionIndex,
+ getInlineSelectionText,
+ initEmptyCodeBlockState,
+} from '../utils/actions/misc.js';
+import {
+ assertBlockCount,
+ assertBlockSelections,
+ assertRichTextInlineRange,
+ assertRichTexts,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+import { getCodeBlock } from './utils.js';
+
+test('click outside should close language list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const codeBlock = getCodeBlock(page);
+ await codeBlock.clickLanguageButton();
+ const locator = codeBlock.langList;
+ await expect(locator).toBeVisible();
+
+ const rect = await page.locator('affine-filterable-list').boundingBox();
+ if (!rect) throw new Error('Failed to get bounding box of code block.');
+ await page.mouse.click(rect.x - 10, rect.y - 10);
+
+ await expect(locator).toBeHidden();
+});
+
+test('split code by enter', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'hello');
+
+ // he|llo
+ await pressArrowLeft(page, 3);
+
+ await pressEnter(page);
+ await assertRichTexts(page, ['he\nllo']);
+
+ await undoByKeyboard(page);
+ await assertRichTexts(page, ['hello']);
+
+ await redoByKeyboard(page);
+ await assertRichTexts(page, ['he\nllo']);
+});
+
+test('split code with selection by enter', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'hello');
+
+ // select 'll'
+ await pressArrowLeft(page, 1);
+ await page.keyboard.down('Shift');
+ await pressArrowLeft(page, 2);
+ await page.keyboard.up('Shift');
+
+ await pressEnter(page);
+ await assertRichTexts(page, ['he\no']);
+
+ await undoByKeyboard(page);
+ await assertRichTexts(page, ['hello']);
+
+ await redoByKeyboard(page);
+ await assertRichTexts(page, ['he\no']);
+});
+
+test('drag select code block can delete it', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ const codeBlock = page.locator('affine-code');
+ const bbox = await codeBlock.boundingBox();
+ if (!bbox) {
+ throw new Error("Failed to get code block's bounding box");
+ }
+ const position = {
+ startX: bbox.x - 10,
+ startY: bbox.y - 10,
+ endX: bbox.x + bbox.width,
+ endY: bbox.y + bbox.height / 2,
+ };
+ await dragBetweenCoords(
+ page,
+ { x: position.startX, y: position.startY },
+ { x: position.endX, y: position.endY },
+ { steps: 20 }
+ );
+ await page.waitForTimeout(10);
+ await page.keyboard.press('Backspace');
+ const locator = page.locator('affine-code');
+ await expect(locator).toBeHidden();
+});
+
+test('press short key and enter at end of code block can jump out', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await pressEnterWithShortkey(page);
+
+ const locator = page.locator('affine-paragraph');
+ await expect(locator).toBeVisible();
+});
+
+test('press short key and enter at end of code block with content can jump out', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+
+ await type(page, 'const a = 10;');
+ await pressEnterWithShortkey(page);
+
+ const locator = page.locator('affine-paragraph');
+ await expect(locator).toBeVisible();
+});
+
+test('press backspace inside should select code block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+ const codeBlock = page.locator('affine-code');
+ const selectedRects = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await page.keyboard.press('Backspace');
+ await expect(selectedRects).toHaveCount(1);
+ await expect(codeBlock).toBeVisible();
+ await page.keyboard.press('Backspace');
+ await expect(selectedRects).toHaveCount(0);
+ await expect(codeBlock).toBeHidden();
+});
+
+test('press backspace after code block can select code block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+ const code = 'const a = 1;';
+ await type(page, code);
+
+ await assertRichTextInlineRange(page, 0, 12);
+ await pressEnterWithShortkey(page);
+ await assertRichTextInlineRange(page, 1, 0);
+ await assertBlockCount(page, 'paragraph', 1);
+ await pressBackspace(page);
+ await assertBlockSelections(page, ['2']);
+ await assertBlockCount(page, 'paragraph', 0);
+});
+
+test('press ArrowUp after code block can enter code block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyCodeBlockState(page);
+ await focusRichText(page);
+ const code = 'const a = 1;';
+ await type(page, code);
+
+ await pressEnterWithShortkey(page);
+ await page.keyboard.press('ArrowUp');
+
+ const index = await getInlineSelectionIndex(page);
+ expect(index).toBe(0);
+
+ const text = await getInlineSelectionText(page);
+ expect(text).toBe(code);
+});
diff --git a/blocksuite/tests-legacy/code/utils.ts b/blocksuite/tests-legacy/code/utils.ts
new file mode 100644
index 0000000000000..c9783c9c3c729
--- /dev/null
+++ b/blocksuite/tests-legacy/code/utils.ts
@@ -0,0 +1,61 @@
+import type { Page } from '@playwright/test';
+
+/**
+ * @example
+ * ```ts
+ * const codeBlockController = getCodeBlock(page);
+ * const codeBlock = codeBlockController.codeBlock;
+ * ```
+ */
+export function getCodeBlock(page: Page) {
+ const codeBlock = page.locator('affine-code');
+ const languageButton = page.getByTestId('lang-button');
+
+ const clickLanguageButton = async () => {
+ await codeBlock.hover();
+ await languageButton.click({ delay: 50 });
+ };
+
+ const langList = page.locator('affine-filterable-list');
+ const langFilterInput = langList.locator('#filter-input');
+
+ const codeToolbar = page.locator('affine-code-toolbar');
+
+ const copyButton = codeToolbar.getByRole('button', { name: 'Copy code' });
+ const captionButton = codeToolbar.getByRole('button', { name: 'Caption' });
+ const moreButton = codeToolbar.getByRole('button', { name: 'More' });
+
+ const menu = page.locator('.more-popup-menu');
+
+ const openMore = async () => {
+ await moreButton.click();
+
+ const wrapButton = menu.getByRole('button', { name: 'Wrap' });
+ const cancelWrapButton = menu.getByRole('button', { name: 'Cancel wrap' });
+ const duplicateButton = menu.getByRole('button', { name: 'Duplicate' });
+ const deleteButton = menu.getByRole('button', { name: 'Delete' });
+
+ return {
+ menu,
+ wrapButton,
+ cancelWrapButton,
+ duplicateButton,
+ deleteButton,
+ };
+ };
+
+ return {
+ codeBlock,
+ codeToolbar,
+ captionButton,
+ languageButton,
+ langList,
+ copyButton,
+ moreButton,
+ langFilterInput,
+ moreMenu: menu,
+
+ openMore,
+ clickLanguageButton,
+ };
+}
diff --git a/blocksuite/tests-legacy/database/actions.ts b/blocksuite/tests-legacy/database/actions.ts
new file mode 100644
index 0000000000000..e78627ad5e268
--- /dev/null
+++ b/blocksuite/tests-legacy/database/actions.ts
@@ -0,0 +1,588 @@
+import type {
+ RichTextCell,
+ RichTextCellEditing,
+} from '@blocks/database-block/properties/rich-text/cell-renderer.js';
+import { press } from '@inline/__tests__/utils.js';
+import { ZERO_WIDTH_SPACE } from '@inline/consts.js';
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import {
+ pressEnter,
+ selectAllByKeyboard,
+ type,
+} from '../utils/actions/keyboard.js';
+import {
+ getBoundingBox,
+ getBoundingClientRect,
+ getEditorLocator,
+ waitNextFrame,
+} from '../utils/actions/misc.js';
+
+export async function initDatabaseColumn(page: Page, title = '') {
+ const editor = getEditorLocator(page);
+ await editor.locator('affine-data-view-table-group').first().hover();
+ const columnAddBtn = editor.locator('.header-add-column-button');
+ await columnAddBtn.click();
+ await waitNextFrame(page, 200);
+
+ if (title) {
+ await selectAllByKeyboard(page);
+ await type(page, title);
+ await waitNextFrame(page);
+ await pressEnter(page);
+ } else {
+ await pressEnter(page);
+ }
+}
+
+export const renameColumn = async (page: Page, name: string) => {
+ const column = page.locator('affine-database-header-column', {
+ hasText: name,
+ });
+ await column.click();
+};
+
+export async function performColumnAction(
+ page: Page,
+ name: string,
+ action: string
+) {
+ await renameColumn(page, name);
+
+ const actionMenu = page.locator(`.affine-menu-button`, { hasText: action });
+ await actionMenu.click();
+}
+
+export async function switchColumnType(
+ page: Page,
+ columnType: string,
+ columnIndex = 1
+) {
+ const { typeIcon } = await getDatabaseHeaderColumn(page, columnIndex);
+ await typeIcon.click();
+
+ await clickColumnType(page, columnType);
+}
+
+export function clickColumnType(page: Page, columnType: string) {
+ const typeMenu = page.locator(`.affine-menu-button`, {
+ hasText: new RegExp(`${columnType}`),
+ });
+ return typeMenu.click();
+}
+
+export function getDatabaseBodyRows(page: Page) {
+ const rowContainer = page.locator('.affine-database-block-rows');
+ return rowContainer.locator('.database-row');
+}
+
+export function getDatabaseBodyRow(page: Page, rowIndex = 0) {
+ const rows = getDatabaseBodyRows(page);
+ return rows.nth(rowIndex);
+}
+
+export function getDatabaseTableContainer(page: Page) {
+ const container = page.locator('.affine-database-table-container');
+ return container;
+}
+
+export async function assertDatabaseTitleColumnText(
+ page: Page,
+ title: string,
+ index = 0
+) {
+ const text = await page.evaluate(index => {
+ const rowContainer = document.querySelector('.affine-database-block-rows');
+ const row = rowContainer?.querySelector(
+ `.database-row:nth-child(${index + 1})`
+ );
+ const titleColumnCell = row?.querySelector('.database-cell:nth-child(1)');
+ const titleSpan = titleColumnCell?.querySelector(
+ '.data-view-header-area-rich-text'
+ ) as HTMLElement;
+ if (!titleSpan) throw new Error('Cannot find database title column editor');
+ return titleSpan.innerText;
+ }, index);
+
+ if (title === '') {
+ expect(text).toMatch(new RegExp(`^(|[${ZERO_WIDTH_SPACE}])$`));
+ } else {
+ expect(text).toBe(title);
+ }
+}
+
+export function getDatabaseBodyCell(
+ page: Page,
+ {
+ rowIndex,
+ columnIndex,
+ }: {
+ rowIndex: number;
+ columnIndex: number;
+ }
+) {
+ const row = getDatabaseBodyRow(page, rowIndex);
+ const cell = row.locator('.database-cell').nth(columnIndex);
+ return cell;
+}
+
+export function getDatabaseBodyCellContent(
+ page: Page,
+ {
+ rowIndex,
+ columnIndex,
+ cellClass,
+ }: {
+ rowIndex: number;
+ columnIndex: number;
+ cellClass: string;
+ }
+) {
+ const cell = getDatabaseBodyCell(page, { rowIndex, columnIndex });
+ const cellContent = cell.locator(`.${cellClass}`);
+ return cellContent;
+}
+
+export function getFirstColumnCell(page: Page, cellClass: string) {
+ const cellContent = getDatabaseBodyCellContent(page, {
+ rowIndex: 0,
+ columnIndex: 1,
+ cellClass,
+ });
+ return cellContent;
+}
+
+export async function clickSelectOption(page: Page, index = 0) {
+ await page.locator('.select-option-icon').nth(index).click();
+}
+
+export async function performSelectColumnTagAction(
+ page: Page,
+ name: string,
+ index = 0
+) {
+ await clickSelectOption(page, index);
+ await page
+ .locator('.affine-menu-button', { hasText: new RegExp(name) })
+ .click();
+}
+
+export async function assertSelectedStyle(
+ page: Page,
+ key: keyof CSSStyleDeclaration,
+ value: string
+) {
+ const style = await getElementStyle(page, '.select-selected', key);
+ expect(style).toBe(value);
+}
+
+export async function clickDatabaseOutside(page: Page) {
+ const docTitle = page.locator('.doc-title-container');
+ await docTitle.click();
+}
+
+export async function assertColumnWidth(locator: Locator, width: number) {
+ const box = await getBoundingBox(locator);
+ expect(box.width).toBe(width + 1);
+ return box;
+}
+
+export async function assertDatabaseCellRichTexts(
+ page: Page,
+ {
+ rowIndex = 0,
+ columnIndex = 1,
+ text,
+ }: {
+ rowIndex?: number;
+ columnIndex?: number;
+ text: string;
+ }
+) {
+ const cellContainer = page.locator(
+ `affine-database-cell-container[data-row-index='${rowIndex}'][data-column-index='${columnIndex}']`
+ );
+
+ const cellEditing = cellContainer.locator(
+ 'affine-database-rich-text-cell-editing'
+ );
+ const cell = cellContainer.locator('affine-database-rich-text-cell');
+
+ const richText = (await cellEditing.count()) === 0 ? cell : cellEditing;
+ const actualTexts = await richText.evaluate(ele => {
+ return (ele as RichTextCellEditing).inlineEditor?.yTextString;
+ });
+ expect(actualTexts).toEqual(text);
+}
+
+export async function assertDatabaseCellNumber(
+ page: Page,
+ {
+ rowIndex = 0,
+ columnIndex = 1,
+ text,
+ }: {
+ rowIndex?: number;
+ columnIndex?: number;
+ text: string;
+ }
+) {
+ const actualText = await page
+ .locator('.affine-database-block-rows')
+ .locator('.database-row')
+ .nth(rowIndex)
+ .locator('.database-cell')
+ .nth(columnIndex)
+ .locator('.number')
+ .textContent();
+ expect(actualText?.trim()).toEqual(text);
+}
+
+export async function assertDatabaseCellLink(
+ page: Page,
+ {
+ rowIndex = 0,
+ columnIndex = 1,
+ text,
+ }: {
+ rowIndex?: number;
+ columnIndex?: number;
+ text: string;
+ }
+) {
+ const actualTexts = await page.evaluate(
+ ({ rowIndex, columnIndex }) => {
+ const rows = document.querySelector('.affine-database-block-rows');
+ const row = rows?.querySelector(
+ `.database-row:nth-child(${rowIndex + 1})`
+ );
+ const cell = row?.querySelector(
+ `.database-cell:nth-child(${columnIndex + 1})`
+ );
+ const richText =
+ cell?.querySelector('affine-database-link-cell') ??
+ cell?.querySelector(
+ 'affine-database-link-cell-editing'
+ );
+ if (!richText) throw new Error('Missing database rich text cell');
+ return richText.inlineEditor.yText.toString();
+ },
+ { rowIndex, columnIndex }
+ );
+ expect(actualTexts).toEqual(text);
+}
+
+export async function assertDatabaseTitleText(page: Page, text: string) {
+ const dbTitle = page.locator('[data-block-is-database-title="true"]');
+ expect(await dbTitle.inputValue()).toEqual(text);
+}
+
+export async function waitSearchTransitionEnd(page: Page) {
+ await waitNextFrame(page, 400);
+}
+
+export async function assertDatabaseSearching(
+ page: Page,
+ isSearching: boolean
+) {
+ const searchExpand = page.locator('.search-container-expand');
+ const count = await searchExpand.count();
+ expect(count).toBe(isSearching ? 1 : 0);
+}
+
+export async function focusDatabaseSearch(page: Page) {
+ await (await getDatabaseMouse(page)).mouseOver();
+
+ const searchExpand = page.locator('.search-container-expand');
+ const count = await searchExpand.count();
+ if (count === 1) {
+ const input = page.locator('.affine-database-search-input');
+ await input.click();
+ } else {
+ const searchIcon = page.locator('.affine-database-search-input-icon');
+ await searchIcon.click();
+ await waitSearchTransitionEnd(page);
+ }
+}
+
+export async function blurDatabaseSearch(page: Page) {
+ const dbTitle = page.locator('[data-block-is-database-title="true"]');
+ await dbTitle.click();
+}
+
+export async function focusDatabaseHeader(page: Page, columnIndex = 0) {
+ const column = page.locator('.affine-database-column').nth(columnIndex);
+ const box = await getBoundingBox(column);
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await waitNextFrame(page);
+ return column;
+}
+
+export async function getDatabaseMouse(page: Page) {
+ const databaseRect = await getBoundingClientRect(
+ page,
+ '.affine-database-table'
+ );
+ return {
+ mouseOver: async () => {
+ await page.mouse.move(databaseRect.x, databaseRect.y);
+ },
+ mouseLeave: async () => {
+ await page.mouse.move(databaseRect.x - 1, databaseRect.y - 1);
+ },
+ };
+}
+
+export async function getDatabaseHeaderColumn(page: Page, index = 0) {
+ const column = page.locator('.affine-database-column').nth(index);
+ const box = await getBoundingBox(column);
+ const textElement = column.locator('.affine-database-column-text-input');
+ const text = await textElement.innerText();
+ const typeIcon = column.locator('.affine-database-column-type-icon');
+
+ return {
+ column,
+ box,
+ text,
+ textElement,
+ typeIcon,
+ };
+}
+
+export async function assertRowsSelection(
+ page: Page,
+ rowIndexes: [start: number, end: number]
+) {
+ const rows = page.locator('data-view-table-row');
+ const startIndex = rowIndexes[0];
+ const endIndex = rowIndexes[1];
+ for (let i = startIndex; i <= endIndex; i++) {
+ const row = rows.nth(i);
+ await row.locator('.row-select-checkbox .selected').isVisible();
+ }
+}
+
+export async function assertCellsSelection(
+ page: Page,
+ cellIndexes: {
+ start: [rowIndex: number, columnIndex: number];
+ end?: [rowIndex: number, columnIndex: number];
+ }
+) {
+ const { start, end } = cellIndexes;
+
+ if (!end) {
+ // single cell
+ const focus = page.locator('.database-focus');
+ const focusBox = await getBoundingBox(focus);
+
+ const [rowIndex, columnIndex] = start;
+ const cell = getDatabaseBodyCell(page, { rowIndex, columnIndex });
+ const cellBox = await getBoundingBox(cell);
+ expect(focusBox).toEqual({
+ x: cellBox.x,
+ y: cellBox.y - 1,
+ height: cellBox.height + 2,
+ width: cellBox.width + 1,
+ });
+ } else {
+ // multi cells
+ const selection = page.locator('.database-selection');
+ const selectionBox = await getBoundingBox(selection);
+
+ const [startRowIndex, startColumnIndex] = start;
+ const [endRowIndex, endColumnIndex] = end;
+
+ const rowIndexStart = Math.min(startRowIndex, endRowIndex);
+ const rowIndexEnd = Math.max(startRowIndex, endRowIndex);
+ const columnIndexStart = Math.min(startColumnIndex, endColumnIndex);
+ const columnIndexEnd = Math.max(startColumnIndex, endColumnIndex);
+
+ let height = 0;
+ let width = 0;
+ let x = 0;
+ let y = 0;
+ for (let i = rowIndexStart; i <= rowIndexEnd; i++) {
+ const cell = getDatabaseBodyCell(page, {
+ rowIndex: i,
+ columnIndex: columnIndexStart,
+ });
+ const box = await getBoundingBox(cell);
+ height += box.height + 1;
+ if (i === rowIndexStart) {
+ y = box.y;
+ }
+ }
+
+ for (let j = columnIndexStart; j <= columnIndexEnd; j++) {
+ const cell = getDatabaseBodyCell(page, {
+ rowIndex: rowIndexStart,
+ columnIndex: j,
+ });
+ const box = await getBoundingBox(cell);
+ width += box.width;
+ if (j === columnIndexStart) {
+ x = box.x;
+ }
+ }
+
+ expect(selectionBox).toEqual({
+ x,
+ y,
+ height,
+ width: width + 1,
+ });
+ }
+}
+
+export async function getElementStyle(
+ page: Page,
+ selector: string,
+ key: keyof CSSStyleDeclaration
+) {
+ const style = await page.evaluate(
+ ({ key, selector }) => {
+ const el = document.querySelector(selector);
+ if (!el) throw new Error(`Missing ${selector} tag`);
+ // @ts-ignore
+ return el.style[key];
+ },
+ {
+ key,
+ selector,
+ }
+ );
+
+ return style;
+}
+
+export async function focusKanbanCardHeader(page: Page, index = 0) {
+ const cardHeader = page.locator('data-view-header-area-text').nth(index);
+ await cardHeader.click();
+}
+
+export async function clickKanbanCardHeader(page: Page, index = 0) {
+ const cardHeader = page.locator('data-view-header-area-text').nth(index);
+ await cardHeader.click();
+ await cardHeader.click();
+}
+
+export async function assertKanbanCardHeaderText(
+ page: Page,
+ text: string,
+ index = 0
+) {
+ const cardHeader = page.locator('data-view-header-area-text').nth(index);
+
+ await expect(cardHeader).toHaveText(text);
+}
+
+export async function assertKanbanCellSelected(
+ page: Page,
+ {
+ groupIndex,
+ cardIndex,
+ cellIndex,
+ }: {
+ groupIndex: number;
+ cardIndex: number;
+ cellIndex: number;
+ }
+) {
+ const border = await page.evaluate(
+ ({ groupIndex, cardIndex, cellIndex }) => {
+ const group = document.querySelector(
+ `affine-data-view-kanban-group:nth-child(${groupIndex + 1})`
+ );
+ const card = group?.querySelector(
+ `affine-data-view-kanban-card:nth-child(${cardIndex + 1})`
+ );
+ const cells = Array.from(
+ card?.querySelectorAll(`affine-data-view-kanban-cell`) ??
+ []
+ );
+ const cell = cells[cellIndex];
+ if (!cell) throw new Error(`Missing cell tag`);
+ return cell.style.border;
+ },
+ {
+ groupIndex,
+ cardIndex,
+ cellIndex,
+ }
+ );
+
+ expect(border).toEqual('1px solid var(--affine-primary-color)');
+}
+
+export async function assertKanbanCardSelected(
+ page: Page,
+ {
+ groupIndex,
+ cardIndex,
+ }: {
+ groupIndex: number;
+ cardIndex: number;
+ }
+) {
+ const border = await page.evaluate(
+ ({ groupIndex, cardIndex }) => {
+ const group = document.querySelector(
+ `affine-data-view-kanban-group:nth-child(${groupIndex + 1})`
+ );
+ const card = group?.querySelector(
+ `affine-data-view-kanban-card:nth-child(${cardIndex + 1})`
+ );
+ if (!card) throw new Error(`Missing card tag`);
+ return card.style.border;
+ },
+ {
+ groupIndex,
+ cardIndex,
+ }
+ );
+
+ expect(border).toEqual('1px solid var(--affine-primary-color)');
+}
+
+export function getKanbanCard(
+ page: Page,
+ {
+ groupIndex,
+ cardIndex,
+ }: {
+ groupIndex: number;
+ cardIndex: number;
+ }
+) {
+ const group = page.locator('affine-data-view-kanban-group').nth(groupIndex);
+ const card = group.locator('affine-data-view-kanban-card').nth(cardIndex);
+ return card;
+}
+export const moveToCenterOf = async (page: Page, locator: Locator) => {
+ const box = (await locator.boundingBox())!;
+ expect(box).toBeDefined();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+};
+export const changeColumnType = async (
+ page: Page,
+ column: number,
+ name: string
+) => {
+ await waitNextFrame(page);
+ await page.locator('affine-database-header-column').nth(column).click();
+ await waitNextFrame(page, 200);
+ await pressKey(page, 'Escape');
+ await pressKey(page, 'ArrowDown');
+ await pressKey(page, 'Enter');
+ await type(page, name);
+ await pressKey(page, 'ArrowDown');
+ await pressKey(page, 'Enter');
+};
+export const pressKey = async (page: Page, key: string, count: number = 1) => {
+ for (let i = 0; i < count; i++) {
+ await waitNextFrame(page);
+ await press(page, key);
+ }
+ await waitNextFrame(page);
+};
diff --git a/blocksuite/tests-legacy/database/clipboard.spec.ts b/blocksuite/tests-legacy/database/clipboard.spec.ts
new file mode 100644
index 0000000000000..d8dd54906086b
--- /dev/null
+++ b/blocksuite/tests-legacy/database/clipboard.spec.ts
@@ -0,0 +1,176 @@
+import { expect } from '@playwright/test';
+
+import { dragBetweenCoords } from '../utils/actions/drag.js';
+import {
+ copyByKeyboard,
+ pasteByKeyboard,
+ pressArrowDown,
+ pressArrowUp,
+ pressEnter,
+ pressEscape,
+ type,
+} from '../utils/actions/keyboard.js';
+import {
+ enterPlaygroundRoom,
+ focusRichText,
+ getBoundingBox,
+ initDatabaseDynamicRowWithData,
+ initDatabaseRowWithData,
+ initEmptyDatabaseState,
+ initEmptyDatabaseWithParagraphState,
+ waitNextFrame,
+} from '../utils/actions/misc.js';
+import { assertRichTexts } from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+import {
+ assertDatabaseTitleColumnText,
+ getDatabaseBodyCell,
+ getElementStyle,
+ initDatabaseColumn,
+ switchColumnType,
+} from './actions.js';
+
+test.describe('copy&paste when editing', () => {
+ test.skip('should support copy&paste of the title column', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseWithParagraphState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'abc123');
+ await pressEscape(page);
+
+ await pressEnter(page);
+ await page.keyboard.down('Shift');
+ for (let i = 0; i < 4; i++) {
+ await page.keyboard.press('ArrowLeft');
+ }
+ await page.keyboard.up('Shift');
+ await copyByKeyboard(page);
+
+ const bgValue = await getElementStyle(page, '.database-focus', 'boxShadow');
+ expect(bgValue).not.toBe('unset');
+
+ await focusRichText(page, 1);
+ await pasteByKeyboard(page);
+ await assertRichTexts(page, ['Database 1', 'c123']);
+ });
+});
+
+test.describe('copy&paste when selecting', () => {
+ test.skip('should support copy&paste of a single cell', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'abc123');
+ await initDatabaseRowWithData(page, '');
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+ await pressArrowUp(page);
+
+ await copyByKeyboard(page);
+ await pressArrowDown(page);
+ await pasteByKeyboard(page, false);
+ await waitNextFrame(page);
+ await assertDatabaseTitleColumnText(page, 'abc123', 1);
+ });
+
+ test.skip('should support copy&paste of multi cells', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'text1');
+ await initDatabaseDynamicRowWithData(page, '123', false);
+ await pressEscape(page);
+ await initDatabaseRowWithData(page, 'text2');
+ await initDatabaseDynamicRowWithData(page, 'a', false);
+ await pressEscape(page);
+
+ await initDatabaseRowWithData(page, '');
+ await initDatabaseRowWithData(page, '');
+
+ const startCell = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 0,
+ });
+ const startCellBox = await getBoundingBox(startCell);
+ const endCell = getDatabaseBodyCell(page, { rowIndex: 1, columnIndex: 1 });
+ const endCellBox = await getBoundingBox(endCell);
+ const startX = startCellBox.x + startCellBox.width / 2;
+ const startY = startCellBox.y + startCellBox.height / 2;
+ const endX = endCellBox.x + endCellBox.width / 2;
+ const endY = endCellBox.y + endCellBox.height / 2;
+ await dragBetweenCoords(
+ page,
+ { x: startX, y: startY },
+ { x: endX, y: endY },
+ {
+ steps: 50,
+ }
+ );
+ await copyByKeyboard(page);
+
+ await pressArrowDown(page);
+ await pressArrowDown(page);
+ await waitNextFrame(page);
+ await pasteByKeyboard(page, false);
+
+ await assertDatabaseTitleColumnText(page, 'text1', 2);
+ await assertDatabaseTitleColumnText(page, 'text2', 3);
+ const selectCell21 = getDatabaseBodyCell(page, {
+ rowIndex: 2,
+ columnIndex: 1,
+ });
+ const selectCell31 = getDatabaseBodyCell(page, {
+ rowIndex: 3,
+ columnIndex: 1,
+ });
+ expect(await selectCell21.innerText()).toBe('123');
+ expect(await selectCell31.innerText()).toBe('a');
+ });
+
+ test.skip('should support copy&paste of a single row', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'text1');
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+ await initDatabaseDynamicRowWithData(page, 'abc', false);
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Number', 2);
+ const numberCell = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 2,
+ });
+ await numberCell.click();
+ await waitNextFrame(page);
+ await type(page, '123');
+ await pressEscape(page);
+ await pressEscape(page);
+ await copyByKeyboard(page);
+
+ await initDatabaseRowWithData(page, '');
+ await pressEscape(page);
+ await pasteByKeyboard(page, false);
+ await waitNextFrame(page);
+
+ await assertDatabaseTitleColumnText(page, 'text1', 1);
+ const selectCell = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 1,
+ });
+ expect(await selectCell.innerText()).toBe('abc');
+ const selectNumberCell = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 2,
+ });
+ expect(await selectNumberCell.innerText()).toBe('123');
+ });
+});
diff --git a/blocksuite/tests-legacy/database/column.spec.ts b/blocksuite/tests-legacy/database/column.spec.ts
new file mode 100644
index 0000000000000..199a5d7f31e29
--- /dev/null
+++ b/blocksuite/tests-legacy/database/column.spec.ts
@@ -0,0 +1,599 @@
+import { expect } from '@playwright/test';
+
+import {
+ assertDatabaseColumnOrder,
+ dragBetweenCoords,
+ enterPlaygroundRoom,
+ getBoundingBox,
+ initDatabaseDynamicRowWithData,
+ initEmptyDatabaseState,
+ pressArrowRight,
+ pressArrowUp,
+ pressArrowUpWithShiftKey,
+ pressBackspace,
+ pressEnter,
+ pressEscape,
+ redoByClick,
+ selectAllByKeyboard,
+ type,
+ undoByClick,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import { test } from '../utils/playwright.js';
+import {
+ assertDatabaseCellNumber,
+ assertDatabaseCellRichTexts,
+ assertSelectedStyle,
+ changeColumnType,
+ clickDatabaseOutside,
+ clickSelectOption,
+ getDatabaseHeaderColumn,
+ getFirstColumnCell,
+ initDatabaseColumn,
+ performColumnAction,
+ performSelectColumnTagAction,
+ switchColumnType,
+} from './actions.js';
+
+test.describe('column operations', () => {
+ test('should support rename column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, 'abc');
+
+ const { textElement } = await getDatabaseHeaderColumn(page, 1);
+ expect(await textElement.innerText()).toBe('abc');
+ await textElement.click();
+ await waitNextFrame(page, 200);
+ await pressArrowRight(page);
+ await type(page, '123');
+ await pressEnter(page);
+ expect(await textElement.innerText()).toBe('abc123');
+
+ await undoByClick(page);
+ expect(await textElement.innerText()).toBe('abc');
+ await redoByClick(page);
+ expect(await textElement.innerText()).toBe('abc123');
+ });
+
+ test('should support add new column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await pressEscape(page);
+ const { text: title1 } = await getDatabaseHeaderColumn(page, 1);
+ expect(title1).toBe('Column 1');
+
+ const selected = getFirstColumnCell(page, 'select-selected');
+ expect(await selected.innerText()).toBe('123');
+
+ await initDatabaseColumn(page, 'abc');
+ const { text: title2 } = await getDatabaseHeaderColumn(page, 2);
+ expect(title2).toBe('abc');
+
+ await initDatabaseColumn(page);
+ const { text: title3 } = await getDatabaseHeaderColumn(page, 3);
+ expect(title3).toBe('Column 2');
+ });
+
+ test('should support right insert column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+
+ await performColumnAction(page, '1', 'Insert right');
+ await selectAllByKeyboard(page);
+ await type(page, '2');
+ await pressEnter(page);
+ const columns = page.locator('.affine-database-column');
+ expect(await columns.count()).toBe(3);
+
+ await assertDatabaseColumnOrder(page, ['1', '2']);
+ });
+
+ test('should support left insert column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+
+ await performColumnAction(page, '1', 'Insert left');
+ await selectAllByKeyboard(page);
+ await type(page, '2');
+ await pressEnter(page);
+ const columns = page.locator('.affine-database-column');
+ expect(await columns.count()).toBe(3);
+
+ await assertDatabaseColumnOrder(page, ['2', '1']);
+ });
+
+ test('should support delete column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+
+ const columns = page.locator('.affine-database-column');
+ expect(await columns.count()).toBe(2);
+
+ await performColumnAction(page, '1', 'Delete');
+ expect(await columns.count()).toBe(1);
+ });
+
+ test('should support duplicate column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await pressEscape(page);
+ await performColumnAction(page, '1', 'duplicate');
+ await pressEscape(page);
+ const cells = page.locator('affine-database-multi-select-cell');
+ expect(await cells.count()).toBe(2);
+
+ const secondCell = cells.nth(1);
+ const selected = secondCell.locator('.select-selected');
+ expect(await selected.innerText()).toBe('123');
+ });
+
+ test('should support move column right', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await pressEscape(page);
+ await initDatabaseColumn(page, '2');
+ await initDatabaseDynamicRowWithData(page, 'abc', false, 1);
+ await pressEscape(page);
+ await assertDatabaseColumnOrder(page, ['1', '2']);
+ await waitNextFrame(page, 350);
+ await performColumnAction(page, '1', 'Move right');
+ await assertDatabaseColumnOrder(page, ['2', '1']);
+
+ await undoByClick(page);
+ const { column } = await getDatabaseHeaderColumn(page, 2);
+ await column.click();
+ const moveLeft = page.locator('.action', { hasText: 'Move right' });
+ expect(await moveLeft.count()).toBe(0);
+ });
+
+ test('should support move column left', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await pressEscape(page);
+ await initDatabaseColumn(page, '2');
+ await initDatabaseDynamicRowWithData(page, 'abc', false, 1);
+ await pressEscape(page);
+ await assertDatabaseColumnOrder(page, ['1', '2']);
+
+ const { column } = await getDatabaseHeaderColumn(page, 0);
+ await column.click();
+ const moveLeft = page.locator('.action', { hasText: 'Move left' });
+ expect(await moveLeft.count()).toBe(0);
+ await waitNextFrame(page, 200);
+ await pressEscape(page);
+ await pressEscape(page);
+
+ await performColumnAction(page, '2', 'Move left');
+ await assertDatabaseColumnOrder(page, ['2', '1']);
+ });
+});
+
+test.describe('switch column type', () => {
+ test('switch to number', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123abc', true);
+ await pressEscape(page);
+ await changeColumnType(page, 1, 'Number');
+
+ const cell = getFirstColumnCell(page, 'number');
+ await assertDatabaseCellNumber(page, {
+ text: '',
+ });
+ await pressEnter(page);
+ await type(page, '123abc');
+ await pressEscape(page);
+ expect((await cell.textContent())?.trim()).toBe('123');
+ });
+
+ test('switch to rich-text', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123abc', true);
+ await pressEscape(page);
+ await switchColumnType(page, 'Text');
+
+ // For now, rich-text will only be initialized on click
+ // Therefore, for the time being, here is to detect whether there is '.affine-database-rich-text'
+ const cell = getFirstColumnCell(page, 'affine-database-rich-text');
+ expect(await cell.count()).toBe(1);
+ await pressEnter(page);
+ await type(page, '123');
+ await pressEscape(page);
+ await pressEnter(page);
+ await type(page, 'abc');
+ await pressEscape(page);
+ await assertDatabaseCellRichTexts(page, { text: '123abc123abc' });
+ });
+
+ test('switch between multi-select and select', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await type(page, 'abc');
+ await pressEnter(page);
+ await pressEscape(page);
+ const cell = getFirstColumnCell(page, 'select-selected');
+ expect(await cell.count()).toBe(2);
+
+ await switchColumnType(page, 'Select', 1);
+ expect(await cell.count()).toBe(1);
+ expect(await cell.innerText()).toBe('123');
+
+ await pressEnter(page);
+ await type(page, 'def');
+ await pressEnter(page);
+ expect(await cell.innerText()).toBe('def');
+
+ await switchColumnType(page, 'Multi-select');
+ await pressEnter(page);
+ await type(page, '666');
+ await pressEnter(page);
+ await pressEscape(page);
+ expect(await cell.count()).toBe(2);
+ expect(await cell.nth(0).innerText()).toBe('def');
+ expect(await cell.nth(1).innerText()).toBe('666');
+
+ await switchColumnType(page, 'Select');
+ expect(await cell.count()).toBe(1);
+ expect(await cell.innerText()).toBe('def');
+
+ await pressEnter(page);
+ await type(page, '888');
+ await pressEnter(page);
+ expect(await cell.innerText()).toBe('888');
+ });
+
+ test('switch between number and rich-text', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Number');
+
+ await initDatabaseDynamicRowWithData(page, '123abc', true);
+ await assertDatabaseCellNumber(page, {
+ text: '123',
+ });
+
+ await switchColumnType(page, 'Text');
+ await pressEnter(page);
+ await type(page, 'abc');
+ await pressEscape(page);
+ await assertDatabaseCellRichTexts(page, { text: '123abc' });
+
+ await switchColumnType(page, 'Number');
+ await assertDatabaseCellNumber(page, {
+ text: '123',
+ });
+ });
+
+ test('switch number to select', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Number');
+
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ const cell = getFirstColumnCell(page, 'number');
+ expect((await cell.textContent())?.trim()).toBe('123');
+
+ await switchColumnType(page, 'Select');
+ await initDatabaseDynamicRowWithData(page, 'abc');
+ const selectCell = getFirstColumnCell(page, 'select-selected');
+ expect(await selectCell.innerText()).toBe('abc');
+
+ await switchColumnType(page, 'Number');
+ await assertDatabaseCellNumber(page, {
+ text: '',
+ });
+ });
+
+ test('switch to checkbox', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+ await changeColumnType(page, 1, 'Checkbox');
+
+ const checkbox = getFirstColumnCell(page, 'checkbox');
+ await expect(checkbox).not.toHaveClass('checked');
+
+ await waitNextFrame(page, 500);
+ await checkbox.click();
+ await expect(checkbox).toHaveClass(/checked/);
+
+ await undoByClick(page);
+ await expect(checkbox).not.toHaveClass('checked');
+ });
+
+ test('checkbox to text', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+ await changeColumnType(page, 1, 'Checkbox');
+
+ let checkbox = getFirstColumnCell(page, 'checkbox');
+ await expect(checkbox).not.toHaveClass('checked');
+
+ // checked
+ await checkbox.click();
+ await changeColumnType(page, 1, 'Text');
+ await clickDatabaseOutside(page);
+ await waitNextFrame(page, 100);
+ await assertDatabaseCellRichTexts(page, { text: 'Yes' });
+ await clickDatabaseOutside(page);
+ await waitNextFrame(page, 100);
+ await changeColumnType(page, 1, 'Checkbox');
+ checkbox = getFirstColumnCell(page, 'checkbox');
+ await expect(checkbox).toHaveClass(/checked/);
+
+ // not checked
+ await checkbox.click();
+ await changeColumnType(page, 1, 'Text');
+ await clickDatabaseOutside(page);
+ await waitNextFrame(page, 100);
+ await assertDatabaseCellRichTexts(page, { text: 'No' });
+ await clickDatabaseOutside(page);
+ await waitNextFrame(page, 100);
+ await changeColumnType(page, 1, 'Checkbox');
+ checkbox = getFirstColumnCell(page, 'checkbox');
+ await expect(checkbox).not.toHaveClass('checked');
+ });
+
+ test('switch to progress', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+ await switchColumnType(page, 'Progress');
+
+ const progress = getFirstColumnCell(page, 'progress');
+ expect(await progress.textContent()).toBe('0');
+ await waitNextFrame(page, 500);
+ const progressBg = page.locator('.affine-database-progress-bg');
+ const {
+ x: progressBgX,
+ y: progressBgY,
+ width: progressBgWidth,
+ } = await getBoundingBox(progressBg);
+ await page.mouse.move(progressBgX, progressBgY);
+ await page.mouse.click(progressBgX, progressBgY);
+ const dragHandle = page.locator('.affine-database-progress-drag-handle');
+ const {
+ x: dragX,
+ y: dragY,
+ width,
+ height,
+ } = await getBoundingBox(dragHandle);
+ const dragCenterX = dragX + width / 2;
+ const dragCenterY = dragY + height / 2;
+ await page.mouse.move(dragCenterX, dragCenterY);
+
+ const endX = dragCenterX + progressBgWidth;
+ await dragBetweenCoords(
+ page,
+ { x: dragCenterX, y: dragCenterY },
+ { x: endX, y: dragCenterY }
+ );
+ expect(await progress.textContent()).toBe('100');
+ await pressEscape(page);
+ await undoByClick(page);
+ expect(await progress.textContent()).toBe('0');
+ });
+
+ test('switch to link', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+
+ await switchColumnType(page, 'Link');
+
+ const linkText = 'http://example.com';
+ const cell = getFirstColumnCell(page, 'affine-database-link');
+ await pressEnter(page);
+ await type(page, linkText);
+ await pressEscape(page);
+ const link = cell.locator('affine-database-link-node > a');
+ const linkContent = link.locator('.link-node-text');
+ await expect(link).toHaveAttribute('href', linkText);
+ expect(await linkContent.textContent()).toBe(linkText);
+
+ // not link text
+ await cell.hover();
+ const linkEdit = getFirstColumnCell(page, 'affine-database-link-icon');
+ await linkEdit.click();
+ await selectAllByKeyboard(page);
+ await type(page, 'abc');
+ await pressEnter(page);
+ await expect(link).toBeHidden();
+ });
+});
+
+test.describe('select column tag action', () => {
+ test('should support select tag renaming', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await type(page, 'abc');
+ await pressEnter(page);
+ await clickSelectOption(page);
+ await waitNextFrame(page);
+ await pressArrowRight(page);
+ await type(page, '4567abc00');
+ await pressEnter(page);
+ const options = page.locator('.select-options-container .tag-text');
+ expect(await options.nth(0).innerText()).toBe('abc4567abc00');
+ expect(await options.nth(1).innerText()).toBe('123');
+ });
+
+ test('should select tag renaming support shortcut key', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await clickSelectOption(page);
+ await waitNextFrame(page);
+ await pressArrowRight(page);
+ await type(page, '456');
+ // esc
+ await pressEscape(page);
+ await pressEscape(page);
+ const options = page.locator('.select-options-container .tag-text');
+ const option1 = options.nth(0);
+ expect(await option1.innerText()).toBe('123456');
+ });
+
+ test('should support select tag deletion', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await performSelectColumnTagAction(page, 'Delete');
+ const options = page.locator('.select-option-name');
+ expect(await options.count()).toBe(0);
+ });
+
+ test('should support modifying select tag color', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await performSelectColumnTagAction(page, 'Red');
+ await pressEscape(page);
+ await assertSelectedStyle(
+ page,
+ 'backgroundColor',
+ 'var(--affine-v2-chip-label-red)'
+ );
+ });
+});
+
+test.describe('drag-to-fill', () => {
+ test('should show when cell in focus and hide on blur', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+
+ await pressEscape(page);
+
+ const dragToFillHandle = page.locator('.drag-to-fill');
+
+ await expect(dragToFillHandle).toBeVisible();
+
+ await pressEscape(page);
+
+ await expect(dragToFillHandle).toBeHidden();
+ });
+
+ test('should not show in multi (row or column) selection', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+
+ const dragToFillHandle = page.locator('.drag-to-fill');
+
+ await expect(dragToFillHandle).toBeVisible();
+
+ await pressArrowUpWithShiftKey(page);
+
+ await expect(dragToFillHandle).toBeHidden();
+ await pressArrowUp(page);
+
+ await expect(dragToFillHandle).toBeVisible();
+ });
+
+ test('should fill columns with data', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+
+ await initDatabaseDynamicRowWithData(page, 'thing', true);
+ await pressEscape(page);
+
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressBackspace(page);
+ await type(page, 'aaa');
+ await pressEnter(page);
+ await pressEnter(page);
+
+ await pressEscape(page);
+ await pressArrowUp(page);
+
+ const cells = page.locator('affine-database-multi-select-cell');
+
+ expect(await cells.nth(0).innerText()).toBe('thing');
+ expect(await cells.nth(1).innerText()).toBe('aaa');
+
+ const dragToFillHandle = page.locator('.drag-to-fill');
+
+ await expect(dragToFillHandle).toBeVisible();
+
+ const bbox = await getBoundingBox(dragToFillHandle);
+
+ if (!bbox) throw new Error('Expected a bounding box');
+
+ await dragBetweenCoords(
+ page,
+ { x: bbox.x + bbox.width / 2, y: bbox.y + bbox.height / 2 },
+ { x: bbox.x, y: bbox.y + 200 }
+ );
+
+ expect(await cells.nth(0).innerText()).toBe('thing');
+ expect(await cells.nth(1).innerText()).toBe('thing');
+ });
+});
diff --git a/blocksuite/tests-legacy/database/database.spec.ts b/blocksuite/tests-legacy/database/database.spec.ts
new file mode 100644
index 0000000000000..3404738cae437
--- /dev/null
+++ b/blocksuite/tests-legacy/database/database.spec.ts
@@ -0,0 +1,670 @@
+import { expect } from '@playwright/test';
+
+import {
+ dragBetweenCoords,
+ enterPlaygroundRoom,
+ focusDatabaseTitle,
+ getBoundingBox,
+ initDatabaseDynamicRowWithData,
+ initDatabaseRowWithData,
+ initEmptyDatabaseState,
+ pressArrowLeft,
+ pressArrowRight,
+ pressBackspace,
+ pressEnter,
+ pressEscape,
+ pressShiftEnter,
+ redoByKeyboard,
+ selectAllByKeyboard,
+ setInlineRangeInInlineEditor,
+ switchReadonly,
+ type,
+ undoByClick,
+ undoByKeyboard,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import {
+ assertBlockProps,
+ assertInlineEditorDeltas,
+ assertRowCount,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+import { getFormatBar } from '../utils/query.js';
+import {
+ assertColumnWidth,
+ assertDatabaseCellRichTexts,
+ assertDatabaseSearching,
+ assertDatabaseTitleText,
+ blurDatabaseSearch,
+ clickColumnType,
+ clickDatabaseOutside,
+ focusDatabaseHeader,
+ focusDatabaseSearch,
+ getDatabaseBodyCell,
+ getDatabaseHeaderColumn,
+ getFirstColumnCell,
+ initDatabaseColumn,
+ switchColumnType,
+} from './actions.js';
+
+test('edit database block title and create new rows', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ const locator = page.locator('affine-database');
+ await expect(locator).toBeVisible();
+ const dbTitle = 'Database 1';
+ await assertBlockProps(page, '2', {
+ title: dbTitle,
+ });
+ await focusDatabaseTitle(page);
+ await selectAllByKeyboard(page);
+ await pressBackspace(page);
+
+ const expected = 'hello';
+ await type(page, expected);
+ await assertBlockProps(page, '2', {
+ title: 'hello',
+ });
+ await undoByClick(page);
+ await assertBlockProps(page, '2', {
+ title: 'Database 1',
+ });
+ await initDatabaseRowWithData(page, '');
+ await initDatabaseRowWithData(page, '');
+ await assertRowCount(page, 2);
+ await waitNextFrame(page, 100);
+ await pressEscape(page);
+ await undoByClick(page);
+ await undoByClick(page);
+ await assertRowCount(page, 0);
+});
+
+test('edit column title', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, '1');
+
+ // first added column
+ const { column } = await getDatabaseHeaderColumn(page, 1);
+ expect(await column.innerText()).toBe('1');
+
+ await undoByClick(page);
+ expect(await column.innerText()).toBe('Column 1');
+});
+
+test('should modify the value when the input loses focus', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Number');
+ await initDatabaseDynamicRowWithData(page, '1', true);
+
+ await clickDatabaseOutside(page);
+ const cell = getFirstColumnCell(page, 'number');
+ const text = await cell.textContent();
+ expect(text?.trim()).toBe('1');
+});
+
+test('should rich-text column support soft enter', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+
+ const cell = getFirstColumnCell(page, 'affine-database-rich-text');
+ await cell.click();
+ await pressArrowLeft(page);
+ await pressEnter(page);
+ await assertDatabaseCellRichTexts(page, { text: '123' });
+
+ await cell.click();
+ await pressArrowRight(page);
+ await pressArrowLeft(page);
+ await pressShiftEnter(page);
+ await pressEnter(page);
+ await assertDatabaseCellRichTexts(page, { text: '12\n3' });
+});
+
+test('should the multi-select mode work correctly', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '1', true);
+ await pressEscape(page);
+ await initDatabaseDynamicRowWithData(page, '2');
+ await pressEscape(page);
+ const cell = getFirstColumnCell(page, 'select-selected');
+ expect(await cell.count()).toBe(2);
+ expect(await cell.nth(0).innerText()).toBe('1');
+ expect(await cell.nth(1).innerText()).toBe('2');
+});
+
+test('should database search work', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'text1');
+ await initDatabaseDynamicRowWithData(page, '123', false);
+ await pressEscape(page);
+ await initDatabaseRowWithData(page, 'text2');
+ await initDatabaseDynamicRowWithData(page, 'a', false);
+ await pressEscape(page);
+ await initDatabaseRowWithData(page, 'text3');
+ await initDatabaseDynamicRowWithData(page, '26', false);
+ await pressEscape(page);
+ // search for '2'
+ await focusDatabaseSearch(page);
+ await type(page, '2');
+ const rows = page.locator('.affine-database-block-row');
+ expect(await rows.count()).toBe(3);
+
+ // search for '23'
+ await type(page, '3');
+ expect(await rows.count()).toBe(1);
+
+ const cell = page.locator('.select-selected');
+ expect(await cell.innerText()).toBe('123');
+
+ // clear search input
+ const closeIcon = page.locator('.close-icon');
+ await closeIcon.click();
+ expect(await rows.count()).toBe(3);
+});
+
+test('should database search input displayed correctly', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await focusDatabaseSearch(page);
+ await blurDatabaseSearch(page);
+ await assertDatabaseSearching(page, false);
+
+ await focusDatabaseSearch(page);
+ await type(page, '2');
+ await blurDatabaseSearch(page);
+ await assertDatabaseSearching(page, true);
+
+ await focusDatabaseSearch(page);
+ await pressBackspace(page);
+ await blurDatabaseSearch(page);
+ await assertDatabaseSearching(page, false);
+
+ await focusDatabaseSearch(page);
+ await type(page, '2');
+ const closeIcon = page.locator('.close-icon');
+ await closeIcon.click();
+ await blurDatabaseSearch(page);
+ await assertDatabaseSearching(page, false);
+
+ await focusDatabaseSearch(page);
+ await type(page, '2');
+ await pressEscape(page);
+ await blurDatabaseSearch(page);
+ await assertDatabaseSearching(page, false);
+});
+
+test('should database title and rich-text support undo/redo', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await undoByKeyboard(page);
+ await assertDatabaseCellRichTexts(page, { text: '' });
+ await pressEscape(page);
+ await redoByKeyboard(page);
+ await assertDatabaseCellRichTexts(page, { text: '123' });
+
+ await focusDatabaseTitle(page);
+ await type(page, 'abc');
+ await assertDatabaseTitleText(page, 'Database 1abc');
+ await undoByKeyboard(page);
+ await assertDatabaseTitleText(page, 'Database 1');
+ await redoByKeyboard(page);
+ await assertDatabaseTitleText(page, 'Database 1abc');
+});
+
+test('should support drag to change column width', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ const headerColumns = page.locator('.affine-database-column');
+ const titleColumn = headerColumns.nth(0);
+ const normalColumn = headerColumns.nth(1);
+
+ const dragDistance = 100;
+ const titleColumnWidth = 260;
+ const normalColumnWidth = 180;
+
+ await assertColumnWidth(titleColumn, titleColumnWidth - 1);
+ const box = await assertColumnWidth(normalColumn, normalColumnWidth - 1);
+
+ await dragBetweenCoords(
+ page,
+ { x: box.x, y: box.y },
+ { x: box.x + dragDistance, y: box.y },
+ {
+ steps: 50,
+ beforeMouseUp: async () => {
+ await waitNextFrame(page);
+ },
+ }
+ );
+
+ await assertColumnWidth(titleColumn, titleColumnWidth + dragDistance);
+ await assertColumnWidth(normalColumn, normalColumnWidth - 1);
+
+ await undoByClick(page);
+ await assertColumnWidth(titleColumn, titleColumnWidth - 1);
+ await assertColumnWidth(normalColumn, normalColumnWidth - 1);
+});
+
+test('should display the add column button on the right side of database correctly', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ const normalColumn = page.locator('.affine-database-column').nth(1);
+
+ const addColumnBtn = page.locator('.header-add-column-button');
+
+ const box = await getBoundingBox(normalColumn);
+ await dragBetweenCoords(
+ page,
+ { x: box.x, y: box.y },
+ { x: box.x + 400, y: box.y },
+ {
+ steps: 50,
+ beforeMouseUp: async () => {
+ await waitNextFrame(page);
+ },
+ }
+ );
+ await focusDatabaseHeader(page);
+ await expect(addColumnBtn).toBeVisible();
+});
+
+test('should support drag and drop to move columns', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page, 'column1');
+ await initDatabaseColumn(page, 'column2');
+ await initDatabaseColumn(page, 'column3');
+
+ const column1 = await focusDatabaseHeader(page, 1);
+ const moveIcon = column1.locator('.affine-database-column-move');
+ const moveIconBox = await getBoundingBox(moveIcon);
+ const x = moveIconBox.x + moveIconBox.width / 2;
+ const y = moveIconBox.y + moveIconBox.height / 2;
+
+ await dragBetweenCoords(
+ page,
+ { x, y },
+ { x: x + 100, y },
+ {
+ steps: 50,
+ beforeMouseUp: async () => {
+ await waitNextFrame(page);
+ const indicator = page.locator('.vertical-indicator').first();
+ await expect(indicator).toBeVisible();
+
+ const { box } = await getDatabaseHeaderColumn(page, 2);
+ const indicatorBox = await getBoundingBox(indicator);
+ expect(box.x + box.width - indicatorBox.x < 10).toBe(true);
+ },
+ }
+ );
+
+ const { text } = await getDatabaseHeaderColumn(page, 2);
+ expect(text).toBe('column1');
+});
+
+test('should title column support quick renaming', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, 'a', true);
+ await pressEscape(page);
+ await focusDatabaseHeader(page, 1);
+ const { textElement } = await getDatabaseHeaderColumn(page, 1);
+ await textElement.click();
+ await waitNextFrame(page);
+ await selectAllByKeyboard(page);
+ await type(page, '123');
+ await pressEnter(page);
+ expect(await textElement.innerText()).toBe('123');
+
+ await undoByClick(page);
+ expect(await textElement.innerText()).toBe('Column 1');
+ await textElement.click();
+ await waitNextFrame(page);
+ await selectAllByKeyboard(page);
+ await type(page, '123');
+ await pressEnter(page);
+ expect(await textElement.innerText()).toBe('123');
+});
+
+test('should title column support quick changing of column type', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, 'a', true);
+ await pressEscape(page);
+ await initDatabaseDynamicRowWithData(page, 'b');
+ await pressEscape(page);
+ await focusDatabaseHeader(page, 1);
+ const { typeIcon } = await getDatabaseHeaderColumn(page, 1);
+ await typeIcon.click();
+ await waitNextFrame(page);
+ await clickColumnType(page, 'Select');
+ const cell = getFirstColumnCell(page, 'select-selected');
+ expect(await cell.count()).toBe(1);
+});
+
+test('database format-bar in header and text column', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, 'column', true);
+ await pressArrowLeft(page);
+ await pressEnter(page);
+ await type(page, 'header');
+ // Title | Column1
+ // ----------------
+ // header | column
+
+ const formatBar = getFormatBar(page);
+ await setInlineRangeInInlineEditor(page, { index: 1, length: 4 }, 1);
+ expect(await formatBar.formatBar.isVisible()).toBe(true);
+ // Title | Column1
+ // ----------------
+ // h|eade|r | column
+
+ await assertInlineEditorDeltas(
+ page,
+ [
+ {
+ insert: 'header',
+ },
+ ],
+ 1
+ );
+ await formatBar.boldBtn.click();
+ await assertInlineEditorDeltas(
+ page,
+ [
+ {
+ insert: 'h',
+ },
+ {
+ insert: 'eade',
+ attributes: {
+ bold: true,
+ },
+ },
+ {
+ insert: 'r',
+ },
+ ],
+ 1
+ );
+
+ await pressEscape(page);
+ await pressArrowRight(page);
+ await pressEnter(page);
+ await setInlineRangeInInlineEditor(page, { index: 2, length: 2 }, 2);
+ expect(await formatBar.formatBar.isVisible()).toBe(true);
+ // Title | Column1
+ // ----------------
+ // header | co|lu|mn
+
+ await assertInlineEditorDeltas(
+ page,
+ [
+ {
+ insert: 'column',
+ },
+ ],
+ 2
+ );
+ await formatBar.boldBtn.click();
+ await assertInlineEditorDeltas(
+ page,
+ [
+ {
+ insert: 'co',
+ },
+ {
+ insert: 'lu',
+ attributes: {
+ bold: true,
+ },
+ },
+ {
+ insert: 'mn',
+ },
+ ],
+ 2
+ );
+});
+
+test.describe('readonly mode', () => {
+ test('database title should not be edited in readonly mode', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ const locator = page.locator('affine-database');
+ await expect(locator).toBeVisible();
+
+ const dbTitle = 'Database 1';
+ await assertBlockProps(page, '2', {
+ title: dbTitle,
+ });
+
+ await focusDatabaseTitle(page);
+ await selectAllByKeyboard(page);
+ await pressBackspace(page);
+
+ await type(page, 'hello');
+ await assertBlockProps(page, '2', {
+ title: 'hello',
+ });
+
+ await switchReadonly(page);
+
+ await type(page, ' world');
+ await assertBlockProps(page, '2', {
+ title: 'hello',
+ });
+
+ await pressBackspace(page, 'hello world'.length);
+ await assertBlockProps(page, '2', {
+ title: 'hello',
+ });
+ });
+
+ test('should rich-text not be edited in readonly mode', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, '', true);
+
+ const cell = getFirstColumnCell(page, 'affine-database-rich-text');
+ await cell.click();
+ await type(page, '123');
+ await assertDatabaseCellRichTexts(page, { text: '123' });
+
+ await switchReadonly(page);
+ await pressBackspace(page);
+ await type(page, '789');
+ await assertDatabaseCellRichTexts(page, { text: '123' });
+ });
+
+ test('should hide edit widget after switch to readonly mode', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, '', true);
+
+ const database = page.locator('affine-database');
+ await expect(database).toBeVisible();
+
+ const databaseMenu = database.locator('.database-ops');
+ await expect(databaseMenu).toBeVisible();
+
+ const addViewButton = database.getByTestId('database-add-view-button');
+ await expect(addViewButton).toBeVisible();
+
+ const titleHeader = page.locator('affine-database-header-column').filter({
+ hasText: 'Title',
+ });
+ await titleHeader.hover();
+ const columnDragBar = titleHeader.locator('.control-r');
+ await expect(columnDragBar).toBeVisible();
+
+ const filter = database.locator('data-view-header-tools-filter');
+ const search = database.locator('data-view-header-tools-search');
+ const options = database.locator('data-view-header-tools-view-options');
+ const headerAddRow = database.locator('data-view-header-tools-add-row');
+
+ await database.hover();
+ await expect(filter).toBeVisible();
+ await expect(search).toBeVisible();
+ await expect(options).toBeVisible();
+ await expect(headerAddRow).toBeVisible();
+
+ const row = database.locator('data-view-table-row');
+ const rowOptions = row.locator('.row-op');
+ const rowDragBar = row.locator('.data-view-table-view-drag-handler>div');
+ await row.hover();
+ await expect(rowOptions).toHaveCount(2);
+ await expect(rowOptions.nth(0)).toBeVisible();
+ await expect(rowOptions.nth(1)).toBeVisible();
+ await expect(rowDragBar).toBeVisible();
+
+ const addRow = database.locator('.data-view-table-group-add-row');
+ await expect(addRow).toBeVisible();
+
+ // Readonly Mode
+ {
+ await switchReadonly(page);
+ await expect(databaseMenu).toBeHidden();
+ await expect(addViewButton).toBeHidden();
+
+ await titleHeader.hover();
+ await expect(columnDragBar).toBeHidden();
+
+ await database.hover();
+ await expect(filter).toBeHidden();
+ await expect(search).toBeVisible(); // Note the search should not be hidden
+ await expect(options).toBeHidden();
+ await expect(headerAddRow).toBeHidden();
+
+ await row.hover();
+ await expect(rowOptions.nth(0)).toBeHidden();
+ await expect(rowOptions.nth(1)).toBeHidden();
+ await expect(rowDragBar).toBeHidden();
+
+ await expect(addRow).toBeHidden();
+ }
+ });
+
+ test('should hide focus border after switch to readonly mode', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, '', true);
+
+ const database = page.locator('affine-database');
+ await expect(database).toBeVisible();
+
+ const cell = getFirstColumnCell(page, 'affine-database-rich-text');
+ await cell.click();
+
+ const focusBorder = database.locator(
+ 'data-view-table-selection .database-focus'
+ );
+ await expect(focusBorder).toBeVisible();
+
+ await switchReadonly(page);
+ await expect(focusBorder).toBeHidden();
+ });
+
+ test('should hide selection after switch to readonly mode', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await switchColumnType(page, 'Text');
+ await initDatabaseDynamicRowWithData(page, '', true);
+
+ const database = page.locator('affine-database');
+ await expect(database).toBeVisible();
+
+ const startCell = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 0,
+ });
+ const endCell = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 1,
+ });
+
+ const startBox = await getBoundingBox(startCell);
+ const endBox = await getBoundingBox(endCell);
+
+ const startX = startBox.x + startBox.width / 2;
+ const startY = startBox.y + startBox.height / 2;
+ const endX = endBox.x + endBox.width / 2;
+ const endY = endBox.y + endBox.height / 2;
+
+ await dragBetweenCoords(
+ page,
+ { x: startX, y: startY },
+ { x: endX, y: endY }
+ );
+
+ const selection = database.locator(
+ 'data-view-table-selection .database-selection'
+ );
+
+ await expect(selection).toBeVisible();
+
+ await switchReadonly(page);
+ await expect(selection).toBeHidden();
+ });
+});
diff --git a/blocksuite/tests-legacy/database/selection.spec.ts b/blocksuite/tests-legacy/database/selection.spec.ts
new file mode 100644
index 0000000000000..e0911a1b7c683
--- /dev/null
+++ b/blocksuite/tests-legacy/database/selection.spec.ts
@@ -0,0 +1,567 @@
+import { expect } from '@playwright/test';
+
+import { dragBetweenCoords } from '../utils/actions/drag.js';
+import { shiftClick } from '../utils/actions/edgeless.js';
+import {
+ pressArrowDown,
+ pressArrowDownWithShiftKey,
+ pressArrowLeft,
+ pressArrowRight,
+ pressArrowUp,
+ pressArrowUpWithShiftKey,
+ pressBackspace,
+ pressEnter,
+ pressEscape,
+ type,
+} from '../utils/actions/keyboard.js';
+import {
+ enterPlaygroundRoom,
+ getBoundingBox,
+ initDatabaseDynamicRowWithData,
+ initDatabaseRowWithData,
+ initEmptyDatabaseState,
+ initKanbanViewState,
+ waitNextFrame,
+} from '../utils/actions/misc.js';
+import { test } from '../utils/playwright.js';
+import {
+ assertCellsSelection,
+ assertDatabaseTitleColumnText,
+ assertKanbanCardHeaderText,
+ assertKanbanCardSelected,
+ assertKanbanCellSelected,
+ assertRowsSelection,
+ clickKanbanCardHeader,
+ focusKanbanCardHeader,
+ getDatabaseBodyCell,
+ getKanbanCard,
+ initDatabaseColumn,
+ switchColumnType,
+} from './actions.js';
+
+test.describe('focus', () => {
+ test('should support move focus by arrow key', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 0]);
+ });
+
+ test('should support multi row selection', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+ await switchColumnType(page, 'Number');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+
+ const selectColumn = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 1,
+ });
+
+ const endBox = await getBoundingBox(selectColumn);
+ const endX = endBox.x + endBox.width / 2;
+
+ await dragBetweenCoords(
+ page,
+ { x: endX, y: endBox.y },
+ { x: endX, y: endBox.y + endBox.height }
+ );
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 1]);
+ });
+
+ test('should support row selection with dynamic height', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123123', true);
+ await type(page, '456456');
+ await pressEnter(page);
+ await type(page, 'abcabc');
+ await pressEnter(page);
+ await type(page, 'defdef');
+ await pressEnter(page);
+ await pressEscape(page);
+
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 0]);
+ });
+});
+
+test.describe('row-level selection', () => {
+ test('should support title selection', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'title');
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+ await assertCellsSelection(page, {
+ start: [0, 0],
+ });
+
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 0]);
+ });
+
+ test('should support pressing esc to trigger row selection', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await pressEscape(page);
+ await waitNextFrame(page, 100);
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 0]);
+ });
+
+ test('should support multi row selection', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+ await switchColumnType(page, 'Number');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+
+ const selectColumn = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 1,
+ });
+
+ const endBox = await getBoundingBox(selectColumn);
+ const endX = endBox.x + endBox.width / 2;
+
+ await dragBetweenCoords(
+ page,
+ { x: endX, y: endBox.y },
+ { x: endX, y: endBox.y + endBox.height }
+ );
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 1]);
+ });
+
+ test('should support row selection with dynamic height', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '123123', true);
+ await type(page, '456456');
+ await pressEnter(page);
+ await type(page, 'abcabc');
+ await pressEnter(page);
+ await type(page, 'defdef');
+ await pressEnter(page);
+ await pressEscape(page);
+
+ await pressEscape(page);
+ await assertRowsSelection(page, [0, 0]);
+ });
+
+ test('move row selection with (up | down)', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+
+ // add two rows
+ await initDatabaseDynamicRowWithData(page, '123123', true);
+ await pressEscape(page);
+
+ await initDatabaseDynamicRowWithData(page, '123123', true);
+ await pressEscape(page);
+
+ await pressEscape(page); // switch to row selection
+
+ await assertRowsSelection(page, [1, 1]);
+
+ await pressArrowUp(page);
+ await assertRowsSelection(page, [0, 0]);
+
+ // should not allow under selection
+ await pressArrowUp(page);
+ await assertRowsSelection(page, [0, 0]);
+
+ await pressArrowDown(page);
+ await assertRowsSelection(page, [1, 1]);
+
+ // should not allow over selection
+ await pressArrowDown(page);
+ await assertRowsSelection(page, [1, 1]);
+ });
+
+ test('increment decrement row selection with shift+(up | down)', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+
+ // add two rows
+ await initDatabaseDynamicRowWithData(page, '123123', true);
+ await pressEscape(page);
+
+ await initDatabaseDynamicRowWithData(page, '123123', true);
+ await pressEscape(page);
+
+ await pressEscape(page); // switch to row selection
+
+ await pressArrowUpWithShiftKey(page);
+ await assertRowsSelection(page, [0, 1]);
+
+ await pressArrowDownWithShiftKey(page);
+ await assertRowsSelection(page, [1, 1]); // should decrement back
+
+ await pressArrowUp(page); // go to first row
+
+ await pressArrowDownWithShiftKey(page);
+ await assertRowsSelection(page, [0, 1]);
+
+ await pressArrowUpWithShiftKey(page);
+ await assertRowsSelection(page, [0, 0]);
+ });
+});
+
+test.describe('cell-level selection', () => {
+ test('should support multi cell selection', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseDynamicRowWithData(page, '', true);
+ await pressEscape(page);
+ await switchColumnType(page, 'Number');
+ await initDatabaseDynamicRowWithData(page, '123', true);
+
+ const startCell = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 0,
+ });
+ const endCell = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 1,
+ });
+
+ const startBox = await getBoundingBox(startCell);
+ const endBox = await getBoundingBox(endCell);
+
+ const startX = startBox.x + startBox.width / 2;
+ const startY = startBox.y + startBox.height / 2;
+ const endX = endBox.x + endBox.width / 2;
+ const endY = endBox.y + endBox.height / 2;
+
+ await dragBetweenCoords(
+ page,
+ { x: startX, y: startY },
+ { x: endX, y: endY }
+ );
+
+ await assertCellsSelection(page, {
+ start: [0, 0],
+ end: [1, 1],
+ });
+ });
+
+ test("should support backspace key to delete cell's content", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ await initDatabaseColumn(page);
+ await initDatabaseRowWithData(page, 'row1');
+ await initDatabaseDynamicRowWithData(page, 'abc', false);
+ await pressEscape(page);
+ await initDatabaseRowWithData(page, 'row2');
+ await initDatabaseDynamicRowWithData(page, '123', false);
+ await pressEscape(page);
+
+ const startCell = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 0,
+ });
+ const endCell = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 1,
+ });
+
+ const startBox = await getBoundingBox(startCell);
+ const endBox = await getBoundingBox(endCell);
+
+ const startX = startBox.x + startBox.width / 2;
+ const startY = startBox.y + startBox.height / 2;
+ const endX = endBox.x + endBox.width / 2;
+ const endY = endBox.y + endBox.height / 2;
+
+ await dragBetweenCoords(
+ page,
+ { x: startX, y: startY },
+ { x: endX, y: endY }
+ );
+
+ await pressBackspace(page);
+ await assertDatabaseTitleColumnText(page, '', 0);
+ await assertDatabaseTitleColumnText(page, '', 1);
+ const selectCell1 = getDatabaseBodyCell(page, {
+ rowIndex: 0,
+ columnIndex: 1,
+ });
+ expect(await selectCell1.innerText()).toBe('');
+ const selectCell2 = getDatabaseBodyCell(page, {
+ rowIndex: 1,
+ columnIndex: 1,
+ });
+ expect(await selectCell2.innerText()).toBe('');
+ });
+});
+
+test.describe('kanban view selection', () => {
+ test("should support move cell's focus by arrow key(up&down) within a card", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1'],
+ columns: [
+ {
+ type: 'number',
+ value: [1],
+ },
+ {
+ type: 'rich-text',
+ value: ['text'],
+ },
+ ],
+ });
+
+ await focusKanbanCardHeader(page);
+ await assertKanbanCellSelected(page, {
+ // group by `number` column, the first(groupIndex: 0) group is `Ungroups`
+ groupIndex: 1,
+ cardIndex: 0,
+ cellIndex: 0,
+ });
+
+ await pressArrowDown(page, 3);
+ await assertKanbanCellSelected(page, {
+ groupIndex: 1,
+ cardIndex: 0,
+ cellIndex: 0,
+ });
+
+ await pressArrowUp(page);
+ await assertKanbanCellSelected(page, {
+ groupIndex: 1,
+ cardIndex: 0,
+ cellIndex: 2,
+ });
+ });
+
+ test("should support move cell's focus by arrow key(up&down) within multi cards", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1', 'row2'],
+ columns: [
+ {
+ type: 'number',
+ value: [1, 2],
+ },
+ {
+ type: 'rich-text',
+ value: ['text'],
+ },
+ ],
+ });
+
+ await focusKanbanCardHeader(page);
+ await pressArrowUp(page);
+ await assertKanbanCellSelected(page, {
+ groupIndex: 1,
+ cardIndex: 1,
+ cellIndex: 2,
+ });
+
+ await pressArrowDown(page);
+ await assertKanbanCellSelected(page, {
+ groupIndex: 1,
+ cardIndex: 0,
+ cellIndex: 0,
+ });
+ });
+
+ test("should support move cell's focus by arrow key(left&right)", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1', 'row2', 'row3'],
+ columns: [
+ {
+ type: 'number',
+ value: [undefined, 1, 10],
+ },
+ ],
+ });
+
+ await focusKanbanCardHeader(page);
+
+ await pressArrowRight(page, 3);
+ await assertKanbanCellSelected(page, {
+ groupIndex: 0,
+ cardIndex: 0,
+ cellIndex: 0,
+ });
+
+ await pressArrowLeft(page);
+ await assertKanbanCellSelected(page, {
+ groupIndex: 2,
+ cardIndex: 0,
+ cellIndex: 0,
+ });
+ });
+
+ test("should support move card's focus by arrow key(up&down)", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1', 'row2', 'row3'],
+ columns: [
+ {
+ type: 'number',
+ value: [undefined, undefined, undefined],
+ },
+ ],
+ });
+
+ await focusKanbanCardHeader(page);
+ await pressEscape(page);
+ await pressEscape(page);
+ await assertKanbanCardSelected(page, {
+ groupIndex: 0,
+ cardIndex: 0,
+ });
+
+ await pressArrowDown(page, 3);
+ await assertKanbanCardSelected(page, {
+ groupIndex: 0,
+ cardIndex: 0,
+ });
+
+ await pressArrowUp(page);
+ await assertKanbanCardSelected(page, {
+ groupIndex: 0,
+ cardIndex: 2,
+ });
+ });
+
+ test("should support move card's focus by arrow key(left&right)", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1', 'row2', 'row3'],
+ columns: [
+ {
+ type: 'number',
+ value: [undefined, 1, 10],
+ },
+ ],
+ });
+
+ await focusKanbanCardHeader(page);
+ await pressEscape(page);
+ await pressEscape(page);
+
+ await pressArrowRight(page, 3);
+ await assertKanbanCardSelected(page, {
+ groupIndex: 0,
+ cardIndex: 0,
+ });
+
+ await pressArrowLeft(page);
+ await assertKanbanCardSelected(page, {
+ groupIndex: 2,
+ cardIndex: 0,
+ });
+ });
+
+ test('should support multi card selection', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1', 'row2'],
+ columns: [
+ {
+ type: 'number',
+ value: [undefined, 1],
+ },
+ ],
+ });
+
+ await focusKanbanCardHeader(page);
+ await pressEscape(page);
+ await pressEscape(page);
+
+ const card = getKanbanCard(page, {
+ groupIndex: 1,
+ cardIndex: 0,
+ });
+ const box = await getBoundingBox(card);
+ await shiftClick(page, {
+ x: box.x + box.width / 2,
+ y: box.y + box.height / 2,
+ });
+
+ await assertKanbanCardSelected(page, {
+ groupIndex: 0,
+ cardIndex: 0,
+ });
+ await assertKanbanCardSelected(page, {
+ groupIndex: 1,
+ cardIndex: 0,
+ });
+ });
+
+ test("should support move cursor in card's title by arrow key(left&right)", async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page);
+ await initKanbanViewState(page, {
+ rows: ['row1'],
+ columns: [
+ {
+ type: 'rich-text',
+ value: ['text'],
+ },
+ ],
+ });
+
+ await clickKanbanCardHeader(page);
+ await type(page, 'abc');
+ await pressArrowLeft(page, 2);
+ await pressArrowRight(page);
+ await pressBackspace(page);
+ await pressEscape(page);
+
+ await assertKanbanCardHeaderText(page, 'row1ac');
+ });
+});
diff --git a/blocksuite/tests-legacy/database/sort.spec.ts b/blocksuite/tests-legacy/database/sort.spec.ts
new file mode 100644
index 0000000000000..8c3eea01eef64
--- /dev/null
+++ b/blocksuite/tests-legacy/database/sort.spec.ts
@@ -0,0 +1,112 @@
+import { expect, type Locator } from '@playwright/test';
+
+import {
+ enterPlaygroundRoom,
+ initDatabaseDynamicRowWithData,
+ initEmptyDatabaseState,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import { test } from '../utils/playwright.js';
+import { initDatabaseColumn, switchColumnType } from './actions.js';
+
+test('database sort with multiple rules', async ({ page }) => {
+ // Initialize database
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+
+ // Add test columns: Name (text) and Age (number)
+ await initDatabaseColumn(page, 'Name');
+ await switchColumnType(page, 'Text', 1);
+ await initDatabaseColumn(page, 'Age');
+ await switchColumnType(page, 'Number', 2);
+
+ // Add test data
+ const testData = [
+ { name: 'Alice', age: '25' },
+ { name: 'Bob', age: '30' },
+ { name: 'Alice', age: '20' },
+ { name: 'Charlie', age: '25' },
+ ];
+
+ for (const data of testData) {
+ await initDatabaseDynamicRowWithData(page, data.name, true, 0);
+ await initDatabaseDynamicRowWithData(page, data.age, false, 1);
+ }
+
+ // Open sort menu
+ const sortButton = page.locator('data-view-header-tools-sort');
+ await sortButton.click();
+
+ // Add first sort rule: Name ascending
+ await page.locator('affine-menu').getByText('Name').click();
+ await waitNextFrame(page);
+
+ // Add second sort rule: Age ascending
+ await page.getByText('Add sort').click();
+ await page.locator('affine-menu').getByText('Age').click();
+ await waitNextFrame(page);
+
+ // Get all rows after sorting
+ const rows = await page.locator('affine-database-row').all();
+ const getCellText = async (row: Locator, index: number) => {
+ const cell = row.locator('.cell').nth(index);
+ return cell.innerText();
+ };
+
+ // Verify sorting results
+ // Should be sorted by Name first, then by Age
+ const expectedOrder = [
+ { name: 'Alice', age: '20' },
+ { name: 'Alice', age: '25' },
+ { name: 'Bob', age: '30' },
+ { name: 'Charlie', age: '25' },
+ ];
+
+ for (let i = 0; i < rows.length; i++) {
+ const name = await getCellText(rows[i], 1);
+ const age = await getCellText(rows[i], 2);
+ expect(name).toBe(expectedOrder[i].name);
+ expect(age).toBe(expectedOrder[i].age);
+ }
+
+ // Change sort order of Name to descending
+ await page.locator('.sort-item').first().getByText('Ascending').click();
+ await page.getByText('Descending').click();
+ await waitNextFrame(page);
+
+ // Verify new sorting results
+ const expectedOrderDesc = [
+ { name: 'Charlie', age: '25' },
+ { name: 'Bob', age: '30' },
+ { name: 'Alice', age: '20' },
+ { name: 'Alice', age: '25' },
+ ];
+
+ const rowsAfterDesc = await page.locator('affine-database-row').all();
+ for (let i = 0; i < rowsAfterDesc.length; i++) {
+ const name = await getCellText(rowsAfterDesc[i], 1);
+ const age = await getCellText(rowsAfterDesc[i], 2);
+ expect(name).toBe(expectedOrderDesc[i].name);
+ expect(age).toBe(expectedOrderDesc[i].age);
+ }
+
+ // Remove first sort rule
+ await page.locator('.sort-item').first().getByRole('img').last().click();
+ await waitNextFrame(page);
+
+ // Verify sorting now only by Age
+ const expectedOrderAgeOnly = [
+ { name: 'Alice', age: '20' },
+ { name: 'Alice', age: '25' },
+ { name: 'Charlie', age: '25' },
+ { name: 'Bob', age: '30' },
+ ];
+
+ const rowsAfterRemove = await page.locator('affine-database-row').all();
+ for (let i = 0; i < rowsAfterRemove.length; i++) {
+ const name = await getCellText(rowsAfterRemove[i], 1);
+ const age = await getCellText(rowsAfterRemove[i], 2);
+ expect(name).toBe(expectedOrderAgeOnly[i].name);
+ expect(age).toBe(expectedOrderAgeOnly[i].age);
+ }
+});
diff --git a/blocksuite/tests-legacy/database/statistics.spec.ts b/blocksuite/tests-legacy/database/statistics.spec.ts
new file mode 100644
index 0000000000000..536db36ca74f3
--- /dev/null
+++ b/blocksuite/tests-legacy/database/statistics.spec.ts
@@ -0,0 +1,103 @@
+import { press } from '@inline/__tests__/utils.js';
+import { expect, type Page } from '@playwright/test';
+
+import { type } from '../utils/actions/index.js';
+import {
+ enterPlaygroundRoom,
+ getAddRow,
+ initEmptyDatabaseState,
+ waitNextFrame,
+} from '../utils/actions/misc.js';
+import { test } from '../utils/playwright.js';
+import { changeColumnType, moveToCenterOf, pressKey } from './actions.js';
+
+const addRow = async (page: Page, count: number = 1) => {
+ await waitNextFrame(page);
+ const addRow = getAddRow(page);
+ for (let i = 0; i < count; i++) {
+ await addRow.click();
+ }
+ await press(page, 'Escape');
+ await waitNextFrame(page);
+};
+const insertRightColumn = async (page: Page, index = 0) => {
+ await waitNextFrame(page);
+ await page.locator('affine-database-header-column').nth(index).click();
+ await waitNextFrame(page, 200);
+ await pressKey(page, 'Escape');
+ const menu = page.locator('.affine-menu-button', {
+ hasText: new RegExp('Insert Right'),
+ });
+ await menu.click();
+ await waitNextFrame(page, 200);
+ await pressKey(page, 'Enter');
+};
+const menuSelect = async (page: Page, selectors: string[]) => {
+ await waitNextFrame(page);
+ for (const name of selectors) {
+ const menu = page.locator('.affine-menu-button', {
+ hasText: new RegExp(name),
+ });
+ await menu.click();
+ }
+};
+test.describe('title', () => {
+ test('empty count', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+ await addRow(page, 3);
+ const statCell = page.locator('affine-database-column-stats-cell').nth(0);
+ await moveToCenterOf(page, statCell);
+ await statCell.click();
+ await menuSelect(page, ['Count', 'Count Empty']);
+ const value = statCell.locator('.value');
+ expect((await value.textContent())?.trim()).toBe('3');
+ await page.locator('affine-database-cell-container').nth(0).click();
+ await pressKey(page, 'Enter');
+ await type(page, 'asd');
+ await pressKey(page, 'Escape');
+ expect((await value.textContent())?.trim()).toBe('2');
+ });
+});
+
+test.describe('rich-text', () => {
+ test('empty count', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+ await addRow(page, 3);
+ await insertRightColumn(page);
+ await changeColumnType(page, 1, 'text');
+ const statCell = page.locator('affine-database-column-stats-cell').nth(1);
+ await moveToCenterOf(page, statCell);
+ await statCell.click();
+ await menuSelect(page, ['Count', 'Count Empty']);
+ const value = statCell.locator('.value');
+ expect((await value.textContent())?.trim()).toBe('3');
+ await page.locator('affine-database-cell-container').nth(1).click();
+ await pressKey(page, 'Enter');
+ await type(page, 'asd');
+ await pressKey(page, 'Escape');
+ expect((await value.textContent())?.trim()).toBe('2');
+ });
+});
+
+test.describe('select', () => {
+ test('empty count', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+ await addRow(page, 3);
+ await insertRightColumn(page);
+ await changeColumnType(page, 1, 'select');
+ const statCell = page.locator('affine-database-column-stats-cell').nth(1);
+ await moveToCenterOf(page, statCell);
+ await statCell.click();
+ await menuSelect(page, ['Count', 'Count Empty']);
+ const value = statCell.locator('.value');
+ expect((await value.textContent())?.trim()).toBe('3');
+ await page.locator('affine-database-cell-container').nth(1).click();
+ await pressKey(page, 'Enter');
+ await type(page, 'select');
+ await pressKey(page, 'Enter');
+ expect((await value.textContent())?.trim()).toBe('2');
+ });
+});
diff --git a/blocksuite/tests-legacy/database/title.spec.ts b/blocksuite/tests-legacy/database/title.spec.ts
new file mode 100644
index 0000000000000..5c5e68440490d
--- /dev/null
+++ b/blocksuite/tests-legacy/database/title.spec.ts
@@ -0,0 +1,19 @@
+import { press } from '@inline/__tests__/utils.js';
+import { expect } from '@playwright/test';
+
+import {
+ enterPlaygroundRoom,
+ initDatabaseDynamicRowWithData,
+ initEmptyDatabaseState,
+} from '../utils/actions/misc.js';
+import { test } from '../utils/playwright.js';
+
+test.describe('title', () => {
+ test('should able to link doc by press @', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyDatabaseState(page);
+ await initDatabaseDynamicRowWithData(page, '123', true);
+ await press(page, '@');
+ await expect(page.locator('.linked-doc-popover')).toBeVisible();
+ });
+});
diff --git a/blocksuite/tests-legacy/drag.spec.ts b/blocksuite/tests-legacy/drag.spec.ts
new file mode 100644
index 0000000000000..1c9146371e310
--- /dev/null
+++ b/blocksuite/tests-legacy/drag.spec.ts
@@ -0,0 +1,768 @@
+import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '@blocks/_common/consts.js';
+import { expect } from '@playwright/test';
+
+import {
+ dragBetweenCoords,
+ dragBetweenIndices,
+ dragHandleFromBlockToBlockBottomById,
+ enterPlaygroundRoom,
+ focusRichText,
+ initEmptyParagraphState,
+ initThreeLists,
+ initThreeParagraphs,
+ pressEnter,
+ pressShiftTab,
+ pressTab,
+ type,
+} from './utils/actions/index.js';
+import {
+ getBoundingClientRect,
+ getEditorHostLocator,
+ getPageSnapshot,
+ initParagraphsByCount,
+} from './utils/actions/misc.js';
+import { assertRichTexts } from './utils/asserts.js';
+import { test } from './utils/playwright.js';
+
+test('only have one drag handle in screen', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ const topLeft = await page.evaluate(() => {
+ const paragraph = document.querySelector('[data-block-id="2"]');
+ const box = paragraph?.getBoundingClientRect();
+ if (!box) {
+ throw new Error();
+ }
+ return { x: box.left, y: box.top + 2 };
+ }, []);
+
+ const bottomRight = await page.evaluate(() => {
+ const paragraph = document.querySelector('[data-block-id="4"]');
+ const box = paragraph?.getBoundingClientRect();
+ if (!box) {
+ throw new Error();
+ }
+ return { x: box.right, y: box.bottom - 2 };
+ }, []);
+
+ await page.mouse.move(topLeft.x, topLeft.y);
+ const length1 = await page.evaluate(() => {
+ const handles = document.querySelectorAll('affine-drag-handle-widget');
+ return handles.length;
+ }, []);
+ expect(length1).toBe(1);
+ await page.mouse.move(bottomRight.x, bottomRight.y);
+ const length2 = await page.evaluate(() => {
+ const handles = document.querySelectorAll('affine-drag-handle-widget');
+ return handles.length;
+ }, []);
+ expect(length2).toBe(1);
+});
+
+test('move drag handle in paragraphs', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+ await dragHandleFromBlockToBlockBottomById(page, '2', '4');
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ await assertRichTexts(page, ['456', '789', '123']);
+});
+
+test('move drag handle in list', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeLists(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+ await dragHandleFromBlockToBlockBottomById(page, '5', '3', false);
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ await assertRichTexts(page, ['789', '123', '456']);
+});
+
+test('move drag handle in nested block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '-');
+ await page.keyboard.press('Space', { delay: 50 });
+ await type(page, '1');
+ await pressEnter(page);
+ await type(page, '2');
+
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, '21');
+ await pressEnter(page);
+ await type(page, '22');
+ await pressEnter(page);
+ await type(page, '23');
+ await pressEnter(page);
+ await pressShiftTab(page);
+
+ await type(page, '3');
+
+ await assertRichTexts(page, ['1', '2', '21', '22', '23', '3']);
+
+ await dragHandleFromBlockToBlockBottomById(page, '5', '7');
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ await assertRichTexts(page, ['1', '2', '22', '23', '21', '3']);
+
+ // FIXME(DND)
+ // await dragHandleFromBlockToBlockBottomById(page, '3', '8');
+ // await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ // await assertRichTexts(page, ['2', '22', '23', '21', '3', '1']);
+});
+
+test('move drag handle into another block', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '-');
+ await page.keyboard.press('Space', { delay: 50 });
+ await type(page, '1');
+ await pressEnter(page);
+ await type(page, '2');
+
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, '21');
+ await pressEnter(page);
+ await type(page, '22');
+ await pressEnter(page);
+ await type(page, '23');
+ await pressEnter(page);
+ await pressShiftTab(page);
+
+ await type(page, '3');
+
+ await assertRichTexts(page, ['1', '2', '21', '22', '23', '3']);
+
+ await dragHandleFromBlockToBlockBottomById(
+ page,
+ '5',
+ '7',
+ true,
+ 2 * BLOCK_CHILDREN_CONTAINER_PADDING_LEFT
+ );
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ await assertRichTexts(page, ['1', '2', '22', '23', '21', '3']);
+ // FIXME(DND)
+ // await assertBlockChildrenIds(page, '7', ['5']);
+
+ // await dragHandleFromBlockToBlockBottomById(
+ // page,
+ // '3',
+ // '8',
+ // true,
+ // 2 * BLOCK_CHILDREN_CONTAINER_PADDING_LEFT
+ // );
+ // await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ // await assertRichTexts(page, ['2', '22', '23', '21', '3', '1']);
+ // await assertBlockChildrenIds(page, '8', ['3']);
+});
+
+test('move to the last block of each level in multi-level nesting', async ({
+ page,
+}, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '-');
+ await page.keyboard.press('Space', { delay: 50 });
+ await type(page, 'A');
+ await pressEnter(page);
+ await type(page, 'B');
+ await pressEnter(page);
+ await type(page, 'C');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, 'D');
+ await pressEnter(page);
+ await type(page, 'E');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, 'F');
+ await pressEnter(page);
+ await type(page, 'G');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_init.json`
+ );
+
+ await dragHandleFromBlockToBlockBottomById(page, '3', '9');
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_drag_3_9.json`
+ );
+
+ // FIXME(DND)
+ // await dragHandleFromBlockToBlockBottomById(
+ // page,
+ // '4',
+ // '3',
+ // true,
+ // -(1 * BLOCK_CHILDREN_CONTAINER_PADDING_LEFT)
+ // );
+ // await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ //
+ // expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ // `${testInfo.title}_drag_4_3.json`
+ // );
+ //
+ // await assertRichTexts(page, ['C', 'D', 'E', 'F', 'G', 'A', 'B']);
+ // await dragHandleFromBlockToBlockBottomById(
+ // page,
+ // '3',
+ // '4',
+ // true,
+ // -(2 * BLOCK_CHILDREN_CONTAINER_PADDING_LEFT)
+ // );
+ // await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ //
+ // expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ // `${testInfo.title}_drag_3_4.json`
+ // );
+ //
+ // await assertRichTexts(page, ['C', 'D', 'E', 'F', 'G', 'B', 'A']);
+});
+
+test('should sync selected-blocks to session-manager when clicking drag handle', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+
+ await focusRichText(page, 1);
+ const rect = await getBoundingClientRect(page, '[data-block-id="1"]');
+ if (!rect) {
+ throw new Error();
+ }
+ await page.mouse.move(rect.x + 10, rect.y + 10, { steps: 2 });
+
+ const handle = page.locator('.affine-drag-handle-container');
+ await handle.click();
+
+ await page.keyboard.press('Backspace');
+ await assertRichTexts(page, ['', '456', '789']);
+});
+
+test.fixme(
+ 'should be able to drag & drop multiple blocks',
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+
+ await dragBetweenIndices(
+ page,
+ [0, 0],
+ [1, 3],
+ { x: -60, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(2);
+
+ await dragHandleFromBlockToBlockBottomById(page, '2', '4', true);
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+
+ await assertRichTexts(page, ['789', '123', '456']);
+
+ // Selection is still 2 after drop
+ await expect(blockSelections).toHaveCount(2);
+ }
+);
+
+test.fixme(
+ 'should be able to drag & drop multiple blocks to nested block',
+ async ({ page }, testInfo) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+
+ await focusRichText(page);
+ await type(page, '-');
+ await page.keyboard.press('Space', { delay: 50 });
+ await type(page, 'A');
+ await pressEnter(page);
+ await type(page, 'B');
+ await pressEnter(page);
+ await type(page, 'C');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, 'D');
+ await pressEnter(page);
+ await type(page, 'E');
+ await pressEnter(page);
+ await pressTab(page);
+ await type(page, 'F');
+ await pressEnter(page);
+ await type(page, 'G');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_init.json`
+ );
+
+ await dragBetweenIndices(
+ page,
+ [0, 0],
+ [1, 1],
+ { x: -80, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(2);
+
+ await dragHandleFromBlockToBlockBottomById(page, '3', '8');
+
+ expect(await getPageSnapshot(page, true)).toMatchSnapshot(
+ `${testInfo.title}_finial.json`
+ );
+ }
+);
+
+test('should blur rich-text first on starting block selection', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+
+ await expect(page.locator('*:focus')).toHaveCount(1);
+
+ await dragHandleFromBlockToBlockBottomById(page, '2', '4');
+ await expect(page.locator('.affine-drag-indicator')).toBeHidden();
+ await assertRichTexts(page, ['456', '789', '123']);
+
+ await expect(page.locator('*:focus')).toHaveCount(0);
+});
+
+test('hide drag handle when mouse is hovering over the title', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+
+ const rect = await getBoundingClientRect(
+ page,
+ '.affine-note-block-container'
+ );
+ const dragHandle = page.locator('.affine-drag-handle-container');
+ // When there is a gap between paragraph blocks, it is the correct behavior for the drag handle to appear
+ // when the mouse is over the gap. Therefore, we use rect.y - 20 to make the Y offset greater than the gap between the
+ // paragraph blocks.
+ await page.mouse.move(rect.x, rect.y - 20, { steps: 2 });
+ await expect(dragHandle).toBeHidden();
+
+ await page.mouse.move(rect.x, rect.y, { steps: 2 });
+ expect(await dragHandle.isVisible()).toBe(true);
+ await expect(dragHandle).toBeVisible();
+});
+
+test.fixme('should create preview when dragging', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+
+ const dragPreview = page.locator('affine-drag-preview');
+
+ await dragBetweenIndices(
+ page,
+ [0, 0],
+ [1, 3],
+ { x: -60, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(2);
+
+ await dragHandleFromBlockToBlockBottomById(
+ page,
+ '2',
+ '4',
+ true,
+ undefined,
+ async () => {
+ await expect(dragPreview).toBeVisible();
+ await expect(dragPreview.locator('[data-block-id]')).toHaveCount(4);
+ }
+ );
+});
+
+test.fixme(
+ 'should drag and drop blocks under block-level selection',
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+
+ await dragBetweenIndices(
+ page,
+ [0, 0],
+ [1, 3],
+ { x: -60, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(2);
+
+ const editorHost = getEditorHostLocator(page);
+ const editors = editorHost.locator('rich-text');
+ const editorRect0 = await editors.nth(0).boundingBox();
+ const editorRect2 = await editors.nth(2).boundingBox();
+ if (!editorRect0 || !editorRect2) {
+ throw new Error();
+ }
+
+ await dragBetweenCoords(
+ page,
+ {
+ x: editorRect0.x - 10,
+ y: editorRect0.y + editorRect0.height / 2,
+ },
+ {
+ x: editorRect2.x + 10,
+ y: editorRect2.y + editorRect2.height / 2 + 10,
+ },
+ {
+ steps: 50,
+ }
+ );
+
+ await assertRichTexts(page, ['789', '123', '456']);
+ await expect(blockSelections).toHaveCount(2);
+ }
+);
+
+test('should trigger click event on editor container when clicking on blocks under block-level selection', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initThreeParagraphs(page);
+ await assertRichTexts(page, ['123', '456', '789']);
+
+ await dragBetweenIndices(
+ page,
+ [0, 0],
+ [1, 3],
+ { x: -60, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(2);
+ await expect(page.locator('*:focus')).toHaveCount(0);
+
+ const editorHost = getEditorHostLocator(page);
+ const editors = editorHost.locator('rich-text');
+ const editorRect0 = await editors.nth(0).boundingBox();
+ if (!editorRect0) {
+ throw new Error();
+ }
+
+ await page.mouse.move(
+ editorRect0.x + 10,
+ editorRect0.y + editorRect0.height / 2
+ );
+ await page.mouse.down();
+ await page.mouse.up();
+ await expect(blockSelections).toHaveCount(0);
+ await expect(page.locator('*:focus')).toHaveCount(1);
+});
+
+test('should get to selected block when dragging unselected block', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '123');
+ await pressEnter(page);
+ await type(page, '456');
+ await assertRichTexts(page, ['123', '456']);
+
+ const editorHost = getEditorHostLocator(page);
+ const editors = editorHost.locator('rich-text');
+ const editorRect0 = await editors.nth(0).boundingBox();
+ const editorRect1 = await editors.nth(1).boundingBox();
+
+ if (!editorRect0 || !editorRect1) {
+ throw new Error();
+ }
+
+ await page.mouse.move(editorRect1.x - 5, editorRect0.y);
+ await page.mouse.down();
+ await page.mouse.up();
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(1);
+
+ await page.mouse.move(editorRect1.x - 5, editorRect0.y);
+ await page.mouse.down();
+ await page.mouse.move(
+ editorRect1.x - 5,
+ editorRect1.y + editorRect1.height / 2 + 1,
+ {
+ steps: 10,
+ }
+ );
+ await page.mouse.up();
+
+ await expect(blockSelections).toHaveCount(1);
+
+ // FIXME(DND)
+ // await assertRichTexts(page, ['456', '123']);
+});
+
+test.fixme(
+ 'should clear the currently selected block when clicked again',
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await focusRichText(page);
+ await type(page, '123');
+ await pressEnter(page);
+ await type(page, '456');
+ await assertRichTexts(page, ['123', '456']);
+
+ const editorHost = getEditorHostLocator(page);
+ const editors = editorHost.locator('rich-text');
+ const editorRect0 = await editors.nth(0).boundingBox();
+ const editorRect1 = await editors.nth(1).boundingBox();
+
+ if (!editorRect0 || !editorRect1) {
+ throw new Error();
+ }
+
+ await page.mouse.move(
+ editorRect1.x + 5,
+ editorRect1.y + editorRect1.height / 2
+ );
+
+ await page.mouse.move(
+ editorRect1.x - 10,
+ editorRect1.y + editorRect1.height / 2
+ );
+ await page.mouse.down();
+ await page.mouse.up();
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(1);
+
+ let selectedBlockRect = await blockSelections.nth(0).boundingBox();
+
+ if (!selectedBlockRect) {
+ throw new Error();
+ }
+
+ expect(editorRect1).toEqual(selectedBlockRect);
+
+ await page.mouse.move(
+ editorRect0.x - 10,
+ editorRect0.y + editorRect0.height / 2
+ );
+ await page.mouse.down();
+ await page.mouse.up();
+
+ await expect(blockSelections).toHaveCount(1);
+
+ selectedBlockRect = await blockSelections.nth(0).boundingBox();
+
+ if (!selectedBlockRect) {
+ throw new Error();
+ }
+
+ expect(editorRect0).toEqual(selectedBlockRect);
+ }
+);
+
+test.fixme(
+ 'should support moving blocks from multiple notes',
+ async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await page.evaluate(() => {
+ const { doc } = window;
+
+ const rootId = doc.addBlock('affine:page', {
+ title: new doc.Text(),
+ });
+ doc.addBlock('affine:surface', {}, rootId);
+
+ ['123', '456', '789', '987', '654', '321'].forEach(text => {
+ const noteId = doc.addBlock('affine:note', {}, rootId);
+ doc.addBlock(
+ 'affine:paragraph',
+ {
+ text: new doc.Text(text),
+ },
+ noteId
+ );
+ });
+
+ doc.resetHistory();
+ });
+
+ await dragBetweenIndices(
+ page,
+ [1, 0],
+ [2, 3],
+ { x: -60, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(2);
+
+ const editorHost = getEditorHostLocator(page);
+ const editors = editorHost.locator('rich-text');
+ const editorRect1 = await editors.nth(1).boundingBox();
+ const editorRect3 = await editors.nth(3).boundingBox();
+ if (!editorRect1 || !editorRect3) {
+ throw new Error();
+ }
+
+ await dragBetweenCoords(
+ page,
+ {
+ x: editorRect1.x - 10,
+ y: editorRect1.y + editorRect1.height / 2,
+ },
+ {
+ x: editorRect3.x + 10,
+ y: editorRect3.y + editorRect3.height / 2 + 10,
+ },
+ {
+ steps: 50,
+ }
+ );
+
+ await assertRichTexts(page, ['123', '987', '456', '789', '654', '321']);
+ await expect(blockSelections).toHaveCount(2);
+
+ await dragBetweenIndices(
+ page,
+ [5, 0],
+ [4, 3],
+ { x: -60, y: 0 },
+ { x: 80, y: 0 },
+ {
+ steps: 50,
+ }
+ );
+
+ const editorRect0 = await editors.nth(0).boundingBox();
+ const editorRect5 = await editors.nth(5).boundingBox();
+ if (!editorRect0 || !editorRect5) {
+ throw new Error();
+ }
+
+ await dragBetweenCoords(
+ page,
+ {
+ x: editorRect5.x - 10,
+ y: editorRect5.y + editorRect5.height / 2,
+ },
+ {
+ x: editorRect0.x + 10,
+ y: editorRect0.y + editorRect0.height / 2 - 5,
+ },
+ {
+ steps: 50,
+ }
+ );
+
+ await assertRichTexts(page, ['654', '321', '123', '987', '456', '789']);
+ await expect(blockSelections).toHaveCount(2);
+ }
+);
+
+test('drag handle should show on right block when scroll viewport', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyParagraphState(page);
+ await initParagraphsByCount(page, 30);
+
+ await page.mouse.wheel(0, 200);
+
+ const editorHost = getEditorHostLocator(page);
+ const editors = editorHost.locator('rich-text');
+ const blockRect28 = await editors.nth(28).boundingBox();
+ if (!blockRect28) {
+ throw new Error();
+ }
+
+ await page.mouse.move(blockRect28.x + 10, blockRect28.y + 10);
+ const dragHandle = page.locator('.affine-drag-handle-container');
+ await expect(dragHandle).toBeVisible();
+
+ await page.mouse.move(
+ blockRect28.x - 10,
+ blockRect28.y + blockRect28.height / 2
+ );
+ await page.mouse.down();
+ await page.mouse.up();
+
+ const blockSelections = page
+ .locator('affine-block-selection')
+ .locator('visible=true');
+ await expect(blockSelections).toHaveCount(1);
+
+ const selectedBlockRect = await blockSelections.nth(0).boundingBox();
+
+ if (!selectedBlockRect) {
+ throw new Error();
+ }
+
+ expect(blockRect28).toEqual(selectedBlockRect);
+});
diff --git a/blocksuite/tests-legacy/edgeless/align.spec.ts b/blocksuite/tests-legacy/edgeless/align.spec.ts
new file mode 100644
index 0000000000000..45b9a9936d80b
--- /dev/null
+++ b/blocksuite/tests-legacy/edgeless/align.spec.ts
@@ -0,0 +1,435 @@
+import { expect } from '@playwright/test';
+
+import {
+ addBasicBrushElement,
+ createConnectorElement,
+ createFrameElement,
+ createNote,
+ createShapeElement,
+ setEdgelessTool,
+ Shape,
+ toViewCoord,
+ triggerComponentToolbarAction,
+} from '../utils/actions/edgeless.js';
+import {
+ clickView,
+ edgelessCommonSetup as commonSetup,
+ selectAllByKeyboard,
+ type,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import {
+ assertEdgelessSelectedModelRect,
+ getSelectedRect,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+
+test.describe('auto arrange align', () => {
+ test('arrange shapes', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+ await createShapeElement(page, [100, -100], [300, 100], Shape.Ellipse);
+ await createShapeElement(page, [200, 300], [300, 400], Shape.Square);
+ await createShapeElement(page, [400, 100], [500, 200], Shape.Triangle);
+ await createShapeElement(
+ page,
+ [0, 200],
+ [100, 300],
+ Shape['Rounded rectangle']
+ );
+
+ await page.mouse.click(0, 0);
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, -100, 500, 500]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 560, 320]);
+ });
+
+ test('arrange rotated shapes', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Ellipse);
+ await createShapeElement(page, [100, 100], [200, 200], Shape.Square);
+
+ const point = await toViewCoord(page, [100, 100]);
+ await page.mouse.click(point[0] + 50, point[1] + 50);
+ await page.mouse.move(point[0] - 5, point[1] - 5);
+ await page.mouse.down();
+ await page.mouse.move(point[0] - 5, point[1] + 45);
+ await page.mouse.up();
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 220, 220]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 261, 141]);
+ });
+
+ test('arrange connected shapes', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createShapeElement(page, [100, 100], [200, 200], Shape.Ellipse);
+ await createConnectorElement(page, [50, 100], [150, 100]);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 200, 200]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, -21, 220, 141.4]);
+ });
+
+ test('arrange connector', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createConnectorElement(page, [200, 200], [300, 200]);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 300, 200]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 220, 100]);
+ });
+
+ test('arrange edgeless text', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+
+ const point = await toViewCoord(page, [200, -100]);
+ await setEdgelessTool(page, 'default');
+ await page.mouse.dblclick(point[0], point[1], {
+ delay: 100,
+ });
+ await waitNextFrame(page);
+ await type(page, 'a');
+ await page.mouse.click(0, 0);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, -125, 225, 225]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 170, 100]);
+ });
+
+ test('arrange note', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createNote(page, [200, 200], 'Hello World');
+ await page.mouse.click(0, 0);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 668, 252]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 618, 100]);
+ });
+
+ test('arrange group', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [200, 300], [300, 400], Shape.Square);
+ await createShapeElement(page, [400, 100], [500, 200], Shape.Triangle);
+ await selectAllByKeyboard(page);
+ await triggerComponentToolbarAction(page, 'addGroup');
+
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 500, 400]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 420, 300]);
+ });
+
+ test('arrange frame', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [200, 300], [300, 400], Shape.Square);
+ await createShapeElement(page, [400, 100], [500, 200], Shape.Triangle);
+ await selectAllByKeyboard(page);
+ await createFrameElement(page, [150, 50], [550, 450]);
+
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+
+ await page.mouse.click(0, 0);
+ await page.mouse.move(75, 395);
+ await page.mouse.down();
+ await page.mouse.move(900, 900);
+ await page.mouse.up();
+ await assertEdgelessSelectedModelRect(page, [0, 0, 550, 450]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 520, 400]);
+ });
+
+ // TODO mindmap size different on CI
+ test('arrange mindmap', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+ await page.keyboard.press('m');
+ await clickView(page, [500, 200]);
+
+ await selectAllByKeyboard(page);
+ const box1 = await getSelectedRect(page);
+ expect(box1.width).toBeGreaterThan(700);
+ expect(box1.height).toBeGreaterThan(300);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ const box2 = await getSelectedRect(page);
+ expect(box2.width).toBeLessThan(550);
+ expect(box2.height).toBeLessThan(210);
+ });
+
+ test('arrange shape, note, connector, brush and edgeless text', async ({
+ page,
+ }) => {
+ await commonSetup(page);
+ // shape
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createShapeElement(page, [150, 150], [300, 300], Shape.Ellipse);
+ //note
+ await createNote(page, [200, 100], 'Hello World');
+ // connector
+ await createConnectorElement(page, [200, -200], [400, -100]);
+ // brush
+ const start = { x: 400, y: 400 };
+ const end = { x: 480, y: 480 };
+ await addBasicBrushElement(page, start, end);
+ // edgeless text
+ const point = await toViewCoord(page, [-100, -100]);
+ await setEdgelessTool(page, 'default');
+ await page.mouse.dblclick(point[0], point[1], {
+ delay: 100,
+ });
+ await waitNextFrame(page);
+ await type(page, 'edgeless text');
+
+ await page.mouse.click(0, 0);
+ await selectAllByKeyboard(page);
+
+ await assertEdgelessSelectedModelRect(page, [-125, -200, 793, 500]);
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoArrange');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [-125, -125, 668, 270]);
+ });
+});
+
+test.describe('auto resize align', () => {
+ test('resize and arrange shapes', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+ await createShapeElement(page, [100, -100], [300, 100], Shape.Ellipse);
+ await createShapeElement(page, [200, 300], [300, 400], Shape.Square);
+ await createShapeElement(page, [400, 100], [500, 200], Shape.Triangle);
+ await createShapeElement(
+ page,
+ [0, 200],
+ [100, 300],
+ Shape['Rounded rectangle']
+ );
+
+ await page.mouse.click(0, 0);
+ await selectAllByKeyboard(page);
+
+ await assertEdgelessSelectedModelRect(page, [0, -100, 500, 500]);
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 860, 420]);
+ });
+
+ test('resize and arrange rotated shapes', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Ellipse);
+ await createShapeElement(page, [100, 100], [200, 200], Shape.Square);
+
+ const point = await toViewCoord(page, [100, 100]);
+ await page.mouse.click(point[0] + 50, point[1] + 50);
+ await page.mouse.move(point[0] - 5, point[1] - 5);
+ await page.mouse.down();
+ await page.mouse.move(point[0] - 5, point[1] + 45);
+ await page.mouse.up();
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 220, 220]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 420, 200]);
+ });
+
+ test('resize and arrange connected shapes', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createShapeElement(page, [100, 100], [200, 200], Shape.Ellipse);
+ await createConnectorElement(page, [50, 100], [150, 100]);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 200, 200]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, -16, 420, 232]);
+ });
+
+ test('resize and arrange connector', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createConnectorElement(page, [200, 200], [300, 200]);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 300, 200]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 320, 200]);
+ });
+
+ test('resize and arrange edgeless text', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+
+ const point = await toViewCoord(page, [200, -100]);
+ await setEdgelessTool(page, 'default');
+ await page.mouse.dblclick(point[0], point[1], {
+ delay: 100,
+ });
+ await waitNextFrame(page);
+ await type(page, 'a');
+ await page.mouse.click(0, 0);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, -125, 225, 225]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 604.6, 200]);
+ });
+
+ test('resize and arrange note', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createNote(page, [200, 200], 'Hello World');
+ await page.mouse.click(0, 0);
+
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 668, 252]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 1302.5, 200]);
+ });
+
+ test('resize and arrange group', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [200, 300], [300, 400], Shape.Square);
+ await createShapeElement(page, [400, 100], [500, 200], Shape.Triangle);
+ await selectAllByKeyboard(page);
+ await triggerComponentToolbarAction(page, 'addGroup');
+
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+ await selectAllByKeyboard(page);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 500, 400]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 420, 200]);
+ });
+
+ test('resize and arrange frame', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [200, 300], [300, 400], Shape.Square);
+ await createShapeElement(page, [400, 100], [500, 200], Shape.Triangle);
+ await selectAllByKeyboard(page);
+ await createFrameElement(page, [150, 50], [550, 450]);
+
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+
+ await page.mouse.click(0, 0);
+ await page.mouse.move(75, 395);
+ await page.mouse.down();
+ await page.mouse.move(900, 900);
+ await page.mouse.up();
+ await assertEdgelessSelectedModelRect(page, [0, 0, 550, 450]);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 420, 200]);
+ });
+
+ // TODO mindmap size different on CI
+ test('resize and arrange mindmap', async ({ page }) => {
+ await commonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Diamond);
+ await page.keyboard.press('m');
+ await clickView(page, [500, 200]);
+
+ await selectAllByKeyboard(page);
+ const box1 = await getSelectedRect(page);
+ expect(box1.width).toBeGreaterThan(700);
+ expect(box1.height).toBeGreaterThan(300);
+
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ const box2 = await getSelectedRect(page);
+ expect(box2.width).toBeLessThan(650);
+ expect(box2.height).toBeLessThan(210);
+ });
+
+ test('resize and arrange shape, note, connector, brush and text', async ({
+ page,
+ }) => {
+ await commonSetup(page);
+ // shape
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await createShapeElement(page, [150, 150], [300, 300], Shape.Ellipse);
+ //note
+ await createNote(page, [200, 100], 'Hello World');
+ // connector
+ await createConnectorElement(page, [200, -200], [400, -100]);
+ // brush
+ const start = { x: 400, y: 400 };
+ const end = { x: 480, y: 480 };
+ await addBasicBrushElement(page, start, end);
+ // edgeless text
+ const point = await toViewCoord(page, [-100, -100]);
+ await setEdgelessTool(page, 'default');
+ await page.mouse.dblclick(point[0], point[1], {
+ delay: 100,
+ });
+ await waitNextFrame(page);
+ await type(page, 'edgeless text');
+
+ await page.mouse.click(0, 0);
+ await selectAllByKeyboard(page);
+
+ await assertEdgelessSelectedModelRect(page, [-125, -200, 793, 500]);
+ // arrange
+ await triggerComponentToolbarAction(page, 'autoResize');
+ await waitNextFrame(page, 200);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 1421.5, 420]);
+ });
+});
diff --git a/blocksuite/tests-legacy/edgeless/auto-complete.spec.ts b/blocksuite/tests-legacy/edgeless/auto-complete.spec.ts
new file mode 100644
index 0000000000000..253cc60478a3f
--- /dev/null
+++ b/blocksuite/tests-legacy/edgeless/auto-complete.spec.ts
@@ -0,0 +1,244 @@
+import { DEFAULT_NOTE_BACKGROUND_COLOR } from '@blocksuite/affine-model';
+import { expect, type Page } from '@playwright/test';
+
+import { clickView, moveView } from '../utils/actions/click.js';
+import { dragBetweenCoords } from '../utils/actions/drag.js';
+import {
+ addNote,
+ changeEdgelessNoteBackground,
+ changeShapeFillColor,
+ changeShapeStrokeColor,
+ createShapeElement,
+ deleteAll,
+ dragBetweenViewCoords,
+ edgelessCommonSetup,
+ getEdgelessSelectedRectModel,
+ Shape,
+ switchEditorMode,
+ toViewCoord,
+ triggerComponentToolbarAction,
+} from '../utils/actions/edgeless.js';
+import {
+ enterPlaygroundRoom,
+ initEmptyEdgelessState,
+ waitForInlineEditorStateUpdated,
+ waitNextFrame,
+} from '../utils/actions/misc.js';
+import {
+ assertConnectorStrokeColor,
+ assertEdgelessCanvasText,
+ assertEdgelessNoteBackground,
+ assertExists,
+ assertRichTexts,
+ assertSelectedBound,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+
+function getAutoCompletePanelButton(page: Page, type: string) {
+ return page
+ .locator('.auto-complete-panel-container')
+ .locator('edgeless-tool-icon-button')
+ .filter({ hasText: `${type}` });
+}
+
+test.describe('auto-complete', () => {
+ test.describe('click on auto-complete button', () => {
+ test('click on right auto-complete button', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await clickView(page, [120, 50]);
+ await assertSelectedBound(page, [200, 0, 100, 100]);
+ });
+ test('click on bottom auto-complete button', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await clickView(page, [50, 120]);
+ await assertSelectedBound(page, [0, 200, 100, 100]);
+ });
+ test('click on left auto-complete button', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await clickView(page, [-20, 50]);
+ await assertSelectedBound(page, [-200, 0, 100, 100]);
+ });
+ test('click on top auto-complete button', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await clickView(page, [50, -20]);
+ await assertSelectedBound(page, [0, -200, 100, 100]);
+ });
+
+ test('click on note auto-complete button', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ await addNote(page, 'note', 100, 100);
+ await page.mouse.click(600, 50);
+ await page.mouse.click(300, 50);
+ await page.mouse.click(150, 120);
+ const rect = await getEdgelessSelectedRectModel(page);
+ await moveView(page, [rect[0] + rect[2] + 30, rect[1] + rect[3] / 2]);
+ await clickView(page, [rect[0] + rect[2] + 30, rect[1] + rect[3] / 2]);
+ const newRect = await getEdgelessSelectedRectModel(page);
+ expect(rect[0]).not.toEqual(newRect[0]);
+ expect(rect[1]).toEqual(newRect[1]);
+ expect(rect[2]).toEqual(newRect[2]);
+ expect(rect[3]).toEqual(newRect[3]);
+ });
+ });
+
+ test.describe('drag on auto-complete button', () => {
+ test('drag on right auto-complete button to add shape', async ({
+ page,
+ }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await dragBetweenViewCoords(page, [120, 50], [200, 0]);
+
+ const ellipseButton = getAutoCompletePanelButton(page, 'ellipse');
+ await expect(ellipseButton).toBeVisible();
+ await ellipseButton.click();
+
+ await assertSelectedBound(page, [200, -50, 100, 100]);
+ });
+
+ test('drag on right auto-complete button to add canvas text', async ({
+ page,
+ }) => {
+ await enterPlaygroundRoom(page, {
+ flags: {
+ enable_edgeless_text: false,
+ },
+ });
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await deleteAll(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await dragBetweenViewCoords(page, [120, 50], [200, 0]);
+
+ const canvasTextButton = getAutoCompletePanelButton(page, 'text');
+ await expect(canvasTextButton).toBeVisible();
+ await canvasTextButton.click();
+
+ await waitForInlineEditorStateUpdated(page);
+ await waitNextFrame(page);
+ await page.keyboard.type('hello');
+ await assertEdgelessCanvasText(page, 'hello');
+ });
+
+ test('drag on right auto-complete button to add note', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await triggerComponentToolbarAction(page, 'changeShapeStrokeColor');
+ const lineColor = '--affine-palette-line-red';
+ await changeShapeStrokeColor(page, lineColor);
+ await triggerComponentToolbarAction(page, 'changeShapeFillColor');
+ const color = '--affine-palette-shape-green';
+ await changeShapeFillColor(page, color);
+ await dragBetweenViewCoords(page, [120, 50], [200, 0]);
+
+ const noteButton = getAutoCompletePanelButton(page, 'note');
+ await expect(noteButton).toBeVisible();
+ await noteButton.click();
+ await waitNextFrame(page);
+
+ const edgelessNote = page.locator('affine-edgeless-note');
+
+ expect(await edgelessNote.count()).toBe(1);
+ const [x, y] = await toViewCoord(page, [240, 20]);
+ await page.mouse.click(x, y);
+ await page.keyboard.type('hello');
+ await waitNextFrame(page);
+ await assertRichTexts(page, ['hello']);
+
+ const noteId = await page.evaluate(() => {
+ const note = document.body.querySelector('affine-edgeless-note');
+ return note?.getAttribute('data-block-id');
+ });
+ assertExists(noteId);
+ await assertEdgelessNoteBackground(
+ page,
+ noteId,
+ DEFAULT_NOTE_BACKGROUND_COLOR
+ );
+
+ const rect = await edgelessNote.boundingBox();
+ assertExists(rect);
+
+ // blur note block
+ await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height * 3);
+ await waitNextFrame(page);
+
+ // select connector
+ await dragBetweenViewCoords(page, [140, 50], [160, 0]);
+ await waitNextFrame(page);
+ await assertConnectorStrokeColor(page, lineColor);
+
+ // select note block
+ await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2);
+ await waitNextFrame(page);
+
+ await triggerComponentToolbarAction(page, 'changeNoteColor');
+ const noteColor = '--affine-note-background-red';
+ await changeEdgelessNoteBackground(page, noteColor);
+
+ // move to arrow icon
+ await page.mouse.move(
+ rect.x + rect.width + 20,
+ rect.y + rect.height / 2,
+ { steps: 5 }
+ );
+ await waitNextFrame(page);
+
+ // drag arrow
+ await dragBetweenCoords(
+ page,
+ {
+ x: rect.x + rect.width + 20,
+ y: rect.y + rect.height / 2,
+ },
+ {
+ x: rect.x + rect.width + 20 + 50,
+ y: rect.y + rect.height / 2 + 50,
+ }
+ );
+
+ // `Add a same object` button has the same type.
+ const noteButton2 = getAutoCompletePanelButton(page, 'note').nth(0);
+ await expect(noteButton2).toBeVisible();
+ await noteButton2.click();
+ await waitNextFrame(page);
+
+ const noteId2 = await page.evaluate(() => {
+ const note = document.body.querySelectorAll('affine-edgeless-note')[1];
+ return note?.getAttribute('data-block-id');
+ });
+ assertExists(noteId2);
+ await assertEdgelessNoteBackground(page, noteId, noteColor);
+
+ expect(await edgelessNote.count()).toBe(2);
+ });
+
+ test('drag on right auto-complete button to add frame', async ({
+ page,
+ }) => {
+ await edgelessCommonSetup(page);
+ await createShapeElement(page, [0, 0], [100, 100], Shape.Square);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+ await dragBetweenViewCoords(page, [120, 50], [200, 0]);
+
+ expect(await page.locator('.affine-frame-container').count()).toBe(0);
+
+ const frameButton = getAutoCompletePanelButton(page, 'frame');
+ await expect(frameButton).toBeVisible();
+ await frameButton.click();
+
+ expect(await page.locator('.affine-frame-container').count()).toBe(1);
+ });
+ });
+});
diff --git a/blocksuite/tests-legacy/edgeless/auto-connect.spec.ts b/blocksuite/tests-legacy/edgeless/auto-connect.spec.ts
new file mode 100644
index 0000000000000..23187c196b858
--- /dev/null
+++ b/blocksuite/tests-legacy/edgeless/auto-connect.spec.ts
@@ -0,0 +1,180 @@
+import { NoteDisplayMode } from '@blocksuite/affine-model';
+import { assertExists } from '@blocksuite/global/utils';
+import { expect, type Page } from '@playwright/test';
+
+import {
+ addNote,
+ changeNoteDisplayModeWithId,
+ dragBetweenViewCoords,
+ edgelessCommonSetup,
+ getNoteBoundBoxInEdgeless,
+ getSelectedBound,
+ selectNoteInEdgeless,
+ zoomResetByKeyboard,
+} from '../utils/actions/edgeless.js';
+import { assertSelectedBound } from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+
+test.describe('auto-connect', () => {
+ async function init(page: Page) {
+ await edgelessCommonSetup(page);
+ }
+ test('navigator', async ({ page }) => {
+ await init(page);
+ const id1 = await addNote(page, 'page1', 200, 300);
+ const id2 = await addNote(page, 'page2', 300, 500);
+ const id3 = await addNote(page, 'page3', 400, 700);
+
+ await page.mouse.click(200, 50);
+ // Notes added in edgeless mode only visible in edgeless mode
+ // To use index label navigator, we need to change display mode to PageAndEdgeless
+ await changeNoteDisplayModeWithId(
+ page,
+ id1,
+ NoteDisplayMode.DocAndEdgeless
+ );
+ await changeNoteDisplayModeWithId(
+ page,
+ id2,
+ NoteDisplayMode.DocAndEdgeless
+ );
+ await changeNoteDisplayModeWithId(
+ page,
+ id3,
+ NoteDisplayMode.DocAndEdgeless
+ );
+
+ await selectNoteInEdgeless(page, id1);
+ const bound = await getSelectedBound(page, 0);
+ await page.locator('.page-visible-index-label').nth(0).click();
+ await assertSelectedBound(page, bound);
+
+ await page.locator('.edgeless-auto-connect-next-button').click();
+ bound[0] += 100;
+ bound[1] += 200;
+ await assertSelectedBound(page, bound);
+
+ await page.locator('.edgeless-auto-connect-next-button').click();
+ bound[0] += 100;
+ bound[1] += 200;
+ await assertSelectedBound(page, bound);
+ });
+
+ test('should display index label when select note', async ({ page }) => {
+ await init(page);
+ const id1 = await addNote(page, 'page1', 200, 300);
+ const id2 = await addNote(page, 'page2', 300, 500);
+
+ await page.mouse.click(200, 50);
+
+ await changeNoteDisplayModeWithId(
+ page,
+ id1,
+ NoteDisplayMode.DocAndEdgeless
+ );
+
+ await selectNoteInEdgeless(page, id2);
+ const edgelessOnlyIndexLabel = page.locator('.edgeless-only-index-label');
+ await expect(edgelessOnlyIndexLabel).toBeVisible();
+ await expect(edgelessOnlyIndexLabel).toHaveCount(1);
+
+ await selectNoteInEdgeless(page, id1);
+ const pageVisibleIndexLabel = page.locator('.page-visible-index-label');
+ await expect(pageVisibleIndexLabel).toBeVisible();
+ await expect(pageVisibleIndexLabel).toHaveCount(1);
+ });
+
+ test('should hide index label when dragging note', async ({ page }) => {
+ await init(page);
+ const id1 = await addNote(page, 'page1', 200, 300);
+
+ await page.mouse.click(200, 50);
+
+ await changeNoteDisplayModeWithId(
+ page,
+ id1,
+ NoteDisplayMode.DocAndEdgeless
+ );
+
+ const pageVisibleIndexLabel = page.locator('.page-visible-index-label');
+ await expect(pageVisibleIndexLabel).toBeVisible();
+ await expect(pageVisibleIndexLabel).toHaveCount(1);
+
+ const bound = await getNoteBoundBoxInEdgeless(page, id1);
+ await page.mouse.move(
+ bound.x + bound.width / 2,
+ bound.y + bound.height / 2
+ );
+ await page.mouse.down();
+ await page.mouse.move(
+ bound.x + bound.width * 2,
+ bound.y + bound.height * 2
+ );
+
+ await expect(pageVisibleIndexLabel).not.toBeVisible();
+
+ await page.mouse.up();
+ await expect(pageVisibleIndexLabel).toBeVisible();
+ });
+
+ test('should update index label position after dragging', async ({
+ page,
+ }) => {
+ await init(page);
+ await zoomResetByKeyboard(page);
+
+ const id1 = await addNote(page, 'page1', 200, 300);
+ const id2 = await addNote(page, 'page2', 300, 500);
+
+ await page.mouse.click(200, 50);
+
+ await changeNoteDisplayModeWithId(
+ page,
+ id1,
+ NoteDisplayMode.DocAndEdgeless
+ );
+
+ await selectNoteInEdgeless(page, id2);
+ const edgelessOnlyIndexLabel = page.locator('.edgeless-only-index-label');
+ await expect(edgelessOnlyIndexLabel).toBeVisible();
+
+ // check initial index label position
+ const noteBound = await getNoteBoundBoxInEdgeless(page, id2);
+ const edgelessOnlyIndexLabelBound =
+ await edgelessOnlyIndexLabel.boundingBox();
+ assertExists(edgelessOnlyIndexLabelBound);
+ const border = 1;
+ const offset = 16;
+ expect(edgelessOnlyIndexLabelBound.x).toBeCloseTo(
+ noteBound.x +
+ noteBound.width / 2 -
+ edgelessOnlyIndexLabelBound.width / 2 +
+ border
+ );
+ expect(edgelessOnlyIndexLabelBound.y).toBeCloseTo(
+ noteBound.y + noteBound.height + offset
+ );
+
+ // move note
+ await dragBetweenViewCoords(
+ page,
+ [noteBound.x + noteBound.width / 2, noteBound.y + noteBound.height / 2],
+ [noteBound.x + noteBound.width, noteBound.y + noteBound.height]
+ );
+
+ // check new index label position
+ const newNoteBound = await getNoteBoundBoxInEdgeless(page, id2);
+ const newEdgelessOnlyIndexLabelBound =
+ await edgelessOnlyIndexLabel.boundingBox();
+ assertExists(newEdgelessOnlyIndexLabelBound);
+ expect(newEdgelessOnlyIndexLabelBound.x).toBeCloseTo(
+ newNoteBound.x +
+ newNoteBound.width / 2 -
+ newEdgelessOnlyIndexLabelBound.width / 2 +
+ border
+ );
+ expect(newEdgelessOnlyIndexLabelBound.y).toBeCloseTo(
+ newNoteBound.y + newNoteBound.height + offset
+ );
+ });
+});
diff --git a/blocksuite/tests-legacy/edgeless/basic.spec.ts b/blocksuite/tests-legacy/edgeless/basic.spec.ts
new file mode 100644
index 0000000000000..392a6cb059a9b
--- /dev/null
+++ b/blocksuite/tests-legacy/edgeless/basic.spec.ts
@@ -0,0 +1,358 @@
+import {
+ DEFAULT_NOTE_HEIGHT,
+ DEFAULT_NOTE_WIDTH,
+} from '@blocksuite/affine-model';
+import { assertExists } from '@blocksuite/global/utils';
+import { expect } from '@playwright/test';
+
+import {
+ createShapeElement,
+ decreaseZoomLevel,
+ deleteAll,
+ edgelessCommonSetup,
+ increaseZoomLevel,
+ locatorEdgelessComponentToolButton,
+ multiTouchDown,
+ multiTouchMove,
+ multiTouchUp,
+ optionMouseDrag,
+ Shape,
+ shiftClickView,
+ switchEditorMode,
+ toggleEditorReadonly,
+ ZOOM_BAR_RESPONSIVE_SCREEN_WIDTH,
+ zoomByMouseWheel,
+ zoomResetByKeyboard,
+} from '../utils/actions/edgeless.js';
+import {
+ addBasicBrushElement,
+ addBasicRectShapeElement,
+ captureHistory,
+ clickView,
+ enterPlaygroundRoom,
+ focusRichText,
+ initEmptyEdgelessState,
+ redoByClick,
+ type,
+ undoByClick,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import {
+ assertEdgelessNonSelectedRect,
+ assertEdgelessSelectedModelRect,
+ assertEdgelessSelectedRect,
+ assertNoteXYWH,
+ assertRichTextInlineRange,
+ assertRichTexts,
+ assertSelectedBound,
+ assertZoomLevel,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+
+const CENTER_X = 450;
+const CENTER_Y = 450;
+
+test('switch to edgeless mode', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await focusRichText(page);
+ await type(page, 'hello');
+ await assertRichTexts(page, ['hello']);
+ await assertRichTextInlineRange(page, 0, 5, 0);
+
+ await switchEditorMode(page);
+ const locator = page.locator('affine-edgeless-root gfx-viewport');
+ await expect(locator).toHaveCount(1);
+ await assertRichTexts(page, ['hello']);
+ await waitNextFrame(page);
+
+ // FIXME: got very flaky result on cursor keeping
+ // await assertNativeSelectionRangeCount(page, 1);
+});
+
+test('can zoom viewport', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+
+ await assertNoteXYWH(page, [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT]);
+
+ await page.mouse.click(CENTER_X, CENTER_Y);
+ const original = [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT];
+ await assertEdgelessSelectedModelRect(page, original);
+ await assertZoomLevel(page, 100);
+
+ await decreaseZoomLevel(page);
+ await assertZoomLevel(page, 75);
+ await decreaseZoomLevel(page);
+ await assertZoomLevel(page, 50);
+
+ const zoomed = [0, 0, original[2] * 0.5, original[3] * 0.5];
+ await assertEdgelessSelectedModelRect(page, zoomed);
+
+ await increaseZoomLevel(page);
+ await assertZoomLevel(page, 75);
+ await increaseZoomLevel(page);
+ await assertZoomLevel(page, 100);
+ await assertEdgelessSelectedModelRect(page, original);
+});
+
+test('zoom by mouse', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+ await assertZoomLevel(page, 100);
+
+ await assertNoteXYWH(page, [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT]);
+
+ await page.mouse.click(CENTER_X, CENTER_Y);
+ const original = [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT];
+ await assertEdgelessSelectedModelRect(page, original);
+
+ await zoomByMouseWheel(page, 0, 125);
+ await assertZoomLevel(page, 90);
+
+ const zoomed = [0, 0, original[2] * 0.9, original[3] * 0.9];
+ await assertEdgelessSelectedModelRect(page, zoomed);
+});
+
+test('zoom by pinch', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+ await assertZoomLevel(page, 100);
+
+ await assertNoteXYWH(page, [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT]);
+
+ await page.mouse.click(CENTER_X, CENTER_Y);
+ const original = [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT];
+ await assertEdgelessSelectedModelRect(page, original);
+
+ const from = [
+ { x: CENTER_X - 100, y: CENTER_Y },
+ { x: CENTER_X + 100, y: CENTER_Y },
+ ];
+ const to = [
+ { x: CENTER_X - 50, y: CENTER_Y - 35 },
+ { x: CENTER_X + 50, y: CENTER_Y + 35 },
+ ];
+ await multiTouchDown(page, from);
+ await multiTouchMove(page, from, to);
+ await multiTouchUp(page, to);
+
+ await assertZoomLevel(page, 50);
+ const zoomed = [0, 0, 0.5 * DEFAULT_NOTE_WIDTH, 46];
+ await assertEdgelessSelectedModelRect(page, zoomed);
+});
+
+test('zoom by pinch when edgeless is readonly', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+ await assertZoomLevel(page, 100);
+
+ await toggleEditorReadonly(page);
+
+ const from = [
+ { x: CENTER_X - 100, y: CENTER_Y },
+ { x: CENTER_X + 100, y: CENTER_Y },
+ ];
+ const to = [
+ { x: CENTER_X - 50, y: CENTER_Y - 35 },
+ { x: CENTER_X + 50, y: CENTER_Y + 35 },
+ ];
+ await multiTouchDown(page, from);
+ await multiTouchMove(page, from, to);
+ await multiTouchUp(page, to);
+
+ await toggleEditorReadonly(page);
+ await assertZoomLevel(page, 50);
+});
+
+test('move by pan', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+ await assertZoomLevel(page, 100);
+
+ await assertNoteXYWH(page, [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT]);
+
+ await page.mouse.click(CENTER_X, CENTER_Y);
+ const original = [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT];
+ await assertEdgelessSelectedModelRect(page, original);
+
+ const from = [
+ { x: CENTER_X - 100, y: CENTER_Y },
+ { x: CENTER_X + 100, y: CENTER_Y },
+ ];
+ const to = [
+ { x: CENTER_X - 50, y: CENTER_Y + 50 },
+ { x: CENTER_X + 150, y: CENTER_Y + 50 },
+ ];
+
+ await multiTouchDown(page, from);
+ await multiTouchMove(page, from, to);
+ await multiTouchUp(page, to);
+
+ const moved = [0, 0, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT];
+ await assertEdgelessSelectedModelRect(page, moved);
+});
+
+test('option/alt mouse drag duplicate a new element', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+ await deleteAll(page);
+
+ const start = [0, 0];
+ const end = [100, 100];
+ await createShapeElement(page, start, end, Shape.Square);
+ await optionMouseDrag(page, [50, 50], [150, 50]);
+ await assertSelectedBound(page, [100, 0, 100, 100]);
+
+ await captureHistory(page);
+ await undoByClick(page);
+ await assertSelectedBound(page, [0, 0, 100, 100]);
+
+ await redoByClick(page);
+ await assertSelectedBound(page, [100, 0, 100, 100]);
+});
+
+test('should cancel select when the selected point is outside the current selected element', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+
+ const firstStart = { x: 100, y: 100 };
+ const firstEnd = { x: 200, y: 200 };
+ await addBasicRectShapeElement(page, firstStart, firstEnd);
+
+ const secondStart = { x: 300, y: 300 };
+ const secondEnd = { x: 400, y: 400 };
+ await addBasicRectShapeElement(page, secondStart, secondEnd);
+
+ // select the first rect
+ await page.mouse.click(110, 150);
+ await assertEdgelessSelectedRect(page, [100, 100, 100, 100]);
+
+ // click outside the selected rect
+ await page.mouse.click(200, 200);
+ await assertEdgelessNonSelectedRect(page);
+});
+
+test('the tooltip of more button should be hidden when the action menu is shown', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+
+ const start = { x: 100, y: 100 };
+ const end = { x: 200, y: 200 };
+ await addBasicBrushElement(page, start, end);
+
+ await page.mouse.click(start.x + 5, start.y + 5);
+ await assertEdgelessSelectedRect(page, [98, 98, 104, 104]);
+
+ const moreButton = locatorEdgelessComponentToolButton(page, 'more');
+ await expect(moreButton).toBeVisible();
+
+ const moreButtonBox = await moreButton.boundingBox();
+ const tooltip = page.locator('.affine-tooltip');
+
+ assertExists(moreButtonBox);
+
+ // need to wait for previous tooltip to be hidden
+ await page.waitForTimeout(100);
+ await page.mouse.move(moreButtonBox.x + 10, moreButtonBox.y + 10);
+ await expect(tooltip).toBeVisible();
+
+ await page.mouse.click(moreButtonBox.x + 10, moreButtonBox.y + 10);
+ await expect(tooltip).toBeHidden();
+
+ await page.mouse.click(moreButtonBox.x + 10, moreButtonBox.y + 10);
+ await expect(tooltip).toBeVisible();
+});
+
+test('shift click multi select and de-select', async ({ page }) => {
+ await edgelessCommonSetup(page);
+ const start = [0, 0];
+ const end = [100, 100];
+ await createShapeElement(page, start, end, Shape.Square);
+ start[0] = 100;
+ end[0] = 200;
+ await createShapeElement(page, start, end, Shape.Square);
+
+ await clickView(page, [50, 0]);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 100, 100]);
+
+ await shiftClickView(page, [150, 50]);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 200, 100]);
+
+ // we will try to write text on a shape element when we dbclick it
+
+ await waitNextFrame(page, 500);
+ await shiftClickView(page, [150, 50]);
+ await assertEdgelessSelectedModelRect(page, [0, 0, 100, 100]);
+});
+
+test('Before and after switching to Edgeless, the previous zoom ratio and position when Edgeless was opened should be remembered', async ({
+ page,
+}) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/toeverything/blocksuite/issues/2479',
+ });
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+ await assertZoomLevel(page, 100);
+ await increaseZoomLevel(page);
+ await assertZoomLevel(page, 125);
+ await switchEditorMode(page);
+ await switchEditorMode(page);
+ await assertZoomLevel(page, 125);
+});
+
+test('should close zoom bar when click blank area', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+
+ const screenWidth = page.viewportSize()?.width;
+ assertExists(screenWidth);
+ if (screenWidth > ZOOM_BAR_RESPONSIVE_SCREEN_WIDTH) {
+ await page.setViewportSize({
+ width: 1000,
+ height: 1000,
+ });
+ }
+
+ await zoomResetByKeyboard(page);
+ await assertZoomLevel(page, 100);
+ await increaseZoomLevel(page);
+ await assertZoomLevel(page, 125);
+
+ const verticalZoomBar = '.edgeless-zoom-toolbar-container.vertical';
+ const zoomBar = page.locator(verticalZoomBar);
+ await expect(zoomBar).toBeVisible();
+
+ // Click Blank Area
+ await page.mouse.click(10, 100);
+ await expect(zoomBar).toBeHidden();
+});
diff --git a/blocksuite/tests-legacy/edgeless/brush.spec.ts b/blocksuite/tests-legacy/edgeless/brush.spec.ts
new file mode 100644
index 0000000000000..893dc2a1ee5c0
--- /dev/null
+++ b/blocksuite/tests-legacy/edgeless/brush.spec.ts
@@ -0,0 +1,193 @@
+import { expect } from '@playwright/test';
+
+import {
+ assertEdgelessTool,
+ deleteAll,
+ pickColorAtPoints,
+ selectBrushColor,
+ selectBrushSize,
+ setEdgelessTool,
+ switchEditorMode,
+ updateExistedBrushElementSize,
+ zoomResetByKeyboard,
+} from '../utils/actions/edgeless.js';
+import {
+ addBasicBrushElement,
+ click,
+ dragBetweenCoords,
+ enterPlaygroundRoom,
+ initEmptyEdgelessState,
+ resizeElementByHandle,
+} from '../utils/actions/index.js';
+import {
+ assertEdgelessColorSameWithHexColor,
+ assertEdgelessSelectedRect,
+ assertSameColor,
+} from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+
+test('change editor mode when brush color palette opening', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await setEdgelessTool(page, 'brush');
+
+ const brushMenu = page.locator('edgeless-brush-menu');
+ await expect(brushMenu).toBeVisible();
+
+ await switchEditorMode(page);
+ await expect(brushMenu).toBeHidden();
+});
+
+test('add brush element', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+
+ const start = { x: 100, y: 100 };
+ const end = { x: 200, y: 200 };
+ await addBasicBrushElement(page, start, end, false);
+
+ await assertEdgelessTool(page, 'brush');
+});
+
+test('resize brush element', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+
+ const start = { x: 100, y: 100 };
+ const end = { x: 200, y: 200 };
+ await addBasicBrushElement(page, start, end);
+
+ await page.mouse.click(start.x + 5, start.y + 5);
+ await assertEdgelessSelectedRect(page, [98, 98, 104, 104]);
+
+ await page.mouse.click(start.x + 5, start.y + 5);
+ const delta = { x: 20, y: 40 };
+ await resizeElementByHandle(page, delta, 'top-left', 10);
+
+ await page.mouse.click(start.x + 25, start.y + 45);
+ await assertEdgelessSelectedRect(page, [118, 138, 84, 64]);
+});
+
+test('add brush element with color', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+
+ await setEdgelessTool(page, 'brush');
+ const color = '--affine-palette-line-blue';
+ await selectBrushColor(page, color);
+
+ const start = { x: 100, y: 100 };
+ const end = { x: 200, y: 200 };
+ await dragBetweenCoords(page, start, end, { steps: 100 });
+
+ const [pickedColor] = await pickColorAtPoints(page, [[110, 110]]);
+
+ await assertEdgelessColorSameWithHexColor(page, color, pickedColor);
+});
+
+test('keep same color when mouse mode switched back to brush', async ({
+ page,
+}) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await deleteAll(page);
+
+ await setEdgelessTool(page, 'brush');
+ const color = '--affine-palette-line-blue';
+ await selectBrushColor(page, color);
+ const start = { x: 200, y: 200 };
+ const end = { x: 300, y: 300 };
+ await dragBetweenCoords(page, start, end, { steps: 100 });
+
+ await setEdgelessTool(page, 'default');
+ await click(page, { x: 50, y: 50 });
+
+ await setEdgelessTool(page, 'brush');
+ const origin = { x: 100, y: 100 };
+ await dragBetweenCoords(page, origin, start, { steps: 100 });
+ const [pickedColor] = await pickColorAtPoints(page, [[110, 110]]);
+ await assertEdgelessColorSameWithHexColor(page, color, pickedColor);
+});
+
+test('add brush element with different size', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+
+ await setEdgelessTool(page, 'brush');
+ await selectBrushSize(page, 'ten');
+ const color = '--affine-palette-line-blue';
+ await selectBrushColor(page, color);
+
+ const start = { x: 100, y: 100 };
+ const end = { x: 200, y: 100 };
+ await dragBetweenCoords(page, start, end, { steps: 100 });
+
+ const [topEdge, bottomEdge, nearTopEdge, nearBottomEdge] =
+ await pickColorAtPoints(page, [
+ // Select two points on the top and bottom border of the line,
+ // their color should be the same as the specified color
+ [110, 95],
+ [110, 104],
+ // Select two points close to the upper and lower boundaries of the line,
+ // their color should be different from the specified color
+ [110, 94],
+ [110, 105],
+ ]);
+
+ await assertEdgelessColorSameWithHexColor(page, color, topEdge);
+ await assertEdgelessColorSameWithHexColor(page, color, bottomEdge);
+ assertSameColor(nearTopEdge, '#4f90ff');
+ assertSameColor(nearBottomEdge, '#4f90ff');
+});
+
+test('change brush element size by component-toolbar', async ({ page }) => {
+ await enterPlaygroundRoom(page);
+ await initEmptyEdgelessState(page);
+ await switchEditorMode(page);
+ await zoomResetByKeyboard(page);
+
+ const start = { x: 100, y: 100 };
+ const end = { x: 200, y: 200 };
+ await addBasicBrushElement(page, start, end);
+
+ // wait for menu hide animation
+ await page.waitForTimeout(500);
+
+ // change to line width 12
+ await page.mouse.click(110, 110);
+ await updateExistedBrushElementSize(page, 6);
+ await assertEdgelessSelectedRect(page, [94, 94, 112, 112]);
+
+ // change to line width 10
+ await page.mouse.click(110, 110);
+ await updateExistedBrushElementSize(page, 5);
+ await assertEdgelessSelectedRect(page, [95, 95, 110, 110]);
+
+ // change to line width 8
+ await page.mouse.click(110, 110);
+ await updateExistedBrushElementSize(page, 4);
+ await assertEdgelessSelectedRect(page, [96, 96, 108, 108]);
+
+ // change to line width 6
+ await page.mouse.click(110, 110);
+ await updateExistedBrushElementSize(page, 3);
+ await assertEdgelessSelectedRect(page, [97, 97, 106, 106]);
+
+ // change to line width 4
+ await page.mouse.click(110, 110);
+ await updateExistedBrushElementSize(page, 2);
+ await assertEdgelessSelectedRect(page, [98, 98, 104, 104]);
+
+ // change to line width 2
+ await page.mouse.click(110, 110);
+ await updateExistedBrushElementSize(page, 1);
+ await assertEdgelessSelectedRect(page, [99, 99, 102, 102]);
+});
diff --git a/blocksuite/tests-legacy/edgeless/clipboard.spec.ts b/blocksuite/tests-legacy/edgeless/clipboard.spec.ts
new file mode 100644
index 0000000000000..52a6a7f3db0d3
--- /dev/null
+++ b/blocksuite/tests-legacy/edgeless/clipboard.spec.ts
@@ -0,0 +1,235 @@
+import { expect } from '@playwright/test';
+
+import {
+ createNote,
+ createShapeElement,
+ decreaseZoomLevel,
+ deleteAll,
+ getAllSortedIds,
+ Shape,
+ switchEditorMode,
+ toViewCoord,
+ triggerComponentToolbarAction,
+} from '../utils/actions/edgeless.js';
+import {
+ copyByKeyboard,
+ cutByKeyboard,
+ edgelessCommonSetup as commonSetup,
+ enterPlaygroundRoom,
+ expectConsoleMessage,
+ focusTitle,
+ getCurrentEditorDocId,
+ initEmptyEdgelessState,
+ mockParseDocUrlService,
+ pasteByKeyboard,
+ pasteContent,
+ selectAllByKeyboard,
+ type,
+ waitNextFrame,
+} from '../utils/actions/index.js';
+import { assertRichImage } from '../utils/asserts.js';
+import { test } from '../utils/playwright.js';
+
+test.describe('mime', () => {
+ test('should paste svg in text/plain mime', async ({ page }) => {
+ expectConsoleMessage(page, 'Error: Image sourceId is missing!', 'warning');
+ await commonSetup(page);
+ const content = {
+ 'text/plain': `
+ `,
+ };
+
+ await pasteContent(page, content);
+
+ // wait for paste
+ await page.waitForTimeout(200);
+ await assertRichImage(page, 1);
+ });
+
+ test('should not paste bad svg', async ({ page }) => {
+ expectConsoleMessage(page, 'BlockSuiteError: val does not exist', 'error');
+ expectConsoleMessage(page, 'Error: Image sourceId is missing!', 'warning');
+
+ await commonSetup(page);
+ const contents = [
+ {
+ 'text/plain': `