Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Document Outline Sidebar
3 *
4 * Extracts headings (H1-H6) from editor content and builds
5 * a navigable tree for the outline sidebar panel.
6 */
7import type { OutlineItem, OutlineTreeNode } from './types.js';
8
9interface EditorJsonNode {
10 type: string;
11 attrs?: { level?: number };
12 content?: EditorJsonChild[];
13}
14
15interface EditorJsonChild {
16 type: string;
17 text?: string;
18}
19
20interface EditorJson {
21 content?: EditorJsonNode[];
22}
23
24/**
25 * Generate a URL-safe anchor ID from heading text.
26 * Appends index suffix for uniqueness when index > 0.
27 */
28export function generateHeadingId(text: string, index?: number): string {
29 let id = text
30 .toLowerCase()
31 .replace(/[^a-z0-9\s-]/g, '')
32 .trim()
33 .replace(/\s+/g, '-')
34 .replace(/-{2,}/g, '-')
35 .replace(/^-|-$/g, '');
36
37 if (!id) id = 'heading';
38 if (index && index > 0) id += `-${index}`;
39 return id;
40}
41
42/**
43 * Extract heading text from a heading node's content array.
44 */
45function getHeadingText(node: EditorJsonNode): string {
46 if (!node.content || !Array.isArray(node.content)) return '';
47 return node.content
48 .filter((child) => child.type === 'text')
49 .map((child) => child.text || '')
50 .join('');
51}
52
53/**
54 * Extract all H1-H6 headings from editor JSON content.
55 * Returns a flat array of { level, text, id } objects.
56 */
57export function extractHeadings(json: EditorJson): OutlineItem[] {
58 if (!json || !json.content) return [];
59
60 const headings: OutlineItem[] = [];
61 const idCounts: Record<string, number> = {};
62
63 for (const node of json.content) {
64 if (node.type !== 'heading') continue;
65 const level = node.attrs?.level;
66 if (level === undefined || level < 1 || level > 6) continue;
67
68 const text = getHeadingText(node);
69 const baseId = generateHeadingId(text);
70 const count = idCounts[baseId] || 0;
71 idCounts[baseId] = count + 1;
72
73 const id = count > 0 ? `${baseId}-${count}` : baseId;
74
75 headings.push({ level, text, id });
76 }
77
78 return headings;
79}
80
81/**
82 * Build a nested tree from a flat list of headings.
83 * Each heading nests under the nearest preceding heading with a lower level.
84 */
85export function buildOutlineTree(headings: OutlineItem[]): OutlineTreeNode[] {
86 if (!headings || headings.length === 0) return [];
87
88 const root: OutlineTreeNode[] = [];
89 const stack: OutlineTreeNode[] = []; // stack of tree nodes
90
91 for (const heading of headings) {
92 const node: OutlineTreeNode = { ...heading, children: [] };
93
94 // Pop stack until we find a parent with a lower level
95 while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
96 stack.pop();
97 }
98
99 if (stack.length === 0) {
100 root.push(node);
101 } else {
102 stack[stack.length - 1].children.push(node);
103 }
104
105 stack.push(node);
106 }
107
108 return root;
109}
110
111/**
112 * Manages outline sidebar state.
113 */
114export class OutlineState {
115 isOpen: boolean;
116 headings: OutlineItem[];
117
118 constructor() {
119 this.isOpen = false;
120 this.headings = [];
121 }
122
123 toggle(): void {
124 this.isOpen = !this.isOpen;
125 }
126
127 open(): void {
128 this.isOpen = true;
129 }
130
131 close(): void {
132 this.isOpen = false;
133 }
134
135 updateHeadings(headings: OutlineItem[]): void {
136 this.headings = headings;
137 }
138
139 getTree(): OutlineTreeNode[] {
140 return buildOutlineTree(this.headings);
141 }
142}