import { type FrameBlockComponent } from '@blocksuite/affine-block-frame'; import { EdgelessCRUDIdentifier, getSurfaceBlock, } from '@blocksuite/affine-block-surface'; import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption'; import { whenHover } from '@blocksuite/affine-components/hover'; import { Peekable } from '@blocksuite/affine-components/peek'; import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference'; import { FrameBlockModel, type SurfaceRefBlockModel, } from '@blocksuite/affine-model'; import { DocModeProvider, EditPropsStore, type OpenDocMode, ThemeProvider, ToolbarRegistryIdentifier, ViewportElementExtension, } from '@blocksuite/affine-shared/services'; import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { requestConnectedFrame, SpecProvider, } from '@blocksuite/affine-shared/utils'; import { DisposableGroup } from '@blocksuite/global/disposable'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { Bound, deserializeXYWH, type SerializedXYWH, } from '@blocksuite/global/gfx'; import { assertType } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons/lit'; import { BlockComponent, BlockSelection, BlockStdScope, type EditorHost, LifeCycleWatcher, TextSelection, } from '@blocksuite/std'; import { GfxBlockElementModel, GfxControllerIdentifier, type GfxModel, GfxPrimitiveElementModel, } from '@blocksuite/std/gfx'; import type { BaseSelection, Store } from '@blocksuite/store'; 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 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; } 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 { .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; 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; } `; private _previewDoc: Store | null = null; private readonly _previewSpec = SpecProvider._.getSpec( 'preview:edgeless' ).extend([ViewportElementExtension('.ref-viewport')]); private _referencedModel: GfxModel | null = null; private readonly _referenceXYWH$ = signal(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.getModelById(paragraphId); if (!model) return; 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 findReferencedModel = (): [GfxModel | null, string] => { if (!this.model.props.reference) return [null, this.doc.id]; const referenceId = this.model.props.reference; 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]; }; const init = () => { const [referencedModel, docId] = findReferencedModel(); this._referencedModel = referencedModel && referencedModel.xywh ? referencedModel : null; // TODO(@L-Sun): clear query cache const doc = this.doc.workspace.getDoc(docId); this._previewDoc = doc?.getStore({ readonly: true }) ?? null; }; init(); this._disposables.add( this.model.propsUpdated.subscribe(payload => { if ( payload.key === 'reference' && this.model.props.reference !== this._referencedModel?.id ) { init(); } }) ); if (this._referencedModel instanceof GfxPrimitiveElementModel) { this._disposables.add( this._referencedModel.surface.elementRemoved.subscribe(({ id }) => { if (this.model.props.reference === id) { init(); } }) ); } if (this._referencedModel instanceof GfxBlockElementModel) { this._disposables.add( this.doc.slots.blockUpdated.subscribe(({ type, id }) => { if (type === 'delete' && id === this.model.props.reference) { init(); } }) ); } } private _initSelection() { const selection = this.host.selection; this._disposables.add( selection.slots.changed.subscribe(selList => { this._focused = selList.some( sel => sel.blockId === this.blockId && sel.is(BlockSelection) ); }) ); } 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; 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 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 gfx = this.std.get(GfxControllerIdentifier); const { surface, viewport } = gfx; if (!surface) return; const referenceElement = crud.getElementById(referenceId); if (!referenceElement) { throw new BlockSuiteError( ErrorCode.MissingViewModelError, `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 => { referenceXYWH$.value = xywh; }) ); const subscription = this.std.view.viewUpdated.subscribe( ({ id, type, method, view }) => { if ( id === referenceElement.id && type === 'block' && method === 'add' ) { assertType(view); view.showBorder = false; subscription.unsubscribe(); } } ); _disposable.add(subscription); } else if (referenceElement instanceof GfxPrimitiveElementModel) { _disposable.add( surface.elementUpdated.subscribe(({ id, oldValues }) => { if ( id === referenceId && oldValues.xywh !== referenceElement.xywh ) { referenceXYWH$.value = referenceElement.xywh; } }) ); } } override unmounted() { this._disposable.dispose(); } } this._previewSpec.extend([SurfaceRefViewportWatcher]); } private _initHover() { const { setReference, setFloating, dispose } = whenHover( hovered => { const message$ = this.std.get(ToolbarRegistryIdentifier).message$; if (hovered) { message$.value = { flavour: this.model.flavour, element: this, setFloating, }; return; } // Clears previous bindings message$.value = null; setFloating(); }, { enterDelay: 500 } ); setReference(this); this._disposables.add(dispose); } private _renderRefContent(referencedModel: GfxModel) { const [, , w, h] = deserializeXYWH(referencedModel.xywh); const _previewSpec = this._previewSpec.value; return html`
${guard(this._previewDoc, () => { return this._previewDoc ? new BlockStdScope({ store: this._previewDoc, extensions: _previewSpec, }).render() : nothing; })}
`; } private _renderRefPlaceholder(model: SurfaceRefBlockModel) { return html`
${noContentPlaceholder}
No Such ${NO_CONTENT_TITLE[model.props.refFlavour ?? 'DEFAULT'] ?? NO_CONTENT_TITLE.DEFAULT}
${NO_CONTENT_REASON[model.props.refFlavour ?? 'DEFAULT'] ?? NO_CONTENT_REASON.DEFAULT}
`; } readonly open = ({ openMode, event, }: { openMode?: OpenDocMode; event?: MouseEvent; } = {}) => { const pageId = this.referenceModel?.surface?.doc.id; if (!pageId) return; this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({ pageId: pageId, params: { mode: 'edgeless', elementIds: [this.model.props.reference], }, openMode, event, host: this.host, }); }; override connectedCallback() { super.connectedCallback(); this.contentEditable = 'false'; if (!this._shouldRender) return; this._initHotkey(); this._initViewport(); this._initReferencedModel(); this._initSelection(); this._initHover(); } override render() { if (!this._shouldRender) return nothing; const { _referencedModel, model } = this; const isEmpty = !_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$.value) return; const viewport = { xywh: this._referenceXYWH$.value, padding: [60, 20, 20, 20] as [number, number, number, number], }; this.std.get(EditPropsStore).setStorage('viewport', viewport); this.std.get(DocModeProvider).setEditorMode('edgeless'); } @state() private accessor _focused: boolean = false; @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; } }