import { whenHover } from '@blocksuite/affine-components/hover'; import { Peekable } from '@blocksuite/affine-components/peek'; import type { ReferenceInfo } from '@blocksuite/affine-model'; import { DEFAULT_DOC_NAME, REFERENCE_NODE, } from '@blocksuite/affine-shared/consts'; import { DocDisplayMetaProvider, ToolbarRegistryIdentifier, } from '@blocksuite/affine-shared/services'; import { affineTextStyles } from '@blocksuite/affine-shared/styles'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import { cloneReferenceInfo, referenceToNode, } from '@blocksuite/affine-shared/utils'; import { WithDisposable } from '@blocksuite/global/lit'; import { LinkedPageIcon } from '@blocksuite/icons/lit'; import type { BlockComponent, BlockStdScope } from '@blocksuite/std'; import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/std'; import { INLINE_ROOT_ATTR, type InlineRootElement, ZERO_WIDTH_FOR_EMBED_NODE, ZERO_WIDTH_FOR_EMPTY_LINE, } from '@blocksuite/std/inline'; import type { DeltaInsert, DocMeta, Store } from '@blocksuite/store'; import { css, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import type { ReferenceNodeConfigProvider } from './reference-config'; import { RefNodeSlotsProvider } from './reference-node-slots'; import type { DocLinkClickedEvent } from './types'; @Peekable({ action: false }) export class AffineReference extends WithDisposable(ShadowlessElement) { static override styles = css` .affine-reference { white-space: normal; word-break: break-word; color: var(--affine-text-primary-color); fill: var(--affine-icon-color); border-radius: 4px; text-decoration: none; cursor: pointer; user-select: none; padding: 1px 2px 1px 0; svg { margin-bottom: 0.1em; } } .affine-reference:hover { background: var(--affine-hover-color); } .affine-reference[data-selected='true'] { background: var(--affine-hover-color); } .affine-reference-title { margin-left: 4px; border-bottom: 0.5px solid var(--affine-divider-color); transition: border 0.2s ease-out; } .affine-reference-title:hover { border-bottom: 0.5px solid var(--affine-icon-color); } `; get docTitle() { return this.refMeta?.title ?? DEFAULT_DOC_NAME; } private readonly _updateRefMeta = (doc: Store) => { const refAttribute = this.delta.attributes?.reference; if (!refAttribute) { return; } const refMeta = doc.workspace.meta.docMetas.find( doc => doc.id === refAttribute.pageId ); this.refMeta = refMeta ? { ...refMeta, } : undefined; }; // Since the linked doc may be deleted, the `_refMeta` could be undefined. @state() accessor refMeta: DocMeta | undefined = undefined; get _icon() { const { pageId, params, title } = this.referenceInfo; return this.std .get(DocDisplayMetaProvider) .icon(pageId, { params, title, referenced: true }).value; } get _title() { const { pageId, params, title } = this.referenceInfo; return ( this.std .get(DocDisplayMetaProvider) .title(pageId, { params, title, referenced: true }).value || title ); } get block() { if (!this.inlineEditor?.rootElement) return null; const block = this.inlineEditor.rootElement.closest( `[${BLOCK_ID_ATTR}]` ); return block; } get customContent() { return this.config.customContent; } get doc() { const doc = this.config.doc; return doc; } get inlineEditor() { const inlineRoot = this.closest>( `[${INLINE_ROOT_ATTR}]` ); return inlineRoot?.inlineEditor; } get referenceInfo(): ReferenceInfo { const reference = this.delta.attributes?.reference; const id = this.doc?.id ?? ''; if (!reference) return { pageId: id }; return cloneReferenceInfo(reference); } get selfInlineRange() { const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this); return selfInlineRange; } readonly open = (event?: Partial) => { if (!this.config.interactable) return; this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({ ...this.referenceInfo, ...event, openMode: event?.event?.button === 1 ? 'open-in-new-tab' : event?.openMode, host: this.std.host, }); }; _whenHover = whenHover( hovered => { if (!this.config.interactable) return; const message$ = this.std.get(ToolbarRegistryIdentifier).message$; if (hovered) { message$.value = { flavour: 'affine:reference', element: this, setFloating: this._whenHover.setFloating, }; return; } // Clears previous bindings message$.value = null; this._whenHover.setFloating(); }, { enterDelay: 500 } ); override connectedCallback() { super.connectedCallback(); this._whenHover.setReference(this); const message$ = this.std.get(ToolbarRegistryIdentifier).message$; this._disposables.add(() => { if (message$?.value) { message$.value = null; } this._whenHover.dispose(); }); if (!this.config) { console.error('`reference-node` need `ReferenceNodeConfig`.'); return; } if (this.delta.insert !== REFERENCE_NODE) { console.error( `Reference node must be initialized with '${REFERENCE_NODE}', but got '${this.delta.insert}'` ); } const doc = this.doc; if (doc) { this._disposables.add( doc.workspace.slots.docListUpdated.subscribe(() => this._updateRefMeta(doc) ) ); } this.updateComplete .then(() => { if (!this.inlineEditor || !doc) return; // observe yText update this.disposables.add( this.inlineEditor.slots.textChange.subscribe(() => this._updateRefMeta(doc) ) ); }) .catch(console.error); } // reference to block/element referenceToNode() { return referenceToNode(this.referenceInfo); } override render() { const refMeta = this.refMeta; const isDeleted = !refMeta; const attributes = this.delta.attributes; const reference = attributes?.reference; const type = reference?.type; if (!attributes || !type) { return nothing; } const title = this._title; const icon = choose(type, [ ['LinkedPage', () => this._icon], [ 'Subpage', () => LinkedPageIcon({ width: '1.25em', height: '1.25em', style: 'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;', }), ], ]); const style = affineTextStyles( attributes, isDeleted ? { color: 'var(--affine-text-disable-color)', textDecoration: 'line-through', fill: 'var(--affine-text-disable-color)', } : {} ); const content = this.customContent ? this.customContent(this) : html`${icon}${title}`; // we need to add `` in an // embed element to make sure inline range calculation is correct return html` this.open({ event })} @auxclick=${(event: MouseEvent) => this.open({ event })} >${content}`; } override willUpdate(_changedProperties: Map) { super.willUpdate(_changedProperties); const doc = this.doc; if (doc) { this._updateRefMeta(doc); } } @property({ attribute: false }) accessor config!: ReferenceNodeConfigProvider; @property({ type: Object }) accessor delta: DeltaInsert = { insert: ZERO_WIDTH_FOR_EMPTY_LINE, attributes: {}, }; @property({ type: Boolean }) accessor selected = false; @property({ attribute: false }) accessor std!: BlockStdScope; }