feat: footnotes (#2384)

* feat: footnotes

* feat: proper DOCX support
This commit is contained in:
Philip Okugbe
2026-08-12 12:30:30 +01:00
committed by GitHub
parent 7439da2f6e
commit a0b2ac6ae3
21 changed files with 987 additions and 9 deletions
@@ -387,6 +387,8 @@
"Insert horizontal rule divider": "Insert horizontal rule divider", "Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break", "Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.", "Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.", "Upload any audio from your device.": "Upload any audio from your device.",
@@ -12,6 +12,7 @@ import {
IconMathFunction, IconMathFunction,
IconRotate2, IconRotate2,
IconSitemap, IconSitemap,
IconSuperscript,
IconTable, IconTable,
IconTag, IconTag,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -270,6 +271,12 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
> >
{t("Math block")} {t("Math block")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -30,6 +30,7 @@ import {
IconTag, IconTag,
IconMoodSmile, IconMoodSmile,
IconRotate2, IconRotate2,
IconSuperscript,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(), editor.chain().focus().deleteRange(range).setPageBreak().run(),
}, },
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -1,5 +1,6 @@
import { markInputRule } from "@tiptap/core"; import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code"; import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -63,6 +64,9 @@ import {
TransclusionReference, TransclusionReference,
TableView, TableView,
BaseEmbed as BaseEmbedNode, BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -132,6 +136,7 @@ lowlight.register("scala", scala);
// @ts-ignore // @ts-ignore
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
document: false,
heading: false, heading: false,
undoRedo: false, undoRedo: false,
link: false, link: false,
@@ -143,6 +148,9 @@ export const mainExtensions = [
codeBlock: false, codeBlock: false,
code: false, code: false,
}), }),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule. // Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule // before the opening backtick as part of the match, causing markInputRule
@@ -203,7 +211,8 @@ export const mainExtensions = [
parentName === "tableCell" || parentName === "tableCell" ||
parentName === "tableHeader" || parentName === "tableHeader" ||
parentName === "callout" || parentName === "callout" ||
parentName === "blockquote" parentName === "blockquote" ||
parentName === "footnote"
) { ) {
return i18n.t("Write..."); return i18n.t("Write...");
} }
@@ -417,6 +426,9 @@ export const mainExtensions = [
}).configure(), }).configure(),
Columns, Columns,
Column, Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({ AutoJoiner.configure({
elementsToJoin: [], elementsToJoin: [],
}), }),
@@ -0,0 +1,26 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,3 +18,4 @@
@import "./columns.css"; @import "./columns.css";
@import "./status.css"; @import "./status.css";
@import "./base-embed.css"; @import "./base-embed.css";
@import "./footnotes.css";
@@ -1,4 +1,5 @@
import { StarterKit } from '@tiptap/starter-kit'; import { StarterKit } from '@tiptap/starter-kit';
import { Document } from '@tiptap/extension-document';
import { TextAlign } from '@tiptap/extension-text-align'; import { TextAlign } from '@tiptap/extension-text-align';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
@@ -45,6 +46,9 @@ import {
TransclusionSource, TransclusionSource,
TransclusionReference, TransclusionReference,
BaseEmbed, BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -58,11 +62,15 @@ import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
document: false,
codeBlock: false, codeBlock: false,
link: false, link: false,
trailingNode: false, trailingNode: false,
heading: false, heading: false,
}), }),
Document.extend({
content: 'block+ footnotes?',
}),
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ['heading', 'paragraph', 'transclusionSource'], types: ['heading', 'paragraph', 'transclusionSource'],
@@ -110,7 +118,10 @@ export const tiptapExtensions = [
Status, Status,
TransclusionSource, TransclusionSource,
TransclusionReference, TransclusionReference,
BaseEmbed BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
+1
View File
@@ -32,6 +32,7 @@ export * from "./lib/columns";
export * from "./lib/status"; export * from "./lib/status";
export * from "./lib/pdf"; export * from "./lib/pdf";
export * from "./lib/page-break"; export * from "./lib/page-break";
export * from "./lib/footnotes";
export * from "./lib/resizable-nodeview"; export * from "./lib/resizable-nodeview";
export { export {
pageNodeToDocxBuffer, pageNodeToDocxBuffer,
@@ -0,0 +1,189 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { mergeAttributes } from "@tiptap/core";
import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
footnote: {
/**
* scrolls to & sets the text selection at the end of the footnote with the given id
* @param id the id of the footote (i.e. the `data-id` attribute value of the footnote)
* @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50")
*/
focusFootnote: (id: string) => ReturnType;
};
}
}
export interface FootnoteOptions extends ListItemOptions {
/**
* Content expression for this node
* @default "paragraph+"
*/
content: string;
}
const Footnote = ListItem.extend<FootnoteOptions>({
name: "footnote",
content() {
return this.options.content;
},
isolating: true,
defining: true,
draggable: false,
addOptions() {
return {
HTMLAttributes: {},
bulletListTypeName: 'bulletList',
orderedListTypeName: 'orderedList',
...this.parent?.(),
content: "paragraph+",
};
},
addAttributes() {
return {
id: {
isRequired: true,
},
// the data-id field should match the data-id field of a footnote reference.
// it's used to link footnotes and references together.
"data-id": {
isRequired: true,
},
};
},
parseHTML() {
return [
{
tag: "li",
getAttrs(node) {
const id = node.getAttribute("data-id");
if (id) {
return {
"data-id": node.getAttribute("data-id"),
};
}
return false;
},
priority: 1000,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"li",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
focusFootnote:
(id: string) =>
({ editor, chain }) => {
const matchedFootnote = editor.$node("footnote", {
"data-id": id,
});
if (matchedFootnote) {
// sets the text selection to the end of the footnote definition and scroll to it.
chain()
.focus()
.setTextSelection(
matchedFootnote.from + matchedFootnote.content.size
)
.run();
matchedFootnote.element.scrollIntoView();
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
// when inside a footnote, Mod-a should select only the footnote content
"Mod-a": ({ editor }) => {
try {
const { selection } = editor.state;
const { $from } = selection;
for (let depth = $from.depth; depth >= 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "footnote") {
const start = $from.start(depth);
const end = $from.end(depth);
editor.commands.setTextSelection({
from: start + 1,
to: end - 1,
});
return true;
}
}
return false;
} catch (e) {
return false;
}
},
// when the user presses tab, adjust the text selection to be at the end of the next footnote
Tab: ({ editor }) => {
try {
const { selection } = editor.state;
const pos = editor.$pos(selection.anchor);
if (!pos.after) return false;
// if the next node is "footnotes", place the text selection at the end of the first footnote
if (pos.after.node.type.name == "footnotes") {
const firstChild = pos.after.node.child(0);
editor
.chain()
.setTextSelection(pos.after.from + firstChild.content.size)
.scrollIntoView()
.run();
return true;
} else {
const startPos = selection.$from.start(2);
if (Number.isNaN(startPos)) return false;
const parent = editor.$pos(startPos);
if (parent.node.type.name != "footnote" || !parent.after) {
return false;
}
// if the next node is a footnote, place the text selection at the end of it
editor
.chain()
.setTextSelection(parent.after.to - 1)
.scrollIntoView()
.run();
return true;
}
} catch {
return false;
}
},
// inverse of the tab command - place the text selection at the end of the previous footnote
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const startPos = selection.$from.start(2);
if (Number.isNaN(startPos)) return false;
const parent = editor.$pos(startPos);
if (parent.node.type.name != "footnote" || !parent.before) {
return false;
}
editor
.chain()
.setTextSelection(parent.before.to - 1)
.scrollIntoView()
.run();
return true;
},
};
},
});
export default Footnote;
@@ -0,0 +1,46 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import OrderedList from "@tiptap/extension-ordered-list";
import FootnoteRules from "./rules";
const Footnotes = OrderedList.extend({
name: "footnotes",
group: "", // removed the default group of the ordered list extension
isolating: true,
defining: true,
draggable: false,
content() {
return "footnote*";
},
addAttributes() {
return {
class: {
default: "footnotes",
},
};
},
parseHTML() {
return [
{
tag: "ol.footnotes",
priority: 1000,
},
];
},
addKeyboardShortcuts() {
return {};
},
addCommands() {
return {};
},
addInputRules() {
return [];
},
addExtensions() {
return [FootnoteRules];
},
});
export default Footnotes;
@@ -0,0 +1,4 @@
export { default as Footnotes } from "./footnotes";
export { default as Footnote } from "./footnote";
export type { FootnoteOptions } from "./footnote";
export { default as FootnoteReference } from "./reference";
@@ -0,0 +1,221 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { mergeAttributes, Node } from "@tiptap/core";
import {
Fragment as PMFragment,
Node as PMNode,
Slice,
} from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import { generateNodeId } from "../utils";
const REFNUM_ATTR = "data-reference-number";
const REF_CLASS = "footnote-ref";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
footnoteReference: {
/**
* add a new footnote reference
* @example editor.commands.addFootnote()
*/
addFootnote: () => ReturnType;
};
}
}
const FootnoteReference = Node.create({
name: "footnoteReference",
inline: true,
content: "text*",
group: "inline",
atom: true,
draggable: true,
parseHTML() {
return [
{
tag: `sup`,
priority: 1000,
getAttrs(node) {
const anchor = node.querySelector<HTMLAnchorElement>(
`a.${REF_CLASS}:first-child`
);
if (!anchor) {
return false;
}
const id = anchor.getAttribute("data-id");
const ref = anchor.getAttribute(REFNUM_ATTR);
return {
"data-id": id ?? generateNodeId(),
referenceNumber: ref ?? anchor.innerText,
};
},
contentElement(node) {
return node.firstChild as HTMLElement;
},
},
];
},
addAttributes() {
return {
class: {
default: REF_CLASS,
},
"data-id": {
renderHTML(attributes) {
return {
"data-id": attributes["data-id"] || generateNodeId(),
};
},
},
referenceNumber: {},
href: {
renderHTML(attributes) {
return {
href: `#fn:${attributes["referenceNumber"]}`,
};
},
},
};
},
renderHTML({ HTMLAttributes }) {
const { referenceNumber, ...attributes } = HTMLAttributes;
const attrs = mergeAttributes(this.options.HTMLAttributes, attributes);
attrs[REFNUM_ATTR] = referenceNumber;
return [
"sup",
{ id: `fnref:${referenceNumber}` },
["a", attrs, HTMLAttributes.referenceNumber],
];
},
addProseMirrorPlugins() {
const { editor } = this;
// Ensures pasted footnote references get unique IDs.
const mapNode = (node: PMNode): PMNode => {
if (node.type.name === this.name) {
const newAttrs = { ...node.attrs, "data-id": generateNodeId() };
return node.type.create(newAttrs, node.content, node.marks);
}
if (node.content && node.content.size > 0) {
const newChildren: PMNode[] = [];
let changed = false;
node.content.forEach((child) => {
const mapped = mapNode(child);
if (mapped !== child) {
changed = true;
}
newChildren.push(mapped);
});
if (changed) {
return node.copy(PMFragment.from(newChildren));
}
}
return node;
};
return [
new Plugin({
key: new PluginKey("footnotePasteHandler"),
props: {
transformPasted(slice) {
const mappedNodes: PMNode[] = [];
let changed = false;
slice.content.forEach((node) => {
const mapped = mapNode(node);
if (mapped !== node) {
changed = true;
}
mappedNodes.push(mapped);
});
if (!changed) {
return slice;
}
return new Slice(
PMFragment.from(mappedNodes),
slice.openStart,
slice.openEnd
);
},
},
}),
new Plugin({
key: new PluginKey("footnoteRefClick"),
props: {
// on double-click, focus on the footnote
handleDoubleClickOn(view, pos, node, nodePos, event) {
if (node.type.name != "footnoteReference") return false;
event.preventDefault();
const id = node.attrs["data-id"];
return editor.commands.focusFootnote(id);
},
// click the footnote reference once to get focus, click twice to scroll to the footnote
handleClickOn(view, pos, node, nodePos, event) {
if (node.type.name != "footnoteReference") return false;
event.preventDefault();
const { selection } = editor.state.tr;
if (selection instanceof NodeSelection && selection.node.eq(node)) {
const id = node.attrs["data-id"];
return editor.commands.focusFootnote(id);
} else {
editor.chain().setNodeSelection(nodePos).run();
return true;
}
},
},
}),
];
},
addCommands() {
return {
addFootnote:
() =>
({ state, tr }) => {
const node = this.type.create({
"data-id": generateNodeId(),
});
tr.insert(state.selection.anchor, node);
return true;
},
};
},
addInputRules() {
// when a user types [^text], add a new footnote
return [
{
find: /\[\^(.*?)\]/,
type: this.type,
undoable: true,
handler({ range, match, chain }) {
const start = range.from;
let end = range.to;
if (match[1]) {
chain().deleteRange({ from: start, to: end }).addFootnote().run();
}
},
},
];
},
});
export default FootnoteReference;
@@ -0,0 +1,90 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReplaceStep } from "@tiptap/pm/transform";
import { Extension } from "@tiptap/core";
import { updateFootnotesList } from "./utils";
const FootnoteRules = Extension.create({
name: "footnoteRules",
priority: 1000,
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("footnoteRules"),
filterTransaction(tr) {
const { from, to } = tr.selection;
// Allow full document selections (Mod-a/Ctrl-a)
if (from === 0 && to === tr.doc.content.size) return true;
let selectedFootnotes = false;
let selectedContent = false;
let footnoteCount = 0;
tr.doc.nodesBetween(from, to, (node, _, parent) => {
if (parent?.type.name == "doc" && node.type.name != "footnotes") {
selectedContent = true;
} else if (node.type.name == "footnote") {
footnoteCount += 1;
} else if (node.type.name == "footnotes") {
selectedFootnotes = true;
}
});
const overSelected = selectedContent && selectedFootnotes;
/*
* Here, we don't allow any transaction that spans between the "content" nodes and the "footnotes" node. This also rejects any transaction that spans between more than 1 footnote.
*/
return !overSelected && footnoteCount <= 1;
},
// if there are some to the footnote references (added/deleted/dragged), append a transaction that updates the footnotes list accordingly
appendTransaction(transactions, oldState, newState) {
let newTr = newState.tr;
let refsChanged = false; // true if the footnote references have been changed, false otherwise
for (let tr of transactions) {
if (!tr.docChanged) continue;
if (refsChanged) break;
for (let step of tr.steps) {
if (!(step instanceof ReplaceStep)) continue;
if (refsChanged) break;
const isDelete = step.from != step.to; // the user deleted items from the document (from != to & the step is a replace step)
const isInsert = step.slice.size > 0;
// check if any footnote references have been inserted
if (isInsert) {
step.slice.content.descendants((node) => {
if (node?.type.name == "footnoteReference") {
refsChanged = true;
return false;
}
});
}
if (isDelete && !refsChanged) {
// check if any footnote references have been deleted
tr.before.nodesBetween(
step.from,
Math.min(tr.before.content.size, step.to), // make sure to not go over the old document's limit
(node) => {
if (node.type.name == "footnoteReference") {
refsChanged = true;
return false;
}
},
);
}
}
}
if (refsChanged) {
updateFootnotesList(newTr, newState);
return newTr;
}
return null;
},
}),
];
},
});
export default FootnoteRules;
@@ -0,0 +1,123 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { EditorState, Transaction } from "@tiptap/pm/state";
import { Fragment, Node } from "@tiptap/pm/model";
// update the reference number of all the footnote references in the document
export function updateFootnoteReferences(tr: Transaction) {
let count = 1;
const nodes: any[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name == "footnoteReference") {
tr.setNodeAttribute(pos, "referenceNumber", `${count}`);
nodes.push(node);
count += 1;
}
});
// return the updated footnote references (in the order that they appear in the document)
return nodes;
}
function getFootnotes(tr: Transaction) {
let footnotesRange: { from: number; to: number } | undefined;
const footnotes: Node[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name == "footnote") {
footnotes.push(node);
} else if (node.type.name == "footnotes") {
footnotesRange = { from: pos, to: pos + node.nodeSize };
} else {
return false;
}
});
return { footnotesRange, footnotes };
}
// update the "footnotes" ordered list based on the footnote references in the document
export function updateFootnotesList(tr: Transaction, state: EditorState) {
const footnoteReferences = updateFootnoteReferences(tr);
const footnoteType = state.schema.nodes.footnote;
const footnotesType = state.schema.nodes.footnotes;
const emptyParagraph = state.schema.nodeFromJSON({
type: "paragraph",
content: [],
});
const { footnotesRange, footnotes } = getFootnotes(tr);
// a mapping of footnote id -> footnote node
const footnoteIds: { [key: string]: Node } = footnotes.reduce(
(obj, footnote) => {
obj[footnote.attrs["data-id"]] = footnote;
return obj;
},
{} as any,
);
const newFootnotes: Node[] = [];
let footnoteRefIds = new Set(
footnoteReferences.map((ref) => ref.attrs["data-id"]),
);
const deleteFootnoteIds: Set<string> = new Set();
for (let footnote of footnotes) {
const id = footnote.attrs["data-id"];
if (!footnoteRefIds.has(id) || deleteFootnoteIds.has(id)) {
deleteFootnoteIds.add(id);
// we traverse through this footnote's content because it may contain footnote references.
// we want to delete the footnotes associated with these references, so we add them to the delete set.
footnote.content.descendants((node) => {
if (node.type.name == "footnoteReference")
deleteFootnoteIds.add(node.attrs["data-id"]);
});
}
}
for (let i = 0; i < footnoteReferences.length; i++) {
let refId = footnoteReferences[i].attrs["data-id"];
if (deleteFootnoteIds.has(refId)) continue;
// if there is a footnote w/ the same id as this `ref`, we preserve its content and update its id attribute
if (refId in footnoteIds) {
let footnote = footnoteIds[refId];
newFootnotes.push(
footnoteType.create(
{ ...footnote.attrs, id: `fn:${i + 1}` },
footnote.content,
),
);
} else {
let newNode = footnoteType.create(
{
"data-id": refId,
id: `fn:${i + 1}`,
},
[emptyParagraph],
);
newFootnotes.push(newNode);
}
}
if (newFootnotes.length == 0) {
// no footnotes in the doc, delete the "footnotes" node
if (footnotesRange) {
tr.delete(footnotesRange.from, footnotesRange.to);
}
} else if (!footnotesRange) {
// there is no footnotes node present in the doc, add it
tr.insert(
tr.doc.content.size,
footnotesType.create(undefined, Fragment.from(newFootnotes)),
);
} else {
tr.replaceWith(
footnotesRange!.from + 1, // add 1 to point at the position after the opening ol tag
footnotesRange!.to - 1, // substract 1 to point to the position before the closing ol tag
Fragment.from(newFootnotes),
);
}
}
@@ -0,0 +1,110 @@
import { Token, marked } from 'marked';
import { generateNodeId } from '../../utils';
interface FootnoteRefToken {
type: 'footnoteRef';
label: string;
raw: string;
}
interface FootnoteDefToken {
type: 'footnoteDef';
label: string;
text: string;
raw: string;
}
// Parse-scoped state: markdownToHtml resets before the top-level parse and
// appends the collected list after it. Nested marked.parse calls (callout,
// footnote definitions) share this state, so hooks cannot be used here.
let footnoteRefs: { label: string; id: string; number: number }[] = [];
let footnoteDefs = new Map<string, string>();
export function resetFootnotes() {
footnoteRefs = [];
footnoteDefs = new Map();
}
export function renderFootnotesList(): string {
if (!footnoteRefs.length) return '';
const items = footnoteRefs.map(({ label, id, number }) => {
const body = footnoteDefs.get(label) || '<p></p>';
return `<li id="fn:${number}" data-id="${id}">${body}</li>`;
});
return `<ol class="footnotes">\n${items.join('\n')}\n</ol>\n`;
}
export const footnoteRefExtension = {
name: 'footnoteRef',
level: 'inline',
start(src: string) {
return src.indexOf('[^');
},
tokenizer(src: string): FootnoteRefToken | undefined {
const match = /^\[\^([^\]\s]+)\]/.exec(src);
if (match) {
return {
type: 'footnoteRef',
raw: match[0],
label: match[1].toLowerCase(),
};
}
},
renderer(token: Token) {
const refToken = token as FootnoteRefToken;
const number = footnoteRefs.length + 1;
const id = generateNodeId();
footnoteRefs.push({ label: refToken.label, id, number });
return `<sup id="fnref:${number}"><a class="footnote-ref" data-id="${id}" data-reference-number="${number}" href="#fn:${number}">${number}</a></sup>`;
},
};
export const footnoteDefExtension = {
name: 'footnoteDef',
level: 'block',
start(src: string) {
return src.match(/^\[\^[^\]\s]+\]:/m)?.index ?? -1;
},
tokenizer(src: string): FootnoteDefToken | undefined {
const firstLine = /^\[\^([^\]\s]+)\]:[ \t]*/.exec(src);
if (!firstLine) return undefined;
const lines = src.split('\n');
const contentLines = [lines[0].slice(firstLine[0].length)];
let consumed = 1;
while (consumed < lines.length) {
const line = lines[consumed];
if (/^[ \t]{2,}\S/.test(line)) {
contentLines.push(line.replace(/^[ \t]{1,4}/, ''));
consumed += 1;
} else if (
/^[ \t]*$/.test(line) &&
consumed + 1 < lines.length &&
/^[ \t]{2,}\S/.test(lines[consumed + 1])
) {
contentLines.push('');
consumed += 1;
} else {
break;
}
}
const raw =
lines.slice(0, consumed).join('\n') +
(consumed < lines.length ? '\n' : '');
return {
type: 'footnoteDef',
raw,
label: firstLine[1].toLowerCase(),
text: contentLines.join('\n').trim(),
};
},
renderer(token: Token) {
const defToken = token as FootnoteDefToken;
const body = defToken.text
? marked.parse(defToken.text).toString()
: '<p></p>';
footnoteDefs.set(defToken.label, body);
return '';
},
};
@@ -2,6 +2,12 @@ import { marked } from "marked";
import { calloutExtension } from "./callout.marked"; import { calloutExtension } from "./callout.marked";
import { mathBlockExtension } from "./math-block.marked"; import { mathBlockExtension } from "./math-block.marked";
import { mathInlineExtension } from "./math-inline.marked"; import { mathInlineExtension } from "./math-inline.marked";
import {
footnoteDefExtension,
footnoteRefExtension,
renderFootnotesList,
resetFootnotes,
} from "./footnotes.marked";
marked.use({ marked.use({
renderer: { renderer: {
@@ -34,7 +40,13 @@ marked.use({
}); });
marked.use({ marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], extensions: [
calloutExtension,
mathBlockExtension,
mathInlineExtension,
footnoteDefExtension,
footnoteRefExtension,
],
}); });
marked.setOptions({ breaks: true }); marked.setOptions({ breaks: true });
@@ -48,5 +60,7 @@ export function markdownToHtml(
.replace(YAML_FONT_MATTER_REGEX, "") .replace(YAML_FONT_MATTER_REGEX, "")
.trimStart(); .trimStart();
return marked.parse(markdown).toString(); resetFootnotes();
const html = marked.parse(markdown).toString();
return html + renderFootnotesList();
} }
@@ -34,6 +34,8 @@ export function htmlToMarkdown(html: string): string {
iframeEmbed, iframeEmbed,
image, image,
video, video,
footnoteRef,
footnotesList,
]); ]);
return turndownService.turndown(html).replaceAll('<br>', ' '); return turndownService.turndown(html).replaceAll('<br>', ' ');
} }
@@ -203,6 +205,56 @@ function image(turndownService: _TurndownService) {
}); });
} }
function getFootnoteAnchor(node: HTMLElement): HTMLElement | null {
const child = node.firstElementChild as HTMLElement | null;
return child?.nodeName === 'A' && child.classList.contains('footnote-ref')
? child
: null;
}
function footnoteRef(turndownService: _TurndownService) {
turndownService.addRule('footnoteRef', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'SUP' && !!getFootnoteAnchor(node);
},
replacement: function (_content: string, node: HTMLInputElement) {
const anchor = getFootnoteAnchor(node);
const number =
anchor.getAttribute('data-reference-number') || anchor.textContent;
return `[^${number}]`;
},
});
}
function footnotesList(turndownService: _TurndownService) {
turndownService.addRule('footnotesList', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'OL' && node.classList.contains('footnotes');
},
replacement: function (_content: string, node: HTMLInputElement) {
const items = Array.from(node.children).filter(
(child) => child.nodeName === 'LI',
);
const definitions = items.map((li, index) => {
const number =
(li.getAttribute('id') || '').replace('fn:', '') ||
String(index + 1);
const markdown = turndownService
.turndown((li as HTMLElement).innerHTML)
.trim();
// continuation lines need a 4-space indent to stay in the footnote
const [first, ...rest] = markdown.split('\n');
const body = [
first,
...rest.map((line: string) => (line.trim() ? ` ${line}` : line)),
].join('\n');
return `[^${number}]: ${body}`;
});
return `\n\n${definitions.join('\n')}\n\n`;
},
});
}
function video(turndownService: _TurndownService) { function video(turndownService: _TurndownService) {
turndownService.addRule('video', { turndownService.addRule('video', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
@@ -1,4 +1,4 @@
import { HeadingLevel, ShadingType } from 'docx'; import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx';
import { Node } from 'prosemirror-model'; import { Node } from 'prosemirror-model';
import { import {
DocxSerializerAsync, DocxSerializerAsync,
@@ -168,6 +168,27 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
pageBreak(state, node) { pageBreak(state, node) {
state.closeBlock(node, { pageBreakBefore: true }); state.closeBlock(node, { pageBreakBefore: true });
}, },
footnoteReference(state, node) {
const number =
Number(node.attrs?.referenceNumber) || state.$footnoteCounter + 1;
state.$footnoteCounter = Math.max(state.$footnoteCounter, number);
// seed an empty body so the reference stays valid even if the trailing
// footnotes list is missing; the footnotes node overwrites it with content
if (!state.footnotes[number]) {
state.footnotes[number] = { children: [new Paragraph('')] };
}
state.current.push(new FootnoteReferenceRun(number));
},
async footnotes(state, node) {
for (let i = 0; i < node.childCount; i += 1) {
const item = node.child(i);
const number =
Number(String(item.attrs?.id ?? '').replace('fn:', '')) || i + 1;
await state.footnoteDefinition(item, number);
}
},
// items are consumed by the footnotes handler above
footnote() {},
// No usable static export representation: skip without failing. // No usable static export representation: skip without failing.
subpages() {}, subpages() {},
transclusionReference() {}, transclusionReference() {},
@@ -824,6 +824,29 @@ export class DocxSerializerStateAsync {
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter)); this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
} }
// Fills the footnote body for an already-referenced footnote number from a
// node holding block content (Docmost keeps footnote text in a trailing
// list, separate from the inline reference).
async footnoteDefinition(node: Node, number: number) {
const { current, children, nextRunOpts, nextParentParagraphOpts } = this;
this.current = [];
this.children = [];
delete this.nextRunOpts;
delete this.nextParentParagraphOpts;
await this.renderContent(node);
this.footnotes[number] = {
children: this.children.filter(
(child): child is Paragraph => child instanceof Paragraph,
),
};
this.current = current;
this.children = children;
this.nextRunOpts = nextRunOpts;
this.nextParentParagraphOpts = nextParentParagraphOpts;
}
closeBlock(node: Node, props?: IParagraphOptions) { closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({ const paragraph = new Paragraph({
children: this.current, children: this.current,
+17 -3
View File
@@ -7,9 +7,19 @@ export interface TrailingNodeExtensionOptions {
} }
function nodeEqualsType({ types, node }: { types: any, node: any }) { function nodeEqualsType({ types, node }: { types: any, node: any }) {
if (!node) return false
return (Array.isArray(types) && types.includes(node.type)) || node.type === types return (Array.isArray(types) && types.includes(node.type)) || node.type === types
} }
// footnotes must stay the last doc child, so the trailing node goes before it
function lastNodeBeforeFootnotes(doc: any) {
const lastChild = doc.lastChild
if (lastChild?.type.name === 'footnotes') {
return doc.childCount > 1 ? doc.child(doc.childCount - 2) : null
}
return lastChild
}
// @ts-ignore // @ts-ignore
/** /**
* Extension based on: * Extension based on:
@@ -40,19 +50,23 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
appendTransaction: (_, __, state) => { appendTransaction: (_, __, state) => {
const { doc, tr, schema } = state; const { doc, tr, schema } = state;
const shouldInsertNodeAtEnd = plugin.getState(state); const shouldInsertNodeAtEnd = plugin.getState(state);
const endPosition = doc.content.size;
const type = schema.nodes[this.options.node] const type = schema.nodes[this.options.node]
if (!shouldInsertNodeAtEnd) { if (!shouldInsertNodeAtEnd) {
return; return;
} }
const lastChild = doc.lastChild
const endPosition = lastChild?.type.name === 'footnotes'
? doc.content.size - lastChild.nodeSize
: doc.content.size
return tr.insert(endPosition, type.create()); return tr.insert(endPosition, type.create());
}, },
state: { state: {
init: (_, state) => { init: (_, state) => {
try { try {
const lastNode = state.tr.doc.lastChild const lastNode = lastNodeBeforeFootnotes(state.tr.doc)
return !nodeEqualsType({ node: lastNode, types: disabledNodes }) return !nodeEqualsType({ node: lastNode, types: disabledNodes })
} catch (err){ } catch (err){
console.log(err) console.log(err)
@@ -70,7 +84,7 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
return value return value
} }
const lastNode = tr.doc.lastChild const lastNode = lastNodeBeforeFootnotes(tr.doc)
return !nodeEqualsType({ node: lastNode, types: disabledNodes }) return !nodeEqualsType({ node: lastNode, types: disabledNodes })
}, },
}, },