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

View File

@@ -1,6 +1,6 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { EdgelessCRUDExtension } from '@blocksuite/affine-block-surface';
import { MindmapStyle } from '@blocksuite/affine-model';
import { MindmapStyle, SurfaceRefBlockSchema } from '@blocksuite/affine-model';
import {
type SlashMenuActionItem,
type SlashMenuConfig,
@@ -20,8 +20,7 @@ const surfaceRefSlashMenuConfig: SlashMenuConfig = {
const crud = std.get(EdgelessCRUDExtension);
const frameMgr = std.get(EdgelessFrameManagerIdentifier);
const findSpace = (bound: Bound) => {
const padding = 20;
const findSpace = (bound: Bound, padding = 20) => {
const gfx = std.get(GfxControllerIdentifier);
let elementInFrameBound = gfx.grid.search(bound);
while (elementInFrameBound.length > 0) {
@@ -78,7 +77,7 @@ const surfaceRefSlashMenuConfig: SlashMenuConfig = {
},
group: `5_Edgeless Element@${index++}`,
action: () => {
const bound = findSpace(Bound.fromXYWH([0, 0, 200, 200]));
const bound = findSpace(Bound.fromXYWH([0, 0, 200, 200]), 150);
const { x, y, h } = bound;
const rootW = 145;
@@ -160,6 +159,6 @@ const surfaceRefSlashMenuConfig: SlashMenuConfig = {
};
export const SurfaceRefSlashMenuConfigExtension = SlashMenuConfigExtension(
'affine:surface-ref',
SurfaceRefBlockSchema.model.flavour,
surfaceRefSlashMenuConfig
);

View File

@@ -2,4 +2,3 @@ export * from './commands.js';
export * from './surface-ref-block.js';
export * from './surface-ref-block-edgeless.js';
export * from './surface-ref-spec.js';
export * from './utils.js';

View File

@@ -1,14 +1,12 @@
import { type FrameBlockComponent } from '@blocksuite/affine-block-frame';
import {
EdgelessCRUDIdentifier,
getSurfaceBlock,
type SurfaceBlockModel,
SurfaceElementModel,
} from '@blocksuite/affine-block-surface';
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
import { Peekable } from '@blocksuite/affine-components/peek';
import {
FrameBlockModel,
RootBlockModel,
type SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import {
@@ -17,8 +15,8 @@ import {
ThemeProvider,
ViewportElementExtension,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
matchModels,
requestConnectedFrame,
SpecProvider,
} from '@blocksuite/affine-shared/utils';
@@ -43,20 +41,18 @@ import {
deserializeXYWH,
type SerializedXYWH,
} from '@blocksuite/global/gfx';
import { DeleteIcon, EdgelessIcon, FrameIcon } from '@blocksuite/icons/lit';
import { assertType } from '@blocksuite/global/utils';
import { DeleteIcon } from '@blocksuite/icons/lit';
import type { BaseSelection, Store } from '@blocksuite/store';
import { css, html, nothing, type TemplateResult } from 'lit';
import { effect, signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
import { noContentPlaceholder } from './utils.js';
const iconSize = { width: '20px', height: '20px' };
const REF_LABEL_ICON = {
'affine:frame': FrameIcon(iconSize),
DEFAULT_NOTE_HEIGHT: EdgelessIcon(iconSize),
} as Record<string, TemplateResult>;
const NO_CONTENT_TITLE = {
'affine:frame': 'Frame',
group: 'Group',
@@ -71,11 +67,28 @@ const NO_CONTENT_REASON = {
@Peekable()
export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockModel> {
static override styles = css`
affine-surface-ref {
position: relative;
}
affine-surface-ref:not(:hover)
affine-surface-ref-toolbar:not([data-open-menu-display='show']) {
display: none;
}
.affine-surface-ref {
position: relative;
user-select: none;
margin: 10px 0;
break-inside: avoid;
border-radius: 8px;
border: 1px solid ${unsafeCSSVarV2('edgeless/frame/border/default')};
background-color: ${unsafeCSSVarV2('layer/background/primary')};
overflow: hidden;
}
.affine-surface-ref.focused {
border-color: ${unsafeCSSVarV2('edgeless/frame/border/active')};
}
@media print {
@@ -150,7 +163,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
.ref-content {
position: relative;
padding: 20px;
background-color: var(--affine-background-primary-color);
background: radial-gradient(
var(--affine-edgeless-grid-color) 1px,
@@ -166,75 +178,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
pointer-events: none;
user-select: none;
}
.ref-viewport.frame {
border-radius: 2px;
border: 1px solid var(--affine-black-30);
}
.surface-ref-mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
break-inside: avoid;
}
.surface-ref-mask:hover {
background-color: rgba(211, 211, 211, 0.1);
}
.surface-ref-mask:hover .ref-label {
display: block;
}
.ref-label {
display: none;
user-select: none;
}
.ref-label {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 8px 16px;
border: 1px solid var(--affine-border-color);
gap: 14px;
background: var(--affine-background-primary-color);
font-size: 12px;
user-select: none;
}
.ref-label .title {
display: inline-block;
font-weight: 600;
font-family: var(--affine-font-family);
line-height: 20px;
color: var(--affine-text-secondary-color);
}
.ref-label .title > svg {
color: var(--affine-icon-secondary);
display: inline-block;
vertical-align: baseline;
width: 20px;
height: 20px;
vertical-align: bottom;
}
.ref-label .suffix {
display: inline-block;
font-weight: 400;
color: var(--affine-text-disable-color);
line-height: 20px;
}
`;
private _previewDoc: Store | null = null;
@@ -245,9 +188,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
private _referencedModel: GfxModel | null = null;
private _referenceXYWH: SerializedXYWH | null = null;
private _viewportEditor: EditorHost | null = null;
private readonly _referenceXYWH$ = signal<SerializedXYWH | null>(null);
private get _shouldRender() {
return (
@@ -312,55 +253,31 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
}
private _initReferencedModel() {
const surfaceModel = getSurfaceBlock(this.doc);
this._surfaceModel = surfaceModel;
const findReferencedModel = (): [GfxModel | null, string] => {
if (!this.model.props.reference) return [null, this.doc.id];
const referenceId = this.model.props.reference;
if (this.doc.getBlock(this.model.props.reference)) {
return [
this.doc.getBlock(this.model.props.reference)
?.model as GfxBlockElementModel,
this.doc.id,
];
}
if (this._surfaceModel?.getElementById(this.model.props.reference)) {
return [
this._surfaceModel.getElementById(this.model.props.reference),
this.doc.id,
];
}
const doc = [...this.std.workspace.docs.values()]
.map(doc => doc.getStore())
.find(
doc =>
doc.getBlock(this.model.props.reference) ||
getSurfaceBlock(doc)?.getElementById(this.model.props.reference)
);
if (doc) {
this._surfaceModel = getSurfaceBlock(doc);
}
if (doc && doc.getBlock(this.model.props.reference)) {
return [
doc.getBlock(this.model.props.reference)
?.model as GfxBlockElementModel,
doc.id,
];
}
if (doc) {
const surfaceBlock = getSurfaceBlock(doc);
if (surfaceBlock) {
return [
surfaceBlock.getElementById(this.model.props.reference),
doc.id,
];
const find = (doc: Store): [GfxModel | null, string] => {
const block = doc.getBlock(referenceId)?.model;
if (block instanceof GfxBlockElementModel) {
return [block, doc.id];
}
const surfaceBlock = getSurfaceBlock(doc);
if (!surfaceBlock) return [null, doc.id];
const element = surfaceBlock.getElementById(referenceId);
if (element) return [element, doc.id];
return [null, doc.id];
};
// find current doc first
let result = find(this.doc);
if (result[0]) return result;
for (const doc of this.std.workspace.docs.values()) {
result = find(doc.getStore());
if (result[0]) return result;
}
return [null, this.doc.id];
@@ -371,10 +288,10 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
this._referencedModel =
referencedModel && referencedModel.xywh ? referencedModel : null;
// TODO(@L-Sun): clear query cache
this._previewDoc = this.doc.workspace.getDoc(docId, {
readonly: true,
});
this._referenceXYWH = this._referencedModel?.xywh ?? null;
};
init();
@@ -390,9 +307,9 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
})
);
if (surfaceModel && this._referencedModel instanceof SurfaceElementModel) {
if (this._referencedModel instanceof GfxPrimitiveElementModel) {
this._disposables.add(
surfaceModel.elementRemoved.subscribe(({ id }) => {
this._referencedModel.surface.elementRemoved.subscribe(({ id }) => {
if (this.model.props.reference === id) {
init();
}
@@ -422,45 +339,37 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
);
}
private _initSpec() {
const refreshViewport = this._refreshViewport.bind(this);
class SurfaceRefViewportInitializer extends LifeCycleWatcher {
static override readonly key = 'surfaceRefViewportInitializer';
private _initViewport() {
const refreshViewport = () => {
if (!this._referenceXYWH$.value) return;
const previewEditorHost = this.previewEditor;
if (!previewEditorHost) return;
const gfx = previewEditorHost.std.get(GfxControllerIdentifier);
const viewport = gfx.viewport;
override mounted() {
const disposable = this.std.view.viewUpdated.subscribe(payload => {
if (payload.type !== 'block') return;
if (
payload.method === 'add' &&
matchModels(payload.view.model, [RootBlockModel])
) {
disposable.unsubscribe();
queueMicrotask(() => refreshViewport());
const gfx = this.std.get(GfxControllerIdentifier);
gfx.viewport.sizeUpdated.subscribe(() => {
refreshViewport();
});
}
});
}
}
this._previewSpec.extend([SurfaceRefViewportInitializer]);
let bound = Bound.deserialize(this._referenceXYWH$.value);
const w = Math.max(this.getBoundingClientRect().width, bound.w);
const aspectRatio = bound.w / bound.h;
const h = w / aspectRatio;
bound = Bound.fromCenter(bound.center, w, h);
viewport.setViewportByBound(bound);
};
this.disposables.add(effect(refreshViewport));
const referenceId = this.model.props.reference;
const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => {
this._referenceXYWH = xywh;
};
class FrameGroupViewWatcher extends LifeCycleWatcher {
static override readonly key = 'surface-ref-group-view-watcher';
const referenceXYWH$ = this._referenceXYWH$;
class SurfaceRefViewportWatcher extends LifeCycleWatcher {
static override readonly key = 'surface-ref-viewport-watcher';
private readonly _disposable = new DisposableGroup();
override mounted() {
const crud = this.std.get(EdgelessCRUDIdentifier);
const { _disposable } = this;
const surfaceModel = getSurfaceBlock(this.std.store);
if (!surfaceModel) return;
const gfx = this.std.get(GfxControllerIdentifier);
const { surface, viewport } = gfx;
if (!surface) return;
const referenceElement = crud.getElementById(referenceId);
if (!referenceElement) {
@@ -469,23 +378,39 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
`can not find element(id:${referenceElement})`
);
}
referenceXYWH$.value = referenceElement.xywh;
const { _disposable } = this;
_disposable.add(viewport.sizeUpdated.subscribe(refreshViewport));
if (referenceElement instanceof FrameBlockModel) {
_disposable.add(
referenceElement.xywh$.subscribe(xywh => {
setReferenceXYWH(xywh);
refreshViewport();
referenceXYWH$.value = xywh;
})
);
const subscription = this.std.view.viewUpdated.subscribe(
({ id, type, method, view }) => {
if (
id === referenceElement.id &&
type === 'block' &&
method === 'add'
) {
assertType<FrameBlockComponent>(view);
view.showBorder = false;
subscription.unsubscribe();
}
}
);
_disposable.add(subscription);
} else if (referenceElement instanceof GfxPrimitiveElementModel) {
_disposable.add(
surfaceModel.elementUpdated.subscribe(({ id, oldValues }) => {
surface.elementUpdated.subscribe(({ id, oldValues }) => {
if (
id === referenceId &&
oldValues.xywh !== referenceElement.xywh
) {
setReferenceXYWH(referenceElement.xywh);
refreshViewport();
referenceXYWH$.value = referenceElement.xywh;
}
})
);
@@ -497,69 +422,29 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
}
}
this._previewSpec.extend([FrameGroupViewWatcher]);
}
private _refreshViewport() {
if (!this._referenceXYWH) return;
const previewEditorHost = this.previewEditor;
if (!previewEditorHost) return;
const gfx = previewEditorHost.std.get(GfxControllerIdentifier);
const viewport = gfx.viewport;
viewport.setViewportByBound(Bound.deserialize(this._referenceXYWH));
}
private _renderMask(referencedModel: GfxModel, flavourOrType: string) {
const title = 'title' in referencedModel ? referencedModel.title : '';
return html`
<div class="surface-ref-mask">
<div class="ref-label">
<div class="title">
${REF_LABEL_ICON[flavourOrType ?? 'DEFAULT'] ??
REF_LABEL_ICON.DEFAULT}
<span>${title}</span>
</div>
<div class="suffix">from edgeless mode</div>
</div>
</div>
`;
this._previewSpec.extend([SurfaceRefViewportWatcher]);
}
private _renderRefContent(referencedModel: GfxModel) {
const [, , w, h] = deserializeXYWH(referencedModel.xywh);
const flavourOrType =
'flavour' in referencedModel
? referencedModel.flavour
: referencedModel.type;
const _previewSpec = this._previewSpec.value;
if (!this._viewportEditor) {
if (this._previewDoc) {
this._viewportEditor = new BlockStdScope({
store: this._previewDoc,
extensions: _previewSpec,
}).render();
} else {
console.error('Preview doc is not found');
}
}
return html`<div class="ref-content">
<div
class="ref-viewport ${flavourOrType === 'affine:frame' ? 'frame' : ''}"
class="ref-viewport"
style=${styleMap({
width: `${w}px`,
aspectRatio: `${w} / ${h}`,
})}
>
${this._viewportEditor}
${guard(this._previewDoc, () => {
return this._previewDoc
? new BlockStdScope({
store: this._previewDoc,
extensions: _previewSpec,
}).render()
: nothing;
})}
</div>
${this._renderMask(referencedModel, flavourOrType)}
</div>`;
}
@@ -592,7 +477,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
if (!this._shouldRender) return;
this._initHotkey();
this._initSpec();
this._initViewport();
this._initReferencedModel();
this._initSelection();
}
@@ -600,9 +485,8 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
override render() {
if (!this._shouldRender) return nothing;
const { _surfaceModel, _referencedModel, model } = this;
const isEmpty =
!_surfaceModel || !_referencedModel || !_referencedModel.xywh;
const { _referencedModel, model } = this;
const isEmpty = !_referencedModel || !_referencedModel.xywh;
const content = isEmpty
? this._renderRefPlaceholder(model)
: this._renderRefContent(_referencedModel);
@@ -610,14 +494,12 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
return html`
<div
class="affine-surface-ref"
class=${classMap({
'affine-surface-ref': true,
focused: this._focused,
})}
data-theme=${edgelessTheme}
@click=${this._focusBlock}
style=${styleMap({
outline: this._focused
? '2px solid var(--affine-primary-color)'
: undefined,
})}
>
${content}
</div>
@@ -629,10 +511,10 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
}
viewInEdgeless() {
if (!this._referenceXYWH) return;
if (!this._referenceXYWH$.value) return;
const viewport = {
xywh: this._referenceXYWH,
xywh: this._referenceXYWH$.value,
padding: [60, 20, 20, 20] as [number, number, number, number],
};
@@ -640,18 +522,9 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
this.std.get(DocModeProvider).setEditorMode('edgeless');
}
override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
if (_changedProperties.has('_referencedModel')) {
this._refreshViewport();
}
}
@state()
private accessor _focused: boolean = false;
@state()
private accessor _surfaceModel: SurfaceBlockModel | null = null;
@query('affine-surface-ref > block-caption-editor')
accessor captionElement!: BlockCaptionEditor;

View File

@@ -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',

View File

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

View File

@@ -25,9 +25,10 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
}
`;
private _popper!: ReturnType<typeof createButtonPopper>;
private _popper: ReturnType<typeof createButtonPopper> | null = null;
override firstUpdated() {
private _updatePopper() {
this._popper?.dispose();
this._popper = createButtonPopper({
reference: this._trigger,
popperElement: this._content,
@@ -48,16 +49,30 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
offsetHeight: 6 * 4,
...this.popperOptions,
});
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('contentPadding')) {
this.style.setProperty('--content-padding', this.contentPadding ?? '');
}
if (this.hasUpdated && changedProperties.has('popperOptions')) {
this._updatePopper();
}
}
override firstUpdated() {
this._updatePopper();
this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
this._popper.hide();
this._popper?.hide();
}
});
this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => {
this._popper.toggle();
this._popper?.toggle();
});
this._disposables.add(this._popper);
this._disposables.add(() => this._popper?.dispose());
}
hide() {
@@ -77,12 +92,6 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
this._popper?.show(force);
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('contentPadding')) {
this.style.setProperty('--content-padding', this.contentPadding ?? '');
}
}
@query('editor-menu-content')
private accessor _content!: EditorMenuContent;

View File

@@ -4,6 +4,7 @@ import {
autoUpdate,
computePosition,
offset,
type Placement,
type Rect,
shift,
size,
@@ -39,6 +40,7 @@ export type ButtonPopperOptions = {
stateUpdated?: (state: { display: Display }) => void;
mainAxis?: number;
crossAxis?: number;
allowedPlacements?: Placement[];
rootBoundary?: Rect | (() => Rect | undefined);
ignoreShift?: boolean;
offsetHeight?: number;
@@ -64,6 +66,7 @@ export function createButtonPopper(options: ButtonPopperOptions) {
stateUpdated = () => {},
mainAxis,
crossAxis,
allowedPlacements = ['top', 'bottom'],
rootBoundary,
ignoreShift,
offsetHeight,
@@ -84,7 +87,7 @@ export function createButtonPopper(options: ButtonPopperOptions) {
crossAxis: crossAxis ?? 0,
}),
autoPlacement({
allowedPlacements: ['top', 'bottom'],
allowedPlacements,
...overflowOptions,
}),
shift(overflowOptions),