Files
AFFiNE-Mirror/tests/blocksuite/e2e/drag.spec.ts
Saul-Mirone 7eb6b268a6 fix(editor): auto focus between tab switch (#12572)
Closes: BS-2290

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **Bug Fixes**
  - Improved focus behavior when switching between tabs to prevent unwanted automatic focusing of the content-editable area.
  - Enhanced selection clearing to avoid unnecessary blurring when the main editable element is already focused.
  - Refined focus checks in tests to specifically target contenteditable elements, ensuring more accurate validation of focus behavior.
  - Adjusted test assertions for block selection to be less strict and removed redundant blur operations for smoother test execution.
  - Updated toolbar dropdown closing method to use keyboard interaction for better reliability.
- **New Features**
  - Added a recoverable property to selection types, improving selection state management and recovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-27 13:38:02 +00:00

767 lines
20 KiB
TypeScript

import { expect } from '@playwright/test';
import {
dragBetweenCoords,
dragBetweenIndices,
dragHandleFromBlockToBlockBottomById,
enterPlaygroundRoom,
focusRichText,
initEmptyParagraphState,
initThreeLists,
initThreeParagraphs,
pressEnter,
pressShiftTab,
pressTab,
type,
} from './utils/actions/index.js';
import {
getEditorHostLocator,
getPageSnapshot,
initParagraphsByCount,
} from './utils/actions/misc.js';
import { assertRichTexts } from './utils/asserts.js';
import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from './utils/bs-alternative.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-drop-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-drop-indicator')).toBeHidden();
await assertRichTexts(page, ['123', '789', '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-drop-indicator')).toBeHidden();
await assertRichTexts(page, ['1', '2', '22', '23', '21', '3']);
await dragHandleFromBlockToBlockBottomById(page, '3', '8');
await expect(page.locator('.affine-drop-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-drop-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-drop-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-drop-indicator')).toBeHidden();
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_drag_3_9.json`
);
await dragHandleFromBlockToBlockBottomById(
page,
'4',
'3',
true,
-(1 * BLOCK_CHILDREN_CONTAINER_PADDING_LEFT)
);
await expect(page.locator('.affine-drop-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-drop-indicator')).toBeHidden();
// FIXME(DND)
// 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 page.locator('[data-block-id="1"]').boundingBox();
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-drop-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('[contenteditable="true"]:focus')).toHaveCount(1);
await dragHandleFromBlockToBlockBottomById(page, '2', '4');
await expect(page.locator('.affine-drop-indicator')).toBeHidden();
await assertRichTexts(page, ['456', '789', '123']);
await expect(page.locator('[contenteditable="true"]: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 page.locator('.affine-note-block-container').boundingBox();
if (!rect) {
throw new Error();
}
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('[contenteditable="true"]: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('[contenteditable="true"]: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 window.$blocksuite.store.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 window.$blocksuite.store.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);
});