feat(editor): footnotes (reference + definitions model)

Adds footnotes: a superscript marker in the text linked to an editable
definition in a Footnotes section at the end of the page, with auto-numbering
and a read-only hover popover. Chose the reference+definitions model (3 plain
nodes) over an inline atom with a sub-editor specifically for collaboration
safety.

editor-ext (packages/editor-ext/src/lib/footnote/):
- footnoteReference (inline atom, id), footnotesList (block, last child),
  footnoteDefinition (paragraph+, id). renderHTML emits sup[data-footnote-ref]
  / section[data-footnotes] / div[data-footnote-def]; parse-rule priority makes
  the empty reference win over the Superscript mark (else it is dropped on the
  server save).
- numbering: a decoration-only plugin (pure function of doc order) -> every
  client computes identical numbers, no document mutation, Yjs-safe.
- sync plugin: single-pass, always SYNC_META-tagged and skipping remote txns
  (terminates, no loop), idempotent; canonicalizes to one trailing footnotesList
  (merging duplicates), creates missing definitions, drops orphans, and
  coexists with TrailingNode. Disabled in read-only.
- commands setFootnote (one tx: reference + definition at the matching index +
  focus) / removeFootnote (cascade, one undo) / scrollTo*. slash /footnote.

client: superscript NodeView + floating-ui read-only popover; bottom-list and
definition NodeViews; registered in mainExtensions.

server: the three nodes registered in tiptapExtensions so collab/save/export
keep them. Round-trip regression spec guards the Superscript parse-priority.

markdown: turndown/marked round-trip to pandoc/GFM [^id] (+ a code-fence guard
so footnote-like lines inside code blocks are not extracted).

MCP mirror: schema + markdown-converter + commentsToFootnotes rewritten to real
footnote nodes + diff marker counting; NUL sentinels written as \u0000 escapes.

v2 follow-ups (per plan): definition reordering on reference move, id-collision
regeneration on paste, multiple references to one footnote.

