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,6 +1,6 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame'; import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { EdgelessCRUDExtension } from '@blocksuite/affine-block-surface'; import { EdgelessCRUDExtension } from '@blocksuite/affine-block-surface';
import { MindmapStyle } from '@blocksuite/affine-model'; import { MindmapStyle, SurfaceRefBlockSchema } from '@blocksuite/affine-model';
import { import {
type SlashMenuActionItem, type SlashMenuActionItem,
type SlashMenuConfig, type SlashMenuConfig,
@@ -20,8 +20,7 @@ const surfaceRefSlashMenuConfig: SlashMenuConfig = {
const crud = std.get(EdgelessCRUDExtension); const crud = std.get(EdgelessCRUDExtension);
const frameMgr = std.get(EdgelessFrameManagerIdentifier); const frameMgr = std.get(EdgelessFrameManagerIdentifier);
const findSpace = (bound: Bound) => { const findSpace = (bound: Bound, padding = 20) => {
const padding = 20;
const gfx = std.get(GfxControllerIdentifier); const gfx = std.get(GfxControllerIdentifier);
let elementInFrameBound = gfx.grid.search(bound); let elementInFrameBound = gfx.grid.search(bound);
while (elementInFrameBound.length > 0) { while (elementInFrameBound.length > 0) {
@@ -78,7 +77,7 @@ const surfaceRefSlashMenuConfig: SlashMenuConfig = {
}, },
group: `5_Edgeless Element@${index++}`, group: `5_Edgeless Element@${index++}`,
action: () => { 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 { x, y, h } = bound;
const rootW = 145; const rootW = 145;
@@ -160,6 +159,6 @@ const surfaceRefSlashMenuConfig: SlashMenuConfig = {
}; };
export const SurfaceRefSlashMenuConfigExtension = SlashMenuConfigExtension( export const SurfaceRefSlashMenuConfigExtension = SlashMenuConfigExtension(
'affine:surface-ref', SurfaceRefBlockSchema.model.flavour,
surfaceRefSlashMenuConfig surfaceRefSlashMenuConfig
); );
@@ -2,4 +2,3 @@ export * from './commands.js';
export * from './surface-ref-block.js'; export * from './surface-ref-block.js';
export * from './surface-ref-block-edgeless.js'; export * from './surface-ref-block-edgeless.js';
export * from './surface-ref-spec.js'; export * from './surface-ref-spec.js';
export * from './utils.js';
@@ -1,14 +1,12 @@
import { type FrameBlockComponent } from '@blocksuite/affine-block-frame';
import { import {
EdgelessCRUDIdentifier, EdgelessCRUDIdentifier,
getSurfaceBlock, getSurfaceBlock,
type SurfaceBlockModel,
SurfaceElementModel,
} from '@blocksuite/affine-block-surface'; } from '@blocksuite/affine-block-surface';
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption'; import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
import { Peekable } from '@blocksuite/affine-components/peek'; import { Peekable } from '@blocksuite/affine-components/peek';
import { import {
FrameBlockModel, FrameBlockModel,
RootBlockModel,
type SurfaceRefBlockModel, type SurfaceRefBlockModel,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import { import {
@@ -17,8 +15,8 @@ import {
ThemeProvider, ThemeProvider,
ViewportElementExtension, ViewportElementExtension,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { import {
matchModels,
requestConnectedFrame, requestConnectedFrame,
SpecProvider, SpecProvider,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
@@ -43,20 +41,18 @@ import {
deserializeXYWH, deserializeXYWH,
type SerializedXYWH, type SerializedXYWH,
} from '@blocksuite/global/gfx'; } 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 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 { 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 { styleMap } from 'lit/directives/style-map.js';
import { noContentPlaceholder } from './utils.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 = { const NO_CONTENT_TITLE = {
'affine:frame': 'Frame', 'affine:frame': 'Frame',
group: 'Group', group: 'Group',
@@ -71,11 +67,28 @@ const NO_CONTENT_REASON = {
@Peekable() @Peekable()
export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockModel> { export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockModel> {
static override styles = css` 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 { .affine-surface-ref {
position: relative; position: relative;
user-select: none; user-select: none;
margin: 10px 0; margin: 10px 0;
break-inside: avoid; 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 { @media print {
@@ -150,7 +163,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
.ref-content { .ref-content {
position: relative; position: relative;
padding: 20px;
background-color: var(--affine-background-primary-color); background-color: var(--affine-background-primary-color);
background: radial-gradient( background: radial-gradient(
var(--affine-edgeless-grid-color) 1px, var(--affine-edgeless-grid-color) 1px,
@@ -166,75 +178,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
pointer-events: none; pointer-events: none;
user-select: 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; private _previewDoc: Store | null = null;
@@ -245,9 +188,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
private _referencedModel: GfxModel | null = null; private _referencedModel: GfxModel | null = null;
private _referenceXYWH: SerializedXYWH | null = null; private readonly _referenceXYWH$ = signal<SerializedXYWH | null>(null);
private _viewportEditor: EditorHost | null = null;
private get _shouldRender() { private get _shouldRender() {
return ( return (
@@ -312,55 +253,31 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
} }
private _initReferencedModel() { private _initReferencedModel() {
const surfaceModel = getSurfaceBlock(this.doc);
this._surfaceModel = surfaceModel;
const findReferencedModel = (): [GfxModel | null, string] => { const findReferencedModel = (): [GfxModel | null, string] => {
if (!this.model.props.reference) return [null, this.doc.id]; if (!this.model.props.reference) return [null, this.doc.id];
const referenceId = this.model.props.reference;
if (this.doc.getBlock(this.model.props.reference)) { const find = (doc: Store): [GfxModel | null, string] => {
return [ const block = doc.getBlock(referenceId)?.model;
this.doc.getBlock(this.model.props.reference) if (block instanceof GfxBlockElementModel) {
?.model as GfxBlockElementModel, return [block, doc.id];
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 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]; return [null, this.doc.id];
@@ -371,10 +288,10 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
this._referencedModel = this._referencedModel =
referencedModel && referencedModel.xywh ? referencedModel : null; referencedModel && referencedModel.xywh ? referencedModel : null;
// TODO(@L-Sun): clear query cache
this._previewDoc = this.doc.workspace.getDoc(docId, { this._previewDoc = this.doc.workspace.getDoc(docId, {
readonly: true, readonly: true,
}); });
this._referenceXYWH = this._referencedModel?.xywh ?? null;
}; };
init(); 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( this._disposables.add(
surfaceModel.elementRemoved.subscribe(({ id }) => { this._referencedModel.surface.elementRemoved.subscribe(({ id }) => {
if (this.model.props.reference === id) { if (this.model.props.reference === id) {
init(); init();
} }
@@ -422,45 +339,37 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
); );
} }
private _initSpec() { private _initViewport() {
const refreshViewport = this._refreshViewport.bind(this); const refreshViewport = () => {
class SurfaceRefViewportInitializer extends LifeCycleWatcher { if (!this._referenceXYWH$.value) return;
static override readonly key = 'surfaceRefViewportInitializer'; const previewEditorHost = this.previewEditor;
if (!previewEditorHost) return;
const gfx = previewEditorHost.std.get(GfxControllerIdentifier);
const viewport = gfx.viewport;
override mounted() { let bound = Bound.deserialize(this._referenceXYWH$.value);
const disposable = this.std.view.viewUpdated.subscribe(payload => { const w = Math.max(this.getBoundingClientRect().width, bound.w);
if (payload.type !== 'block') return; const aspectRatio = bound.w / bound.h;
if ( const h = w / aspectRatio;
payload.method === 'add' &&
matchModels(payload.view.model, [RootBlockModel]) bound = Bound.fromCenter(bound.center, w, h);
) {
disposable.unsubscribe(); viewport.setViewportByBound(bound);
queueMicrotask(() => refreshViewport()); };
const gfx = this.std.get(GfxControllerIdentifier); this.disposables.add(effect(refreshViewport));
gfx.viewport.sizeUpdated.subscribe(() => {
refreshViewport();
});
}
});
}
}
this._previewSpec.extend([SurfaceRefViewportInitializer]);
const referenceId = this.model.props.reference; const referenceId = this.model.props.reference;
const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => { const referenceXYWH$ = this._referenceXYWH$;
this._referenceXYWH = xywh; class SurfaceRefViewportWatcher extends LifeCycleWatcher {
}; static override readonly key = 'surface-ref-viewport-watcher';
class FrameGroupViewWatcher extends LifeCycleWatcher {
static override readonly key = 'surface-ref-group-view-watcher';
private readonly _disposable = new DisposableGroup(); private readonly _disposable = new DisposableGroup();
override mounted() { override mounted() {
const crud = this.std.get(EdgelessCRUDIdentifier); const crud = this.std.get(EdgelessCRUDIdentifier);
const { _disposable } = this; const gfx = this.std.get(GfxControllerIdentifier);
const surfaceModel = getSurfaceBlock(this.std.store); const { surface, viewport } = gfx;
if (!surfaceModel) return; if (!surface) return;
const referenceElement = crud.getElementById(referenceId); const referenceElement = crud.getElementById(referenceId);
if (!referenceElement) { if (!referenceElement) {
@@ -469,23 +378,39 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
`can not find element(id:${referenceElement})` `can not find element(id:${referenceElement})`
); );
} }
referenceXYWH$.value = referenceElement.xywh;
const { _disposable } = this;
_disposable.add(viewport.sizeUpdated.subscribe(refreshViewport));
if (referenceElement instanceof FrameBlockModel) { if (referenceElement instanceof FrameBlockModel) {
_disposable.add( _disposable.add(
referenceElement.xywh$.subscribe(xywh => { referenceElement.xywh$.subscribe(xywh => {
setReferenceXYWH(xywh); referenceXYWH$.value = xywh;
refreshViewport();
}) })
); );
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) { } else if (referenceElement instanceof GfxPrimitiveElementModel) {
_disposable.add( _disposable.add(
surfaceModel.elementUpdated.subscribe(({ id, oldValues }) => { surface.elementUpdated.subscribe(({ id, oldValues }) => {
if ( if (
id === referenceId && id === referenceId &&
oldValues.xywh !== referenceElement.xywh oldValues.xywh !== referenceElement.xywh
) { ) {
setReferenceXYWH(referenceElement.xywh); referenceXYWH$.value = referenceElement.xywh;
refreshViewport();
} }
}) })
); );
@@ -497,69 +422,29 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
} }
} }
this._previewSpec.extend([FrameGroupViewWatcher]); this._previewSpec.extend([SurfaceRefViewportWatcher]);
}
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>
`;
} }
private _renderRefContent(referencedModel: GfxModel) { private _renderRefContent(referencedModel: GfxModel) {
const [, , w, h] = deserializeXYWH(referencedModel.xywh); const [, , w, h] = deserializeXYWH(referencedModel.xywh);
const flavourOrType =
'flavour' in referencedModel
? referencedModel.flavour
: referencedModel.type;
const _previewSpec = this._previewSpec.value; 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"> return html`<div class="ref-content">
<div <div
class="ref-viewport ${flavourOrType === 'affine:frame' ? 'frame' : ''}" class="ref-viewport"
style=${styleMap({ style=${styleMap({
width: `${w}px`,
aspectRatio: `${w} / ${h}`, aspectRatio: `${w} / ${h}`,
})} })}
> >
${this._viewportEditor} ${guard(this._previewDoc, () => {
return this._previewDoc
? new BlockStdScope({
store: this._previewDoc,
extensions: _previewSpec,
}).render()
: nothing;
})}
</div> </div>
${this._renderMask(referencedModel, flavourOrType)}
</div>`; </div>`;
} }
@@ -592,7 +477,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
if (!this._shouldRender) return; if (!this._shouldRender) return;
this._initHotkey(); this._initHotkey();
this._initSpec(); this._initViewport();
this._initReferencedModel(); this._initReferencedModel();
this._initSelection(); this._initSelection();
} }
@@ -600,9 +485,8 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
override render() { override render() {
if (!this._shouldRender) return nothing; if (!this._shouldRender) return nothing;
const { _surfaceModel, _referencedModel, model } = this; const { _referencedModel, model } = this;
const isEmpty = const isEmpty = !_referencedModel || !_referencedModel.xywh;
!_surfaceModel || !_referencedModel || !_referencedModel.xywh;
const content = isEmpty const content = isEmpty
? this._renderRefPlaceholder(model) ? this._renderRefPlaceholder(model)
: this._renderRefContent(_referencedModel); : this._renderRefContent(_referencedModel);
@@ -610,14 +494,12 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
return html` return html`
<div <div
class="affine-surface-ref" class=${classMap({
'affine-surface-ref': true,
focused: this._focused,
})}
data-theme=${edgelessTheme} data-theme=${edgelessTheme}
@click=${this._focusBlock} @click=${this._focusBlock}
style=${styleMap({
outline: this._focused
? '2px solid var(--affine-primary-color)'
: undefined,
})}
> >
${content} ${content}
</div> </div>
@@ -629,10 +511,10 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
} }
viewInEdgeless() { viewInEdgeless() {
if (!this._referenceXYWH) return; if (!this._referenceXYWH$.value) return;
const viewport = { const viewport = {
xywh: this._referenceXYWH, xywh: this._referenceXYWH$.value,
padding: [60, 20, 20, 20] as [number, number, number, number], 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'); this.std.get(DocModeProvider).setEditorMode('edgeless');
} }
override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
if (_changedProperties.has('_referencedModel')) {
this._refreshViewport();
}
}
@state() @state()
private accessor _focused: boolean = false; private accessor _focused: boolean = false;
@state()
private accessor _surfaceModel: SurfaceBlockModel | null = null;
@query('affine-surface-ref > block-caption-editor') @query('affine-surface-ref > block-caption-editor')
accessor captionElement!: BlockCaptionEditor; accessor captionElement!: BlockCaptionEditor;
@@ -1,4 +1,4 @@
import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface'; import { type SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
import { import {
CopyIcon, CopyIcon,
DeleteIcon, DeleteIcon,
@@ -19,7 +19,7 @@ export const BUILT_IN_GROUPS: MenuItemGroup<SurfaceRefToolbarContext>[] = [
items: [ items: [
{ {
type: 'copy', type: 'copy',
label: 'Copy', label: 'Copy as Image',
icon: CopyIcon, icon: CopyIcon,
action: ctx => { action: ctx => {
if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) { if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) {
@@ -56,6 +56,7 @@ export const BUILT_IN_GROUPS: MenuItemGroup<SurfaceRefToolbarContext>[] = [
ctx.close(); ctx.close();
}, },
}, },
// TODO(@L-Sun): add duplicate action after refactoring toolbar
{ {
type: 'download', type: 'download',
label: 'Download', label: 'Download',
@@ -1,33 +1,44 @@
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref'; import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
import { HoverController } from '@blocksuite/affine-components/hover'; import { peek } from '@blocksuite/affine-components/peek';
import { CaptionIcon, OpenIcon } from '@blocksuite/affine-components/icons'; import { toast } from '@blocksuite/affine-components/toast';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { import {
cloneGroups, cloneGroups,
getMoreMenuConfig, getMoreMenuConfig,
type MenuItem, type MenuItem,
type MenuItemGroup, type MenuItemGroup,
renderGroups, renderGroups,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar'; } from '@blocksuite/affine-components/toolbar';
import type { SurfaceRefBlockModel } from '@blocksuite/affine-model';
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import { import {
BlockSelection, FrameBlockModel,
TextSelection, GroupElementModel,
WidgetComponent, MindmapElementModel,
} from '@blocksuite/block-std'; 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 { import {
ArrowDownSmallIcon, ArrowDownSmallIcon,
CaptionIcon,
CenterPeekIcon, CenterPeekIcon,
CopyIcon,
EdgelessIcon, EdgelessIcon,
FrameIcon,
GroupIcon,
MindmapIcon,
MoreVerticalIcon, MoreVerticalIcon,
OpenInNewIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { offset, shift } from '@floating-ui/dom'; import { css, html, nothing, type TemplateResult } from 'lit';
import { html, nothing } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.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 { BUILT_IN_GROUPS } from './config.js';
import { SurfaceRefToolbarContext } from './context.js'; import { SurfaceRefToolbarContext } from './context.js';
@@ -38,6 +49,59 @@ export class AffineSurfaceRefToolbar extends WidgetComponent<
SurfaceRefBlockModel, SurfaceRefBlockModel,
SurfaceRefBlockComponent 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. * Caches the more menu items.
* Currently only supports configuring more menu. * Currently only supports configuring more menu.
@@ -45,68 +109,167 @@ export class AffineSurfaceRefToolbar extends WidgetComponent<
moreGroups: MenuItemGroup<SurfaceRefToolbarContext>[] = moreGroups: MenuItemGroup<SurfaceRefToolbarContext>[] =
cloneGroups(BUILT_IN_GROUPS); 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() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); 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; [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>
`;
}
@@ -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({ this._popper = createButtonPopper({
reference: this._trigger, reference: this._trigger,
popperElement: this._content, popperElement: this._content,
@@ -48,16 +49,30 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
offsetHeight: 6 * 4, offsetHeight: 6 * 4,
...this.popperOptions, ...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) => { this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
if (e.key === 'Escape') { if (e.key === 'Escape') {
this._popper.hide(); this._popper?.hide();
} }
}); });
this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => { 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() { hide() {
@@ -77,12 +92,6 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
this._popper?.show(force); this._popper?.show(force);
} }
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('contentPadding')) {
this.style.setProperty('--content-padding', this.contentPadding ?? '');
}
}
@query('editor-menu-content') @query('editor-menu-content')
private accessor _content!: EditorMenuContent; private accessor _content!: EditorMenuContent;
@@ -4,6 +4,7 @@ import {
autoUpdate, autoUpdate,
computePosition, computePosition,
offset, offset,
type Placement,
type Rect, type Rect,
shift, shift,
size, size,
@@ -39,6 +40,7 @@ export type ButtonPopperOptions = {
stateUpdated?: (state: { display: Display }) => void; stateUpdated?: (state: { display: Display }) => void;
mainAxis?: number; mainAxis?: number;
crossAxis?: number; crossAxis?: number;
allowedPlacements?: Placement[];
rootBoundary?: Rect | (() => Rect | undefined); rootBoundary?: Rect | (() => Rect | undefined);
ignoreShift?: boolean; ignoreShift?: boolean;
offsetHeight?: number; offsetHeight?: number;
@@ -64,6 +66,7 @@ export function createButtonPopper(options: ButtonPopperOptions) {
stateUpdated = () => {}, stateUpdated = () => {},
mainAxis, mainAxis,
crossAxis, crossAxis,
allowedPlacements = ['top', 'bottom'],
rootBoundary, rootBoundary,
ignoreShift, ignoreShift,
offsetHeight, offsetHeight,
@@ -84,7 +87,7 @@ export function createButtonPopper(options: ButtonPopperOptions) {
crossAxis: crossAxis ?? 0, crossAxis: crossAxis ?? 0,
}), }),
autoPlacement({ autoPlacement({
allowedPlacements: ['top', 'bottom'], allowedPlacements,
...overflowOptions, ...overflowOptions,
}), }),
shift(overflowOptions), shift(overflowOptions),
+4 -4
View File
@@ -132,13 +132,13 @@ test('can open peek view for embedded frames', async ({ page }) => {
await page.locator('affine-surface-ref .affine-surface-ref').hover(); await page.locator('affine-surface-ref .affine-surface-ref').hover();
await page await page
.locator('.surface-ref-toolbar-container') .locator('affine-surface-ref-toolbar editor-menu-button[aria-label="Open"]')
.locator('editor-icon-button[aria-label="Open doc"]')
.click(); .click();
await page await page
.locator('.surface-ref-toolbar-container') .locator(
.locator('editor-menu-action:has-text("center peek")') 'affine-surface-ref-toolbar editor-menu-action[aria-label="Open in center peek"]'
)
.click(); .click();
// verify peek view is opened // verify peek view is opened