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
@@ -0,0 +1,14 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import { type BlockStdScope, ConfigExtension } from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
export type NoteConfig = {
edgelessNoteHeader: (context: {
note: NoteBlockModel;
std: BlockStdScope;
}) => TemplateResult;
};
export function NoteConfigExtension(config: NoteConfig) {
return ConfigExtension('affine:note', config);
}
@@ -12,6 +12,7 @@ import type { indentBlock } from './commands/indent-block';
import type { indentBlocks } from './commands/indent-blocks';
import type { selectBlock } from './commands/select-block';
import type { selectBlocksBetween } from './commands/select-blocks-between';
import type { NoteConfig } from './config';
import { NoteBlockComponent } from './note-block';
import {
EdgelessNoteBlockComponent,
@@ -48,5 +49,8 @@ declare global {
interface BlockServices {
'affine:note': NoteBlockService;
}
interface BlockConfigs {
'affine:note': NoteConfig;
}
}
}
@@ -1,5 +1,6 @@
export * from './adapters';
export * from './commands';
export * from './config';
export * from './note-block';
export * from './note-edgeless-block';
export * from './note-service';
@@ -7,7 +7,10 @@ import {
StrokeStyle,
} from '@blocksuite/affine-model';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
getClosestBlockComponentByPoint,
handleNativeRangeAtPoint,
@@ -77,7 +80,7 @@ export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) {
bottom: `${-extra}px`,
right: `${-extra}px`,
zIndex: '1',
pointerEvents: this.display ? 'auto' : 'none',
pointerEvents: this.editing ? 'none' : 'auto',
borderRadius: `${
this.model.edgeless.style.borderRadius * this.zoom
}px`,
@@ -86,9 +89,6 @@ export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) {
`;
}
@property({ attribute: false })
accessor display!: boolean;
@property({ attribute: false })
accessor editing!: boolean;
@@ -128,9 +128,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
.edgeless-note-collapse-button.flip {
transform: translateX(-50%) rotate(180deg);
}
.edgeless-note-collapse-button.hide {
display: none;
}
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
> .note-background {
@@ -156,8 +153,16 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
);
});
private get _enablePageHeader() {
return this.std.get(FeatureFlagService).getFlag('enable_page_block_header');
}
private get _isShowCollapsedContent() {
return this.model.edgeless.collapse && (this._isResizing || this._isHover);
return (
this.model.edgeless.collapse &&
this.gfx.selection.has(this.model.id) &&
(this._isResizing || this._isHover)
);
}
get _zoom() {
@@ -240,12 +245,28 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
}
}
private _isFirstNote() {
return (
this.model.parent?.children.find(child =>
matchFlavours(child, ['affine:note'])
) === this.model
);
}
private _leaved() {
if (this._isHover) {
this._isHover = false;
}
}
private _renderHeader() {
const header = this.host.std
.getConfig('affine:note')
?.edgelessNoteHeader({ note: this.model, std: this.std });
return header;
}
private _setCollapse(event: MouseEvent) {
event.stopImmediatePropagation();
@@ -466,7 +487,9 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
style=${styleMap(backgroundStyle)}
@pointerdown=${stopPropagation}
@click=${this._handleClickAtBackground}
></div>
>
${this._enablePageHeader ? this._renderHeader() : nothing}
</div>
<div
class="edgeless-note-page-content"
@@ -479,12 +502,18 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
${this.renderPageContent()}
</div>
${isCollapsable
<edgeless-note-mask
.model=${this.model}
.host=${this.host}
.zoom=${this.gfx.viewport.zoom ?? 1}
.editing=${this._editing}
></edgeless-note-mask>
${isCollapsable && (!this._isFirstNote() || !this._enablePageHeader)
? html`<div
class="${classMap({
'edgeless-note-collapse-button': true,
flip: isCollapseArrowUp,
hide: this._isSelected,
})}"
style=${styleMap({
bottom: this._editing ? `${-extra}px` : '0',
@@ -497,14 +526,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
</div>`
: nothing}
${this._collapsedContent()}
<edgeless-note-mask
.model=${this.model}
.display=${!this._editing}
.host=${this.host}
.zoom=${this.gfx.viewport.zoom ?? 1}
.editing=${this._editing}
></edgeless-note-mask>
</div>
`;
}
@@ -518,9 +539,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
@state()
private accessor _isResizing = false;
@state()
private accessor _isSelected = false;
@state()
private accessor _noteFullHeight = 0;
@@ -18,6 +18,7 @@ export interface BlockSuiteFlags {
enable_shape_shadow_blur: boolean;
enable_mobile_keyboard_toolbar: boolean;
enable_mobile_linked_doc_menu: boolean;
enable_page_block_header: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -40,6 +41,7 @@ export class FeatureFlagService extends StoreExtension {
enable_shape_shadow_blur: false,
enable_mobile_keyboard_toolbar: false,
enable_mobile_linked_doc_menu: false,
enable_page_block_header: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
@@ -145,6 +145,18 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
return this.edgeless.doc;
}
private get _enableAutoHeight() {
return !(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_page_block_header') &&
this.notes.length === 1 &&
this.notes[0].parent?.children.find(child =>
matchFlavours(child, ['affine:note'])
) === this.notes[0]
);
}
private _getScaleLabel(scale: number) {
return Math.round(scale * 100) + '%';
}
@@ -434,15 +446,18 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
onlyOne ? this.quickConnectButton : nothing,
html`
<editor-icon-button
aria-label="Size"
.tooltip=${collapse ? 'Auto height' : 'Customized height'}
@click=${() => this._setCollapse()}
>
${collapse ? ExpandIcon : ShrinkIcon}
</editor-icon-button>
this._enableAutoHeight
? html`<editor-icon-button
aria-label="Size"
data-testid="edgeless-note-auto-height"
.tooltip=${collapse ? 'Auto height' : 'Customized height'}
@click=${() => this._setCollapse()}
>
${collapse ? ExpandIcon : ShrinkIcon}
</editor-icon-button>`
: nothing,
html`
<editor-menu-button
${ref(this._scalePanelRef)}
.contentPadding=${'8px'}
@@ -1,3 +1,4 @@
export { EDGELESS_TOOLBAR_WIDGET } from '../edgeless/components/toolbar/edgeless-toolbar.js';
export {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
@@ -55,6 +55,7 @@ import {
patchEdgelessClipboard,
patchForAttachmentEmbedViews,
patchForClipboardInElectron,
patchForEdgelessNoteConfig,
patchForMobile,
patchForSharedPage,
patchGenerateDocUrlExtension,
@@ -159,6 +160,7 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
patched = patched.concat(patchForAttachmentEmbedViews(reactToLit));
}
patched = patched.concat(patchForEdgelessNoteConfig(reactToLit));
patched = patched.concat(patchNotificationService(confirmModal));
patched = patched.concat(patchPeekViewService(peekViewService));
patched = patched.concat(patchOpenDocExtension());
@@ -54,6 +54,7 @@ import {
GenerateDocUrlExtension,
MobileSpecsPatches,
NativeClipboardExtension,
NoteConfigExtension,
NotificationExtension,
OpenDocExtension,
ParseDocUrlExtension,
@@ -83,6 +84,7 @@ import { pick } from 'lodash-es';
import type { DocProps } from '../../../../../blocksuite/initialization';
import { AttachmentEmbedPreview } from '../../../../attachment-viewer/pdf-viewer-embedded';
import { generateUrl } from '../../../../hooks/affine/use-share-url';
import { EdgelessNoteHeader } from './widgets/edgeless-note-header';
import { createKeyboardToolbarConfig } from './widgets/keyboard-toolbar';
export type ReferenceReactRenderer = (
@@ -654,3 +656,12 @@ export function patchForClipboardInElectron(framework: FrameworkProvider) {
copyAsPNG: desktopApi.handler.clipboard.copyAsPNG,
});
}
export function patchForEdgelessNoteConfig(
reactToLit: (element: ElementOrFactory) => TemplateResult
) {
return NoteConfigExtension({
edgelessNoteHeader: ({ note }) =>
reactToLit(<EdgelessNoteHeader note={note} />),
});
}
@@ -0,0 +1,30 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
const headerPadding = 8;
export const header = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 4,
padding: headerPadding,
zIndex: 2, // should have higher z-index than the note mask
pointerEvents: 'none',
});
export const title = style({
flex: 1,
color: cssVarV2('text/primary'),
fontFamily: 'Inter',
fontWeight: 600,
lineHeight: '30px',
});
export const iconSize = 24;
const buttonPadding = 4;
export const button = style({
padding: buttonPadding,
pointerEvents: 'auto',
});
export const headerHeight = 2 * headerPadding + iconSize + 2 * buttonPadding;
@@ -0,0 +1,178 @@
import { IconButton } from '@affine/component';
import { useSharingUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { EditorService } from '@affine/core/modules/editor';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { useInsidePeekView } from '@affine/core/modules/peek-view/view/modal-container';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { matchFlavours, type NoteBlockModel } from '@blocksuite/affine/blocks';
import { Bound } from '@blocksuite/affine/global/utils';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
ExpandFullIcon,
InformationIcon,
LinkIcon,
ToggleDownIcon,
ToggleRightIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import * as styles from './edgeless-note-header.css';
const EdgelessNoteToggleButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const [collapsed, setCollapsed] = useState(note.edgeless.collapse);
const editor = useService(EditorService).editor;
const editorContainer = useLiveData(editor.editorContainer$);
const gfx = editorContainer?.std.get(GfxControllerIdentifier);
useEffect(() => {
setCollapsed(note.edgeless.collapse);
}, [note.edgeless.collapse]);
useEffect(() => {
if (!gfx) return;
const { selection } = gfx;
const dispose = selection.slots.updated.on(() => {
if (selection.has(note.id) && selection.editing) {
note.doc.transact(() => {
note.edgeless.collapse = false;
});
}
});
return () => dispose.dispose();
}, [gfx, note]);
const toggle = useCallback(() => {
note.doc.transact(() => {
if (collapsed) {
note.edgeless.collapse = false;
} else {
const bound = Bound.deserialize(note.xywh);
bound.h = styles.headerHeight * (note.edgeless.scale ?? 1);
note.xywh = bound.serialize();
note.edgeless.collapse = true;
note.edgeless.collapsedHeight = styles.headerHeight;
gfx?.selection.clear();
}
});
}, [collapsed, gfx, note]);
return (
<>
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.editor.edgeless-note-header.fold-page-block']()}
data-testid="edgeless-note-toggle-button"
onClick={toggle}
>
{collapsed ? <ToggleRightIcon /> : <ToggleDownIcon />}
</IconButton>
<div className={styles.title} data-testid="edgeless-note-title">
{collapsed && (note.doc.meta?.title ?? 'Untitled')}
</div>
</>
);
};
const ExpandFullButton = () => {
const t = useI18n();
const editor = useService(EditorService).editor;
const expand = useCallback(() => {
editor.setMode('page');
}, [editor]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.editor.edgeless-note-header.view-in-page']()}
data-testid="edgeless-note-expand-button"
onClick={expand}
>
<ExpandFullIcon />
</IconButton>
);
};
const InfoButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService);
const onOpenInfoModal = useCallback(() => {
track.doc.editor.pageBlockHeader.openDocInfo();
workspaceDialogService.open('doc-info', { docId: note.doc.id });
}, [note.doc.id, workspaceDialogService]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.page-properties.page-info.view']()}
data-testid="edgeless-note-info-button"
onClick={onOpenInfoModal}
>
<InformationIcon />
</IconButton>
);
};
const LinkButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const { workspaceService, editorService } = useServices({
WorkspaceService,
EditorService,
});
const { onClickCopyLink } = useSharingUrl({
workspaceId: workspaceService.workspace.id,
pageId: editorService.editor.doc.id,
});
const copyLink = useCallback(() => {
onClickCopyLink('edgeless', [note.id]);
}, [note.id, onClickCopyLink]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.share-menu.copy']()}
data-testid="edgeless-note-link-button"
onClick={copyLink}
>
<LinkIcon />
</IconButton>
);
};
export const EdgelessNoteHeader = ({ note }: { note: NoteBlockModel }) => {
const flags = useService(FeatureFlagService).flags;
const insidePeekView = useInsidePeekView();
if (!flags.enable_page_block_header) return null;
const isFirstNote =
note.parent?.children.find(child =>
matchFlavours(child, ['affine:note'])
) === note;
if (!isFirstNote) return null;
return (
<div className={styles.header} data-testid="edgeless-page-block-header">
<EdgelessNoteToggleButton note={note} />
<ExpandFullButton />
{!insidePeekView && <InfoButton note={note} />}
<LinkButton note={note} />
</div>
);
};
@@ -239,6 +239,17 @@ export const AFFINE_FLAGS = {
configurable: !isMobile,
defaultState: isCanaryBuild,
},
// TODO(@L-Sun): remove this flag when ready
enable_page_block_header: {
category: 'blocksuite',
bsFlag: 'enable_page_block_header',
displayName:
'com.affine.settings.workspace.experimental-features.enable-page-block-header.name',
description:
'com.affine.settings.workspace.experimental-features.enable-page-block-header.description',
configurable: isCanaryBuild,
defaultState: isCanaryBuild,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare
@@ -1332,6 +1332,8 @@
"com.affine.settings.workspace.experimental-features.enable-mobile-edgeless-editing.description": "Once enabled, users can edit edgeless canvas.",
"com.affine.settings.workspace.experimental-features.enable-pdf-embed-preview.name": "PDF embed preview",
"com.affine.settings.workspace.experimental-features.enable-pdf-embed-preview.description": "Once enabled, you can preview PDF in embed view.",
"com.affine.settings.workspace.experimental-features.enable-page-block-header.name": "Page Block Header",
"com.affine.settings.workspace.experimental-features.enable-page-block-header.description": "Once enabled, the header of page block will be displayed.",
"com.affine.settings.workspace.not-owner": "Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.",
"com.affine.settings.workspace.preferences": "Preference",
"com.affine.settings.workspace.billing": "Billing",
@@ -1590,6 +1592,8 @@
"com.affine.editor.at-menu.date-picker": "Select a specific date",
"com.affine.editor.bi-directional-link-panel.show": "Show",
"com.affine.editor.bi-directional-link-panel.hide": "Hide",
"com.affine.editor.edgeless-note-header.fold-page-block": "Fold page block",
"com.affine.editor.edgeless-note-header.view-in-page": "View in page",
"com.affine.upgrade-to-team-page.title": "Empower Your Team with Seamless Collaboration",
"com.affine.upgrade-to-team-page.workspace-selector.placeholder": "Select an existing workspace or create a new one",
"com.affine.upgrade-to-team-page.workspace-selector.create-workspace": "Create Workspace",
+1
View File
@@ -314,6 +314,7 @@ const PageEvents = {
pageRef: ['navigate'],
toolbar: ['copyBlockToLink'],
aiActions: ['requestSignIn'],
pageBlockHeader: ['openDocInfo'],
},
inlineDocInfo: {
$: ['toggle'],
@@ -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);
});
});
+1
View File
@@ -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",
+274 -1
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);
}
}
-1
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;
});
+4 -1
View File
@@ -6,5 +6,8 @@
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [{ "path": "../../tools/utils" }]
"references": [
{ "path": "../../tools/utils" },
{ "path": "../../blocksuite/affine/all" }
]
}
+1 -1
View File
@@ -661,7 +661,7 @@ export const PackageList = [
{
location: 'tests/kit',
name: '@affine-test/kit',
workspaceDependencies: ['tools/utils'],
workspaceDependencies: ['tools/utils', 'blocksuite/affine/all'],
},
{
location: 'tools/@types/build-config',
+1
View File
@@ -82,6 +82,7 @@ __metadata:
resolution: "@affine-test/kit@workspace:tests/kit"
dependencies:
"@affine-tools/utils": "workspace:*"
"@blocksuite/affine": "workspace:*"
"@node-rs/argon2": "npm:^2.0.2"
"@playwright/test": "npm:=1.49.1"
express: "npm:^4.21.2"