import './surface-ref-portal.js'; import { PathFinder } from '@blockeditor/block-std'; import { assertExists, type Disposable, noop } from '@blockeditor/global/utils'; import { BlockElement } from '@blockeditor/lit'; import { css, html, nothing, type TemplateResult } from 'lit'; import { customElement, query, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { EdgelessModeIcon, FrameIcon, MoreDeleteIcon, } from '../_common/icons/index.js'; import { requestConnectedFrame, stopPropagation, } from '../_common/utils/event.js'; import { buildPath, getEditorContainer } from '../_common/utils/query.js'; import type { PageService } from '../index.js'; import type { FrameBlockModel, SurfaceBlockModel } from '../models.js'; import { getBackgroundGrid } from '../page-block/edgeless/utils/query.js'; import type { Renderer } from '../surface-block/canvas-renderer/renderer.js'; import type { ElementModel } from '../surface-block/element-model/base.js'; import { Bound } from '../surface-block/utils/bound.js'; import { deserializeXYWH } from '../surface-block/utils/xywh.js'; import type { SurfaceRefBlockModel } from './surface-ref-model.js'; import { SurfaceRefPortal } from './surface-ref-portal.js'; import type { SurfaceRefRenderer } from './surface-ref-renderer.js'; import type { SurfaceRefBlockService } from './surface-ref-service.js'; import { noContentPlaceholder } from './utils.js'; noop(SurfaceRefPortal); const REF_LABEL_ICON = { 'affine:frame': FrameIcon, DEFAULT_NOTE_HEIGHT: EdgelessModeIcon, } 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; type RefElement = ElementModel | FrameBlockModel; @customElement('affine-surface-ref') export class SurfaceRefBlockComponent extends BlockElement< SurfaceRefBlockModel, SurfaceRefBlockService > { static override styles = css` .affine-surface-ref { position: relative; user-select: none; margin: 10px 0; } .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); } .ref-canvas-container { height: 100%; width: 100%; position: relative; } .surface-ref-mask { position: absolute; left: 0; top: 0; width: 100%; height: 100%; } .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; } .surface-ref-caption { margin-top: 10px; text-align: center; } .caption-input { border: 0; outline: none; width: 100%; display: block; text-align: center; font-size: var(--affine-font-sm); color: var(--affine-icon-color); background-color: transparent; } .caption-input::placeholder { color: var(--affine-placeholder-color); } `; @state() private _surfaceModel: SurfaceBlockModel | null = null; @state() private _focused: boolean = false; @state() private _caption: string = ''; @state() private _showCaption: boolean = false; private _surfaceRefRenderer!: SurfaceRefRenderer; private _referencedModel: RefElement | null = null; @query('.ref-canvas-container') container!: HTMLDivElement; @query('surface-ref-portal') portal!: SurfaceRefPortal; private get _shouldRender() { return ( this.isConnected && this.parentElement && !this.parentBlockElement.closest('affine-surface-ref') ); } get surfaceRenderer() { return this._surfaceRefRenderer.surfaceRenderer; } get referenceModel() { return this._referencedModel; } override connectedCallback() { super.connectedCallback(); if (!this._shouldRender) return; const service = this.service; assertExists(service, `Surface ref block must run with its service.`); this._surfaceRefRenderer = service.getRenderer( PathFinder.id(this.path), this.page, true ); this._disposables.add( this._surfaceRefRenderer.slots.surfaceModelChanged.on(model => { this._surfaceModel = model; }) ); this._disposables.add( this._surfaceRefRenderer.slots.surfaceRendererRefresh.on(() => { this.requestUpdate(); }) ); this._disposables.add( this._surfaceRefRenderer.slots.surfaceRendererInit.on(() => { let lastWidth = 0; const observer = new ResizeObserver(entries => { if (entries[0].contentRect.width !== lastWidth) { lastWidth = entries[0].contentRect.width; this._refreshViewport(); } }); observer.observe(this); this._disposables.add(() => observer.disconnect()); }) ); this._disposables.add( this._surfaceRefRenderer.surfaceService.layer.slots.layerUpdated.on( () => { this.portal.setStackingCanvas( this._surfaceRefRenderer.surfaceRenderer.stackingCanvas ); } ) ); this._surfaceRefRenderer.mount(); this._initHotkey(); this._initReferencedModel(); this._initSelection(); } override disconnectedCallback() { if (!this._shouldRender) return; this.service?.removeRenderer(this._surfaceRefRenderer.id); super.disconnectedCallback(); } override updated() { if (!this._shouldRender) return; this._attachRenderer(); } private _attachRenderer() { if ( this._surfaceRefRenderer?.surfaceRenderer.canvas.isConnected || !this.container || !this.portal ) return; this.surfaceRenderer.attach(this.container); if (this.portal.isUpdatePending) { this.portal.updateComplete .then(() => { this.portal.setStackingCanvas( this._surfaceRefRenderer.surfaceRenderer.stackingCanvas ); }) .catch(console.error); } else { this.portal.setStackingCanvas( this._surfaceRefRenderer.surfaceRenderer.stackingCanvas ); } } private _initHotkey() { const selection = this.host.selection; const addParagraph = () => { if (!this.page.getParent(this.model)) return; const [paragraphId] = this.page.addSiblingBlocks(this.model, [ { flavour: 'affine:paragraph', }, ]); const path = buildPath(this.page.getBlockById(paragraphId)); requestConnectedFrame(() => { selection.update(selList => { return selList .filter(sel => !sel.is('block')) .concat( selection.create('text', { from: { path, index: 0, length: 0, }, to: null, }) ); }); }, this); }; this.bindHotKey({ Enter: () => { if (!this._focused) return; addParagraph(); return true; }, }); } private _initReferencedModel() { let refWathcer: Disposable | null = null; const init = () => { refWathcer?.dispose(); const referencedModel = this._surfaceRefRenderer.getModel( this.model.reference ) as RefElement; this._referencedModel = referencedModel && 'xywh' in referencedModel ? referencedModel : null; if (!referencedModel) return; if ('propsUpdated' in referencedModel) { refWathcer = referencedModel.propsUpdated.on(() => { if (referencedModel.flavour !== this.model.refFlavour) { this.page.updateBlock(this.model, { refFlavour: referencedModel.flavour, }); } this.updateComplete .then(() => { this._refreshViewport(); }) .catch(console.error); }); } this._refreshViewport(); }; init(); this._disposables.add(() => { this.page.slots.blockUpdated.on(({ type, id }) => { if (type === 'delete' && id === this.model.reference) { init(); } }); }); this._disposables.add(() => { this.model.propsUpdated.on(() => { this._caption = this.model.caption ?? ''; if (this.model.reference !== this._referencedModel?.id) { init(); } }); }); this._disposables.add(() => { refWathcer?.dispose(); }); } private _initSelection() { const selection = this.host.selection; this._disposables.add( selection.slots.changed.on(selList => { this._focused = selList.some( sel => PathFinder.equals(sel.path, this.path) && sel.is('block') ); }) ); } private _refreshViewport() { if (!this._referencedModel) { return; } const referencedModel = this._referencedModel; // trigger a rerender to update element's size // and set viewport after element's size has been updated this.requestUpdate(); this.updateComplete .then(() => { this.surfaceRenderer.onResize(); this.surfaceRenderer.setViewportByBound( Bound.fromXYWH(deserializeXYWH(referencedModel.xywh)) ); // update portal transform this.portal?.setViewport(this.surfaceRenderer); }) .catch(console.error); } private _deleteThis() { this.page.deleteBlock(this.model); } private _focusBlock() { this.selection.update(() => { return [this.selection.create('block', { path: this.path })]; }); } private _updateCaption() { this.page.updateBlock(this.model, { caption: this._caption, }); } private _onCaptionKeydown(e: KeyboardEvent) { if (e.isComposing) return; if (e.key === 'Enter') { e.stopPropagation(); this._updateCaption(); } } private _onCaptionBlur() { this._updateCaption(); if (!this._caption.length && this._showCaption) { this._showCaption = false; } } private _renderCaption() { if (!this._caption && !this._showCaption) return nothing; return html`
(this._caption = (e.target as HTMLInputElement).value)} @keydown=${this._onCaptionKeydown} @blur=${this._onCaptionBlur} @pointerdown=${stopPropagation} @click=${stopPropagation} @paste=${stopPropagation} @cut=${stopPropagation} @copy=${stopPropagation} />
`; } showCaption() { this._showCaption = true; this.updateComplete .then(() => { ( this.renderRoot.querySelector('.caption-input') as HTMLInputElement )?.focus(); }) .catch(console.error); } viewInEdgeless() { if (!this._referencedModel) return; const editorContainer = getEditorContainer(this.host); if (editorContainer.mode !== 'edgeless') { editorContainer.mode = 'edgeless'; const viewport = { xywh: '', // FIXME referenceId: this.model.reference, padding: [60, 20, 20, 20] as [number, number, number, number], }; (( this.std.spec.getService('affine:page') )).editSession.setItem('viewport', viewport); } this.selection.update(selections => { return selections.filter(sel => !PathFinder.equals(sel.path, this.path)); }); } private _renderMask(referencedModel: RefElement, 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 _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}
`; } private _renderRefContent(referencedModel: RefElement, renderer: Renderer) { const [, , w, h] = deserializeXYWH(referencedModel.xywh); const { zoom } = renderer; const { gap } = getBackgroundGrid(zoom, true); const flavourOrType = 'flavour' in referencedModel ? referencedModel.flavour : referencedModel.type; const edgelessBlocks = flavourOrType === 'affine:frame' || flavourOrType === 'group' ? html`` : nothing; return html`
${edgelessBlocks}
${this._renderMask(referencedModel, flavourOrType)}
`; } override render() { if (!this._shouldRender) return; const { _surfaceModel, _referencedModel, surfaceRenderer, model } = this; const isEmpty = !_surfaceModel || !_referencedModel || !_referencedModel.xywh; const content = isEmpty ? this._renderRefPlaceholder(model) : this._renderRefContent(_referencedModel, surfaceRenderer); return html`
${content} ${this._renderCaption()}
${Object.values(this.widgets)} `; } } declare global { interface HTMLElementTagNameMap { 'affine-surface-ref': SurfaceRefBlockComponent; } }