feat(editor): header of edgeless embed doc (#12029)

Close [BS-3268](https://linear.app/affine-design/issue/BS-3268/edgeless-下,-dark-mode-embed的配色应该更加清晰)
Close [BS-3067](https://linear.app/affine-design/issue/BS-3067/在embed上,添加split-view等相关的操作入口,基本接近page-block(见设计稿))

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced an interactive header for embedded synced documents with fold/unfold toggle, document opening, and multiple view options.
  - Added info and copy link buttons for embedded synced documents and notes to improve document management and sharing.
- **Enhancements**
  - Updated styles for embedded synced document blocks and headers for better visual consistency.
  - Added new localization entries for header actions: "Fold", "Unfold", and "Open".
  - Disabled redundant open document actions in toolbars, centralizing controls in the header.
- **Refactor**
  - Unified header button components for notes and embedded synced documents into reusable components.
  - Simplified header components by delegating button behaviors to shared components.
- **Bug Fixes**
  - Fixed conditional rendering of editor content in embedded synced documents when folded.
- **Chores**
  - Upgraded theme dependency version from "^1.1.12" to "^1.1.14" across multiple packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-04-30 03:11:38 +00:00
parent 0665d20d67
commit 315ea00390
82 changed files with 671 additions and 273 deletions
@@ -58,11 +58,14 @@ import {
import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service';
import { patchDocModeService } from '../extensions/doc-mode-service';
import { patchDocUrlExtensions } from '../extensions/doc-url';
import {
patchForEdgelessNoteConfig,
patchForEmbedSyncedDocConfig,
} from '../extensions/edgeless-block-header';
import { EdgelessClipboardAIChatConfig } from '../extensions/edgeless-clipboard';
import { patchForClipboardInElectron } from '../extensions/electron-clipboard';
import { enableEditorExtension } from '../extensions/entry/enable-editor';
import { enableMobileExtension } from '../extensions/entry/enable-mobile';
import { patchForEdgelessNoteConfig } from '../extensions/note-config';
import { patchNotificationService } from '../extensions/notification-service';
import { patchOpenDocExtension } from '../extensions/open-doc';
import { patchPeekViewService } from '../extensions/peek-view-service';
@@ -162,6 +165,7 @@ const usePatchSpecs = (mode: DocMode) => {
[
patchReferenceRenderer(reactToLit, referenceRenderer),
patchForEdgelessNoteConfig(framework, reactToLit, insidePeekView),
patchForEmbedSyncedDocConfig(reactToLit),
patchNotificationService(confirmModal),
patchPeekViewService(peekViewService),
patchOpenDocExtension(),
@@ -0,0 +1,80 @@
import { IconButton } from '@affine/component';
import { useSharingUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { type DocMode } from '@blocksuite/affine/model';
import { InformationIcon, LinkIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
import * as styles from './edgeless-block-header.css';
export const DocInfoButton = ({
docId,
trackFn,
'data-testid': dataTestId,
}: {
docId: string;
trackFn?: () => void;
'data-testid'?: string;
}) => {
const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService);
const onClick = useCallback(() => {
trackFn?.();
workspaceDialogService.open('doc-info', { docId });
}, [docId, trackFn, workspaceDialogService]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.page-properties.page-info.view']()}
data-testid={dataTestId}
onClick={onClick}
>
<InformationIcon />
</IconButton>
);
};
export const CopyLinkButton = ({
pageId,
blockId,
mode,
trackFn,
'data-testid': dataTestId,
}: {
pageId: string;
blockId?: string;
mode?: DocMode;
trackFn?: () => void;
'data-testid'?: string;
}) => {
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const { onClickCopyLink } = useSharingUrl({
workspaceId: workspace.id,
pageId,
});
const copyLink = useCallback(() => {
trackFn?.();
onClickCopyLink(mode, blockId ? [blockId] : undefined);
}, [blockId, mode, onClickCopyLink, trackFn]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.share-menu.copy']()}
data-testid={dataTestId}
onClick={copyLink}
>
<LinkIcon />
</IconButton>
);
};
@@ -17,18 +17,50 @@ export const title = style({
alignItems: 'center',
gap: 4,
flex: 1,
color: cssVarV2('text/primary'),
fontFamily: 'Inter',
fontWeight: 600,
lineHeight: '30px',
});
export const noteTitle = style([
title,
{
color: cssVarV2('text/primary'),
fontWeight: 600,
lineHeight: '30px',
},
]);
export const embedSyncedDocTitle = style([
title,
{
color: cssVarV2('text/secondary'),
fontWeight: 400,
lineHeight: '24px',
fontSize: '15px',
selectors: {
'&[data-collapsed="true"]': {
color: cssVarV2('text/primary'),
fontWeight: 500,
},
},
},
]);
export const iconSize = 24;
const buttonPadding = 4;
export const button = style({
padding: buttonPadding,
pointerEvents: 'auto',
color: cssVarV2('icon/transparentBlack'),
borderRadius: 4,
});
export const buttonText = style([
embedSyncedDocTitle,
{
paddingLeft: 4,
paddingRight: 4,
fontWeight: 500,
},
]);
export const headerHeight = 2 * headerPadding + iconSize + 2 * buttonPadding;
@@ -0,0 +1,265 @@
import { Button, IconButton, Menu, MenuItem } from '@affine/component';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { stopPropagation } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { EmbedSyncedDocBlockComponent } from '@blocksuite/affine/blocks/embed';
import { isPeekable, peek } from '@blocksuite/affine/components/peek';
import { DisposableGroup } from '@blocksuite/affine/global/disposable';
import { Bound } from '@blocksuite/affine/global/gfx';
import type { EmbedSyncedDocModel } from '@blocksuite/affine-model';
import {
ArrowDownSmallIcon,
CenterPeekIcon,
ExpandFullIcon,
LinkedPageIcon,
OpenInNewIcon,
SplitViewIcon,
ToggleDownIcon,
ToggleRightIcon,
} from '@blocksuite/icons/rc';
import type { BlockStdScope } from '@blocksuite/std';
import { batch } from '@preact/signals-core';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CopyLinkButton, DocInfoButton } from './common';
import * as styles from './edgeless-block-header.css';
const ToggleButton = ({ model }: { model: EmbedSyncedDocModel }) => {
const [isFolded, setIsFolded] = useState(model.isFolded);
const t = useI18n();
useEffect(() => {
const disposables = new DisposableGroup();
disposables.add(
model.props.preFoldHeight$.subscribe(value => setIsFolded(!!value))
);
// the height may be changed by dragging selected rect
disposables.add(
model.xywh$.subscribe(value => {
const bound = Bound.deserialize(value);
const preFoldHeight = model.props.preFoldHeight$.peek();
if (
bound.h !== styles.headerHeight &&
preFoldHeight !== undefined &&
bound.h !== preFoldHeight
) {
model.props.preFoldHeight$.value = 0;
}
})
);
return () => disposables.dispose();
}, [model.props.preFoldHeight$, model.xywh$]);
const toggle = useCallback(() => {
model.doc.captureSync();
batch(() => {
const { x, y, w, h } = model.elementBound;
if (model.isFolded) {
model.props.xywh$.value = `[${x},${y},${w},${model.props.preFoldHeight$.peek() ?? 1}]`;
model.props.preFoldHeight$.value = 0;
} else {
model.props.preFoldHeight$.value = h;
model.props.xywh$.value = `[${x},${y},${w},${styles.headerHeight}]`;
}
});
}, [model]);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
onClick={toggle}
tooltip={
isFolded
? t['com.affine.editor.edgeless-embed-synced-doc-header.unfold']()
: t['com.affine.editor.edgeless-embed-synced-doc-header.fold']()
}
icon={isFolded ? <ToggleRightIcon /> : <ToggleDownIcon />}
/>
);
};
const Title = ({ model }: { model: EmbedSyncedDocModel }) => {
const docDisplayMetaService = useService(DocDisplayMetaService);
const title = useLiveData(
docDisplayMetaService.title$(model.props.pageId, {
title: model.props.title,
reference: true,
})
);
return (
<div
className={styles.embedSyncedDocTitle}
data-collapsed={!!model.props.preFoldHeight}
data-testid="edgeless-embed-synced-doc-title"
>
<LinkedPageIcon />
<span>{title}</span>
</div>
);
};
const EmbedSyncedDocInfoButton = ({
model,
}: {
model: EmbedSyncedDocModel;
}) => {
return (
<DocInfoButton
docId={model.props.pageId}
data-testid="edgeless-embed-synced-doc-info-button"
/>
);
};
const EmbedSyncedDocCopyLinkButton = ({
model,
}: {
model: EmbedSyncedDocModel;
}) => {
return (
<CopyLinkButton
pageId={model.props.pageId}
data-testid="edgeless-embed-synced-doc-copy-link-button"
/>
);
};
const OpenButton = ({ model }: { model: EmbedSyncedDocModel }) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const open = useCallback(() => {
workbench.openDoc({
docId: model.props.pageId,
});
}, [model.props.pageId, workbench]);
return (
<Button
className={styles.button}
variant="plain"
size="custom"
onClick={open}
prefixStyle={{
width: `${styles.iconSize}px`,
height: `${styles.iconSize}px`,
}}
prefix={<ExpandFullIcon />}
>
<span className={styles.buttonText}>
{t['com.affine.editor.edgeless-embed-synced-doc-header.open']()}
</span>
</Button>
);
};
const MoreMenu = ({
model,
std,
}: {
model: EmbedSyncedDocModel;
std: BlockStdScope;
}) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const controls = useMemo(() => {
return [
{
type: 'open-in-active-view',
label: t['com.affine.peek-view-controls.open-doc'](),
icon: <ExpandFullIcon />,
onClick: () => {
workbench.openDoc(model.props.pageId);
},
enabled: true,
},
{
type: 'open-in-center-peek',
label: t['com.affine.peek-view-controls.open-doc-in-center-peek'](),
icon: <CenterPeekIcon />,
onClick: () => {
const block = std.view.getBlock(model.id);
if (
!(
block instanceof EmbedSyncedDocBlockComponent && isPeekable(block)
)
)
return;
peek(block);
},
enabled: true,
},
{
type: 'open-in-split-view',
label: t['com.affine.peek-view-controls.open-doc-in-split-view'](),
icon: <SplitViewIcon />,
onClick: () => {
workbench.openDoc(model.props.pageId, { at: 'beside' });
},
enabled: BUILD_CONFIG.isElectron,
},
{
type: 'open-in-new-tab',
label: t['com.affine.peek-view-controls.open-doc-in-new-tab'](),
icon: <OpenInNewIcon />,
onClick: () => {
workbench.openDoc(model.props.pageId, {
at: 'new-tab',
});
},
enabled: true,
},
].filter(({ enabled }) => enabled);
}, [model.id, model.props.pageId, std.view, t, workbench]);
return (
<Menu
items={controls.map(option => (
<MenuItem
key={option.type}
type="default"
prefixIcon={option.icon}
onClick={option.onClick}
>
{option.label}
</MenuItem>
))}
contentOptions={{
align: 'center',
}}
>
<IconButton
className={styles.button}
size={styles.iconSize}
icon={<ArrowDownSmallIcon />}
onDoubleClickCapture={stopPropagation}
/>
</Menu>
);
};
export const EdgelessEmbedSyncedDocHeader = ({
model,
std,
}: {
model: EmbedSyncedDocModel;
std: BlockStdScope;
}) => {
return (
<div className={styles.header} onPointerDown={stopPropagation}>
<ToggleButton model={model} />
<Title model={model} />
<OpenButton model={model} />
<MoreMenu model={model} std={std} />
<EmbedSyncedDocInfoButton model={model} />
<EmbedSyncedDocCopyLinkButton model={model} />
</div>
);
};
@@ -1,10 +1,7 @@
import { IconButton } from '@affine/component';
import { useSharingUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocService } from '@affine/core/modules/doc';
import { EditorService } from '@affine/core/modules/editor';
import { useInsidePeekView } from '@affine/core/modules/peek-view/view/modal-container';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
@@ -12,16 +9,15 @@ import { Bound } from '@blocksuite/affine/global/gfx';
import { type NoteBlockModel } from '@blocksuite/affine/model';
import { GfxControllerIdentifier } from '@blocksuite/affine/std/gfx';
import {
InformationIcon,
LinkedPageIcon,
LinkIcon,
ToggleDownIcon,
ToggleRightIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as styles from './edgeless-note-header.css';
import { CopyLinkButton, DocInfoButton } from './common';
import * as styles from './edgeless-block-header.css';
const EdgelessNoteToggleButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
@@ -97,7 +93,7 @@ const EdgelessNoteToggleButton = ({ note }: { note: NoteBlockModel }) => {
>
{collapsed ? <ToggleRightIcon /> : <ToggleDownIcon />}
</IconButton>
<div className={styles.title} data-testid="edgeless-note-title">
<div className={styles.noteTitle} data-testid="edgeless-note-title">
{collapsed && (
<>
{emoji && <span>{emoji}</span>}
@@ -131,55 +127,33 @@ const ViewInPageButton = () => {
);
};
const InfoButton = ({ note }: { note: NoteBlockModel }) => {
const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService);
const onOpenInfoModal = useCallback(() => {
const PageBlockInfoButton = ({ note }: { note: NoteBlockModel }) => {
const trackFn = useCallback(() => {
track.edgeless.pageBlock.headerToolbar.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']()}
<DocInfoButton
docId={note.doc.id}
trackFn={trackFn}
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(() => {
const NoteCopyLinkButton = ({ note }: { note: NoteBlockModel }) => {
const trackFn = useCallback(() => {
track.edgeless.pageBlock.headerToolbar.copyBlockToLink();
onClickCopyLink('edgeless', [note.id]);
}, [note.id, onClickCopyLink]);
}, []);
return (
<IconButton
className={styles.button}
size={styles.iconSize}
tooltip={t['com.affine.share-menu.copy']()}
<CopyLinkButton
pageId={note.doc.id}
blockId={note.id}
mode="edgeless"
trackFn={trackFn}
data-testid="edgeless-note-link-button"
onClick={copyLink}
>
<LinkIcon />
</IconButton>
/>
);
};
@@ -192,8 +166,8 @@ export const EdgelessNoteHeader = ({ note }: { note: NoteBlockModel }) => {
<div className={styles.header} data-testid="edgeless-page-block-header">
<EdgelessNoteToggleButton note={note} />
<ViewInPageButton />
{!insidePeekView && <InfoButton note={note} />}
<LinkButton note={note} />
{!insidePeekView && <PageBlockInfoButton note={note} />}
<NoteCopyLinkButton note={note} />
</div>
);
};
@@ -1,5 +1,6 @@
import type { ElementOrFactory } from '@affine/component';
import { JournalService } from '@affine/core/modules/journal';
import { EmbedSyncedDocConfigExtension } from '@blocksuite/affine/blocks/embed';
import { NoteConfigExtension } from '@blocksuite/affine/blocks/note';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine/blocks/root';
import { Bound, Vec } from '@blocksuite/affine/global/gfx';
@@ -12,6 +13,7 @@ import type { FrameworkProvider } from '@toeverything/infra';
import { html, type TemplateResult } from 'lit';
import { BlocksuiteEditorJournalDocTitle } from '../../block-suite-editor/journal-doc-title';
import { EdgelessEmbedSyncedDocHeader } from './edgeless-embed-synced-doc-header';
import { EdgelessNoteHeader } from './edgeless-note-header';
export function patchForEdgelessNoteConfig(
@@ -97,3 +99,12 @@ export function patchForEdgelessNoteConfig(
},
});
}
export function patchForEmbedSyncedDocConfig(
reactToLit: (element: ElementOrFactory) => TemplateResult
) {
return EmbedSyncedDocConfigExtension({
edgelessHeader: ({ model, std }) =>
reactToLit(<EdgelessEmbedSyncedDocHeader model={model} std={std} />),
});
}
@@ -1094,7 +1094,19 @@ export const createCustomToolbarExtension = (
actions: [
embedSyncedDocToolbarConfig.actions,
createOpenDocActionGroup(EmbedSyncedDocBlockComponent, settings),
createEdgelessOpenDocActionGroup(EmbedSyncedDocBlockComponent),
].flat(),
},
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:surface:embed-synced-doc'),
config: {
actions: [
// the open actions are provided by the header of embed-edgeless-synced-doc-block
{
id: 'A.open-doc',
when: () => false,
},
].flat(),
},
}),