feat(footnotes): multi-backlinks — definition returns to ALL its references (#168)
After #166 a repeated `[^a]` is one footnote (reuse): one number, one definition, N forward links. But the definition's ↩ only returned to the FIRST reference. Now a definition with N references shows ↩ a b c …, each backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a single-reference footnote keeps the plain ↩ unchanged. - editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached alongside the number map in the numbering plugin state; `getFootnoteRefCount` getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks the index-th `sup[data-footnote-ref][data-id]` occurrence (document order), falling back to the first. - client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …) per occurrence when refCount > 1; the chrome stays after the contentDOM so the #146 caret invariant holds. i18n keys (ru) added. Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown id => 0); structure test gains 3 cases (N lettered links render, click jumps to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the backlink row needs a real browser to verify (jsdom can't); the structural and behavioral contract is covered headless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -405,6 +405,8 @@
|
|||||||
"Footnote {{number}}": "Сноска {{number}}",
|
"Footnote {{number}}": "Сноска {{number}}",
|
||||||
"Go to footnote": "Перейти к сноске",
|
"Go to footnote": "Перейти к сноске",
|
||||||
"Back to reference": "Вернуться к ссылке",
|
"Back to reference": "Вернуться к ссылке",
|
||||||
|
"Back to references": "Вернуться к ссылкам",
|
||||||
|
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||||
"Empty footnote": "Пустая сноска",
|
"Empty footnote": "Пустая сноска",
|
||||||
"Math inline": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
|
|||||||
@@ -1,25 +1,45 @@
|
|||||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
|
||||||
import classes from "./footnote.module.css";
|
import classes from "./footnote.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
|
||||||
|
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
|
||||||
|
*/
|
||||||
|
function backlinkLabel(index: number): string {
|
||||||
|
let out = "";
|
||||||
|
let x = index;
|
||||||
|
while (x >= 0) {
|
||||||
|
out = String.fromCharCode(97 + (x % 26)) + out;
|
||||||
|
x = Math.floor(x / 26) - 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NodeView for a single footnote definition: a decorative number marker, the
|
* NodeView for a single footnote definition: a decorative number marker, the
|
||||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||||
* The number is derived from the document (not stored).
|
* The number is derived from the document (not stored).
|
||||||
|
*
|
||||||
|
* After #166 a footnote can be referenced more than once (one number, one
|
||||||
|
* definition, N forward links). When it is, the back-link becomes a row of
|
||||||
|
* per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
|
||||||
|
* a single-reference footnote keeps the plain ↩.
|
||||||
*/
|
*/
|
||||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||||
const { node, editor } = props;
|
const { node, editor } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const id = node.attrs.id as string;
|
const id = node.attrs.id as string;
|
||||||
|
|
||||||
// Read the cached number from the numbering plugin (computed once per doc
|
// Read the cached number/ref-count from the numbering plugin (computed once
|
||||||
// change) rather than recomputing the whole map on every render.
|
// per doc change) rather than recomputing the whole map on every render.
|
||||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||||
|
const refCount = getFootnoteRefCount(editor.state, id);
|
||||||
|
|
||||||
const handleBack = (e: React.MouseEvent) => {
|
const jumpTo = (e: React.MouseEvent, index: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor.commands.scrollToReference(id);
|
editor.commands.scrollToReference(id, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,16 +62,47 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
|
|||||||
>
|
>
|
||||||
{number}.
|
{number}.
|
||||||
</span>
|
</span>
|
||||||
<span
|
{refCount > 1 ? (
|
||||||
className={classes.backLink}
|
// Multiple references -> ↩ followed by one lettered link per occurrence.
|
||||||
contentEditable={false}
|
<span
|
||||||
onClick={handleBack}
|
className={classes.backLinks}
|
||||||
role="button"
|
contentEditable={false}
|
||||||
aria-label={t("Back to reference")}
|
role="group"
|
||||||
title={t("Back to reference")}
|
aria-label={t("Back to references")}
|
||||||
>
|
>
|
||||||
↩
|
<span className={classes.backLinkArrow} aria-hidden="true">
|
||||||
</span>
|
↩
|
||||||
|
</span>
|
||||||
|
{Array.from({ length: refCount }, (_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={classes.backLink}
|
||||||
|
onClick={(e) => jumpTo(e, i)}
|
||||||
|
role="button"
|
||||||
|
aria-label={t("Back to reference {{label}}", {
|
||||||
|
label: backlinkLabel(i),
|
||||||
|
})}
|
||||||
|
title={t("Back to reference {{label}}", {
|
||||||
|
label: backlinkLabel(i),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{backlinkLabel(i)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
// Single reference -> the plain ↩ (unchanged behavior).
|
||||||
|
<span
|
||||||
|
className={classes.backLink}
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={(e) => jumpTo(e, 0)}
|
||||||
|
role="button"
|
||||||
|
aria-label={t("Back to reference")}
|
||||||
|
title={t("Back to reference")}
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structural regression guard for #146 (PR #147).
|
* Structural regression guard for #146 (PR #147).
|
||||||
@@ -36,10 +36,14 @@ vi.mock("react-i18next", () => ({
|
|||||||
useTranslation: () => ({ t: (key: string) => key }),
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// footnote-definition-view reads a cached number from the numbering plugin;
|
// footnote-definition-view reads a cached number + reference count from the
|
||||||
// stub it so we don't need a live ProseMirror state.
|
// numbering plugin; stub them so we don't need a live ProseMirror state. The
|
||||||
|
// ref-count is a hoisted mutable so a test can drive the single-vs-multi
|
||||||
|
// backlink branch (#168). Default 1 = single reference (the #146 cases).
|
||||||
|
const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
|
||||||
vi.mock("@docmost/editor-ext", () => ({
|
vi.mock("@docmost/editor-ext", () => ({
|
||||||
getFootnoteNumber: () => 1,
|
getFootnoteNumber: () => 1,
|
||||||
|
getFootnoteRefCount: () => mockRefCount.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
|
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
|
||||||
@@ -59,7 +63,8 @@ vi.mock("@mantine/core", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/components/common/copy-button", () => ({
|
vi.mock("@/components/common/copy-button", () => ({
|
||||||
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
|
CopyButton: ({ children }: any) =>
|
||||||
|
children({ copied: false, copy: () => {} }),
|
||||||
}));
|
}));
|
||||||
vi.mock("@tabler/icons-react", () => ({
|
vi.mock("@tabler/icons-react", () => ({
|
||||||
IconCheck: () => null,
|
IconCheck: () => null,
|
||||||
@@ -141,3 +146,71 @@ describe("#146 editable NodeView contentDOM-first invariant", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #168: a footnote referenced more than once shows one lettered backlink per
|
||||||
|
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
|
||||||
|
// footnote keeps the plain ↩.
|
||||||
|
describe("#168 footnote definition multi-backlinks", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset the shared ref-count mock so other tests see a single reference.
|
||||||
|
mockRefCount.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeProps = () =>
|
||||||
|
({
|
||||||
|
node: { attrs: { id: "fn-1" }, textContent: "" },
|
||||||
|
editor: {
|
||||||
|
state: {},
|
||||||
|
isEditable: true,
|
||||||
|
commands: { scrollToReference: vi.fn() },
|
||||||
|
},
|
||||||
|
getPos: () => 0,
|
||||||
|
updateAttributes: () => {},
|
||||||
|
deleteNode: () => {},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
|
||||||
|
mockRefCount.value = 3;
|
||||||
|
const { getByTestId } = render(<FootnoteDefinitionView {...makeProps()} />);
|
||||||
|
const wrapper = getByTestId("nvw");
|
||||||
|
|
||||||
|
const links = wrapper.querySelectorAll('[role="button"]');
|
||||||
|
expect(Array.from(links).map((l) => l.textContent)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
]);
|
||||||
|
// The ↩ arrow is present (as decorative chrome, not a button).
|
||||||
|
expect(wrapper.textContent).toContain("↩");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
|
||||||
|
mockRefCount.value = 3;
|
||||||
|
const props = makeProps();
|
||||||
|
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
|
||||||
|
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
|
||||||
|
|
||||||
|
fireEvent.click(links[1]); // "b"
|
||||||
|
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
|
||||||
|
"fn-1",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a single-reference footnote renders just one ↩ (no letters)", () => {
|
||||||
|
mockRefCount.value = 1;
|
||||||
|
const props = makeProps();
|
||||||
|
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
|
||||||
|
const wrapper = getByTestId("nvw");
|
||||||
|
|
||||||
|
const links = wrapper.querySelectorAll('[role="button"]');
|
||||||
|
expect(links.length).toBe(1);
|
||||||
|
expect(links[0].textContent).toBe("↩");
|
||||||
|
|
||||||
|
fireEvent.click(links[0]);
|
||||||
|
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
|
||||||
|
"fn-1",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -115,3 +115,18 @@
|
|||||||
.backLink:hover {
|
.backLink:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
|
||||||
|
occurrence. Sits on the right, after the content, like the single ↩. */
|
||||||
|
.backLinks {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLinkArrow {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
import {
|
import {
|
||||||
FOOTNOTE_DEFINITION_NAME,
|
FOOTNOTE_DEFINITION_NAME,
|
||||||
FOOTNOTE_REFERENCE_NAME,
|
FOOTNOTE_REFERENCE_NAME,
|
||||||
computeFootnoteNumbers,
|
computeFootnoteNumbers,
|
||||||
} from "./footnote-util";
|
computeFootnoteRefCounts,
|
||||||
|
} from './footnote-util';
|
||||||
|
|
||||||
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
||||||
"footnoteNumbering",
|
'footnoteNumbering',
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +22,9 @@ export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
|||||||
interface FootnoteNumberingState {
|
interface FootnoteNumberingState {
|
||||||
/** referenceId -> 1-based display number, for the current doc. */
|
/** referenceId -> 1-based display number, for the current doc. */
|
||||||
numbers: Map<string, number>;
|
numbers: Map<string, number>;
|
||||||
|
/** referenceId -> number of reference occurrences (>= 1), for the definition's
|
||||||
|
* multi-backlink UI (#168). */
|
||||||
|
refCounts: Map<string, number>;
|
||||||
/** Decorations rendering those numbers (refs + definitions). */
|
/** Decorations rendering those numbers (refs + definitions). */
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,7 @@ function buildFootnoteNumberingState(
|
|||||||
doc: ProseMirrorNode,
|
doc: ProseMirrorNode,
|
||||||
): FootnoteNumberingState {
|
): FootnoteNumberingState {
|
||||||
const numbers = computeFootnoteNumbers(doc);
|
const numbers = computeFootnoteNumbers(doc);
|
||||||
|
const refCounts = computeFootnoteRefCounts(doc);
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = [];
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
@@ -54,7 +59,7 @@ function buildFootnoteNumberingState(
|
|||||||
if (num != null) {
|
if (num != null) {
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.node(pos, pos + node.nodeSize, {
|
Decoration.node(pos, pos + node.nodeSize, {
|
||||||
"data-footnote-number": String(num),
|
'data-footnote-number': String(num),
|
||||||
style: `--footnote-number: "${num}";`,
|
style: `--footnote-number: "${num}";`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -65,7 +70,7 @@ function buildFootnoteNumberingState(
|
|||||||
if (num != null) {
|
if (num != null) {
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.node(pos, pos + node.nodeSize, {
|
Decoration.node(pos, pos + node.nodeSize, {
|
||||||
"data-footnote-number": String(num),
|
'data-footnote-number': String(num),
|
||||||
style: `--footnote-number: "${num}";`,
|
style: `--footnote-number: "${num}";`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -73,7 +78,11 @@ function buildFootnoteNumberingState(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { numbers, decorations: DecorationSet.create(doc, decorations) };
|
return {
|
||||||
|
numbers,
|
||||||
|
refCounts,
|
||||||
|
decorations: DecorationSet.create(doc, decorations),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +99,16 @@ export function getFootnoteNumber(
|
|||||||
return footnoteNumberingPluginKey.getState(state)?.numbers.get(id);
|
return footnoteNumberingPluginKey.getState(state)?.numbers.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the cached reference-occurrence count for `id` (how many `[^id]` links
|
||||||
|
* point at this definition). Drives the definition's multi-backlink UI (#168):
|
||||||
|
* `> 1` renders ↩ a b c …, each scrolling to its own occurrence. Returns 0 when
|
||||||
|
* the plugin is not installed or the id is unknown (caller treats as single).
|
||||||
|
*/
|
||||||
|
export function getFootnoteRefCount(state: EditorState, id: string): number {
|
||||||
|
return footnoteNumberingPluginKey.getState(state)?.refCounts.get(id) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProseMirror plugin that renders footnote numbers as decorations. It never
|
* ProseMirror plugin that renders footnote numbers as decorations. It never
|
||||||
* mutates the document (safe in read-only / share and in collaboration) — it
|
* mutates the document (safe in read-only / share and in collaboration) — it
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { mergeAttributes, Node } from "@tiptap/core";
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
import { TextSelection, Transaction } from "@tiptap/pm/state";
|
import { TextSelection, Transaction } from '@tiptap/pm/state';
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import {
|
import {
|
||||||
FOOTNOTE_DEFINITION_NAME,
|
FOOTNOTE_DEFINITION_NAME,
|
||||||
FOOTNOTE_REFERENCE_NAME,
|
FOOTNOTE_REFERENCE_NAME,
|
||||||
FOOTNOTES_LIST_NAME,
|
FOOTNOTES_LIST_NAME,
|
||||||
generateFootnoteId,
|
generateFootnoteId,
|
||||||
} from "./footnote-util";
|
} from './footnote-util';
|
||||||
import { footnoteNumberingPlugin } from "./footnote-numbering";
|
import { footnoteNumberingPlugin } from './footnote-numbering';
|
||||||
import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync";
|
import { footnoteSyncPlugin, footnotePastePlugin } from './footnote-sync';
|
||||||
|
|
||||||
export interface FootnoteReferenceOptions {
|
export interface FootnoteReferenceOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
@@ -27,7 +27,7 @@ export interface FootnoteReferenceOptions {
|
|||||||
enableSync?: boolean;
|
enableSync?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
footnote: {
|
footnote: {
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +42,11 @@ declare module "@tiptap/core" {
|
|||||||
removeFootnote: (id: string) => ReturnType;
|
removeFootnote: (id: string) => ReturnType;
|
||||||
/** Scroll to (and focus) a footnote definition by id. */
|
/** Scroll to (and focus) a footnote definition by id. */
|
||||||
scrollToFootnote: (id: string) => ReturnType;
|
scrollToFootnote: (id: string) => ReturnType;
|
||||||
/** Scroll to (and select) a footnote reference by id. */
|
/** Scroll to a footnote reference by id. `index` selects WHICH occurrence
|
||||||
scrollToReference: (id: string) => ReturnType;
|
* to scroll to when the id is referenced more than once (reuse, #166):
|
||||||
|
* 0-based, defaults to the first. Used by the definition's multi-backlink
|
||||||
|
* UI (#168). */
|
||||||
|
scrollToReference: (id: string, index?: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +69,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
// Superscript mark's <sup> rule.
|
// Superscript mark's <sup> rule.
|
||||||
priority: 101,
|
priority: 101,
|
||||||
|
|
||||||
group: "inline",
|
group: 'inline',
|
||||||
inline: true,
|
inline: true,
|
||||||
atom: true,
|
atom: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
@@ -99,10 +102,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
return {
|
return {
|
||||||
id: {
|
id: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-id"),
|
parseHTML: (element) => element.getAttribute('data-id'),
|
||||||
renderHTML: (attributes) => {
|
renderHTML: (attributes) => {
|
||||||
if (!attributes.id) return {};
|
if (!attributes.id) return {};
|
||||||
return { "data-id": attributes.id };
|
return { 'data-id': attributes.id };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -113,7 +116,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
{
|
{
|
||||||
// High priority so the Superscript mark (which also matches <sup>) does
|
// High priority so the Superscript mark (which also matches <sup>) does
|
||||||
// not claim a footnote reference and drop it as empty content.
|
// not claim a footnote reference and drop it as empty content.
|
||||||
tag: "sup[data-footnote-ref]",
|
tag: 'sup[data-footnote-ref]',
|
||||||
priority: 100,
|
priority: 100,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -121,9 +124,9 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return [
|
||||||
"sup",
|
'sup',
|
||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-footnote-ref": "", class: "footnote-ref" },
|
{ 'data-footnote-ref': '', class: 'footnote-ref' },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes,
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
@@ -132,7 +135,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
|
|
||||||
// Plain-text representation (used by generateText / markdown text fallbacks).
|
// Plain-text representation (used by generateText / markdown text fallbacks).
|
||||||
renderText({ node }) {
|
renderText({ node }) {
|
||||||
return `[^${node.attrs.id ?? ""}]`;
|
return `[^${node.attrs.id ?? ''}]`;
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
@@ -170,8 +173,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
|
|
||||||
// Make sure the parent accepts an inline atom here.
|
// Make sure the parent accepts an inline atom here.
|
||||||
const insertPos = selection.from;
|
const insertPos = selection.from;
|
||||||
if (!$from.parent.type.spec.content?.includes("inline") &&
|
if (
|
||||||
!$from.parent.isTextblock) {
|
!$from.parent.type.spec.content?.includes('inline') &&
|
||||||
|
!$from.parent.isTextblock
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,19 +316,23 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
|||||||
`[data-footnote-def][data-id="${id}"]`,
|
`[data-footnote-def][data-id="${id}"]`,
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (!dom) return false;
|
if (!dom) return false;
|
||||||
dom.scrollIntoView({ behavior: "smooth", block: "center" });
|
dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollToReference:
|
scrollToReference:
|
||||||
(id: string) =>
|
(id: string, index = 0) =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
const dom = editor.view.dom.querySelector(
|
// querySelectorAll returns the occurrences in document order, so the
|
||||||
|
// index maps 1:1 to the definition's a/b/c backlink (#168). Fall back
|
||||||
|
// to the first match for an out-of-range index.
|
||||||
|
const matches = editor.view.dom.querySelectorAll(
|
||||||
`sup[data-footnote-ref][data-id="${id}"]`,
|
`sup[data-footnote-ref][data-id="${id}"]`,
|
||||||
) as HTMLElement | null;
|
);
|
||||||
|
const dom = (matches[index] ?? matches[0]) as HTMLElement | undefined;
|
||||||
if (!dom) return false;
|
if (!dom) return false;
|
||||||
dom.scrollIntoView({ behavior: "smooth", block: "center" });
|
dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node type names for the footnote feature. Centralized so every part of the
|
* Node type names for the footnote feature. Centralized so every part of the
|
||||||
* feature (nodes, plugins, commands) references the same string.
|
* feature (nodes, plugins, commands) references the same string.
|
||||||
*/
|
*/
|
||||||
export const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
|
export const FOOTNOTE_REFERENCE_NAME = 'footnoteReference';
|
||||||
export const FOOTNOTES_LIST_NAME = "footnotesList";
|
export const FOOTNOTES_LIST_NAME = 'footnotesList';
|
||||||
export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
export const FOOTNOTE_DEFINITION_NAME = 'footnoteDefinition';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext
|
* Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext
|
||||||
@@ -15,10 +15,10 @@ export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
|||||||
*/
|
*/
|
||||||
export function generateFootnoteId(): string {
|
export function generateFootnoteId(): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeHex = now.toString(16).padStart(12, "0");
|
const timeHex = now.toString(16).padStart(12, '0');
|
||||||
|
|
||||||
const rand = (length: number) => {
|
const rand = (length: number) => {
|
||||||
let out = "";
|
let out = '';
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
out += Math.floor(Math.random() * 16).toString(16);
|
out += Math.floor(Math.random() * 16).toString(16);
|
||||||
}
|
}
|
||||||
@@ -26,19 +26,19 @@ export function generateFootnoteId(): string {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// version 7 nibble, then variant (8..b) nibble.
|
// version 7 nibble, then variant (8..b) nibble.
|
||||||
const versioned = "7" + rand(3);
|
const versioned = '7' + rand(3);
|
||||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||||
const variant = variantNibble + rand(3);
|
const variant = variantNibble + rand(3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
timeHex.slice(0, 8) +
|
timeHex.slice(0, 8) +
|
||||||
"-" +
|
'-' +
|
||||||
timeHex.slice(8, 12) +
|
timeHex.slice(8, 12) +
|
||||||
"-" +
|
'-' +
|
||||||
versioned +
|
versioned +
|
||||||
"-" +
|
'-' +
|
||||||
variant +
|
variant +
|
||||||
"-" +
|
'-' +
|
||||||
rand(12)
|
rand(12)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ export function deriveFootnoteId(
|
|||||||
* Purely deterministic.
|
* Purely deterministic.
|
||||||
*/
|
*/
|
||||||
function suffix(n: number): string {
|
function suffix(n: number): string {
|
||||||
let out = "";
|
let out = '';
|
||||||
let x = n;
|
let x = n;
|
||||||
while (x > 0) {
|
while (x > 0) {
|
||||||
const rem = (x - 1) % 25;
|
const rem = (x - 1) % 25;
|
||||||
@@ -131,3 +131,19 @@ export function computeFootnoteNumbers(
|
|||||||
}
|
}
|
||||||
return numbers;
|
return numbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of `referenceId -> number of reference occurrences` (>= 1) from
|
||||||
|
* document order. After #166 the same id may be referenced multiple times
|
||||||
|
* (reuse: one number, one definition, N forward links); this count drives the
|
||||||
|
* definition's multi-backlink UI (↩ a b c …, #168). Pure function of the doc.
|
||||||
|
*/
|
||||||
|
export function computeFootnoteRefCounts(
|
||||||
|
doc: ProseMirrorNode,
|
||||||
|
): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const id of collectReferenceIds(doc)) {
|
||||||
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user