diff --git a/blocksuite/affine/components/src/rich-text/all-extensions.ts b/blocksuite/affine/components/src/rich-text/all-extensions.ts index deff7d00ee519..a0213db48630d 100644 --- a/blocksuite/affine/components/src/rich-text/all-extensions.ts +++ b/blocksuite/affine/components/src/rich-text/all-extensions.ts @@ -6,6 +6,7 @@ import { BoldInlineSpecExtension, CodeInlineSpecExtension, ColorInlineSpecExtension, + FootNoteInlineSpecExtension, InlineAdapterExtensions, InlineSpecExtensions, ItalicInlineSpecExtension, @@ -31,6 +32,7 @@ export const DefaultInlineManagerExtension = InlineManagerExtension({ LatexInlineSpecExtension.identifier, ReferenceInlineSpecExtension.identifier, LinkInlineSpecExtension.identifier, + FootNoteInlineSpecExtension.identifier, ], }); diff --git a/blocksuite/affine/components/src/rich-text/effects.ts b/blocksuite/affine/components/src/rich-text/effects.ts index 64a44e761da09..0f2a7aeefb385 100644 --- a/blocksuite/affine/components/src/rich-text/effects.ts +++ b/blocksuite/affine/components/src/rich-text/effects.ts @@ -16,8 +16,14 @@ import type { toggleTextStyleCommand, toggleUnderline, } from './format/text-style.js'; -import { AffineLink, AffineReference } from './inline/index.js'; +import { + AffineFootnoteNode, + AffineLink, + AffineReference, +} from './inline/index.js'; import { AffineText } from './inline/presets/nodes/affine-text.js'; +import { FootNotePopup } from './inline/presets/nodes/footnote-node/footnote-popup.js'; +import { FootNotePopupChip } from './inline/presets/nodes/footnote-node/footnote-popup-chip.js'; import { LatexEditorMenu } from './inline/presets/nodes/latex-node/latex-editor-menu.js'; import { LatexEditorUnit } from './inline/presets/nodes/latex-node/latex-editor-unit.js'; import { AffineLatexNode } from './inline/presets/nodes/latex-node/latex-node.js'; @@ -37,12 +43,18 @@ export function effects() { customElements.define('reference-popup', ReferencePopup); customElements.define('reference-alias-popup', ReferenceAliasPopup); customElements.define('affine-reference', AffineReference); + customElements.define('affine-footnote-node', AffineFootnoteNode); + customElements.define('footnote-popup', FootNotePopup); + customElements.define('footnote-popup-chip', FootNotePopupChip); } declare global { interface HTMLElementTagNameMap { 'affine-latex-node': AffineLatexNode; 'affine-reference': AffineReference; + 'affine-footnote-node': AffineFootnoteNode; + 'footnote-popup': FootNotePopup; + 'footnote-popup-chip': FootNotePopupChip; 'affine-link': AffineLink; 'affine-text': AffineText; 'rich-text': RichText; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts b/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts index 04518ea7853f0..fe957b0638b0f 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts @@ -1,4 +1,4 @@ -import { ReferenceInfoSchema } from '@blocksuite/affine-model'; +import { FootNoteSchema, ReferenceInfoSchema } from '@blocksuite/affine-model'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import { StdIdentifier } from '@blocksuite/block-std'; import type { InlineEditor, InlineRootElement } from '@blocksuite/inline'; @@ -6,6 +6,7 @@ import { html } from 'lit'; import { z } from 'zod'; import { InlineSpecExtension } from '../../extension/index.js'; +import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js'; import { ReferenceNodeConfigIdentifier, ReferenceNodeConfigProvider, @@ -178,6 +179,30 @@ export const LatexEditorUnitSpecExtension = InlineSpecExtension({ }, }); +export const FootNoteInlineSpecExtension = InlineSpecExtension( + 'footnote', + provider => { + const std = provider.get(StdIdentifier); + const config = + provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined; + return { + name: 'footnote', + schema: FootNoteSchema.optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.footnote; + }, + renderer: ({ delta }) => { + return html`<affine-footnote-node + .delta=${delta} + .std=${std} + .config=${config} + ></affine-footnote-node>`; + }, + embed: true, + }; + } +); + export const InlineSpecExtensions = [ BoldInlineSpecExtension, ItalicInlineSpecExtension, @@ -190,4 +215,5 @@ export const InlineSpecExtensions = [ ReferenceInlineSpecExtension, LinkInlineSpecExtension, LatexEditorUnitSpecExtension, + FootNoteInlineSpecExtension, ]; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts new file mode 100644 index 0000000000000..2bc4c7fd11f2c --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts @@ -0,0 +1,93 @@ +import type { FootNote } from '@blocksuite/affine-model'; +import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { ExtensionType } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +type FootNoteNodeRenderer = ( + footnote: FootNote, + std: BlockStdScope +) => TemplateResult<1>; + +type FootNotePopupRenderer = ( + footnote: FootNote, + std: BlockStdScope, + abortController: AbortController +) => TemplateResult<1>; + +export interface FootNoteNodeConfig { + customNodeRenderer?: FootNoteNodeRenderer; + customPopupRenderer?: FootNotePopupRenderer; + interactive?: boolean; + hidePopup?: boolean; +} + +export class FootNoteNodeConfigProvider { + private _customNodeRenderer?: FootNoteNodeRenderer; + private _customPopupRenderer?: FootNotePopupRenderer; + private _hidePopup: boolean; + private _interactive: boolean; + + get customNodeRenderer() { + return this._customNodeRenderer; + } + + get customPopupRenderer() { + return this._customPopupRenderer; + } + + get doc() { + return this.std.store; + } + + get hidePopup() { + return this._hidePopup; + } + + get interactive() { + return this._interactive; + } + + constructor( + config: FootNoteNodeConfig, + readonly std: BlockStdScope + ) { + this._customNodeRenderer = config.customNodeRenderer; + this._customPopupRenderer = config.customPopupRenderer; + this._hidePopup = config.hidePopup ?? false; + this._interactive = config.interactive ?? true; + } + + setCustomNodeRenderer(renderer: FootNoteNodeRenderer) { + this._customNodeRenderer = renderer; + } + + setCustomPopupRenderer(renderer: FootNotePopupRenderer) { + this._customPopupRenderer = renderer; + } + + setHidePopup(hidePopup: boolean) { + this._hidePopup = hidePopup; + } + + setInteractive(interactive: boolean) { + this._interactive = interactive; + } +} + +export const FootNoteNodeConfigIdentifier = + createIdentifier<FootNoteNodeConfigProvider>('AffineFootNoteNodeConfig'); + +export function FootNoteNodeConfigExtension( + config: FootNoteNodeConfig +): ExtensionType { + return { + setup: di => { + di.addImpl( + FootNoteNodeConfigIdentifier, + provider => + new FootNoteNodeConfigProvider(config, provider.get(StdIdentifier)) + ); + }, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts new file mode 100644 index 0000000000000..865acd4cba523 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts @@ -0,0 +1,167 @@ +import type { FootNote } from '@blocksuite/affine-model'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + BlockSelection, + type BlockStdScope, + ShadowlessElement, + TextSelection, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { + type DeltaInsert, + INLINE_ROOT_ATTR, + type InlineRootElement, + ZERO_WIDTH_NON_JOINER, + ZERO_WIDTH_SPACE, +} from '@blocksuite/inline'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ref } from 'lit-html/directives/ref.js'; + +import { HoverController } from '../../../../../hover/controller'; +import type { FootNoteNodeConfigProvider } from './footnote-config'; + +export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { + static override styles = css` + .footnote-node { + padding: 0 2px; + user-select: none; + cursor: pointer; + } + + .footnote-content-default { + display: inline-block; + background: ${unsafeCSSVarV2('button/primary')}; + color: ${unsafeCSSVarV2('button/pureWhiteText')}; + width: 14px; + height: 14px; + line-height: 14px; + font-size: 10px; + font-weight: 400; + border-radius: 50%; + text-align: center; + text-overflow: ellipsis; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + `; + + get customNodeRenderer() { + return this.config?.customNodeRenderer; + } + + get customPopupRenderer() { + return this.config?.customPopupRenderer; + } + + get interactive() { + return this.config?.interactive; + } + + get hidePopup() { + return this.config?.hidePopup; + } + + get inlineEditor() { + const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>( + `[${INLINE_ROOT_ATTR}]` + ); + return inlineRoot?.inlineEditor; + } + + get selfInlineRange() { + const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this); + return selfInlineRange; + } + + private readonly _FootNoteDefaultContent = (footnote: FootNote) => { + return html`<span class="footnote-content-default" + >${footnote.label}</span + >`; + }; + + private readonly _FootNotePopup = ( + footnote: FootNote, + abortController: AbortController + ) => { + return this.customPopupRenderer + ? this.customPopupRenderer(footnote, this.std, abortController) + : html`<footnote-popup + .footnote=${footnote} + .std=${this.std} + .abortController=${abortController} + ></footnote-popup>`; + }; + + private readonly _whenHover: HoverController = new HoverController( + this, + ({ abortController }) => { + const footnote = this.delta.attributes?.footnote; + if (!footnote) return null; + + if ( + this.config?.hidePopup || + !this.selfInlineRange || + !this.inlineEditor + ) { + return null; + } + + const selection = this.std?.selection; + if (!selection) { + return null; + } + const textSelection = selection.find(TextSelection); + if (!!textSelection && !textSelection.isCollapsed()) { + return null; + } + + const blockSelections = selection.filter(BlockSelection); + if (blockSelections.length) { + return null; + } + + return { + template: this._FootNotePopup(footnote, abortController), + container: this, + computePosition: { + referenceElement: this, + placement: 'top', + autoUpdate: true, + }, + }; + }, + { enterDelay: 500 } + ); + + override render() { + const attributes = this.delta.attributes; + const footnote = attributes?.footnote; + if (!footnote) { + return nothing; + } + + const node = this.customNodeRenderer + ? this.customNodeRenderer(footnote, this.std) + : this._FootNoteDefaultContent(footnote); + + return html`<span + ${this.hidePopup ? '' : ref(this._whenHover.setReference)} + class="footnote-node" + >${node}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text + ></span>`; + } + + @property({ attribute: false }) + accessor config: FootNoteNodeConfigProvider | undefined = undefined; + + @property({ type: Object }) + accessor delta: DeltaInsert<AffineTextAttributes> = { + insert: ZERO_WIDTH_SPACE, + attributes: {}, + }; + + @property({ attribute: false }) + accessor std!: BlockStdScope; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup-chip.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup-chip.ts new file mode 100644 index 0000000000000..66193f7c27fa2 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup-chip.ts @@ -0,0 +1,89 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class FootNotePopupChip extends LitElement { + static override styles = css` + .popup-chip-container { + display: flex; + border-radius: 4px; + max-width: 173px; + height: 24px; + padding: 2px 4px; + align-items: center; + gap: 4px; + box-sizing: border-box; + cursor: default; + } + + .prefix-icon, + .suffix-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + border-radius: 4px; + + svg { + width: 16px; + height: 16px; + } + } + + .suffix-icon:hover { + background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + cursor: pointer; + } + + .popup-chip-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + height: 20px; + line-height: 20px; + color: ${unsafeCSSVarV2('text/primary')}; + font-size: 12px; + font-weight: 400; + } + `; + + override render() { + return html` + <div class="popup-chip-container" @click=${this.onClick}> + ${this.prefixIcon + ? html`<div class="prefix-icon" @click=${this.onPrefixClick}> + ${this.prefixIcon} + </div>` + : nothing} + <div class="popup-chip-label">${this.label}</div> + ${this.suffixIcon + ? html`<div class="suffix-icon" @click=${this.onSuffixClick}> + ${this.suffixIcon} + </div>` + : nothing} + </div> + `; + } + + @property({ attribute: false }) + accessor prefixIcon: TemplateResult | undefined = undefined; + + @property({ attribute: false }) + accessor label: string = ''; + + @property({ attribute: false }) + accessor suffixIcon: TemplateResult | undefined = undefined; + + @property({ attribute: false }) + accessor onClick: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onPrefixClick: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onSuffixClick: (() => void) | undefined = undefined; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts new file mode 100644 index 0000000000000..e929b67531c44 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts @@ -0,0 +1,126 @@ +import type { FootNote } from '@blocksuite/affine-model'; +import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services'; +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { DualLinkIcon, LinkIcon } from '@blocksuite/icons/lit'; +import { css, html, LitElement, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { getAttachmentFileIcons } from '../../../../../icons'; +import { RefNodeSlotsProvider } from '../../../../extension/ref-node-slots'; + +export class FootNotePopup extends WithDisposable(LitElement) { + static override styles = css` + .footnote-popup-container { + border-radius: 4px; + box-shadow: ${unsafeCSSVar('overlayPanelShadow')}; + border-radius: 4px; + background-color: ${unsafeCSSVarV2('layer/background/primary')}; + border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + } + `; + + private readonly _prefixIcon = () => { + const referenceType = this.footnote.reference.type; + if (referenceType === 'doc') { + const docId = this.footnote.reference.docId; + if (!docId) { + return undefined; + } + return this.std.get(DocDisplayMetaProvider).icon(docId).value; + } else if (referenceType === 'attachment') { + const fileType = this.footnote.reference.fileType; + if (!fileType) { + return undefined; + } + return getAttachmentFileIcons(fileType); + } + return undefined; + }; + + private readonly _suffixIcon = (): TemplateResult | undefined => { + const referenceType = this.footnote.reference.type; + if (referenceType === 'doc') { + return DualLinkIcon({ width: '16px', height: '16px' }); + } else if (referenceType === 'url') { + return LinkIcon({ width: '16px', height: '16px' }); + } + return undefined; + }; + + private readonly _popupLabel = () => { + const referenceType = this.footnote.reference.type; + let label = ''; + const { docId, fileName, url } = this.footnote.reference; + switch (referenceType) { + case 'doc': + if (!docId) { + return label; + } + label = this.std.get(DocDisplayMetaProvider).title(docId).value; + break; + case 'attachment': + if (!fileName) { + return label; + } + label = fileName; + break; + case 'url': + if (!url) { + return label; + } + // TODO(@chen): get url title from url, need to implement after LinkPreviewer refactored as an extension + label = url; + break; + } + return label; + }; + + /** + * When clicking the chip, we will navigate to the reference doc or open the url + */ + private readonly _onChipClick = () => { + const referenceType = this.footnote.reference.type; + const { docId, url } = this.footnote.reference; + switch (referenceType) { + case 'doc': + if (!docId) { + break; + } + this.std + .getOptional(RefNodeSlotsProvider) + ?.docLinkClicked.emit({ pageId: docId }); + break; + case 'url': + if (!url) { + break; + } + window.open(url, '_blank'); + break; + } + this.abortController.abort(); + }; + + override render() { + return html` + <div class="footnote-popup-container"> + <footnote-popup-chip + .prefixIcon=${this._prefixIcon()} + .label=${this._popupLabel()} + .suffixIcon=${this._suffixIcon()} + .onClick=${this._onChipClick} + ></footnote-popup-chip> + </div> + `; + } + + @property({ attribute: false }) + accessor footnote!: FootNote; + + @property({ attribute: false }) + accessor std!: BlockStdScope; + + @property({ attribute: false }) + accessor abortController!: AbortController; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts index ece36cc15c8b3..fc1e91136ada9 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts @@ -1,3 +1,5 @@ +export * from './footnote-node/footnote-config.js'; +export { AffineFootnoteNode } from './footnote-node/footnote-node.js'; export { AffineLink, toggleLinkPopup } from './link-node/index.js'; export * from './reference-node/reference-config.js'; export { AffineReference } from './reference-node/reference-node.js'; diff --git a/blocksuite/affine/model/src/consts/doc.ts b/blocksuite/affine/model/src/consts/doc.ts index 22a5c11d899c5..84b2144217f2f 100644 --- a/blocksuite/affine/model/src/consts/doc.ts +++ b/blocksuite/affine/model/src/consts/doc.ts @@ -4,6 +4,10 @@ export type DocMode = 'edgeless' | 'page'; export const DocModes = ['edgeless', 'page'] as const; +export type FootNoteReferenceType = 'doc' | 'attachment' | 'url'; + +export const FootNoteReferenceTypes = ['doc', 'attachment', 'url'] as const; + /** * Custom title and description information. * @@ -42,3 +46,32 @@ export const ReferenceInfoSchema = z .merge(AliasInfoSchema); export type ReferenceInfo = z.infer<typeof ReferenceInfoSchema>; + +/** + * FootNoteReferenceParamsSchema is used to define the parameters for a footnote reference. + * It supports the following types: + * 1. docId: string - the id of the doc + * 2. blobId: string - the id of the attachment + * 3. url: string - the url of the reference + * 4. fileName: string - the name of the attachment + * 5. fileType: string - the type of the attachment + */ +export const FootNoteReferenceParamsSchema = z.object({ + type: z.enum(FootNoteReferenceTypes), + docId: z.string().optional(), + blobId: z.string().optional(), + fileName: z.string().optional(), + fileType: z.string().optional(), + url: z.string().optional(), +}); + +export type FootNoteReferenceParams = z.infer< + typeof FootNoteReferenceParamsSchema +>; + +export const FootNoteSchema = z.object({ + label: z.string(), + reference: FootNoteReferenceParamsSchema, +}); + +export type FootNote = z.infer<typeof FootNoteSchema>; diff --git a/blocksuite/affine/shared/src/types/index.ts b/blocksuite/affine/shared/src/types/index.ts index aa33d3d815965..3f69765626fcc 100644 --- a/blocksuite/affine/shared/src/types/index.ts +++ b/blocksuite/affine/shared/src/types/index.ts @@ -1,4 +1,8 @@ -import type { EmbedCardStyle, ReferenceInfo } from '@blocksuite/affine-model'; +import type { + EmbedCardStyle, + FootNote, + ReferenceInfo, +} from '@blocksuite/affine-model'; import type { BlockComponent } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; @@ -70,4 +74,5 @@ export interface AffineTextAttributes { background?: string | null; color?: string | null; latex?: string | null; + footnote?: FootNote | null; }