Implements docs/footnotes-plan.md (variant B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 11:39:00 +03:00
parent c8af637654
commit 4d17befb0d
38 changed files with 2906 additions and 151 deletions

View File

@@ -0,0 +1,47 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { computeFootnoteNumbers } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
/**
* NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored).
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
const numbers = computeFootnoteNumbers(editor.state.doc);
const number = numbers.get(id) ?? "?";
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
editor.commands.scrollToReference(id);
};
return (
<NodeViewWrapper
data-footnote-def=""
data-id={id}
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
<span className={classes.definitionMarker} contentEditable={false}>
{number}.
</span>
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.backLink}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import {
FOOTNOTE_DEFINITION_NAME,
computeFootnoteNumbers,
} from "@docmost/editor-ext";
import { ActionIcon } from "@mantine/core";
import { IconArrowDown } from "@tabler/icons-react";
import classes from "./footnote.module.css";
/**
* Read the plain text of the footnote definition with `id` directly from the
* editor state. No sub-editor: the popover is read-only.
*/
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
let text = "";
editor.state.doc.descendants((node) => {
if (
node.type.name === FOOTNOTE_DEFINITION_NAME &&
node.attrs.id === id
) {
text = node.textContent;
return false;
}
return undefined;
});
return text;
}
export default function FootnoteReferenceView(props: NodeViewProps) {
const { node, editor, selected } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
const anchorRef = useRef<HTMLElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
// Number is derived (not stored) — recompute from the current doc.
const numbers = computeFootnoteNumbers(editor.state.doc);
const number = numbers.get(id) ?? "?";
const defText = open ? getDefinitionText(editor, id) : "";
const position = useCallback(() => {
const anchor = anchorRef.current;
const popup = popoverRef.current;
if (!anchor || !popup) return;
computePosition(anchor, popup, {
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
});
}, []);
useEffect(() => {
if (!open) return;
const anchor = anchorRef.current;
const popup = popoverRef.current;
if (!anchor || !popup) return;
const cleanup = autoUpdate(anchor, popup, position);
const onPointerDown = (e: PointerEvent) => {
if (
popup.contains(e.target as Node) ||
anchor.contains(e.target as Node)
) {
return;
}
setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown, true);
return () => {
cleanup();
document.removeEventListener("pointerdown", onPointerDown, true);
};
}, [open, position]);
const handleGoTo = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
editor.commands.scrollToFootnote(id);
};
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
<sup
ref={(el) => (anchorRef.current = el)}
data-footnote-ref=""
data-id={id}
className={`${classes.reference} ${selected ? classes.selected : ""}`}
onMouseEnter={() => setOpen(true)}
onClick={(e) => {
e.preventDefault();
setOpen((v) => !v);
}}
// The decoration sets --footnote-number; provide a fallback inline.
style={{ ["--footnote-number" as any]: `"${number}"` }}
aria-label={t("Footnote {{number}}", { number })}
role="button"
/>
{open &&
createPortal(
<div
ref={popoverRef}
className={classes.popover}
role="tooltip"
onMouseLeave={() => setOpen(false)}
>
<div className={classes.popoverHeader}>
<span className={classes.popoverNumber}>
{t("Footnote {{number}}", { number })}
</span>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={handleGoTo}
aria-label={t("Go to footnote")}
>
<IconArrowDown size={16} />
</ActionIcon>
</div>
<div className={classes.popoverBody}>
{defText || t("Empty footnote")}
</div>
</div>,
document.body,
)}
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,106 @@
/* Superscript reference marker. The visible number comes from the numbering
plugin decoration which sets the --footnote-number CSS variable. */
.reference {
cursor: pointer;
color: var(--mantine-color-blue-6);
font-weight: 500;
vertical-align: super;
font-size: 0.75em;
line-height: 0;
user-select: none;
white-space: nowrap;
}
.reference::after {
content: var(--footnote-number, "");
}
.reference:hover {
text-decoration: underline;
}
.reference.selected {
background-color: var(--mantine-color-blue-1);
border-radius: 2px;
}
/* Read-only popover shown on hover/click of a reference. */
.popover {
position: absolute;
z-index: 1000;
max-width: 360px;
padding: var(--mantine-spacing-sm);
background: var(--mantine-color-body);
color: var(--mantine-color-default-color);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-md);
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
}
.popoverHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
margin-bottom: 4px;
}
.popoverNumber {
font-weight: 600;
color: var(--mantine-color-dimmed);
}
.popoverBody {
white-space: pre-wrap;
word-break: break-word;
}
/* Bottom footnotes container. */
.list {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
}
.listHeading {
font-weight: 600;
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
margin-bottom: var(--mantine-spacing-xs);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.definition {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-xs);
padding: 2px 0;
}
.definitionMarker {
flex: 0 0 auto;
min-width: 1.5em;
font-variant-numeric: tabular-nums;
color: var(--mantine-color-dimmed);
user-select: none;
}
.definitionContent {
flex: 1 1 auto;
min-width: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;
color: var(--mantine-color-blue-6);
user-select: none;
font-size: 0.9em;
}
.backLink:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,20 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import classes from "./footnote.module.css";
/**
* NodeView for the bottom footnotes container. Renders a visual separator and a
* localized heading, then the editable list of definitions via NodeViewContent.
*/
export default function FootnotesListView(_props: NodeViewProps) {
const { t } = useTranslation();
return (
<NodeViewWrapper>
<div className={classes.list} contentEditable={false}>
<div className={classes.listHeading}>{t("Footnotes")}</div>
</div>
<NodeViewContent />
</NodeViewWrapper>
);
}

View File

@@ -28,6 +28,7 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -366,6 +367,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDetails().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setFootnote().run(),
},
{
title: "Callout",
description: "Insert callout notice.",