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

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

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

View File

@@ -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());

View File

@@ -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} />),
});
}

View File

@@ -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;

View File

@@ -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>
);
};