diff --git a/blocksuite/affine/block-note/src/config.ts b/blocksuite/affine/block-note/src/config.ts
new file mode 100644
index 0000000000..3048b00a8a
--- /dev/null
+++ b/blocksuite/affine/block-note/src/config.ts
@@ -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);
+}
diff --git a/blocksuite/affine/block-note/src/effects.ts b/blocksuite/affine/block-note/src/effects.ts
index 64e73cf3a4..812586d3b8 100644
--- a/blocksuite/affine/block-note/src/effects.ts
+++ b/blocksuite/affine/block-note/src/effects.ts
@@ -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;
+ }
}
}
diff --git a/blocksuite/affine/block-note/src/index.ts b/blocksuite/affine/block-note/src/index.ts
index 60d4bdd7ea..f89b092a1a 100644
--- a/blocksuite/affine/block-note/src/index.ts
+++ b/blocksuite/affine/block-note/src/index.ts
@@ -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';
diff --git a/blocksuite/affine/block-note/src/note-edgeless-block.ts b/blocksuite/affine/block-note/src/note-edgeless-block.ts
index 8284cd596b..1f3405edf2 100644
--- a/blocksuite/affine/block-note/src/note-edgeless-block.ts
+++ b/blocksuite/affine/block-note/src/note-edgeless-block.ts
@@ -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}
- >
+ >
+ ${this._enablePageHeader ? this._renderHeader() : nothing}
+
- ${isCollapsable
+
+
+ ${isCollapsable && (!this._isFirstNote() || !this._enablePageHeader)
? html`
`
: nothing}
${this._collapsedContent()}
-
-
`;
}
@@ -518,9 +539,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
@state()
private accessor _isResizing = false;
- @state()
- private accessor _isSelected = false;
-
@state()
private accessor _noteFullHeight = 0;
diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts
index f457ad1c54..d38f82f8b8 100644
--- a/blocksuite/affine/shared/src/services/feature-flag-service.ts
+++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts
@@ -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) {
diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts
index 21c60fa8ae..15884a90f8 100644
--- a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts
+++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts
@@ -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`
-
this._setCollapse()}
- >
- ${collapse ? ExpandIcon : ShrinkIcon}
-
+ this._enableAutoHeight
+ ? html`
this._setCollapse()}
+ >
+ ${collapse ? ExpandIcon : ShrinkIcon}
+ `
+ : nothing,
+ html`
{
patched = patched.concat(patchForAttachmentEmbedViews(reactToLit));
}
+ patched = patched.concat(patchForEdgelessNoteConfig(reactToLit));
patched = patched.concat(patchNotificationService(confirmModal));
patched = patched.concat(patchPeekViewService(peekViewService));
patched = patched.concat(patchOpenDocExtension());
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx
index 7387ca0f43..720e17dd0d 100644
--- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx
@@ -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(),
+ });
+}
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.css.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.css.ts
new file mode 100644
index 0000000000..c245fef011
--- /dev/null
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.css.ts
@@ -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;
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx
new file mode 100644
index 0000000000..ac64476ea8
--- /dev/null
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx
@@ -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 (
+ <>
+
+ {collapsed ? : }
+
+
+ {collapsed && (note.doc.meta?.title ?? 'Untitled')}
+
+ >
+ );
+};
+
+const ExpandFullButton = () => {
+ const t = useI18n();
+ const editor = useService(EditorService).editor;
+
+ const expand = useCallback(() => {
+ editor.setMode('page');
+ }, [editor]);
+
+ return (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ {!insidePeekView && }
+
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts
index 09a3d78859..91f29bbdd2 100644
--- a/packages/frontend/core/src/modules/feature-flag/constant.ts
+++ b/packages/frontend/core/src/modules/feature-flag/constant.ts
@@ -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
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 8d8b784376..588ab161fc 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -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",
diff --git a/packages/frontend/track/src/events.ts b/packages/frontend/track/src/events.ts
index 11539ad8c1..a340b0b7c1 100644
--- a/packages/frontend/track/src/events.ts
+++ b/packages/frontend/track/src/events.ts
@@ -314,6 +314,7 @@ const PageEvents = {
pageRef: ['navigate'],
toolbar: ['copyBlockToLink'],
aiActions: ['requestSignIn'],
+ pageBlockHeader: ['openDocInfo'],
},
inlineDocInfo: {
$: ['toggle'],
diff --git a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
new file mode 100644
index 0000000000..f416bceaf2
--- /dev/null
+++ b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
@@ -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);
+ });
+});
diff --git a/tests/kit/package.json b/tests/kit/package.json
index 1c15cbc0c7..4e539dec2b 100644
--- a/tests/kit/package.json
+++ b/tests/kit/package.json
@@ -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",
diff --git a/tests/kit/src/utils/editor.ts b/tests/kit/src/utils/editor.ts
index 78db85e496..b88921ece3 100644
--- a/tests/kit/src/utils/editor.ts
+++ b/tests/kit/src/utils/editor.ts
@@ -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 => {
+ 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);
+ }
+}
diff --git a/tests/kit/src/utils/image.ts b/tests/kit/src/utils/image.ts
index 8d801507b3..26be9bb58e 100644
--- a/tests/kit/src/utils/image.ts
+++ b/tests/kit/src/utils/image.ts
@@ -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;
});
diff --git a/tests/kit/tsconfig.json b/tests/kit/tsconfig.json
index de91285f6a..2486e4116b 100644
--- a/tests/kit/tsconfig.json
+++ b/tests/kit/tsconfig.json
@@ -6,5 +6,8 @@
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
- "references": [{ "path": "../../tools/utils" }]
+ "references": [
+ { "path": "../../tools/utils" },
+ { "path": "../../blocksuite/affine/all" }
+ ]
}
diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts
index 82ebb3e041..2cbd2e6590 100644
--- a/tools/utils/src/workspace.gen.ts
+++ b/tools/utils/src/workspace.gen.ts
@@ -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',
diff --git a/yarn.lock b/yarn.lock
index 8dcee3f213..74089adf04 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"