mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user