From 94c9717a35f4297be4436e7958d3870fa013a984 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Wed, 15 Jan 2025 12:04:43 +0000 Subject: [PATCH] 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"`** --- blocksuite/affine/block-note/src/config.ts | 14 + blocksuite/affine/block-note/src/effects.ts | 4 + blocksuite/affine/block-note/src/index.ts | 1 + .../block-note/src/note-edgeless-block.ts | 64 ++-- .../src/services/feature-flag-service.ts | 2 + .../element-toolbar/change-note-button.ts | 31 +- .../blocks/src/root-block/widgets/index.ts | 1 + .../block-suite-editor/lit-adaper.tsx | 2 + .../specs/custom/spec-patchers.tsx | 11 + .../widgets/edgeless-note-header.css.ts | 30 ++ .../custom/widgets/edgeless-note-header.tsx | 178 ++++++++++++ .../core/src/modules/feature-flag/constant.ts | 11 + packages/frontend/i18n/src/resources/en.json | 4 + packages/frontend/track/src/events.ts | 1 + .../e2e/blocksuite/edgeless/note.spec.ts | 156 ++++++++++ tests/kit/package.json | 1 + tests/kit/src/utils/editor.ts | 275 +++++++++++++++++- tests/kit/src/utils/image.ts | 1 - tests/kit/tsconfig.json | 5 +- tools/utils/src/workspace.gen.ts | 2 +- yarn.lock | 1 + 21 files changed, 760 insertions(+), 35 deletions(-) create mode 100644 blocksuite/affine/block-note/src/config.ts create mode 100644 packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.css.ts create mode 100644 packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx create mode 100644 tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts 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"