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

Configure Feed

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

at main 173 lines 4.7 kB view raw
1/** 2 * TocBlock TipTap extension — auto-updating Table of Contents. 3 * 4 * Renders a navigable TOC from document headings. Updates automatically 5 * when headings change. Stored as an atom node in the document model. 6 * 7 * UX: 8 * - Inserts via /tableOfContents slash command 9 * - Auto-refreshes on editor content changes 10 * - Clickable heading links scroll to the target 11 * - Nested list structure mirrors heading hierarchy 12 */ 13 14import { Node, mergeAttributes } from '@tiptap/core'; 15import { extractHeadings, generateHeadingId } from '../outline.js'; 16 17declare module '@tiptap/core' { 18 interface Commands<ReturnType> { 19 tocBlock: { 20 insertTocBlock: () => ReturnType; 21 }; 22 } 23} 24 25interface TocHeading { 26 level: number; 27 text: string; 28 id: string; 29} 30 31function escapeHtml(s: string): string { 32 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 33} 34 35function buildTocDom(headings: TocHeading[]): HTMLElement { 36 const nav = document.createElement('nav'); 37 nav.className = 'toc-block-content'; 38 nav.setAttribute('aria-label', 'Table of Contents'); 39 40 if (headings.length === 0) { 41 const empty = document.createElement('div'); 42 empty.className = 'toc-block-empty'; 43 empty.textContent = 'No headings in document'; 44 nav.appendChild(empty); 45 return nav; 46 } 47 48 const minLevel = Math.min(...headings.map(h => h.level)); 49 50 const root = document.createElement('ul'); 51 root.className = 'toc-list'; 52 const stack: { ul: HTMLUListElement; level: number }[] = [{ ul: root, level: minLevel - 1 }]; 53 54 for (const heading of headings) { 55 // Close deeper levels 56 while (stack.length > 1 && stack[stack.length - 1].level >= heading.level) { 57 stack.pop(); 58 } 59 60 // Open new levels if needed 61 while (stack[stack.length - 1].level < heading.level - 1) { 62 const nested = document.createElement('ul'); 63 const parent = stack[stack.length - 1].ul; 64 let lastLi = parent.lastElementChild; 65 if (!lastLi) { 66 lastLi = document.createElement('li'); 67 parent.appendChild(lastLi); 68 } 69 lastLi.appendChild(nested); 70 stack.push({ ul: nested, level: stack[stack.length - 1].level + 1 }); 71 } 72 73 const li = document.createElement('li'); 74 li.className = `toc-item toc-level-${heading.level}`; 75 const a = document.createElement('a'); 76 a.href = `#${heading.id}`; 77 a.textContent = heading.text; 78 a.addEventListener('click', (e) => { 79 e.preventDefault(); 80 const target = document.getElementById(heading.id); 81 if (target) { 82 target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 83 } 84 }); 85 li.appendChild(a); 86 stack[stack.length - 1].ul.appendChild(li); 87 } 88 89 nav.appendChild(root); 90 return nav; 91} 92 93export const TocBlock = Node.create({ 94 name: 'tocBlock', 95 96 group: 'block', 97 atom: true, 98 selectable: true, 99 draggable: true, 100 101 addAttributes() { 102 return { 103 title: { default: 'Table of Contents' }, 104 }; 105 }, 106 107 parseHTML() { 108 return [{ tag: 'div[data-toc-block]' }]; 109 }, 110 111 renderHTML({ HTMLAttributes }) { 112 return ['div', mergeAttributes(HTMLAttributes, { 113 'data-toc-block': '', 114 class: 'toc-block', 115 }), 0]; 116 }, 117 118 addCommands() { 119 return { 120 insertTocBlock: 121 () => 122 ({ commands }) => { 123 return commands.insertContent({ 124 type: this.name, 125 attrs: { title: 'Table of Contents' }, 126 }); 127 }, 128 }; 129 }, 130 131 addNodeView() { 132 return ({ editor }) => { 133 const dom = document.createElement('div'); 134 dom.className = 'toc-block-wrapper'; 135 dom.setAttribute('data-toc-block', ''); 136 137 const header = document.createElement('div'); 138 header.className = 'toc-block-header'; 139 header.textContent = 'Table of Contents'; 140 dom.appendChild(header); 141 142 let contentEl: HTMLElement = buildTocDom([]); 143 dom.appendChild(contentEl); 144 145 function refresh() { 146 const json = editor.getJSON(); 147 const headings = extractHeadings(json as { content?: { type: string; attrs?: { level?: number }; content?: { type: string; text?: string }[] }[] }); 148 const newContent = buildTocDom(headings); 149 dom.replaceChild(newContent, contentEl); 150 contentEl = newContent; 151 } 152 153 // Initial render 154 refresh(); 155 156 // Auto-update on content changes 157 const onUpdate = () => refresh(); 158 editor.on('update', onUpdate); 159 160 return { 161 dom, 162 update(updatedNode) { 163 if (updatedNode.type.name !== 'tocBlock') return false; 164 refresh(); 165 return true; 166 }, 167 destroy() { 168 editor.off('update', onUpdate); 169 }, 170 }; 171 }; 172 }, 173});