From 26976875278dd09e18c33beb07b8df1cd65386c4 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 7 Jul 2026 14:06:31 +0200 Subject: [PATCH 01/14] feat(markdownit): add footnote support customize markdown-it-footnote for simpler HTML output structure. Signed-off-by: Jonas --- package-lock.json | 18 +++++++++++ package.json | 2 ++ src/markdownit/footnotes.ts | 41 ++++++++++++++++++++++++++ src/markdownit/index.js | 2 ++ src/tests/markdownit/footnotes.spec.ts | 41 ++++++++++++++++++++++++++ 5 files changed, 104 insertions(+) create mode 100644 src/markdownit/footnotes.ts create mode 100644 src/tests/markdownit/footnotes.spec.ts diff --git a/package-lock.json b/package-lock.json index 71add2ea8e0..98327462f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "lowlight": "^3.3.0", "markdown-it": "^14.3.0", "markdown-it-container": "^4.0.0", + "markdown-it-footnote": "^4.0.0", "markdown-it-front-matter": "^0.2.4", "markdown-it-image-figures": "^2.1.1", "markdown-it-mark": "^4.0.0", @@ -95,6 +96,7 @@ "@nextcloud/vite-config": "^2.5.4", "@playwright/test": "^1.61.1", "@types/markdown-it": "^14.1.2", + "@types/markdown-it-footnote": "^3.0.4", "@vitejs/plugin-vue": "^6.0.7", "@vitest/coverage-v8": "^4.1.10", "@vue/test-utils": "^2.4.11", @@ -5950,6 +5952,16 @@ "@types/mdurl": "^2" } }, + "node_modules/@types/markdown-it-footnote": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/markdown-it-footnote/-/markdown-it-footnote-3.0.4.tgz", + "integrity": "sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/markdown-it": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -13526,6 +13538,12 @@ "integrity": "sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==", "license": "MIT" }, + "node_modules/markdown-it-footnote": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-4.0.0.tgz", + "integrity": "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==", + "license": "MIT" + }, "node_modules/markdown-it-front-matter": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/markdown-it-front-matter/-/markdown-it-front-matter-0.2.4.tgz", diff --git a/package.json b/package.json index 6e13e099c0d..6f409e2024a 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "lowlight": "^3.3.0", "markdown-it": "^14.3.0", "markdown-it-container": "^4.0.0", + "markdown-it-footnote": "^4.0.0", "markdown-it-front-matter": "^0.2.4", "markdown-it-image-figures": "^2.1.1", "markdown-it-mark": "^4.0.0", @@ -113,6 +114,7 @@ "@nextcloud/vite-config": "^2.5.4", "@playwright/test": "^1.61.1", "@types/markdown-it": "^14.1.2", + "@types/markdown-it-footnote": "^3.0.4", "@vitejs/plugin-vue": "^6.0.7", "@vitest/coverage-v8": "^4.1.10", "@vue/test-utils": "^2.4.11", diff --git a/src/markdownit/footnotes.ts b/src/markdownit/footnotes.ts new file mode 100644 index 00000000000..0935df7a89c --- /dev/null +++ b/src/markdownit/footnotes.ts @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type MarkdownIt from 'markdown-it' +import type Token from 'markdown-it/lib/token.mjs' + +import footnote from 'markdown-it-footnote' +import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' + +/** + * Return footnote label + * + * @param token markdown-it token + */ +function labelOf(token: Token): string { + return (token.meta && token.meta.label) || String(token.meta?.id ?? '') +} + +/** + * Customized markdown-it footnotes extension + * + * @param md markdown-it markdown object + */ +export default function footnotes(md: MarkdownIt): void { + md.use(footnote) + + md.renderer.rules.footnote_ref = (tokens, idx) => { + const label = labelOf(tokens[idx]) + return `` + } + md.renderer.rules.footnote_block_open = () => '
\n' + md.renderer.rules.footnote_block_close = () => '
\n' + md.renderer.rules.footnote_open = (tokens, idx) => { + const label = labelOf(tokens[idx]) + return `
\n` + } + md.renderer.rules.footnote_close = () => '
\n' + md.renderer.rules.footnote_anchor = () => '' +} diff --git a/src/markdownit/index.js b/src/markdownit/index.js index 8e257896418..c894399a239 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -12,6 +12,7 @@ import multimdTable from 'markdown-it-multimd-table' import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' import callouts from './callouts.js' import details from './details.ts' +import footnotes from './footnotes.ts' import hardbreak from './hardbreak.js' import keepSyntax from './keepSyntax.js' import mathematics from './mathematics.ts' @@ -32,6 +33,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(hardbreak) .use(callouts) .use(details) + .use(footnotes) .use(preview) .use(keepSyntax) .use(markdownitMentions) diff --git a/src/tests/markdownit/footnotes.spec.ts b/src/tests/markdownit/footnotes.spec.ts new file mode 100644 index 00000000000..f15bf733f04 --- /dev/null +++ b/src/tests/markdownit/footnotes.spec.ts @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import markdownit from '../../markdownit/index.js' + +describe('footnotes (markdown-it)', () => { + it('simple footnote', () => { + expect(markdownit.render('Foo[^1]\n\n[^1]: bar')) + .to.equal('

Foo

\n
\n
\n

bar

\n
\n
\n') + }) + it('footnote with custom numeric label', () => { + expect(markdownit.render('Foo[^42]\n\n[^42]: bar')) + .to.equal('

Foo

\n
\n
\n

bar

\n
\n
\n') + }) + it('footnote with custom string label', () => { + expect(markdownit.render('Foo[^label]\n\n[^label]: bar')) + .to.equal('

Foo

\n
\n
\n

bar

\n
\n
\n') + }) + it('multi-line footnote', () => { + expect(markdownit.render('Foo[^12]\n\n[^12]: first\n second')) + .to.equal('

Foo

\n
\n
\n

first\nsecond

\n
\n
\n') + }) + it('multi-paragraph footnote with list item', () => { + expect(markdownit.render('Foo[^1]\n\n[^1]: first\n\n * item')) + .to.equal('

Foo

\n
\n
\n

first

\n
    \n
  • item
  • \n
\n
\n
\n') + }) + it('multiple footnotes', () => { + expect(markdownit.render('Foo[^1] bar[^2]\n\n[^1]: first\n\n[^2]: second')) + .to.equal('

Foo bar

\n
\n
\n

first

\n
\n
\n

second

\n
\n
\n') + }) + it('multiple references to same label', () => { + expect(markdownit.render('Foo[^x] bar[^x]\n\n[^x]: label')) + .to.equal('

Foo bar

\n
\n
\n

label

\n
\n
\n') + }) + it('dangling reference', () => { + expect(markdownit.render('Foo[^dangling]')) + .to.equal('

Foo[^dangling]

\n') + }) +}) From e4b7ede634a90c906dc487384a1c41721fcf913d Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 7 Jul 2026 15:10:18 +0200 Subject: [PATCH 02/14] feat(editor): add basic footnote Tiptap nodes Signed-off-by: Jonas --- src/extensions/RichText.ts | 11 +++++- src/nodes/Footnote.ts | 39 +++++++++++++++++++ src/nodes/FootnoteReference.ts | 38 ++++++++++++++++++ src/nodes/Footnotes.ts | 34 ++++++++++++++++ src/tests/nodes/Footnotes.spec.ts | 64 +++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/nodes/Footnote.ts create mode 100644 src/nodes/FootnoteReference.ts create mode 100644 src/nodes/Footnotes.ts create mode 100644 src/tests/nodes/Footnotes.spec.ts diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index 19247507d2a..b921ff3fb95 100644 --- a/src/extensions/RichText.ts +++ b/src/extensions/RichText.ts @@ -29,6 +29,7 @@ import Callouts from '../nodes/Callout.js' import CodeBlock from '../nodes/CodeBlock.js' import Details from '../nodes/Details.js' import EditableTable from '../nodes/EditableTable.js' +import Footnotes from '../nodes/Footnotes.ts' import FrontMatter from '../nodes/FrontMatter.js' import HardBreak from '../nodes/HardBreak.js' import Heading from '../nodes/Heading.js' @@ -76,7 +77,9 @@ export default Extension.create({ addExtensions() { const defaultExtensions = [ Markdown, - Document, + Document.extend({ + content: 'block+ footnotes?', + }), Text, Paragraph, HardBreak, @@ -93,6 +96,7 @@ export default Extension.create({ defaultLanguage: 'plaintext', }), Details, + Footnotes, BulletList, HorizontalRule, OrderedList, @@ -140,12 +144,15 @@ export default Extension.create({ placeholder: t('text', "Start writing or type '/' to add…"), })] : []), - TrailingNode, + TrailingNode.configure({ + notAfter: ['paragraph', 'footnotes'], + }), TextDirection.configure({ types: [ 'blockquote', 'callout', 'detailsSummary', + 'footnote', 'heading', 'listItem', 'paragraph', diff --git a/src/nodes/Footnote.ts b/src/nodes/Footnote.ts new file mode 100644 index 00000000000..692fe763523 --- /dev/null +++ b/src/nodes/Footnote.ts @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' + +const Footnote = Node.create({ + name: 'footnote', + content: 'block+', + defining: true, + isolating: true, + + addAttributes() { + return { + referenceId: { + default: '', + parseHTML: (el) => el.getAttribute('data-reference-id') ?? '', + renderHTML: (attrs) => ({ + 'data-reference-id': attrs.referenceId, + }), + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="footnote"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { 'data-type': 'footnote' }), + 0, + ] + }, +}) + +export default Footnote diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts new file mode 100644 index 00000000000..4b717dd1fdf --- /dev/null +++ b/src/nodes/FootnoteReference.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' + +const FootnoteReference = Node.create({ + name: 'footnoteReference', + group: 'inline', + inline: true, + atom: true, + + addAttributes() { + return { + referenceId: { + default: '', + parseHTML: (el) => el.getAttribute('data-reference-id') ?? '', + renderHTML: (attrs) => ({ + 'data-reference-id': attrs.referenceId, + }), + }, + } + }, + + parseHTML() { + return [{ tag: 'sup[data-type="footnote-reference"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'sup', + mergeAttributes(HTMLAttributes, { 'data-type': 'footnote-reference' }), + ] + }, +}) + +export default FootnoteReference diff --git a/src/nodes/Footnotes.ts b/src/nodes/Footnotes.ts new file mode 100644 index 00000000000..899c4289536 --- /dev/null +++ b/src/nodes/Footnotes.ts @@ -0,0 +1,34 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' +import Footnote from './Footnote.ts' +import FootnoteReference from './FootnoteReference.ts' + +const Footnotes = Node.create({ + name: 'footnotes', + content: 'footnote+', + defining: true, + isolating: true, + allowGapCursor: false, + + addExtensions() { + return [Footnote, FootnoteReference] + }, + + parseHTML() { + return [{ tag: 'section[data-type="footnotes"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'section', + mergeAttributes(HTMLAttributes, { 'data-type': 'footnotes' }), + 0, + ] + }, +}) + +export default Footnotes diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts new file mode 100644 index 00000000000..cb8df38c7fd --- /dev/null +++ b/src/tests/nodes/Footnotes.spec.ts @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Document } from '@tiptap/extension-document' +import { describe, expect } from 'vitest' +import Footnotes from '../../nodes/Footnotes.ts' +import testEditor from '../testHelpers/testEditor.ts' + +const test = testEditor.override('extensions', [ + Document.extend({ content: 'block+ footnotes?' }), + Footnotes, +]) + +describe('Footnotes extension', () => { + test('registers footnotes, footnote and footnoteReference nodes', ({ editor }) => { + expect(editor.schema.nodes.footnote).toBeDefined() + expect(editor.schema.nodes.footnotes).toBeDefined() + expect(editor.schema.nodes.footnoteReference).toBeDefined() + }) + + test('parses footnote reference with custom label from HTML', ({ editor }) => { + editor.commands + .setContent('

Foo

' + + '
' + + '

Footnote

' + + '
') + + const ref = editor.state.doc.firstChild!.child(1) + const footnotes = editor.state.doc.lastChild! + const footnote = footnotes.firstChild! + + expect(ref.type.name).toBe('footnoteReference') + expect(ref.attrs.referenceId).toBe('label') + expect(footnotes.type.name).toBe('footnotes') + expect(footnote.type.name).toBe('footnote') + expect(footnote.attrs.referenceId).toBe('label') + }) + + test('preserves data attributes across HTML round-trip', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
' + + '

Footnote

' + + '
') + const out = editor.getHTML() + expect(out).toContain('data-type="footnote-reference"') + expect(out).toContain('data-reference-id="label"') + expect(out).toContain('data-type="footnotes"') + expect(out).toContain('data-type="footnote"') + }) + + test('parses multi-paragraph footnote body', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
' + + '

first

second

' + + '
') + const footnote = editor.state.doc.lastChild!.firstChild! + expect(footnote.type.name).toBe('footnote') + expect(footnote.childCount).toBe(2) + expect(footnote.firstChild!.textContent).toBe('first') + expect(footnote.lastChild!.textContent).toBe('second') + }) +}) From f59fc8202698fdc835ed02b381441391431e316a Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 7 Jul 2026 15:27:16 +0200 Subject: [PATCH 03/14] feat(editor): add markdown serialization to footnote nodes Signed-off-by: Jonas --- src/nodes/Footnote.ts | 7 ++++++ src/nodes/FootnoteReference.ts | 4 +++ src/nodes/Footnotes.ts | 4 +++ src/tests/nodes/Footnotes.spec.ts | 41 +++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/src/nodes/Footnote.ts b/src/nodes/Footnote.ts index 692fe763523..831ee439aeb 100644 --- a/src/nodes/Footnote.ts +++ b/src/nodes/Footnote.ts @@ -34,6 +34,13 @@ const Footnote = Node.create({ 0, ] }, + + toMarkdown(state, node) { + const marker = `[^${node.attrs.referenceId}]: ` + const wrapper = ' '.repeat(marker.length) + state.wrapBlock(wrapper, marker, node, () => state.renderContent(node)) + state.closeBlock(node) + }, }) export default Footnote diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 4b717dd1fdf..f53ca37669e 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -33,6 +33,10 @@ const FootnoteReference = Node.create({ mergeAttributes(HTMLAttributes, { 'data-type': 'footnote-reference' }), ] }, + + toMarkdown(state, node) { + state.write(`[^${node.attrs.referenceId}]`) + }, }) export default FootnoteReference diff --git a/src/nodes/Footnotes.ts b/src/nodes/Footnotes.ts index 899c4289536..9ab96a8a424 100644 --- a/src/nodes/Footnotes.ts +++ b/src/nodes/Footnotes.ts @@ -29,6 +29,10 @@ const Footnotes = Node.create({ 0, ] }, + + toMarkdown(state, node) { + state.renderContent(node) + }, }) export default Footnotes diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts index cb8df38c7fd..684ed7d9b72 100644 --- a/src/tests/nodes/Footnotes.spec.ts +++ b/src/tests/nodes/Footnotes.spec.ts @@ -4,13 +4,19 @@ */ import { Document } from '@tiptap/extension-document' +import { ListItem } from '@tiptap/extension-list' import { describe, expect } from 'vitest' +import KeepSyntax from '../../extensions/KeepSyntax.js' +import BulletList from '../../nodes/BulletList.ts' import Footnotes from '../../nodes/Footnotes.ts' import testEditor from '../testHelpers/testEditor.ts' const test = testEditor.override('extensions', [ Document.extend({ content: 'block+ footnotes?' }), Footnotes, + BulletList, + KeepSyntax, + ListItem, ]) describe('Footnotes extension', () => { @@ -62,3 +68,38 @@ describe('Footnotes extension', () => { expect(footnote.lastChild!.textContent).toBe('second') }) }) + +describe('Footnotes Markdown roundtrip', () => { + test('simple footnote', ({ markdownThroughEditor }) => { + const test = 'Foo[^1]\n\n[^1]: bar' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('footnote with custom numeric label', ({ markdownThroughEditor }) => { + const test = 'Foo[^42]\n\n[^42]: bar' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('footnote with custom string label', ({ markdownThroughEditor }) => { + const test = 'Foo[^label]\n\n[^label]: bar' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('multi-line footnote', ({ markdownThroughEditor }) => { + const test = 'Foo[^1]\n\n[^1]: first\n second' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('multi-paragraph footnote with list item', ({ markdownThroughEditor }) => { + const test = 'Foo[^1]\n\n[^1]: first\n\n * item' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('multiple footnotes', ({ markdownThroughEditor }) => { + const test = 'Foo[^1] bar[^2]\n\n[^1]: first\n\n[^2]: second' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('multiple references to same label', ({ markdownThroughEditor }) => { + const test = 'Foo[^x] bar[^x]\n\n[^x]: label' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('dangling reference', ({ markdownThroughEditor }) => { + const test = 'Foo[^dangling]' + expect(markdownThroughEditor(test)).toBe(test) + }) +}) From a13afba57aa8d61e8a85cfe2416b08525fdd506f Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 7 Jul 2026 16:34:44 +0200 Subject: [PATCH 04/14] feat(editor): add navigation between reference and footnote Signed-off-by: Jonas --- src/css/prosemirror.scss | 50 +++++++++++++++++++++++++++++++ src/nodes/Footnote.ts | 17 ++++++++--- src/nodes/FootnoteReference.ts | 10 +++++-- src/tests/nodes/Footnotes.spec.ts | 2 ++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index e22d9ff5814..5f7ed618433 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -423,6 +423,56 @@ div.ProseMirror { z-index: 2; } } + + sup[data-type="footnote-reference"] { + a.footnote-ref { + color: var(--color-primary-element); + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + } + } + + section[data-type="footnotes"] { + margin-top: calc(2 * var(--default-grid-baseline)); + border-top: 1px solid var(--color-border); + font-size: 0.9em; + + div[data-type="footnote"] { + display: flex; + gap: 0.5em; + margin-bottom: 0.5em; + + .footnote-label { + flex: 0 0 auto; + font-weight: bold; + user-select: none; + } + + .footnote-body { + flex: 1; + + // Make sure back-arrow sits close to the end of the body + > p:last-child { + margin-bottom: 0; + } + } + + .footnote-backref { + flex: 0 0 auto; + color: var(--color-primary-element); + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + } + } + } } .ProseMirror-focused .ProseMirror-gapcursor { diff --git a/src/nodes/Footnote.ts b/src/nodes/Footnote.ts index 831ee439aeb..8b43a444d31 100644 --- a/src/nodes/Footnote.ts +++ b/src/nodes/Footnote.ts @@ -24,14 +24,23 @@ const Footnote = Node.create({ }, parseHTML() { - return [{ tag: 'div[data-type="footnote"]' }] + return [ + { + tag: 'div[data-type="footnote"]', + // Skip the label span; recurse into the body wrapper + contentElement: (dom) => (dom as HTMLElement).querySelector('.footnote-body') ?? (dom as HTMLElement), + }, + ] }, - renderHTML({ HTMLAttributes }) { + renderHTML({ node, HTMLAttributes }) { + const id = node.attrs.referenceId return [ 'div', - mergeAttributes(HTMLAttributes, { 'data-type': 'footnote' }), - 0, + mergeAttributes(HTMLAttributes, { 'data-type': 'footnote', id: `fn-${id}` }), + ['span', { class: 'footnote-label', contenteditable: 'false' }, `[${id}]`], + ['div', { class: 'footnote-body' }, 0], + ['a', { href: `#fnref-${id}`, class: 'footnote-backref', contenteditable: 'false', 'aria-label': 'Back to reference' }, '↩'], ] }, diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index f53ca37669e..399fd450aed 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -27,10 +27,16 @@ const FootnoteReference = Node.create({ return [{ tag: 'sup[data-type="footnote-reference"]' }] }, - renderHTML({ HTMLAttributes }) { + renderHTML({ node, HTMLAttributes }) { + const id = node.attrs.referenceId return [ 'sup', - mergeAttributes(HTMLAttributes, { 'data-type': 'footnote-reference' }), + mergeAttributes(HTMLAttributes, { 'data-type': 'footnote-reference', id: `fnref-${id}` }), + [ + 'a', + { href: `#fn-${id}`, class: 'footnote-ref', role: 'doc-noteref' }, + `[${id}]`, + ], ] }, diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts index 684ed7d9b72..c2d0cc888a6 100644 --- a/src/tests/nodes/Footnotes.spec.ts +++ b/src/tests/nodes/Footnotes.spec.ts @@ -54,6 +54,8 @@ describe('Footnotes extension', () => { expect(out).toContain('data-reference-id="label"') expect(out).toContain('data-type="footnotes"') expect(out).toContain('data-type="footnote"') + expect(out).toContain('id="fnref-label"') + expect(out).toContain('id="fn-label"') }) test('parses multi-paragraph footnote body', ({ editor }) => { From bf349f04f7d0c9c6ef98c93facde2f7629efa7c7 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 7 Jul 2026 17:09:06 +0200 Subject: [PATCH 05/14] feat(footnote): allow to create footnotes Signed-off-by: Jonas --- src/css/prosemirror.scss | 27 +++++--- src/nodes/Footnote.ts | 10 ++- src/nodes/FootnoteReference.ts | 109 ++++++++++++++++++++++++++++++ src/tests/nodes/Footnotes.spec.ts | 49 ++++++++++++++ 4 files changed, 181 insertions(+), 14 deletions(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 5f7ed618433..dfc95a4dc0c 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -439,32 +439,37 @@ div.ProseMirror { section[data-type="footnotes"] { margin-top: calc(2 * var(--default-grid-baseline)); border-top: 1px solid var(--color-border); - font-size: 0.9em; div[data-type="footnote"] { display: flex; - gap: 0.5em; - margin-bottom: 0.5em; + gap: var(--default-grid-baseline); .footnote-label { - flex: 0 0 auto; + flex: 0 0 44px; + font-size: 0.9em; font-weight: bold; user-select: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .footnote-body { - flex: 1; + .footnote-body > p { + margin: 0; + line-height: unset; + } - // Make sure back-arrow sits close to the end of the body - > p:last-child { - margin-bottom: 0; - } + .footnote-body-wrapper { + display: flex; + gap: var(--default-grid-baseline); + font-size: 0.9em; } .footnote-backref { - flex: 0 0 auto; + padding: 0; color: var(--color-primary-element); text-decoration: none; + width: unset; // overwrite default `a` width &:hover, &:focus { diff --git a/src/nodes/Footnote.ts b/src/nodes/Footnote.ts index 8b43a444d31..e549d83c997 100644 --- a/src/nodes/Footnote.ts +++ b/src/nodes/Footnote.ts @@ -38,9 +38,13 @@ const Footnote = Node.create({ return [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': 'footnote', id: `fn-${id}` }), - ['span', { class: 'footnote-label', contenteditable: 'false' }, `[${id}]`], - ['div', { class: 'footnote-body' }, 0], - ['a', { href: `#fnref-${id}`, class: 'footnote-backref', contenteditable: 'false', 'aria-label': 'Back to reference' }, '↩'], + ['span', { class: 'footnote-label', contenteditable: 'false', title: id }, id], + [ + 'div', + { class: 'footnote-body-wrapper' }, + ['div', { class: 'footnote-body' }, 0], + ['a', { href: `#fnref-${id}`, class: 'footnote-backref', contenteditable: 'false', 'aria-label': 'Back to reference' }, '↩'], + ], ] }, diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 399fd450aed..35d72deeb3b 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -3,8 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + import { mergeAttributes, Node } from '@tiptap/core' +declare module '@tiptap/core' { + interface Commands { + footnoteReference: { + /** + * Insert a new footnote at the current selection and + * create a matching definition in the trailing footnotes + * block. + */ + insertFootnote: (options?: { referenceId?: string }) => ReturnType + } + } +} + const FootnoteReference = Node.create({ name: 'footnoteReference', group: 'inline', @@ -43,6 +58,100 @@ const FootnoteReference = Node.create({ toMarkdown(state, node) { state.write(`[^${node.attrs.referenceId}]`) }, + + addCommands() { + return { + insertFootnote: (options?: { referenceId?: string }) => ({ state, chain }) => { + const referenceId = options?.referenceId + ? String(options.referenceId) + : generateFootnoteId(state.doc) + if (!referenceId) { + return false + } + + const existingFootnote = footnoteExists(state.doc, referenceId) + + const footnotesType = state.schema.nodes.footnotes + const footnoteType = state.schema.nodes.footnote + const paragraphType = state.schema.nodes.paragraph + + let c = chain() + .insertContent({ type: 'footnoteReference', attrs: { referenceId } }) + + if (!existingFootnote) { + const newFootnote = footnoteType.create({ referenceId }, paragraphType.create()) + const lastChild = state.doc.lastChild + const hasFootnotesBlock = lastChild?.type === footnotesType + + if (hasFootnotesBlock) { + // Append inside the existing footnotes container + const insertPos = state.doc.content.size - 1 + c = c.insertContentAt(insertPos, newFootnote.toJSON()) + } else { + c = c.insertContentAt(state.doc.content.size, { + type: 'footnotes', + content: [newFootnote.toJSON()], + }) + } + } + + return c + .focus() + .scrollIntoView() + .run() + }, + } + }, + + addKeyboardShortcuts() { + return { + 'Mod-Shift-f': () => this.editor.commands.insertFootnote(), + } + }, }) +/** + * Get first unused numeric footnote id + * + * @param doc the document node + */ +function generateFootnoteId(doc: ProseMirrorNode): string { + const existing = new Set() + doc.descendants((node) => { + if (node.type.name === 'footnoteReference' || node.type.name === 'footnote') { + const id = node.attrs.referenceId + if (id) { + existing.add(String(id)) + } + } + }) + for (let i = 1; i < 10_000; i++) { + const candidate = String(i) + if (!existing.has(candidate)) { + return candidate + } + } + return '' +} + +/** + * Check if footnote with reference id exists + * + * @param doc - the ProseMirror node + * @param id - the searched reference id + */ +function footnoteExists(doc: ProseMirrorNode, id: string): boolean { + let found = false + doc.descendants((node) => { + if (found) { + return false + } + if (node.type.name === 'footnote' && node.attrs.referenceId === id) { + found = true + return false + } + }) + return found +} + export default FootnoteReference diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts index c2d0cc888a6..2e40c28eba7 100644 --- a/src/tests/nodes/Footnotes.spec.ts +++ b/src/tests/nodes/Footnotes.spec.ts @@ -71,6 +71,55 @@ describe('Footnotes extension', () => { }) }) +describe('insertFootnote command', () => { + test('inserts a reference and a matching definition', ({ editor }) => { + editor.commands.setContent('

