feat(editor): edgeless page block toolbar (#9707)

Close [BS-2315](https://linear.app/affine-design/issue/BS-2315/page-block-header)

### What Changes
- Add header toolbar to page block (the first note in canvas)
- Add e2e tests
- Add some edgeless e2e test utils.  **The package `@blocksuite/affine` was added to `"@affine-test/kit"`**
This commit is contained in:
L-Sun
2025-01-15 12:04:43 +00:00
parent 494a9473d5
commit 94c9717a35
21 changed files with 760 additions and 35 deletions

View File

@@ -1,4 +1,10 @@
import { expect, type Page } from '@playwright/test';
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
import {
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
EDGELESS_TOOLBAR_WIDGET,
} from '@blocksuite/blocks';
import type { IVec, XYWH } from '@blocksuite/global/utils';
import { expect, type Locator, type Page } from '@playwright/test';
export function locateModeSwitchButton(
page: Page,
@@ -41,3 +47,270 @@ export async function getPageMode(page: Page): Promise<'page' | 'edgeless'> {
}
throw new Error('Unknown mode');
}
export function locateEditorContainer(page: Page, editorIndex = 0) {
return page.locator('[data-affine-editor-container]').nth(editorIndex);
}
// ================== Edgeless ==================
export async function getEdgelessSelectedIds(page: Page, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container: AffineEditorContainer) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.selection.selectedIds;
});
}
/**
* Convert a canvas point to view coordinate
* @param point the coordinate on the canvas
*/
export async function toViewCoord(page: Page, point: IVec, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container: AffineEditorContainer, point) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.viewport.toViewCoord(point[0], point[1]);
}, point);
}
/**
* Click a point on the canvas
* @param point the coordinate on the canvas
*/
export async function clickView(page: Page, point: IVec, editorIndex = 0) {
const [x, y] = await toViewCoord(page, point, editorIndex);
await page.mouse.click(x, y);
}
/**
* Double click a point on the canvas
* @param point the coordinate on the canvas
*/
export async function dblclickView(page: Page, point: IVec, editorIndex = 0) {
const [x, y] = await toViewCoord(page, point, editorIndex);
await page.mouse.dblclick(x, y);
}
export async function dragView(
page: Page,
from: IVec,
to: IVec,
editorIndex = 0
) {
const [x1, y1] = await toViewCoord(page, from, editorIndex);
const [x2, y2] = await toViewCoord(page, to, editorIndex);
await page.mouse.move(x1, y1);
await page.mouse.down();
await page.mouse.move(x2, y2);
await page.mouse.up();
}
export function locateEdgelessToolbar(page: Page, editorIndex = 0) {
return locateEditorContainer(page, editorIndex).locator(
EDGELESS_TOOLBAR_WIDGET
);
}
type EdgelessTool =
| 'default'
| 'pan'
| 'note'
| 'shape'
| 'brush'
| 'eraser'
| 'text'
| 'connector'
| 'frame'
| 'frameNavigator'
| 'lasso';
/**
* @param type the type of the tool in the toolbar
* @param innerContainer the button may have an inner container
*/
export async function locateEdgelessToolButton(
page: Page,
type: EdgelessTool,
innerContainer = true,
editorIndex = 0
) {
const toolbar = locateEdgelessToolbar(page, editorIndex);
const selector = {
default: '.edgeless-default-button',
pan: '.edgeless-default-button',
shape: '.edgeless-shape-button',
brush: '.edgeless-brush-button',
eraser: '.edgeless-eraser-button',
text: '.edgeless-mindmap-button',
connector: '.edgeless-connector-button',
note: '.edgeless-note-button',
frame: '.edgeless-frame-button',
frameNavigator: '.edgeless-frame-navigator-button',
lasso: '.edgeless-lasso-button',
}[type];
let buttonType;
switch (type) {
case 'brush':
case 'text':
case 'eraser':
case 'shape':
case 'note':
buttonType = 'edgeless-toolbar-button';
break;
default:
buttonType = 'edgeless-tool-icon-button';
}
const locateEdgelessToolButtonSenior = async (
selector: string
): Promise<Locator> => {
const target = toolbar.locator(selector);
const visible = await target.isVisible();
if (visible) return target;
// try to click next page
const nextButton = toolbar.locator(
'.senior-nav-button-wrapper.next > icon-button'
);
const nextExists = await nextButton.count();
const isDisabled =
// oxlint-disable-next-line unicorn/prefer-dom-node-dataset
(await nextButton.getAttribute('data-test-disabled')) === 'true';
if (!nextExists || isDisabled) return target;
await nextButton.click();
await page.waitForTimeout(200);
return locateEdgelessToolButtonSenior(selector);
};
const button = await locateEdgelessToolButtonSenior(
`${buttonType}${selector}`
);
return innerContainer ? button.locator('.icon-container') : button;
}
export enum Shape {
Diamond = 'Diamond',
Ellipse = 'Ellipse',
'Rounded rectangle' = 'Rounded rectangle',
Square = 'Square',
Triangle = 'Triangle',
}
/**
* Set edgeless tool by clicking button in edgeless toolbar
*/
export async function setEdgelessTool(
page: Page,
tool: EdgelessTool,
shape = Shape.Square,
editorIndex = 0
) {
const toolbar = locateEdgelessToolbar(page, editorIndex);
switch (tool) {
// text tool is removed, use shortcut to trigger
case 'text':
await page.keyboard.press('t', { delay: 100 });
break;
case 'default': {
const button = await locateEdgelessToolButton(
page,
'default',
false,
editorIndex
);
const classes = (await button.getAttribute('class'))?.split(' ');
if (!classes?.includes('default')) {
await button.click();
await page.waitForTimeout(100);
}
break;
}
case 'pan': {
const button = await locateEdgelessToolButton(
page,
'default',
false,
editorIndex
);
const classes = (await button.getAttribute('class'))?.split(' ');
if (classes?.includes('default')) {
await button.click();
await page.waitForTimeout(100);
} else if (classes?.includes('pan')) {
await button.click(); // change to default
await page.waitForTimeout(100);
await button.click(); // change to pan
await page.waitForTimeout(100);
}
break;
}
case 'lasso':
case 'note':
case 'brush':
case 'eraser':
case 'frame':
case 'connector': {
const button = await locateEdgelessToolButton(
page,
tool,
false,
editorIndex
);
await button.click();
break;
}
case 'shape': {
const shapeToolButton = await locateEdgelessToolButton(
page,
'shape',
false,
editorIndex
);
// Avoid clicking on the shape-element (will trigger dragging mode)
await shapeToolButton.click({ position: { x: 5, y: 5 } });
const squareShapeButton = toolbar
.locator('edgeless-slide-menu edgeless-tool-icon-button')
.filter({ hasText: shape });
await squareShapeButton.click();
break;
}
}
}
export function locateElementToolbar(page: Page, editorIndex = 0) {
return locateEditorContainer(page, editorIndex).locator(
EDGELESS_ELEMENT_TOOLBAR_WIDGET
);
}
/**
* Create a not block in canvas
* @param position the position or xwyh of the note block in canvas
*/
export async function createEdgelessNoteBlock(
page: Page,
position: IVec | XYWH,
editorIndex = 0
) {
await setEdgelessTool(page, 'note', undefined, editorIndex);
if (position.length === 4) {
dragView(
page,
[position[0], position[1]],
[position[0] + position[2], position[1] + position[3]]
);
} else {
await clickView(page, position, editorIndex);
}
}

View File

@@ -5,7 +5,6 @@ export async function importImage(page: Page, pathInFixtures: string) {
await page.evaluate(() => {
// Force fallback to input[type=file] in tests
// See https://github.com/microsoft/playwright/issues/8850
// @ts-expect-error allow
window.showOpenFilePicker = undefined;
});