import { Peekable } from '@blocksuite/affine-components/peek'; import { type AliasInfo, type DocMode, type EmbedSyncedDocModel, NoteDisplayMode, type ReferenceInfo, } from '@blocksuite/affine-model'; import { type DocLinkClickedEvent, RefNodeSlotsProvider, } from '@blocksuite/affine-rich-text'; import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts'; import { DocDisplayMetaProvider, DocModeProvider, EditorSettingExtension, EditorSettingProvider, GeneralSettingSchema, ThemeExtensionIdentifier, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { cloneReferenceInfo, SpecProvider, } from '@blocksuite/affine-shared/utils'; import { BlockSelection, BlockStdScope, type EditorHost, LifeCycleWatcher, } from '@blocksuite/block-std'; import { GfxControllerIdentifier, GfxExtension, } from '@blocksuite/block-std/gfx'; import { Bound, getCommonBound } from '@blocksuite/global/gfx'; import { type GetBlocksOptions, type Query, Text } from '@blocksuite/store'; import { computed, signal } from '@preact/signals-core'; import { html, nothing, 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: 'hidden', }, ], }; protected _buildPreviewSpec = (name: 'preview:page' | 'preview:edgeless') => { const nextDepth = this.depth + 1; const previewSpecBuilder = SpecProvider._.getSpec(name); const currentDisposables = this.disposables; const editorSetting = this.std.getOptional(EditorSettingProvider) ?? signal(GeneralSettingSchema.parse({})); class EmbedSyncedDocWatcher extends LifeCycleWatcher { static override key = 'embed-synced-doc-watcher'; override mounted(): void { const { view } = this.std; view.viewUpdated.on(payload => { if ( payload.type !== 'block' || payload.view.model.flavour !== 'affine:embed-synced-doc' ) { return; } const nextComponent = payload.view as EmbedSyncedDocBlockComponent; if (payload.method === 'add') { nextComponent.depth = nextDepth; currentDisposables.add(() => { nextComponent.depth = 0; }); return; } if (payload.method === 'delete') { nextComponent.depth = 0; return; } }); } } class GfxViewportInitializer extends GfxExtension { static override key = 'gfx-viewport-initializer'; override mounted(): void { this.gfx.fitToScreen(); } } previewSpecBuilder.extend([ EmbedSyncedDocWatcher, GfxViewportInitializer, EditorSettingExtension(editorSetting), ]); return previewSpecBuilder.value; }; protected _renderSyncedView = () => { const syncedDoc = this.syncedDoc; const editorMode = this.editorMode; const isPageMode = this.isPageMode; if (!syncedDoc) { console.error('Synced doc is not found'); return html`${nothing}`; } 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; this.dataset.nestedEditor = ''; const renderEditor = () => { return choose(editorMode, [ [ 'page', () => html`
${new BlockStdScope({ store: syncedDoc, extensions: this._buildPreviewSpec('preview:page'), }).render()}
`, ], [ 'edgeless', () => html`
${new BlockStdScope({ store: syncedDoc, extensions: this._buildPreviewSpec('preview:edgeless'), }).render()}
`, ], ]); }; return this.renderEmbed( () => html`
${isPageMode && this._isEmptySyncedDoc ? html`
This is a linked doc, you can add content here.
` : guard( [editorMode, syncedDoc, appTheme, edgelessTheme], 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); if (!parent) { console.error( `Trying to convert synced doc to card, but the parent is not found.` ); return; } const index = parent.children.indexOf(this.model); const blockId = doc.addBlock( 'affine:embed-linked-doc', { caption, ...this.referenceInfo, ...aliasInfo }, parent, index ); doc.deleteBlock(this.model); this.std.selection.setGroup('note', [ this.std.selection.create(BlockSelection, { blockId }), ]); }; covertToInline = () => { const { doc } = this.model; const parent = doc.getParent(this.model); if (!parent) { console.error( `Trying to convert synced doc to inline, but the parent is not found.` ); return; } 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 = (event?: Partial) => { const pageId = this.model.pageId; if (pageId === this.doc.id) return; this.std .getOptional(RefNodeSlotsProvider) ?.docLinkClicked.emit({ ...event, pageId, host: this.host }); }; 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 }); }); 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.std.selection; const blockSelection = selectionManager.create(BlockSelection, { 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.linkedMode) { const docMode = this.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; }