From a0b2ac6ae3f481dc05028877eeeebaaf7ab05fde Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:30:30 +0100 Subject: [PATCH] feat: footnotes (#2384) * feat: footnotes * feat: proper DOCX support --- .../public/locales/en-US/translation.json | 2 + .../groups/more-inserts-group.tsx | 7 + .../components/slash-menu/menu-items.ts | 11 + .../features/editor/extensions/extensions.ts | 14 +- .../src/features/editor/styles/footnotes.css | 26 +++ .../src/features/editor/styles/index.css | 1 + .../src/collaboration/collaboration.util.ts | 13 +- apps/server/src/ee | 2 +- packages/editor-ext/src/index.ts | 1 + .../editor-ext/src/lib/footnotes/footnote.ts | 189 +++++++++++++++ .../editor-ext/src/lib/footnotes/footnotes.ts | 46 ++++ .../editor-ext/src/lib/footnotes/index.ts | 4 + .../editor-ext/src/lib/footnotes/reference.ts | 221 ++++++++++++++++++ .../editor-ext/src/lib/footnotes/rules.ts | 90 +++++++ .../editor-ext/src/lib/footnotes/utils.ts | 123 ++++++++++ .../lib/markdown/utils/footnotes.marked.ts | 110 +++++++++ .../src/lib/markdown/utils/marked.utils.ts | 18 +- .../src/lib/markdown/utils/turndown.utils.ts | 52 +++++ .../src/lib/prosemirror-docx/schema.ts | 23 +- .../src/lib/prosemirror-docx/serializer.ts | 23 ++ packages/editor-ext/src/lib/trailing-node.ts | 20 +- 21 files changed, 987 insertions(+), 9 deletions(-) create mode 100644 apps/client/src/features/editor/styles/footnotes.css create mode 100644 packages/editor-ext/src/lib/footnotes/footnote.ts create mode 100644 packages/editor-ext/src/lib/footnotes/footnotes.ts create mode 100644 packages/editor-ext/src/lib/footnotes/index.ts create mode 100644 packages/editor-ext/src/lib/footnotes/reference.ts create mode 100644 packages/editor-ext/src/lib/footnotes/rules.ts create mode 100644 packages/editor-ext/src/lib/footnotes/utils.ts create mode 100644 packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 18c981434..aaa340181 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -387,6 +387,8 @@ "Insert horizontal rule divider": "Insert horizontal rule divider", "Page break": "Page break", "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 video from your device.": "Upload any video from your device.", "Upload any audio from your device.": "Upload any audio from your device.", diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx index b12f609cf..f2ae8b912 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -12,6 +12,7 @@ import { IconMathFunction, IconRotate2, IconSitemap, + IconSuperscript, IconTable, IconTag, } from "@tabler/icons-react"; @@ -270,6 +271,12 @@ export const MoreInsertsGroup: FC = ({ editor, templateMode }) => { > {t("Math block")} + } + onClick={() => editor.chain().focus().addFootnote().run()} + > + {t("Footnote")} + ); diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 0327acdde..d8598c5ea 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -30,6 +30,7 @@ import { IconTag, IconMoodSmile, IconRotate2, + IconSuperscript, } from "@tabler/icons-react"; import { CommandProps, @@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => 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", description: "Upload any image from your device.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index bdbd78ad8..c72456e6e 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -1,5 +1,6 @@ import { markInputRule } from "@tiptap/core"; import { StarterKit } from "@tiptap/starter-kit"; +import { Document } from "@tiptap/extension-document"; import { Code } from "@tiptap/extension-code"; import { TextAlign } from "@tiptap/extension-text-align"; import { TaskList, TaskItem } from "@tiptap/extension-list"; @@ -63,6 +64,9 @@ import { TransclusionReference, TableView, BaseEmbed as BaseEmbedNode, + Footnotes, + Footnote, + FootnoteReference, } from "@docmost/editor-ext"; import { randomElement, @@ -132,6 +136,7 @@ lowlight.register("scala", scala); // @ts-ignore export const mainExtensions = [ StarterKit.configure({ + document: false, heading: false, undoRedo: false, link: false, @@ -143,6 +148,9 @@ export const mainExtensions = [ codeBlock: false, code: false, }), + Document.extend({ + content: "block+ footnotes?", + }), // Override TipTap's Code extension to fix the inline code input rule. // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // before the opening backtick as part of the match, causing markInputRule @@ -203,7 +211,8 @@ export const mainExtensions = [ parentName === "tableCell" || parentName === "tableHeader" || parentName === "callout" || - parentName === "blockquote" + parentName === "blockquote" || + parentName === "footnote" ) { return i18n.t("Write..."); } @@ -417,6 +426,9 @@ export const mainExtensions = [ }).configure(), Columns, Column, + Footnotes, + Footnote, + FootnoteReference, AutoJoiner.configure({ elementsToJoin: [], }), diff --git a/apps/client/src/features/editor/styles/footnotes.css b/apps/client/src/features/editor/styles/footnotes.css new file mode 100644 index 000000000..26edf51cf --- /dev/null +++ b/apps/client/src/features/editor/styles/footnotes.css @@ -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; +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 7b1ce93e8..cb49785ab 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -18,3 +18,4 @@ @import "./columns.css"; @import "./status.css"; @import "./base-embed.css"; +@import "./footnotes.css"; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index e8e8d4273..7ee5ba4aa 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -1,4 +1,5 @@ import { StarterKit } from '@tiptap/starter-kit'; +import { Document } from '@tiptap/extension-document'; import { TextAlign } from '@tiptap/extension-text-align'; import { Superscript } from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; @@ -45,6 +46,9 @@ import { TransclusionSource, TransclusionReference, BaseEmbed, + Footnotes, + Footnote, + FootnoteReference, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -58,11 +62,15 @@ import { Logger } from '@nestjs/common'; export const tiptapExtensions = [ StarterKit.configure({ + document: false, codeBlock: false, link: false, trailingNode: false, heading: false, }), + Document.extend({ + content: 'block+ footnotes?', + }), Heading, UniqueID.configure({ types: ['heading', 'paragraph', 'transclusionSource'], @@ -110,7 +118,10 @@ export const tiptapExtensions = [ Status, TransclusionSource, TransclusionReference, - BaseEmbed + BaseEmbed, + Footnotes, + Footnote, + FootnoteReference, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/ee b/apps/server/src/ee index 05529bcf9..c7b77ffb9 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 05529bcf97919d84f17442a9faf1a93904a5a85a +Subproject commit c7b77ffb9ed6a7bf462a4683de4f84355122e00b diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index d8ed68f2f..80a67f449 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -32,6 +32,7 @@ export * from "./lib/columns"; export * from "./lib/status"; export * from "./lib/pdf"; export * from "./lib/page-break"; +export * from "./lib/footnotes"; export * from "./lib/resizable-nodeview"; export { pageNodeToDocxBuffer, diff --git a/packages/editor-ext/src/lib/footnotes/footnote.ts b/packages/editor-ext/src/lib/footnotes/footnote.ts new file mode 100644 index 000000000..f83ab757a --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/footnote.ts @@ -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 { + 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({ + 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; diff --git a/packages/editor-ext/src/lib/footnotes/footnotes.ts b/packages/editor-ext/src/lib/footnotes/footnotes.ts new file mode 100644 index 000000000..c07528b16 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/footnotes.ts @@ -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; diff --git a/packages/editor-ext/src/lib/footnotes/index.ts b/packages/editor-ext/src/lib/footnotes/index.ts new file mode 100644 index 000000000..b22501b3b --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/footnotes/reference.ts b/packages/editor-ext/src/lib/footnotes/reference.ts new file mode 100644 index 000000000..6bada1281 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/reference.ts @@ -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 { + 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( + `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; diff --git a/packages/editor-ext/src/lib/footnotes/rules.ts b/packages/editor-ext/src/lib/footnotes/rules.ts new file mode 100644 index 000000000..7064916c0 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/rules.ts @@ -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; diff --git a/packages/editor-ext/src/lib/footnotes/utils.ts b/packages/editor-ext/src/lib/footnotes/utils.ts new file mode 100644 index 000000000..cb9190178 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/utils.ts @@ -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 = 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), + ); + } +} diff --git a/packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts b/packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts new file mode 100644 index 000000000..11c732d57 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts @@ -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(); + +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) || '

'; + return `
  • ${body}
  • `; + }); + return `
      \n${items.join('\n')}\n
    \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 `${number}`; + }, +}; + +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() + : '

    '; + footnoteDefs.set(defToken.label, body); + return ''; + }, +}; diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 7556aa4f0..0377ab7f7 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -2,6 +2,12 @@ import { marked } from "marked"; import { calloutExtension } from "./callout.marked"; import { mathBlockExtension } from "./math-block.marked"; import { mathInlineExtension } from "./math-inline.marked"; +import { + footnoteDefExtension, + footnoteRefExtension, + renderFootnotesList, + resetFootnotes, +} from "./footnotes.marked"; marked.use({ renderer: { @@ -34,7 +40,13 @@ marked.use({ }); marked.use({ - extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], + extensions: [ + calloutExtension, + mathBlockExtension, + mathInlineExtension, + footnoteDefExtension, + footnoteRefExtension, + ], }); marked.setOptions({ breaks: true }); @@ -48,5 +60,7 @@ export function markdownToHtml( .replace(YAML_FONT_MATTER_REGEX, "") .trimStart(); - return marked.parse(markdown).toString(); + resetFootnotes(); + const html = marked.parse(markdown).toString(); + return html + renderFootnotesList(); } diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index ebfc3423e..55f4afd37 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -34,6 +34,8 @@ export function htmlToMarkdown(html: string): string { iframeEmbed, image, video, + footnoteRef, + footnotesList, ]); return turndownService.turndown(html).replaceAll('
    ', ' '); } @@ -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) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { diff --git a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts index 1a2797213..df89cf1d7 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts @@ -1,4 +1,4 @@ -import { HeadingLevel, ShadingType } from 'docx'; +import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx'; import { Node } from 'prosemirror-model'; import { DocxSerializerAsync, @@ -168,6 +168,27 @@ export const defaultAsyncNodes: NodeSerializerAsync = { pageBreak(state, node) { 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. subpages() {}, transclusionReference() {}, diff --git a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts index fa62a8cf6..b349ece3e 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts @@ -824,6 +824,29 @@ export class DocxSerializerStateAsync { 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) { const paragraph = new Paragraph({ children: this.current, diff --git a/packages/editor-ext/src/lib/trailing-node.ts b/packages/editor-ext/src/lib/trailing-node.ts index a4d77b3df..6b3d0f584 100644 --- a/packages/editor-ext/src/lib/trailing-node.ts +++ b/packages/editor-ext/src/lib/trailing-node.ts @@ -7,9 +7,19 @@ export interface TrailingNodeExtensionOptions { } function nodeEqualsType({ types, node }: { types: any, node: any }) { + if (!node) return false 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 /** * Extension based on: @@ -40,19 +50,23 @@ export const TrailingNode = Extension.create({ appendTransaction: (_, __, state) => { const { doc, tr, schema } = state; const shouldInsertNodeAtEnd = plugin.getState(state); - const endPosition = doc.content.size; const type = schema.nodes[this.options.node] if (!shouldInsertNodeAtEnd) { 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()); }, state: { init: (_, state) => { try { - const lastNode = state.tr.doc.lastChild + const lastNode = lastNodeBeforeFootnotes(state.tr.doc) return !nodeEqualsType({ node: lastNode, types: disabledNodes }) } catch (err){ console.log(err) @@ -70,7 +84,7 @@ export const TrailingNode = Extension.create({ return value } - const lastNode = tr.doc.lastChild + const lastNode = lastNodeBeforeFootnotes(tr.doc) return !nodeEqualsType({ node: lastNode, types: disabledNodes }) }, },