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/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}"]`) } 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 @@ - {{ t('text', 'Insert emoji') }} + {{ t('text', 'Footnote') }} + + [^{{ t('text', 'label') }}] + + + {{ ctrlOrModKey }} + + + {{ t('text', 'Shift') }} + + + F + + + + {{ t('text', 'Emoji') }} :{{ t('text', 'emoji') }} diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index e22d9ff5814..fd415cf1fca 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -423,6 +423,64 @@ 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"] { + display: flex; + flex-direction: column; + margin-top: calc(2 * var(--default-grid-baseline)); + 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; + gap: var(--default-grid-baseline); + + .footnote-label { + flex: 0 0 16px; + font-size: 0.9em; + font-weight: bold; + user-select: none; + overflow: hidden; + } + + .footnote-body > p { + margin: 0; + line-height: unset; + } + + .footnote-body-wrapper { + display: flex; + gap: var(--default-grid-baseline); + font-size: 0.9em; + } + + .footnote-backref { + padding: 0; + color: var(--color-primary-element); + text-decoration: none; + width: unset; // overwrite default `a` width + + &:hover, + &:focus { + text-decoration: underline; + } + } + } + } } .ProseMirror-focused .ProseMirror-gapcursor { @@ -445,3 +503,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/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/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/nodes/Footnote.ts b/src/nodes/Footnote.ts new file mode 100644 index 00000000000..364fcc793dc --- /dev/null +++ b/src/nodes/Footnote.ts @@ -0,0 +1,68 @@ +/** + * 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, + }), + }, + ordinal: { + default: 0, + rendered: false, + }, + } + }, + + parseHTML() { + 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({ node, HTMLAttributes }) { + const id = node.attrs.referenceId + const ordinal = node.attrs.ordinal + return [ + 'div', + 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' }, + ['div', { class: 'footnote-body' }, 0], + ['a', { href: `#fnref-${id}`, class: 'footnote-backref', contenteditable: 'false', 'aria-label': 'Back to reference' }, '↩'], + ], + ] + }, + + 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 new file mode 100644 index 00000000000..73b5a808fef --- /dev/null +++ b/src/nodes/FootnoteReference.ts @@ -0,0 +1,256 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +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, TextSelection } from '@tiptap/pm/state' + +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', + inline: true, + atom: true, + + addAttributes() { + return { + referenceId: { + default: '', + parseHTML: (el) => el.getAttribute('data-reference-id') ?? '', + renderHTML: (attrs) => ({ + 'data-reference-id': attrs.referenceId, + }), + }, + ordinal: { + default: 0, + rendered: false, + }, + } + }, + + parseHTML() { + return [{ tag: 'sup[data-type="footnote-reference"]' }] + }, + + renderHTML({ node, HTMLAttributes }) { + const id = node.attrs.referenceId + const ordinal = node.attrs.ordinal + return [ + 'sup', + mergeAttributes(HTMLAttributes, { + 'data-type': 'footnote-reference', + id: `fnref-${id}`, + }), + [ + 'a', + { href: `#fn-${id}`, class: 'footnote-ref', role: 'doc-noteref', title: id }, + String(ordinal || ''), + ], + ] + }, + + toMarkdown(state, node) { + state.write(`[^${node.attrs.referenceId}]`) + }, + + addCommands() { + return { + insertFootnote: (options?: { referenceId?: string }) => ({ state, chain }) => { + if (isInsideFootnote(state)) { + return false + } + + 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) { + // Create footnote + const newFootnote = footnoteType.create({ referenceId }, paragraphType.create()) + const lastChild = state.doc.lastChild + const hasFootnotesBlock = lastChild?.type === footnotesType + + if (hasFootnotesBlock) { + // 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 + .focus() + .scrollIntoView() + .run() + }, + } + }, + + addKeyboardShortcuts() { + return { + 'Mod-Shift-f': () => this.editor.commands.insertFootnote(), + } + }, + + addInputRules() { + return [ + new InputRule({ + find: /\[\^([^\]\s]+)\]$/, + handler: ({ state, range, match, chain }) => { + const referenceId = match[1] ?? '' + + if (isInsideFootnote(state)) { + return null + } + + chain() + .deleteRange({ from: range.from, to: range.to }) + .insertFootnote({ referenceId }) + .run() + }, + }), + ] + }, + + 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 + }, + }, + }), + ] + }, +}) + +/** + * 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 + * + * @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/nodes/Footnotes.ts b/src/nodes/Footnotes.ts new file mode 100644 index 00000000000..3c5f5a5d76c --- /dev/null +++ b/src/nodes/Footnotes.ts @@ -0,0 +1,148 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +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, + ] + }, + + toMarkdown(state, node) { + state.renderContent(node) + }, + + 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) { + 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/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') + }) +}) diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts new file mode 100644 index 00000000000..90836ec1d28 --- /dev/null +++ b/src/tests/nodes/Footnotes.spec.ts @@ -0,0 +1,218 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * 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' +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', () => { + 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"') + expect(out).toContain('id="fnref-label"') + expect(out).toContain('id="fn-label"') + }) + + 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') + }) +}) + +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 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' + 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) + }) +})