Full document, spreadsheet, slideshow, and diagram tooling
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
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});