mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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:
156
tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
Normal file
156
tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { test } from '@affine-test/kit/playwright';
|
||||
import {
|
||||
clickEdgelessModeButton,
|
||||
createEdgelessNoteBlock,
|
||||
getEdgelessSelectedIds,
|
||||
getPageMode,
|
||||
locateEditorContainer,
|
||||
locateElementToolbar,
|
||||
} from '@affine-test/kit/utils/editor';
|
||||
import {
|
||||
pasteByKeyboard,
|
||||
selectAllByKeyboard,
|
||||
} from '@affine-test/kit/utils/keyboard';
|
||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||
import {
|
||||
clickNewPageButton,
|
||||
waitForEditorLoad,
|
||||
} from '@affine-test/kit/utils/page-logic';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
const title = 'Edgeless Note Header Test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openHomePage(page);
|
||||
await waitForEditorLoad(page);
|
||||
await clickNewPageButton(page, title);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Hello');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('World');
|
||||
await clickEdgelessModeButton(page);
|
||||
const container = locateEditorContainer(page);
|
||||
await container.click();
|
||||
});
|
||||
|
||||
test.describe('edgeless page header toolbar', () => {
|
||||
const locateHeaderToolbar = (page: Page) =>
|
||||
page.getByTestId('edgeless-page-block-header');
|
||||
|
||||
test('only first note block has header toolbar and its element toolbar', async ({
|
||||
page,
|
||||
}) => {
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
await expect(toolbar).toHaveCount(1);
|
||||
await expect(toolbar).toBeVisible();
|
||||
|
||||
await createEdgelessNoteBlock(page, [100, 100]);
|
||||
|
||||
await expect(toolbar).toHaveCount(1);
|
||||
await expect(toolbar).toBeVisible();
|
||||
});
|
||||
|
||||
test('should shrink note block when clicking on the toggle button', async ({
|
||||
page,
|
||||
}) => {
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
const toolBox = await toolbar.boundingBox();
|
||||
const noteBox = await page.locator('affine-edgeless-note').boundingBox();
|
||||
if (!noteBox || !toolBox) throw new Error('Bounding box not found');
|
||||
expect(noteBox.height).toBeGreaterThan(toolBox.height);
|
||||
|
||||
const toggleButton = toolbar.getByTestId('edgeless-note-toggle-button');
|
||||
await toggleButton.click();
|
||||
|
||||
const newNoteBox = await page.locator('affine-edgeless-note').boundingBox();
|
||||
if (!newNoteBox) throw new Error('Bounding box not found');
|
||||
expect(newNoteBox.height).toBe(toolBox.height);
|
||||
|
||||
await toggleButton.click();
|
||||
const newNoteBox2 = await page
|
||||
.locator('affine-edgeless-note')
|
||||
.boundingBox();
|
||||
if (!newNoteBox2) throw new Error('Bounding box not found');
|
||||
expect(newNoteBox2).toEqual(noteBox);
|
||||
});
|
||||
|
||||
test('page title should be displayed when page block is collapsed and hidden when page block is not collapsed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
const toolbarTitle = toolbar.getByTestId('edgeless-note-title');
|
||||
await expect(toolbarTitle).toHaveText('');
|
||||
|
||||
const toggleButton = toolbar.getByTestId('edgeless-note-toggle-button');
|
||||
await toggleButton.click();
|
||||
await expect(toolbarTitle).toHaveText(title);
|
||||
|
||||
await toggleButton.click();
|
||||
await expect(toolbarTitle).toHaveText('');
|
||||
});
|
||||
|
||||
test('should switch to page mode when expand button is clicked', async ({
|
||||
page,
|
||||
}) => {
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
const expandButton = toolbar.getByTestId('edgeless-note-expand-button');
|
||||
await expandButton.click();
|
||||
|
||||
expect(await getPageMode(page)).toBe('page');
|
||||
});
|
||||
|
||||
test('should open doc properties dialog when info button is clicked', async ({
|
||||
page,
|
||||
}) => {
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
const infoButton = toolbar.getByTestId('edgeless-note-info-button');
|
||||
await infoButton.click();
|
||||
const infoModal = page.getByTestId('info-modal');
|
||||
await expect(infoModal).toBeVisible();
|
||||
});
|
||||
|
||||
test('should copy note edgeless link to clipboard when link button is clicked', async ({
|
||||
page,
|
||||
}) => {
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
await selectAllByKeyboard(page);
|
||||
const noteId = (await getEdgelessSelectedIds(page))[0];
|
||||
|
||||
const linkButton = toolbar.getByTestId('edgeless-note-link-button');
|
||||
await linkButton.click();
|
||||
|
||||
const url = page.url();
|
||||
const link = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(link).toBe(`${url}&blockIds=${noteId}`);
|
||||
});
|
||||
|
||||
test('info button should hidden in peek view', async ({ page }) => {
|
||||
const url = page.url();
|
||||
await page.evaluate(url => navigator.clipboard.writeText(url), url);
|
||||
|
||||
await clickNewPageButton(page);
|
||||
await page.keyboard.press('Enter');
|
||||
await pasteByKeyboard(page);
|
||||
const reference = page.locator('affine-reference');
|
||||
await reference.click({ modifiers: ['Shift'] });
|
||||
|
||||
const toolbar = locateHeaderToolbar(page);
|
||||
const infoButton = toolbar.getByTestId('edgeless-note-info-button');
|
||||
|
||||
await expect(toolbar).toBeVisible();
|
||||
await expect(infoButton).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('edgeless note element toolbar', () => {
|
||||
test('the toolbar of page block should not contains auto-height', async ({
|
||||
page,
|
||||
}) => {
|
||||
await selectAllByKeyboard(page);
|
||||
const toolbar = locateElementToolbar(page);
|
||||
const autoHeight = toolbar.getByTestId('edgeless-note-auto-height');
|
||||
|
||||
await expect(toolbar).toBeVisible();
|
||||
await expect(autoHeight).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@playwright/test": "=1.49.1",
|
||||
"express": "^4.21.2",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../../tools/utils" }]
|
||||
"references": [
|
||||
{ "path": "../../tools/utils" },
|
||||
{ "path": "../../blocksuite/affine/all" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user