import { EdgelessCRUDExtension, 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 { DocModeProvider, EditPropsStore, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { matchModels, requestConnectedFrame, SpecProvider, } from '@blocksuite/affine-shared/utils'; import { BlockComponent, BlockSelection, BlockStdScope, type EditorHost, LifeCycleWatcher, TextSelection, } from '@blocksuite/block-std'; import { GfxBlockElementModel, GfxControllerIdentifier, type GfxModel, GfxPrimitiveElementModel, } from '@blocksuite/block-std/gfx'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { assertExists, Bound, deserializeXYWH, DisposableGroup, type SerializedXYWH, } from '@blocksuite/global/utils'; import { DeleteIcon, EdgelessIcon, FrameIcon } from '@blocksuite/icons/lit'; import type { BaseSelection, Store } from '@blocksuite/store'; import { css, html, nothing, type TemplateResult } from 'lit'; import { query, state } from 'lit/decorators.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; const NO_CONTENT_TITLE = { 'affine:frame': 'Frame', group: 'Group', DEFAULT: 'Content', } as Record; const NO_CONTENT_REASON = { group: 'This content was ungrouped or deleted on edgeless mode', DEFAULT: 'This content was deleted on edgeless mode', } as Record; @Peekable() export class SurfaceRefBlockComponent extends BlockComponent { static override styles = css` .affine-surface-ref { position: relative; user-select: none; margin: 10px 0; break-inside: avoid; } @media print { .affine-surface-ref { outline: none !important; } } .ref-placeholder { padding: 26px 0px 0px; } .placeholder-image { margin: 0 auto; text-align: center; } .placeholder-text { margin: 12px auto 0; text-align: center; font-size: 28px; font-weight: 600; line-height: 36px; font-family: var(--affine-font-family); } .placeholder-action { margin: 32px auto 0; text-align: center; } .delete-button { width: 204px; padding: 4px 18px; display: inline-flex; justify-content: center; align-items: center; gap: 4px; border-radius: 8px; border: 1px solid var(--affine-border-color); font-family: var(--affine-font-family); font-size: 12px; font-weight: 500; line-height: 20px; background-color: transparent; cursor: pointer; } .delete-button > .icon > svg { color: var(--affine-icon-color); width: 16px; height: 16px; display: block; } .placeholder-reason { margin: 72px auto 0; padding: 10px; text-align: center; font-size: 12px; font-family: var(--affine-font-family); line-height: 20px; color: var(--affine-warning-color); background-color: var(--affine-background-error-color); } .ref-content { position: relative; padding: 20px; background-color: var(--affine-background-primary-color); background: radial-gradient( var(--affine-edgeless-grid-color) 1px, var(--affine-background-primary-color) 1px ); } .ref-viewport { max-width: 100%; margin: 0 auto; position: relative; overflow: hidden; 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; private readonly _previewSpec = SpecProvider.getInstance().getSpec('preview:edgeless'); private _referencedModel: GfxModel | null = null; private _referenceXYWH: SerializedXYWH | null = null; private _viewportEditor: EditorHost | null = null; private get _shouldRender() { return ( this.isConnected && // prevent surface-ref from render itself in loop !this.parentComponent?.closest('affine-surface-ref') ); } get referenceModel() { return this._referencedModel; } private _deleteThis() { this.doc.deleteBlock(this.model); } private _focusBlock() { this.selection.update(() => { return [this.selection.create(BlockSelection, { blockId: this.blockId })]; }); } private _initHotkey() { const selection = this.host.selection; const addParagraph = () => { if (!this.doc.getParent(this.model)) return; const [paragraphId] = this.doc.addSiblingBlocks(this.model, [ { flavour: 'affine:paragraph', }, ]); const model = this.doc.getBlockById(paragraphId); assertExists(model, `Failed to add paragraph block.`); requestConnectedFrame(() => { selection.update(selList => { return selList .filter(sel => !sel.is(BlockSelection)) .concat( selection.create(TextSelection, { from: { blockId: model.id, index: 0, length: 0, }, to: null, }) ); }); }, this); }; this.bindHotKey({ Enter: () => { if (!this._focused) return; addParagraph(); return true; }, }); } private _initReferencedModel() { const surfaceModel = getSurfaceBlock(this.doc); this._surfaceModel = surfaceModel; const findReferencedModel = (): [GfxModel | null, string] => { if (!this.model.reference) return [null, this.doc.id]; if (this.doc.getBlock(this.model.reference)) { return [ this.doc.getBlock(this.model.reference) ?.model as GfxBlockElementModel, this.doc.id, ]; } if (this._surfaceModel?.getElementById(this.model.reference)) { return [ this._surfaceModel.getElementById(this.model.reference), this.doc.id, ]; } const doc = [...this.std.workspace.docs.values()] .map(doc => doc.getStore()) .find( doc => doc.getBlock(this.model.reference) || getSurfaceBlock(doc)?.getElementById(this.model.reference) ); if (doc) { this._surfaceModel = getSurfaceBlock(doc); } if (doc && doc.getBlock(this.model.reference)) { return [ doc.getBlock(this.model.reference)?.model as GfxBlockElementModel, doc.id, ]; } if (doc) { const surfaceBlock = getSurfaceBlock(doc); if (surfaceBlock) { return [surfaceBlock.getElementById(this.model.reference), doc.id]; } } return [null, this.doc.id]; }; const init = () => { const [referencedModel, docId] = findReferencedModel(); this._referencedModel = referencedModel && referencedModel.xywh ? referencedModel : null; this._previewDoc = this.doc.workspace.getDoc(docId, { readonly: true, }); this._referenceXYWH = this._referencedModel?.xywh ?? null; }; init(); this._disposables.add( this.model.propsUpdated.on(payload => { if ( payload.key === 'reference' && this.model.reference !== this._referencedModel?.id ) { init(); } }) ); if (surfaceModel && this._referencedModel instanceof SurfaceElementModel) { this._disposables.add( surfaceModel.elementRemoved.on(({ id }) => { if (this.model.reference === id) { init(); } }) ); } if (this._referencedModel instanceof GfxBlockElementModel) { this._disposables.add( this.doc.slots.blockUpdated.on(({ type, id }) => { if (type === 'delete' && id === this.model.reference) { init(); } }) ); } } private _initSelection() { const selection = this.host.selection; this._disposables.add( selection.slots.changed.on(selList => { this._focused = selList.some( sel => sel.blockId === this.blockId && sel.is(BlockSelection) ); }) ); } private _initSpec() { const refreshViewport = this._refreshViewport.bind(this); class SurfaceRefViewportInitializer extends LifeCycleWatcher { static override readonly key = 'surfaceRefViewportInitializer'; override mounted() { const disposable = this.std.view.viewUpdated.on(payload => { if ( payload.type === 'add' && matchModels(payload.view.model, [RootBlockModel]) ) { disposable.dispose(); queueMicrotask(() => refreshViewport()); const gfx = this.std.get(GfxControllerIdentifier); gfx.viewport.sizeUpdated.on(() => { refreshViewport(); }); } }); } } this._previewSpec.extend([SurfaceRefViewportInitializer]); const referenceId = this.model.reference; const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => { this._referenceXYWH = xywh; }; class FrameGroupViewWatcher extends LifeCycleWatcher { static override readonly key = 'surface-ref-group-view-watcher'; private readonly _disposable = new DisposableGroup(); override mounted() { const crud = this.std.get(EdgelessCRUDExtension); const { _disposable } = this; const surfaceModel = getSurfaceBlock(this.std.store); if (!surfaceModel) return; const referenceElement = crud.getElementById(referenceId); if (!referenceElement) { throw new BlockSuiteError( ErrorCode.MissingViewModelError, `can not find element(id:${referenceElement})` ); } if (referenceElement instanceof FrameBlockModel) { _disposable.add( referenceElement.xywh$.subscribe(xywh => { setReferenceXYWH(xywh); refreshViewport(); }) ); } else if (referenceElement instanceof GfxPrimitiveElementModel) { _disposable.add( surfaceModel.elementUpdated.on(({ id, oldValues }) => { if ( id === referenceId && oldValues.xywh !== referenceElement.xywh ) { setReferenceXYWH(referenceElement.xywh); refreshViewport(); } }) ); } } override unmounted() { this._disposable.dispose(); } } 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`
${REF_LABEL_ICON[flavourOrType ?? 'DEFAULT'] ?? REF_LABEL_ICON.DEFAULT} ${title}
from edgeless mode
`; } 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`
${this._viewportEditor}
${this._renderMask(referencedModel, flavourOrType)}
`; } private _renderRefPlaceholder(model: SurfaceRefBlockModel) { return html`
${noContentPlaceholder}
No Such ${NO_CONTENT_TITLE[model.refFlavour ?? 'DEFAULT'] ?? NO_CONTENT_TITLE.DEFAULT}
${NO_CONTENT_REASON[model.refFlavour ?? 'DEFAULT'] ?? NO_CONTENT_REASON.DEFAULT}
`; } override connectedCallback() { super.connectedCallback(); this.contentEditable = 'false'; if (!this._shouldRender) return; this._initHotkey(); this._initSpec(); this._initReferencedModel(); this._initSelection(); } override render() { if (!this._shouldRender) return nothing; const { _surfaceModel, _referencedModel, model } = this; const isEmpty = !_surfaceModel || !_referencedModel || !_referencedModel.xywh; const content = isEmpty ? this._renderRefPlaceholder(model) : this._renderRefContent(_referencedModel); const edgelessTheme = this.std.get(ThemeProvider).edgeless$.value; return html`
${content}
${Object.values(this.widgets)} `; } viewInEdgeless() { if (!this._referenceXYWH) return; const viewport = { xywh: this._referenceXYWH, padding: [60, 20, 20, 20] as [number, number, number, number], }; this.std.get(EditPropsStore).setStorage('viewport', viewport); this.std.get(DocModeProvider).setEditorMode('edgeless'); } override willUpdate(_changedProperties: Map): 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; @query('editor-host') accessor previewEditor!: EditorHost | null; } declare global { interface HTMLElementTagNameMap { 'affine-surface-ref': SurfaceRefBlockComponent; } }