diff --git a/blocksuite/affine/components/src/rich-text/all-extensions.ts b/blocksuite/affine/components/src/rich-text/all-extensions.ts
index deff7d00e..a0213db48 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 64a44e761..0f2a7aeef 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 04518ea78..fe957b063 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``;
+ },
+ 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 000000000..2bc4c7fd1
--- /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('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 000000000..865acd4cb
--- /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>(
+ `[${INLINE_ROOT_ATTR}]`
+ );
+ return inlineRoot?.inlineEditor;
+ }
+
+ get selfInlineRange() {
+ const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
+ return selfInlineRange;
+ }
+
+ private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
+ return html``;
+ };
+
+ private readonly _FootNotePopup = (
+ footnote: FootNote,
+ abortController: AbortController
+ ) => {
+ return this.customPopupRenderer
+ ? this.customPopupRenderer(footnote, this.std, abortController)
+ : html``;
+ };
+
+ 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``;
+ }
+
+ @property({ attribute: false })
+ accessor config: FootNoteNodeConfigProvider | undefined = undefined;
+
+ @property({ type: Object })
+ accessor delta: DeltaInsert = {
+ 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 000000000..66193f7c2
--- /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`
+
+ `;
+ }
+
+ @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 000000000..e929b6753
--- /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`
+
+ `;
+ }
+
+ @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 ece36cc15..fc1e91136 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 22a5c11d8..84b214421 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;
+
+/**
+ * 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;
diff --git a/blocksuite/affine/shared/src/types/index.ts b/blocksuite/affine/shared/src/types/index.ts
index aa33d3d81..3f6976562 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;
}