Foo

') + editor.commands.focus('end') + + const result = editor.commands.insertFootnote() + expect(result).toBe(true) + + const paragraph = editor.state.doc.firstChild! + expect(paragraph.childCount).toBe(2) + expect(paragraph.child(1).type.name).toBe('footnoteReference') + + const footnotes = editor.state.doc.lastChild! + const footnote = footnotes.firstChild! + expect(footnote.type.name).toBe('footnote') + }) + + test('generates the lowest unused numeric id', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
data-type="footnotes">' + + '

x

' + + '
') + editor.commands.focus('start') + editor.commands.setTextSelection(1) + editor.commands.insertFootnote() + + const refs: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'footnoteReference') { + refs.push(node.attrs.referenceId) + } + }) + expect(refs).toContain('1') + expect(refs).toContain('2') + }) + + test('accepts explicit referenceId option', ({ editor }) => { + editor.commands.setContent('

Foo

') + editor.commands.focus('end') + editor.commands.insertFootnote({ referenceId: 'custom' }) + + const ref = editor.state.doc.firstChild!.lastChild! + expect(ref.attrs.referenceId).toBe('custom') + + const footnote = editor.state.doc.lastChild!.firstChild! + expect(footnote.attrs.referenceId).toBe('custom') + }) +}) + describe('Footnotes Markdown roundtrip', () => { test('simple footnote', ({ markdownThroughEditor }) => { const test = 'Foo[^1]\n\n[^1]: bar' From 32e3de23c2ce3a592339422b2870a480afe5b92c Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 12:40:30 +0200 Subject: [PATCH 06/14] feat(footnotes): highlight target at navigating between ref and footnote Signed-off-by: Jonas --- src/css/prosemirror.scss | 10 +++++++ src/nodes/FootnoteReference.ts | 50 ++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index dfc95a4dc0c..e5fb92a943f 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -500,3 +500,13 @@ div.ProseMirror { box-sizing: border-box; visibility: visible !important; } + +.footnote-highlight { + animation: highlight-animation 5s 1; + border-radius: var(--border-radius-small); +} +@keyframes highlight-animation { + 0% { background-color: var(--color-background-hover); } + 50% { background-color: var(--color-background-hover); } + 100% { background-color: transparent; } +} diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 35d72deeb3b..adb7174f66e 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -5,7 +5,8 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model' -import { mergeAttributes, Node } from '@tiptap/core' +import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core' +import { Plugin } from '@tiptap/pm/state' declare module '@tiptap/core' { interface Commands { @@ -46,11 +47,14 @@ const FootnoteReference = Node.create({ const id = node.attrs.referenceId return [ 'sup', - mergeAttributes(HTMLAttributes, { 'data-type': 'footnote-reference', id: `fnref-${id}` }), + mergeAttributes(HTMLAttributes, { + 'data-type': 'footnote-reference', + id: `fnref-${id}`, + }), [ 'a', { href: `#fn-${id}`, class: 'footnote-ref', role: 'doc-noteref' }, - `[${id}]`, + id, ], ] }, @@ -108,6 +112,46 @@ const FootnoteReference = Node.create({ 'Mod-Shift-f': () => this.editor.commands.insertFootnote(), } }, + + addInputRules() { + return [ + nodeInputRule({ + find: /\[\^([^\]\s]+)\]$/, + type: this.type, + getAttributes: (match) => ({ referenceId: match[1] }), + }), + ] + }, + + addProseMirrorPlugins() { + return [ + // Highlight target when navigating between footnote references and footnotes + new Plugin({ + props: { + handleClick(view, _pos, event) { + const link = (event.target as HTMLElement).closest('.footnote-ref, .footnote-backref') + if (!link) { + return false + } + + const href = link.getAttribute('href') + if (!href?.startsWith('#')) { + return false + } + + const target = view.dom.ownerDocument.getElementById(href.slice(1)) + if (!target) { + return false + } + + target.classList.add('footnote-highlight') + setTimeout(() => target.classList.remove('footnote-highlight'), 5000) + return false + }, + }, + }), + ] + }, }) /** From 1d4433ee38796679da2345d0364678db8e4f8379 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 13:59:04 +0200 Subject: [PATCH 07/14] fix(FloatingButtons): don't display for footnotes Signed-off-by: Jonas --- src/components/Editor/FloatingButtons.vue | 17 +++++++++++++++-- src/nodes/FootnoteReference.ts | 14 ++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/components/Editor/FloatingButtons.vue b/src/components/Editor/FloatingButtons.vue index 608113f2def..e53f763b2e0 100644 --- a/src/components/Editor/FloatingButtons.vue +++ b/src/components/Editor/FloatingButtons.vue @@ -7,7 +7,10 @@ ({ referenceId: match[1] }), + handler: ({ range, match, chain }) => { + const referenceId = match[1] ?? '' + + chain() + .deleteRange({ from: range.from, to: range.to }) + .insertFootnote({ referenceId }) + .run() + }, }), ] }, From b7d83656a31333bede30630dac01b6faa901a259 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 15:04:40 +0200 Subject: [PATCH 08/14] fix(footnote): prevent nested footnotes Signed-off-by: Jonas --- src/nodes/FootnoteReference.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index e6689ded8cf..e164a6a5e20 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -4,6 +4,7 @@ */ import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import type { EditorState } from '@tiptap/pm/state' import { InputRule, mergeAttributes, Node } from '@tiptap/core' import { Plugin } from '@tiptap/pm/state' @@ -66,6 +67,10 @@ const FootnoteReference = Node.create({ addCommands() { return { insertFootnote: (options?: { referenceId?: string }) => ({ state, chain }) => { + if (isInsideFootnote(state)) { + return false + } + const referenceId = options?.referenceId ? String(options.referenceId) : generateFootnoteId(state.doc) @@ -117,9 +122,13 @@ const FootnoteReference = Node.create({ return [ new InputRule({ find: /\[\^([^\]\s]+)\]$/, - handler: ({ range, match, chain }) => { + handler: ({ state, range, match, chain }) => { const referenceId = match[1] ?? '' + if (isInsideFootnote(state)) { + return null + } + chain() .deleteRange({ from: range.from, to: range.to }) .insertFootnote({ referenceId }) @@ -160,6 +169,21 @@ const FootnoteReference = Node.create({ }, }) +/** + * Check if selection is inside a footnote + * + * @param state the editor state + */ +function isInsideFootnote(state: EditorState): boolean { + const { $from } = state.selection + for (let d = $from.depth; d > 0; d--) { + if ($from.node(d).type.name === 'footnote') { + return true + } + } + return false +} + /** * Get first unused numeric footnote id * From 18c757322dcb1aef63a5abeb3ce92be4b0460e78 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 15:25:43 +0200 Subject: [PATCH 09/14] fix(footnote): jump into existing footnote when inserting reference Signed-off-by: Jonas --- src/nodes/FootnoteReference.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index e164a6a5e20..23fb26a372a 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -7,7 +7,7 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import type { EditorState } from '@tiptap/pm/state' import { InputRule, mergeAttributes, Node } from '@tiptap/core' -import { Plugin } from '@tiptap/pm/state' +import { Plugin, TextSelection } from '@tiptap/pm/state' declare module '@tiptap/core' { interface Commands { @@ -88,20 +88,40 @@ const FootnoteReference = Node.create({ .insertContent({ type: 'footnoteReference', attrs: { referenceId } }) if (!existingFootnote) { + // Create footnote const newFootnote = footnoteType.create({ referenceId }, paragraphType.create()) const lastChild = state.doc.lastChild const hasFootnotesBlock = lastChild?.type === footnotesType if (hasFootnotesBlock) { - // Append inside the existing footnotes container + // Append footnote inside the existing footnotes container const insertPos = state.doc.content.size - 1 c = c.insertContentAt(insertPos, newFootnote.toJSON()) } else { + // Create footnotes container + footnote c = c.insertContentAt(state.doc.content.size, { type: 'footnotes', content: [newFootnote.toJSON()], }) } + } else { + // Jump cursor into existing footnote + c = c.command(({ tr }) => { + let target: number | null = null + tr.doc.descendants((node, pos) => { + if (target !== null) { + return false + } + if (node.type.name === 'footnote' && node.attrs.referenceId === referenceId) { + target = pos + node.nodeSize - 2 + return false + } + }) + if (target !== null) { + tr.setSelection(TextSelection.near(tr.doc.resolve(target))) + } + return true + }) } return c From ba207fe05ced6d1c83d43b817245690d03130f99 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 16:55:45 +0200 Subject: [PATCH 10/14] fix(footnotes): cleanup orphaned footnotes when deleting references Signed-off-by: Jonas --- src/nodes/Footnotes.ts | 71 +++++++++++++++++++++++++++++++ src/tests/nodes/Footnotes.spec.ts | 62 +++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/nodes/Footnotes.ts b/src/nodes/Footnotes.ts index 9ab96a8a424..049e947fd44 100644 --- a/src/nodes/Footnotes.ts +++ b/src/nodes/Footnotes.ts @@ -4,6 +4,7 @@ */ import { mergeAttributes, Node } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' import Footnote from './Footnote.ts' import FootnoteReference from './FootnoteReference.ts' @@ -33,6 +34,76 @@ const Footnotes = Node.create({ toMarkdown(state, node) { state.renderContent(node) }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('footnotesCleanup'), + appendTransaction(transactions, _oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) { + return null + } + + const referencedLabels = new Set() + newState.doc.descendants((node) => { + if (node.type.name === 'footnoteReference') { + referencedLabels.add(node.attrs.referenceId) + } + }) + + const deletions: { pos: number, size: number }[] = [] + newState.doc.forEach((child, offset) => { + if (child.type.name !== 'footnotes') { + return + } + + const containerPos = offset + + const orphans: { pos: number, size: number }[] = [] + let remainingNodeCount = 0 + let inner = 0 + + child.forEach((node) => { + if (node.type.name !== 'footnote') { + return + } + + if (!referencedLabels.has(node.attrs.referenceId)) { + orphans.push({ pos: containerPos + 1 + inner, size: node.nodeSize }) + } else { + remainingNodeCount++ + } + inner += node.nodeSize + }) + + if (orphans.length === 0) { + return + } + + if (remainingNodeCount === 0) { + // Delete the whole footnotes container + deletions.push({ pos: containerPos, size: child.nodeSize }) + } else { + // Delete only orphaned footnotes + deletions.push(...orphans) + } + }) + + if (deletions.length === 0) { + return null + } + + // Delete right-to-left so earlier positions remain valid + deletions.sort((a, b) => b.pos - a.pos) + const tr = newState.tr + for (const del of deletions) { + tr.delete(del.pos, del.pos + del.size) + } + return tr + }, + }), + ] + }, }) export default Footnotes diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts index 2e40c28eba7..90836ec1d28 100644 --- a/src/tests/nodes/Footnotes.spec.ts +++ b/src/tests/nodes/Footnotes.spec.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { Editor } from '@tiptap/core' + import { Document } from '@tiptap/extension-document' import { ListItem } from '@tiptap/extension-list' import { describe, expect } from 'vitest' @@ -120,6 +122,66 @@ describe('insertFootnote command', () => { }) }) +describe('Footnotes cleanup', () => { + function deleteFirstReference(editor: Editor) { + // Delete reference node + let refPos = -1 + let refSize = 0 + editor.state.doc.descendants((node, pos) => { + if (refPos !== -1) { + return false + } + if (node.type.name === 'footnoteReference') { + refPos = pos + refSize = node.nodeSize + return false + } + }) + editor.commands.setTextSelection({ from: refPos, to: refPos + refSize }) + editor.commands.deleteSelection() + } + + function getFootnoteNodesCount(editor: Editor): { footnoteCount: number, footnotesContainerCount: number } { + let footnoteCount = 0 + let footnotesContainerCount = 0 + editor.state.doc.descendants((n) => { + if (n.type.name === 'footnote') { + footnoteCount++ + } + if (n.type.name === 'footnotes') { + footnotesContainerCount++ + } + }) + return { footnoteCount, footnotesContainerCount } + } + + test('removes footnotes when last reference is deleted', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
data-type="footnotes">' + + '

