import { Peekable } from '@blocksuite/affine-components/peek'; import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text'; import { type AliasInfo, type DocMode, type EmbedSyncedDocModel, NoteDisplayMode, type ReferenceInfo, } from '@blocksuite/affine-model'; import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts'; import { DocDisplayMetaProvider, DocModeProvider, ThemeExtensionIdentifier, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { cloneReferenceInfo, SpecProvider, } from '@blocksuite/affine-shared/utils'; import { BlockServiceWatcher, BlockStdScope, type EditorHost, } from '@blocksuite/block-std'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import { assertExists, Bound, getCommonBound } from '@blocksuite/global/utils'; import { BlockViewType, type GetBlocksOptions, type Query, Text, } from '@blocksuite/store'; import { computed } from '@preact/signals-core'; import { html, type PropertyValues } from 'lit'; import { query, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; import * as Y from 'yjs'; import { EmbedBlockComponent } from '../common/embed-block-element.js'; import { isEmptyDoc } from '../common/render-linked-doc.js'; import type { EmbedSyncedDocCard } from './components/embed-synced-doc-card.js'; import { blockStyles } from './styles.js'; @Peekable({ enableOn: ({ doc }: EmbedSyncedDocBlockComponent) => !doc.readonly, }) export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent { static override styles = blockStyles; // Caches total bounds, includes all blocks and elements. private _cachedBounds: Bound | null = null; private readonly _initEdgelessFitEffect = () => { const fitToContent = () => { if (this.isPageMode) return; const controller = this.syncedDocEditorHost?.std.getOptional( GfxControllerIdentifier ); if (!controller) return; const viewport = controller.viewport; if (!viewport) return; if (!this._cachedBounds) { this._cachedBounds = getCommonBound([ ...controller.layer.blocks.map(block => Bound.deserialize(block.xywh) ), ...controller.layer.canvasElements, ]); } viewport.onResize(); const { centerX, centerY, zoom } = viewport.getFitToScreenData( this._cachedBounds ); viewport.setCenter(centerX, centerY); viewport.setZoom(zoom); }; const observer = new ResizeObserver(fitToContent); const block = this.embedBlock; observer.observe(block); this._disposables.add(() => { observer.disconnect(); }); this.syncedDocEditorHost?.updateComplete .then(() => fitToContent()) .catch(() => {}); }; private readonly _pageFilter: Query = { mode: 'loose', match: [ { flavour: 'affine:note', props: { displayMode: NoteDisplayMode.EdgelessOnly, }, viewType: BlockViewType.Hidden, }, ], }; protected _buildPreviewSpec = (name: 'page:preview' | 'edgeless:preview') => { const nextDepth = this.depth + 1; const previewSpecBuilder = SpecProvider.getInstance().getSpec(name); const currentDisposables = this.disposables; class EmbedSyncedDocWatcher extends BlockServiceWatcher { static override readonly flavour = 'affine:embed-synced-doc'; override mounted() { const disposableGroup = this.blockService.disposables; const slots = this.blockService.specSlots; disposableGroup.add( slots.viewConnected.on(({ component }) => { const nextComponent = component as EmbedSyncedDocBlockComponent; nextComponent.depth = nextDepth; currentDisposables.add(() => { nextComponent.depth = 0; }); }) ); disposableGroup.add( slots.viewDisconnected.on(({ component }) => { const nextComponent = component as EmbedSyncedDocBlockComponent; nextComponent.depth = 0; }) ); } } previewSpecBuilder.extend([EmbedSyncedDocWatcher]); return previewSpecBuilder.value; }; protected _renderSyncedView = () => { const syncedDoc = this.syncedDoc; const editorMode = this.editorMode; const isPageMode = this.isPageMode; assertExists(syncedDoc); if (isPageMode) { this.dataset.pageMode = ''; } const containerStyleMap = styleMap({ position: 'relative', width: '100%', }); const themeService = this.std.get(ThemeProvider); const themeExtension = this.std.getOptional(ThemeExtensionIdentifier); const appTheme = themeService.app$.value; let edgelessTheme = themeService.edgeless$.value; if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) { edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value; } const theme = isPageMode ? appTheme : edgelessTheme; const isSelected = !!this.selected?.is('block'); this.dataset.nestedEditor = ''; const renderEditor = () => { return choose(editorMode, [ [ 'page', () => html`
${new BlockStdScope({ doc: syncedDoc, extensions: this._buildPreviewSpec('page:preview'), }).render()}
`, ], [ 'edgeless', () => html`
${new BlockStdScope({ doc: syncedDoc, extensions: this._buildPreviewSpec('edgeless:preview'), }).render()}
`, ], ]); }; return this.renderEmbed( () => html`
${isPageMode && this._isEmptySyncedDoc ? html`
This is a linked doc, you can add content here.
` : guard([editorMode, syncedDoc], renderEditor)}
${this.icon$.value} ${this.title$}
` ); }; protected cardStyleMap = styleMap({ position: 'relative', display: 'block', width: '100%', }); convertToCard = (aliasInfo?: AliasInfo) => { const { doc, caption } = this.model; const parent = doc.getParent(this.model); assertExists(parent); const index = parent.children.indexOf(this.model); doc.addBlock( 'affine:embed-linked-doc', { caption, ...this.referenceInfo, ...aliasInfo }, parent, index ); this.std.selection.setGroup('note', []); doc.deleteBlock(this.model); }; covertToInline = () => { const { doc } = this.model; const parent = doc.getParent(this.model); assertExists(parent); const index = parent.children.indexOf(this.model); const yText = new Y.Text(); yText.insert(0, REFERENCE_NODE); yText.format(0, REFERENCE_NODE.length, { reference: { type: 'LinkedPage', ...this.referenceInfo, }, }); const text = new Text(yText); doc.addBlock( 'affine:paragraph', { text, }, parent, index ); doc.deleteBlock(this.model); }; protected override embedContainerStyle: StyleInfo = { height: 'unset', }; icon$ = computed(() => { const { pageId, params } = this.model; return this.std .get(DocDisplayMetaProvider) .icon(pageId, { params, referenced: true }).value; }); open = () => { const pageId = this.model.pageId; if (pageId === this.doc.id) return; this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({ pageId }); }; refreshData = () => { this._load().catch(e => { console.error(e); this._error = true; }); }; title$ = computed(() => { const { pageId, params } = this.model; return this.std .get(DocDisplayMetaProvider) .title(pageId, { params, referenced: true }); }); private get _rootService() { return this.std.getService('affine:page'); } get blockState() { return { isLoading: this._loading, isError: this._error, isDeleted: this._deleted, isCycle: this._cycle, }; } get docTitle() { return this.syncedDoc?.meta?.title || 'Untitled'; } get docUpdatedAt() { return this._docUpdatedAt; } get editorMode() { return this.linkedMode ?? this.syncedDocMode; } protected get isPageMode() { return this.editorMode === 'page'; } get linkedMode() { return this.referenceInfo.params?.mode; } get referenceInfo(): ReferenceInfo { return cloneReferenceInfo(this.model); } get syncedDoc() { const options: GetBlocksOptions = { readonly: true }; if (this.isPageMode) options.query = this._pageFilter; return this.std.workspace.getDoc(this.model.pageId, options); } private _checkCycle() { let editorHost: EditorHost | null = this.host; while (editorHost && !this._cycle) { this._cycle = !!editorHost && editorHost.doc.id === this.model.pageId; editorHost = editorHost.parentElement?.closest('editor-host') ?? null; } } private _isClickAtBorder( event: MouseEvent, element: HTMLElement, tolerance = 8 ): boolean { const { x, y } = event; const rect = element.getBoundingClientRect(); if (!rect) { return false; } return ( Math.abs(x - rect.left) < tolerance || Math.abs(x - rect.right) < tolerance || Math.abs(y - rect.top) < tolerance || Math.abs(y - rect.bottom) < tolerance ); } private async _load() { this._loading = true; this._error = false; this._deleted = false; this._cycle = false; const syncedDoc = this.syncedDoc; if (!syncedDoc) { this._deleted = true; this._loading = false; return; } this._checkCycle(); if (!syncedDoc.loaded) { try { syncedDoc.load(); } catch (e) { console.error(e); this._error = true; } } if (!this._error && !syncedDoc.root) { await new Promise(resolve => { syncedDoc.slots.rootAdded.once(() => resolve()); }); } this._loading = false; } private _selectBlock() { const selectionManager = this.host.selection; const blockSelection = selectionManager.create('block', { blockId: this.blockId, }); selectionManager.setGroup('note', [blockSelection]); } private _setDocUpdatedAt() { const meta = this.doc.workspace.meta.getDocMeta(this.model.pageId); if (meta) { const date = meta.updatedDate || meta.createDate; this._docUpdatedAt = new Date(date); } } protected _handleClick(_event: MouseEvent) { this._selectBlock(); } override connectedCallback() { super.connectedCallback(); this._cardStyle = this.model.style; this.style.display = 'block'; this._load().catch(e => { console.error(e); this._error = true; }); this.contentEditable = 'false'; this.disposables.add( this.model.propsUpdated.on(({ key }) => { if (key === 'pageId' || key === 'style') { this._load().catch(e => { console.error(e); this._error = true; }); } }) ); this._setDocUpdatedAt(); this.disposables.add( this.doc.workspace.slots.docListUpdated.on(() => { this._setDocUpdatedAt(); }) ); if (this._rootService && !this.linkedMode) { const docMode = this._rootService.std.get(DocModeProvider); this.syncedDocMode = docMode.getPrimaryMode(this.model.pageId); this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode); this.disposables.add( docMode.onPrimaryModeChange(mode => { this.syncedDocMode = mode; this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode); }, this.model.pageId) ); } this.syncedDoc && this.disposables.add( this.syncedDoc.slots.blockUpdated.on(() => { this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode); }) ); } override firstUpdated() { this.disposables.addFromEvent(this, 'click', e => { e.stopPropagation(); if (this._isClickAtBorder(e, this)) { e.preventDefault(); this._selectBlock(); } }); this._initEdgelessFitEffect(); } override renderBlock() { delete this.dataset.nestedEditor; const syncedDoc = this.syncedDoc; const { isLoading, isError, isDeleted, isCycle } = this.blockState; const isCardOnly = this.depth >= 1; if ( isLoading || isError || isDeleted || isCardOnly || isCycle || !syncedDoc ) { return this.renderEmbed( () => html` ` ); } return this._renderSyncedView(); } override updated(changedProperties: PropertyValues) { super.updated(changedProperties); this.syncedDocCard?.requestUpdate(); } @state() private accessor _cycle = false; @state() private accessor _deleted = false; @state() private accessor _docUpdatedAt: Date = new Date(); @state() private accessor _error = false; @state() protected accessor _isEmptySyncedDoc: boolean = true; @state() private accessor _loading = false; @state() accessor depth = 0; @query( ':scope > .affine-block-component > .embed-block-container > affine-embed-synced-doc-card' ) accessor syncedDocCard: EmbedSyncedDocCard | null = null; @query( ':scope > .affine-block-component > .embed-block-container > .affine-embed-synced-doc-container > .affine-embed-synced-doc-editor > div > editor-host' ) accessor syncedDocEditorHost: EditorHost | null = null; @state() accessor syncedDocMode: DocMode = 'page'; override accessor useCaptionEditor = true; }