Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(docs): footnotes with auto-numbered markers (#122)' (#110) from feat/footnotes into main

scott c90b6eff 4ef5a11e

+344
+45
src/css/app.css
··· 3012 3012 margin-bottom: 0.5rem; 3013 3013 } 3014 3014 3015 + /* --- Footnotes (#122) --- */ 3016 + .tiptap .footnote-marker { 3017 + cursor: pointer; 3018 + color: var(--color-accent, #0563C1); 3019 + font-size: 0.75em; 3020 + vertical-align: super; 3021 + line-height: 0; 3022 + padding: 0 1px; 3023 + font-weight: 600; 3024 + } 3025 + 3026 + .tiptap .footnote-marker:hover { 3027 + text-decoration: underline; 3028 + } 3029 + 3030 + .tiptap .footnote-marker.ProseMirror-selectednode { 3031 + outline: 2px solid var(--color-accent, #0563C1); 3032 + border-radius: 2px; 3033 + } 3034 + 3035 + .footnote-section { 3036 + border-top: 1px solid var(--color-border); 3037 + margin-top: 2rem; 3038 + padding-top: 1rem; 3039 + font-size: 0.875rem; 3040 + color: var(--color-text-muted, #666); 3041 + } 3042 + 3043 + .footnote-section h4 { 3044 + font-size: 0.75rem; 3045 + text-transform: uppercase; 3046 + letter-spacing: 0.05em; 3047 + margin: 0 0 0.5rem; 3048 + color: var(--color-text-muted, #999); 3049 + } 3050 + 3051 + .footnote-section ol { 3052 + margin: 0; 3053 + padding-left: 1.5rem; 3054 + } 3055 + 3056 + .footnote-section li { 3057 + margin-bottom: 0.25rem; 3058 + } 3059 + 3015 3060 /* --- Print styles --- */ 3016 3061 @media print { 3017 3062 .app-topbar,
+165
src/docs/extensions/footnote.ts
··· 1 + /** 2 + * Footnote extension (#122) 3 + * 4 + * Inline footnote markers that render as superscript numbers. 5 + * Each footnote stores its content in a data attribute. 6 + * A footnote section is auto-rendered at the bottom of the document. 7 + * 8 + * Design: inline atom node (non-editable marker), content stored as 9 + * data-footnote-content attribute. Numbers auto-assigned based on 10 + * document order. 11 + */ 12 + 13 + import { Node, mergeAttributes } from '@tiptap/core'; 14 + import type { Editor } from '@tiptap/core'; 15 + 16 + export interface FootnoteOptions { 17 + HTMLAttributes: Record<string, string>; 18 + } 19 + 20 + /** 21 + * Generate a short random ID for footnote tracking. 22 + */ 23 + function generateFootnoteId(): string { 24 + return 'fn-' + Math.random().toString(36).substring(2, 9); 25 + } 26 + 27 + /** 28 + * Count the position of a footnote in the document (1-based). 29 + * Walks all footnote nodes in document order. 30 + */ 31 + export function getFootnoteNumber(editor: Editor, targetId: string): number { 32 + let count = 0; 33 + editor.state.doc.descendants((node) => { 34 + if (node.type.name === 'footnote') { 35 + count++; 36 + if (node.attrs.footnoteId === targetId) return false; 37 + } 38 + return true; 39 + }); 40 + return count; 41 + } 42 + 43 + /** 44 + * Get all footnotes in document order. 45 + */ 46 + export function getAllFootnotes(editor: Editor): Array<{ id: string; content: string; number: number }> { 47 + const footnotes: Array<{ id: string; content: string; number: number }> = []; 48 + let count = 0; 49 + editor.state.doc.descendants((node) => { 50 + if (node.type.name === 'footnote') { 51 + count++; 52 + footnotes.push({ 53 + id: node.attrs.footnoteId, 54 + content: node.attrs.content || '', 55 + number: count, 56 + }); 57 + } 58 + return true; 59 + }); 60 + return footnotes; 61 + } 62 + 63 + export const Footnote = Node.create<FootnoteOptions>({ 64 + name: 'footnote', 65 + 66 + group: 'inline', 67 + inline: true, 68 + atom: true, 69 + selectable: true, 70 + draggable: false, 71 + 72 + addOptions() { 73 + return { 74 + HTMLAttributes: {}, 75 + }; 76 + }, 77 + 78 + addAttributes() { 79 + return { 80 + footnoteId: { 81 + default: null, 82 + parseHTML: (el: HTMLElement) => el.getAttribute('data-footnote-id'), 83 + renderHTML: (attrs: Record<string, string | null>) => ({ 84 + 'data-footnote-id': attrs.footnoteId, 85 + }), 86 + }, 87 + content: { 88 + default: '', 89 + parseHTML: (el: HTMLElement) => el.getAttribute('data-footnote-content'), 90 + renderHTML: (attrs: Record<string, string | null>) => ({ 91 + 'data-footnote-content': attrs.content, 92 + }), 93 + }, 94 + }; 95 + }, 96 + 97 + parseHTML() { 98 + return [ 99 + { 100 + tag: 'sup[data-footnote-id]', 101 + }, 102 + ]; 103 + }, 104 + 105 + renderHTML({ HTMLAttributes }) { 106 + return [ 107 + 'sup', 108 + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 109 + class: 'footnote-marker', 110 + }), 111 + '*', 112 + ]; 113 + }, 114 + 115 + addCommands() { 116 + return { 117 + insertFootnote: 118 + (content?: string) => 119 + ({ commands }) => { 120 + const footnoteContent = content || prompt('Footnote text:'); 121 + if (!footnoteContent) return false; 122 + 123 + return commands.insertContent({ 124 + type: this.name, 125 + attrs: { 126 + footnoteId: generateFootnoteId(), 127 + content: footnoteContent, 128 + }, 129 + }); 130 + }, 131 + }; 132 + }, 133 + 134 + addNodeView() { 135 + return ({ node, editor }) => { 136 + const dom = document.createElement('sup'); 137 + dom.className = 'footnote-marker'; 138 + dom.setAttribute('data-footnote-id', node.attrs.footnoteId || ''); 139 + dom.setAttribute('data-footnote-content', node.attrs.content || ''); 140 + dom.contentEditable = 'false'; 141 + 142 + // Compute the number based on document order 143 + const updateNumber = () => { 144 + const num = getFootnoteNumber(editor, node.attrs.footnoteId); 145 + dom.textContent = String(num); 146 + dom.title = node.attrs.content || ''; 147 + }; 148 + 149 + updateNumber(); 150 + 151 + return { 152 + dom, 153 + update(updatedNode) { 154 + if (updatedNode.type.name !== 'footnote') return false; 155 + dom.setAttribute('data-footnote-id', updatedNode.attrs.footnoteId || ''); 156 + dom.setAttribute('data-footnote-content', updatedNode.attrs.content || ''); 157 + const num = getFootnoteNumber(editor, updatedNode.attrs.footnoteId); 158 + dom.textContent = String(num); 159 + dom.title = updatedNode.attrs.content || ''; 160 + return true; 161 + }, 162 + }; 163 + }; 164 + }, 165 + });
+3
src/docs/extensions/slash-commands.ts
··· 124 124 // Callout is a blockquote with special styling 125 125 editor.chain().focus().toggleBlockquote().run(); 126 126 }, 127 + footnote: (editor: Editor) => { 128 + editor.chain().focus().insertFootnote().run(); 129 + }, 127 130 pageBreak: (editor: Editor) => { 128 131 editor.chain().focus().insertPageBreak().run(); 129 132 },
+1
src/docs/index.html
··· 374 374 <div class="editor-container"> 375 375 <div class="editor-wrapper" id="editor"></div> 376 376 <textarea class="markdown-source-textarea" id="markdown-source" style="display:none" spellcheck="false" aria-label="Markdown source editor"></textarea> 377 + <div class="footnote-section" id="footnote-section" style="display:none"></div> 377 378 </div> 378 379 379 380 <!-- Version history sidebar -->
+19
src/docs/main.ts
··· 37 37 import { ParagraphSpacing, PARAGRAPH_SPACING_PRESETS } from './extensions/paragraph-spacing.js'; 38 38 import { PageBreak } from './extensions/page-break.js'; 39 39 import { ToggleBlock, ToggleSummary } from './extensions/toggle-block.js'; 40 + import { Footnote, getAllFootnotes } from './extensions/footnote.js'; 40 41 import { SuggestionInsert } from './extensions/suggestion-insert.js'; 41 42 import { SuggestionDelete } from './extensions/suggestion-delete.js'; 42 43 import { SearchReplace } from './search-replace.js'; ··· 147 148 PageBreak, 148 149 ToggleBlock, 149 150 ToggleSummary, 151 + Footnote, 150 152 SuggestionInsert, 151 153 SuggestionDelete, 152 154 SearchReplace.configure({ ··· 1193 1195 editor.on('selectionUpdate', updateSelectionCount); 1194 1196 updateWordCount(); 1195 1197 1198 + // --- Footnote Section (#122) --- 1199 + const footnoteSectionEl = $('footnote-section'); 1200 + function updateFootnoteSection() { 1201 + const footnotes = getAllFootnotes(editor); 1202 + if (footnotes.length === 0) { 1203 + footnoteSectionEl.style.display = 'none'; 1204 + footnoteSectionEl.innerHTML = ''; 1205 + return; 1206 + } 1207 + footnoteSectionEl.style.display = ''; 1208 + const items = footnotes.map(fn => `<li>${fn.content}</li>`).join(''); 1209 + footnoteSectionEl.innerHTML = `<h4>Footnotes</h4><ol>${items}</ol>`; 1210 + } 1211 + editor.on('update', updateFootnoteSection); 1212 + updateFootnoteSection(); 1213 + 1196 1214 // --- Keyboard Shortcut Cheatsheet Modal (#15) --- 1197 1215 const DOCS_SHORTCUTS = [ 1198 1216 { category: 'Formatting', shortcuts: [ ··· 2192 2210 { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => importFile() }, 2193 2211 { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); updateFindBar(); } }, 2194 2212 { id: 'toggle', label: 'Insert Toggle Block', category: 'action', icon: '\u25B6', action: () => editor.chain().focus().insertToggleBlock().run() }, 2213 + { id: 'footnote', label: 'Insert Footnote', category: 'action', icon: '\u2020', action: () => editor.chain().focus().insertFootnote().run() }, 2195 2214 ], 2196 2215 fetchDocuments: async (): Promise<PaletteAction[]> => { 2197 2216 try {
+8
src/docs/slash-menu.ts
··· 166 166 shortcut: null, 167 167 }, 168 168 { 169 + id: 'footnote', 170 + name: 'Footnote', 171 + description: 'Insert a footnote reference', 172 + category: 'advanced', 173 + icon: '\u2020', 174 + shortcut: null, 175 + }, 176 + { 169 177 id: 'pageBreak', 170 178 name: 'Page Break', 171 179 description: 'Insert a page break',
+103
tests/footnote.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Unit tests for footnote extension helper functions. 5 + * 6 + * The TipTap Node integration (addNodeView, addCommands) requires a full 7 + * editor instance, so we test the pure utility functions here. 8 + * The getFootnoteNumber and getAllFootnotes functions depend on the editor, 9 + * so we test them via a mock document structure. 10 + */ 11 + 12 + // Test the generateFootnoteId pattern (extracted logic) 13 + describe('footnote ID generation', () => { 14 + it('generates IDs starting with fn-', () => { 15 + // The extension uses: 'fn-' + Math.random().toString(36).substring(2, 9) 16 + const id = 'fn-' + Math.random().toString(36).substring(2, 9); 17 + expect(id).toMatch(/^fn-[a-z0-9]{1,7}$/); 18 + }); 19 + 20 + it('generates unique IDs', () => { 21 + const ids = new Set<string>(); 22 + for (let i = 0; i < 100; i++) { 23 + ids.add('fn-' + Math.random().toString(36).substring(2, 9)); 24 + } 25 + expect(ids.size).toBe(100); 26 + }); 27 + }); 28 + 29 + // Test the footnote numbering logic (extracted from getFootnoteNumber) 30 + describe('footnote numbering', () => { 31 + interface MockNode { 32 + type: { name: string }; 33 + attrs: { footnoteId: string; content: string }; 34 + } 35 + 36 + function numberFootnotes(nodes: MockNode[], targetId: string): number { 37 + let count = 0; 38 + for (const node of nodes) { 39 + if (node.type.name === 'footnote') { 40 + count++; 41 + if (node.attrs.footnoteId === targetId) return count; 42 + } 43 + } 44 + return count; 45 + } 46 + 47 + function collectFootnotes(nodes: MockNode[]): Array<{ id: string; content: string; number: number }> { 48 + const result: Array<{ id: string; content: string; number: number }> = []; 49 + let count = 0; 50 + for (const node of nodes) { 51 + if (node.type.name === 'footnote') { 52 + count++; 53 + result.push({ id: node.attrs.footnoteId, content: node.attrs.content, number: count }); 54 + } 55 + } 56 + return result; 57 + } 58 + 59 + const nodes: MockNode[] = [ 60 + { type: { name: 'paragraph' }, attrs: { footnoteId: '', content: '' } }, 61 + { type: { name: 'footnote' }, attrs: { footnoteId: 'fn-aaa', content: 'First note' } }, 62 + { type: { name: 'paragraph' }, attrs: { footnoteId: '', content: '' } }, 63 + { type: { name: 'footnote' }, attrs: { footnoteId: 'fn-bbb', content: 'Second note' } }, 64 + { type: { name: 'footnote' }, attrs: { footnoteId: 'fn-ccc', content: 'Third note' } }, 65 + ]; 66 + 67 + it('assigns sequential numbers based on document order', () => { 68 + expect(numberFootnotes(nodes, 'fn-aaa')).toBe(1); 69 + expect(numberFootnotes(nodes, 'fn-bbb')).toBe(2); 70 + expect(numberFootnotes(nodes, 'fn-ccc')).toBe(3); 71 + }); 72 + 73 + it('returns total count when footnote ID not found (walks all nodes)', () => { 74 + // When ID isn't found, the function finishes iterating and returns the total count 75 + expect(numberFootnotes(nodes, 'fn-nonexistent')).toBe(3); 76 + }); 77 + 78 + it('collects all footnotes in order', () => { 79 + const result = collectFootnotes(nodes); 80 + expect(result).toEqual([ 81 + { id: 'fn-aaa', content: 'First note', number: 1 }, 82 + { id: 'fn-bbb', content: 'Second note', number: 2 }, 83 + { id: 'fn-ccc', content: 'Third note', number: 3 }, 84 + ]); 85 + }); 86 + 87 + it('returns empty array when no footnotes', () => { 88 + const noFootnotes: MockNode[] = [ 89 + { type: { name: 'paragraph' }, attrs: { footnoteId: '', content: '' } }, 90 + ]; 91 + expect(collectFootnotes(noFootnotes)).toEqual([]); 92 + }); 93 + 94 + it('handles single footnote', () => { 95 + const single: MockNode[] = [ 96 + { type: { name: 'footnote' }, attrs: { footnoteId: 'fn-only', content: 'Only one' } }, 97 + ]; 98 + expect(numberFootnotes(single, 'fn-only')).toBe(1); 99 + expect(collectFootnotes(single)).toEqual([ 100 + { id: 'fn-only', content: 'Only one', number: 1 }, 101 + ]); 102 + }); 103 + });