x

' + + '
') + deleteFirstReference(editor) + + // Verify no footnote // footnotesContainer node exists + const { footnoteCount, footnotesContainerCount } = getFootnoteNodesCount(editor) + expect(footnotesContainerCount).toBe(0) + expect(footnoteCount).toBe(0) + }) + + test('keeps footnote when other references still point to it', ({ editor }) => { + editor.commands.setContent('

Foo' + + ' bar

' + + '
data-type="footnotes">' + + '

x

' + + '
') + deleteFirstReference(editor) + + const { footnoteCount, footnotesContainerCount } = getFootnoteNodesCount(editor) + expect(footnotesContainerCount).toBe(1) + expect(footnoteCount).toBe(1) + }) +}) + describe('Footnotes Markdown roundtrip', () => { test('simple footnote', ({ markdownThroughEditor }) => { const test = 'Foo[^1]\n\n[^1]: bar' From dd981844f9bb988d4e9a5333a5eba721d25163a2 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 17:35:29 +0200 Subject: [PATCH 11/14] feat(footnotes): display sequential ordinals, not reference id Signed-off-by: Jonas --- src/css/prosemirror.scss | 6 +++--- src/nodes/Footnote.ts | 13 ++++++++++-- src/nodes/FootnoteReference.ts | 9 ++++++-- src/nodes/Footnotes.ts | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index e5fb92a943f..5c9223eac2d 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -437,6 +437,8 @@ div.ProseMirror { } section[data-type="footnotes"] { + display: flex; + flex-direction: column; margin-top: calc(2 * var(--default-grid-baseline)); border-top: 1px solid var(--color-border); @@ -445,13 +447,11 @@ div.ProseMirror { gap: var(--default-grid-baseline); .footnote-label { - flex: 0 0 44px; + flex: 0 0 16px; font-size: 0.9em; font-weight: bold; user-select: none; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .footnote-body > p { diff --git a/src/nodes/Footnote.ts b/src/nodes/Footnote.ts index e549d83c997..364fcc793dc 100644 --- a/src/nodes/Footnote.ts +++ b/src/nodes/Footnote.ts @@ -20,6 +20,10 @@ const Footnote = Node.create({ 'data-reference-id': attrs.referenceId, }), }, + ordinal: { + default: 0, + rendered: false, + }, } }, @@ -35,10 +39,15 @@ const Footnote = Node.create({ renderHTML({ node, HTMLAttributes }) { const id = node.attrs.referenceId + const ordinal = node.attrs.ordinal return [ 'div', - mergeAttributes(HTMLAttributes, { 'data-type': 'footnote', id: `fn-${id}` }), - ['span', { class: 'footnote-label', contenteditable: 'false', title: id }, id], + mergeAttributes(HTMLAttributes, { + 'data-type': 'footnote', + id: `fn-${id}`, + style: `order: ${ordinal || 0}`, + }), + ['span', { class: 'footnote-label', contenteditable: 'false', title: id }, String(ordinal || '')], [ 'div', { class: 'footnote-body-wrapper' }, diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 23fb26a372a..73b5a808fef 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -37,6 +37,10 @@ const FootnoteReference = Node.create({ 'data-reference-id': attrs.referenceId, }), }, + ordinal: { + default: 0, + rendered: false, + }, } }, @@ -46,6 +50,7 @@ const FootnoteReference = Node.create({ renderHTML({ node, HTMLAttributes }) { const id = node.attrs.referenceId + const ordinal = node.attrs.ordinal return [ 'sup', mergeAttributes(HTMLAttributes, { @@ -54,8 +59,8 @@ const FootnoteReference = Node.create({ }), [ 'a', - { href: `#fn-${id}`, class: 'footnote-ref', role: 'doc-noteref' }, - id, + { href: `#fn-${id}`, class: 'footnote-ref', role: 'doc-noteref', title: id }, + String(ordinal || ''), ], ] }, diff --git a/src/nodes/Footnotes.ts b/src/nodes/Footnotes.ts index 049e947fd44..3c5f5a5d76c 100644 --- a/src/nodes/Footnotes.ts +++ b/src/nodes/Footnotes.ts @@ -37,6 +37,45 @@ const Footnotes = Node.create({ addProseMirrorPlugins() { return [ + new Plugin({ + key: new PluginKey('footnoteOrdinals'), + appendTransaction(transactions, _oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) { + return null + } + + // Compute ordinals + const ordinals = new Map() + newState.doc.descendants((node) => { + if (node.type.name === 'footnoteReference') { + const id = node.attrs.referenceId + if (id && !ordinals.has(id)) { + ordinals.set(id, ordinals.size + 1) + } + } + }) + + const tr = newState.tr + let hasChange = false + newState.doc.descendants((node, pos) => { + if (node.type.name !== 'footnoteReference' && node.type.name !== 'footnote') { + return + } + + const targetOrdinal = ordinals.get(node.attrs.referenceId) ?? 0 + if (node.attrs.ordinal !== targetOrdinal) { + tr.setNodeAttribute(pos, 'ordinal', targetOrdinal) + hasChange = true + } + }) + + if (!hasChange) { + return null + } + tr.setMeta('addToHistory', false) + return tr + }, + }), new Plugin({ key: new PluginKey('footnotesCleanup'), appendTransaction(transactions, _oldState, newState) { From 9db774154dedf8b15c3b41522aa054ad0cab87bf Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 18:38:19 +0200 Subject: [PATCH 12/14] test(playwright): add tests for footnotes Signed-off-by: Jonas --- playwright/e2e/footnotes.spec.ts | 41 ++++++++++++++++++++ playwright/support/sections/EditorSection.ts | 7 ++++ 2 files changed, 48 insertions(+) create mode 100644 playwright/e2e/footnotes.spec.ts diff --git a/playwright/e2e/footnotes.spec.ts b/playwright/e2e/footnotes.spec.ts new file mode 100644 index 00000000000..180ff291a70 --- /dev/null +++ b/playwright/e2e/footnotes.spec.ts @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { test as uploadFileTest } from '../support/fixtures/upload-file.ts' + +const test = mergeTests(editorTest, uploadFileTest) + +test.describe('renders footnotes', () => { + test.use({ + fileContent: 'Foo[^bar] baz\n\n[^bar]: footnote\n', + }) + + test('shows reference and footnote from Markdown', async ({ editor, open }) => { + await open() + await expect(editor.getFootnoteReference('bar')).toBeVisible() + await expect(editor.getFootnoteReference('bar').locator('a')).toHaveText('1') + await expect(editor.getFootnote('bar')).toContainText('footnote') + }) +}) + +test('inserts footnote via keyboard shortcut', async ({ editor, open }) => { + await open() + await editor.type('Some text') + await editor.press('ControlOrMeta+Shift+F') + await expect(editor.getFootnoteReference('1')).toBeVisible() + await expect(editor.getFootnote('1')).toBeVisible() + + await editor.type('footnote') + await expect(editor.getFootnote('1')).toContainText('footnote') +}) + +test('inserts footnote via [^label] input rule', async ({ editor, open }) => { + await open() + await editor.type('hello[^bar]') + await expect(editor.getFootnoteReference('bar')).toBeVisible() + await expect(editor.getFootnote('bar')).toBeVisible() +}) diff --git a/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts index f50bf315407..c9ae8d1209c 100644 --- a/playwright/support/sections/EditorSection.ts +++ b/playwright/support/sections/EditorSection.ts @@ -19,6 +19,8 @@ export class EditorSection { public readonly sessionList: Locator public readonly suggestionsContainer: Locator public readonly suggestions: Locator + public readonly footnoteReferences: Locator + public readonly footnotesSection: Locator constructor(public readonly page: Page) { this.el = this.page.locator('.editor').first() @@ -34,6 +36,8 @@ export class EditorSection { this.sessionList = this.el.locator('.session-list') this.suggestionsContainer = this.page.locator('.container-suggestions') this.suggestions = this.page.locator('.tippy-box .suggestion-list') + this.footnoteReferences = this.el.locator('sup[data-type="footnote-reference"]') + this.footnotesSection = this.el.locator('section[data-type="footnotes"]') } public async type(keys: string): Promise { @@ -74,4 +78,7 @@ export class EditorSection { getHeading = (options: object = {}) => this.content.getByRole('heading', options) getSuggestion = (name: string) => this.suggestions.getByText(name) + + getFootnoteReference = (id: string) => this.footnoteReferences.locator(`:scope[data-reference-id="${id}"]`) + getFootnote = (id: string) => this.footnotesSection.locator(`[data-reference-id="${id}"]`) } From 5828a66398de7459a8426e4e1f9e6d6caf9af6aa Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 8 Jul 2026 19:07:04 +0200 Subject: [PATCH 13/14] fix(HelpModal): document footnote input rule and keyboard shortcut Signed-off-by: Jonas --- src/components/HelpModal.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index ad1077aa6c8..f18d788454b 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -224,7 +224,20 @@ - {{ t('text', 'Insert emoji') }} + {{ t('text', 'Footnote') }} + + [^{{ t('text', 'label') }}] + + + {{ ctrlOrModKey }} + + + {{ t('text', 'Shift') }} + + + F + + + + {{ t('text', 'Emoji') }} :{{ t('text', 'emoji') }} From a627bbdafcceff4b3eba67106f4df95b7b206c2d Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 9 Jul 2026 01:52:06 +0200 Subject: [PATCH 14/14] feat(footnote): add visual container for footnotes Signed-off-by: Jonas --- src/css/prosemirror.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 5c9223eac2d..fd415cf1fca 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -440,7 +440,10 @@ div.ProseMirror { display: flex; flex-direction: column; margin-top: calc(2 * var(--default-grid-baseline)); - border-top: 1px solid var(--color-border); + background-color: var(--color-background-dark); + border-radius: var(--border-radius-container); + padding: calc(3 * var(--default-grid-baseline)); + margin-inline: calc(3 * var(--default-grid-baseline) * -1); div[data-type="footnote"] { display: flex;