refactor(editor): adjust ui of surface-ref inner toolbar (#11129)

Close [BS-2803](https://linear.app/affine-design/issue/BS-2803/inserted-frame-ui%E8%B0%83%E6%95%B4)
Close [BS-2815](https://linear.app/affine-design/issue/BS-2815/inserted-group-ui调整)

### What Changes
- Add an inner toolbar for hovered `surface-ref-block`
- Simplify viewport related codes of `surface-ref-block`
- Expose popover floating options from `affine-menu-button`

https://github.com/user-attachments/assets/916b0a22-6271-4a6f-b338-6630e0426967
This commit is contained in:
L-Sun
2025-03-25 03:48:12 +00:00
parent 314e5795eb
commit a2e3d318ba
8 changed files with 382 additions and 452 deletions
@@ -1,4 +1,4 @@
import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
import { type SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
import {
CopyIcon,
DeleteIcon,
@@ -19,7 +19,7 @@ export const BUILT_IN_GROUPS: MenuItemGroup<SurfaceRefToolbarContext>[] = [
items: [
{
type: 'copy',
label: 'Copy',
label: 'Copy as Image',
icon: CopyIcon,
action: ctx => {
if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) {
@@ -56,6 +56,7 @@ export const BUILT_IN_GROUPS: MenuItemGroup<SurfaceRefToolbarContext>[] = [
ctx.close();
},
},
// TODO(@L-Sun): add duplicate action after refactoring toolbar
{
type: 'download',
label: 'Download',
@@ -1,33 +1,44 @@
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
import { HoverController } from '@blocksuite/affine-components/hover';
import { CaptionIcon, OpenIcon } from '@blocksuite/affine-components/icons';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { peek } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
cloneGroups,
getMoreMenuConfig,
type MenuItem,
type MenuItemGroup,
renderGroups,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import type { SurfaceRefBlockModel } from '@blocksuite/affine-model';
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import {
BlockSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
FrameBlockModel,
GroupElementModel,
MindmapElementModel,
ShapeElementModel,
type SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import {
copySelectedModelsCommand,
draftSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { ButtonPopperOptions } from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import {
ArrowDownSmallIcon,
CaptionIcon,
CenterPeekIcon,
CopyIcon,
EdgelessIcon,
FrameIcon,
GroupIcon,
MindmapIcon,
MoreVerticalIcon,
OpenInNewIcon,
} from '@blocksuite/icons/lit';
import { offset, shift } from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { css, html, nothing, type TemplateResult } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { BUILT_IN_GROUPS } from './config.js';
import { SurfaceRefToolbarContext } from './context.js';
@@ -38,6 +49,59 @@ export class AffineSurfaceRefToolbar extends WidgetComponent<
SurfaceRefBlockModel,
SurfaceRefBlockComponent
> {
static override styles = css`
:host {
position: absolute;
top: 0;
left: 0;
width: 100%;
gap: 4px;
padding: 4px;
margin: 0;
display: flex;
justify-content: flex-end;
editor-icon-button,
editor-menu-button {
background: ${unsafeCSSVarV2('button/iconButtonSolid')};
color: ${unsafeCSSVarV2('text/primary')};
box-shadow: ${unsafeCSSVar('shadow1')};
border-radius: 4px;
}
}
.surface-ref-toolbar-title {
display: flex;
padding: 2px 4px;
margin-right: auto;
align-items: center;
gap: 4px;
border-radius: 2px;
background: ${unsafeCSSVarV2('button/iconButtonSolid')};
svg {
color: ${unsafeCSSVarV2('icon/primary')};
width: 16px;
height: 16px;
}
span {
color: ${unsafeCSSVarV2('text/primary')};
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
}
`;
private readonly _popoverOptions: Partial<ButtonPopperOptions> = {
mainAxis: 4,
stateUpdated: ({ display }) => {
this.dataset.openMenuDisplay = display;
},
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
@@ -45,68 +109,167 @@ export class AffineSurfaceRefToolbar extends WidgetComponent<
moreGroups: MenuItemGroup<SurfaceRefToolbarContext>[] =
cloneGroups(BUILT_IN_GROUPS);
private readonly _hoverController = new HoverController(
this,
({ abortController }) => {
const surfaceRefBlock = this.block;
if (!surfaceRefBlock) {
return null;
}
const selection = this.host.selection;
const textSelection = selection.find(TextSelection);
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== surfaceRefBlock.blockId)
) {
return null;
}
return {
template: SurfaceRefToolbarOptions({
context: new SurfaceRefToolbarContext(this.block, abortController),
groups: this.moreGroups,
}),
computePosition: {
referenceElement: this.block,
placement: 'top-start',
middleware: [
offset({
mainAxis: 12,
crossAxis: 10,
}),
shift({
crossAxis: true,
padding: {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
right: 12,
},
}),
],
autoUpdate: true,
},
};
}
);
override connectedCallback() {
super.connectedCallback();
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
if (!this.block) {
return;
this.disposables.addFromEvent(this, 'dblclick', e => {
e.stopPropagation();
});
}
private _renderTitle() {
if (!this.block) return nothing;
const { referenceModel } = this.block;
if (!referenceModel) return nothing;
let title = '';
let icon: TemplateResult<1> | null = null;
if (referenceModel instanceof GroupElementModel) {
title = referenceModel.title.toString();
icon = GroupIcon();
} else if (referenceModel instanceof FrameBlockModel) {
title = referenceModel.props.title.toString();
icon = FrameIcon();
} else if (referenceModel instanceof MindmapElementModel) {
const rootElement = referenceModel.tree.element;
if (rootElement instanceof ShapeElementModel) {
title = rootElement.text?.toString() ?? '';
}
icon = MindmapIcon();
}
this._hoverController.setReference(this.block);
return html`<div class="surface-ref-toolbar-title">
${icon}
<span>${title}</span>
</div>`;
}
private _renderOpenButton() {
const referenceModel = this.block?.referenceModel;
if (!referenceModel) return nothing;
const openMenuActions: MenuItem[] = [
{
type: 'open-in-edgeless',
label: 'Open in Edgeless',
icon: EdgelessIcon(),
action: () => this.block?.viewInEdgeless(),
disabled: this.block.model.doc.readonly,
},
{
type: 'open-in-center-peek',
label: 'Open in center peek',
icon: CenterPeekIcon(),
action: () => this.block && peek(this.block),
},
// TODO(@L-Sun): add split view and new tab
];
return html`<editor-menu-button
data-show
aria-label="Open"
style=${styleMap({
'--content-padding': '8px',
})}
.button=${html`
<editor-icon-button .iconContainerPadding=${4} .iconSize=${'16px'}>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
.popperOptions=${this._popoverOptions}
>
<div data-orientation="vertical">
${repeat(
openMenuActions,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>`;
}
private _moreButton() {
if (!this.block) return nothing;
const moreMenuActions = renderGroups(
this.moreGroups,
new SurfaceRefToolbarContext(this.block, new AbortController())
);
return html`<editor-menu-button
data-show
style=${styleMap({
'--content-padding': '8px',
})}
.button=${html`
<editor-icon-button .iconContainerPadding=${4} .iconSize=${'16px'}>
${MoreVerticalIcon()}
</editor-icon-button>
`}
.popperOptions=${this._popoverOptions}
>
<div data-orientation="vertical">${moreMenuActions}</div>
</editor-menu-button>`;
}
private _renderButtons() {
if (!this.block) return nothing;
const readonly = this.block.model.doc.readonly;
const buttons = [
this._renderOpenButton(),
when(
!readonly,
() =>
html`<editor-icon-button
.iconContainerPadding=${4}
.iconSize=${'16px'}
@click=${() => {
if (!this.block) return;
this.std.command
.chain()
.pipe(draftSelectedModelsCommand, {
selectedModels: [this.block.model],
})
.pipe(copySelectedModelsCommand)
.run();
toast(this.block.std.host, 'Copied to clipboard');
}}
>
${CopyIcon()}
</editor-icon-button>`
),
when(
!readonly,
() =>
html`<editor-icon-button
.iconContainerPadding=${4}
.iconSize=${'16px'}
@click=${() => {
if (!this.block) return;
this.block.captionElement.show();
}}
>
${CaptionIcon()}
</editor-icon-button>`
),
this._moreButton(),
];
return buttons;
}
override render() {
if (!this.block) return nothing;
return html`${this._renderTitle()} ${this._renderButtons()}`;
}
}
@@ -115,120 +278,3 @@ declare global {
[AFFINE_SURFACE_REF_TOOLBAR]: AffineSurfaceRefToolbar;
}
}
function SurfaceRefToolbarOptions({
context,
groups,
}: {
context: SurfaceRefToolbarContext;
groups: MenuItemGroup<SurfaceRefToolbarContext>[];
}) {
const { blockComponent, abortController } = context;
const readonly = blockComponent.model.doc.readonly;
const hasValidReference = !!blockComponent.referenceModel;
const openMenuActions: MenuItem[] = [];
const iconSize = { width: '20px', height: '20px' };
if (hasValidReference) {
openMenuActions.push({
type: 'open-in-edgeless',
label: 'Open in edgeless',
icon: EdgelessIcon(iconSize),
action: () => blockComponent.viewInEdgeless(),
disabled: readonly,
});
if (isPeekable(blockComponent)) {
openMenuActions.push({
type: 'open-in-center-peek',
label: 'Open in center peek',
icon: CenterPeekIcon(iconSize),
action: () => peek(blockComponent),
});
}
}
const moreMenuActions = renderGroups(groups, context);
const buttons = [
openMenuActions.length
? html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open doc"
.justify=${'space-between'}
.labelHeight=${'20px'}
>
${OpenIcon}${ArrowDownSmallIcon({
width: '16px',
height: '16px',
})}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
openMenuActions,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`
: nothing,
readonly
? nothing
: html`
<editor-icon-button
aria-label="Caption"
.tooltip=${'Add Caption'}
@click=${() => {
abortController.abort();
blockComponent.captionElement.show();
}}
>
${CaptionIcon}
</editor-icon-button>
`,
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="More"
.tooltip=${'More'}
.iconSize=${'20px'}
>
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${moreMenuActions}
</div>
</editor-menu-button>
`,
];
return html`
<editor-toolbar class="surface-ref-toolbar-container">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
}