mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(editor): edgeless page block toolbar (#9707)
Close [BS-2315](https://linear.app/affine-design/issue/BS-2315/page-block-header) ### What Changes - Add header toolbar to page block (the first note in canvas) - Add e2e tests - Add some edgeless e2e test utils. **The package `@blocksuite/affine` was added to `"@affine-test/kit"`**
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user