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 142 lines 3.3 kB view raw
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}