import React, { useMemo, useCallback } from 'react'; import type { Block, Annotation } from '../types'; import { buildTocHierarchy, getAnnotationCountBySection, type TocItem, } from '../utils/annotationHelpers'; import { getScrollViewportRect, getScrollViewportTop, scrollViewportTo, useScrollViewport, } from '../hooks/useScrollViewport'; interface TableOfContentsProps { blocks: Block[]; annotations: Annotation[]; activeId: string | null; onNavigate: (blockId: string) => void; className?: string; style?: React.CSSProperties; linkedDocFilepath?: string | null; onLinkedDocBack?: () => void; backLabel?: string; } // The prototype's TOC is a FLAT list — heading depth is conveyed by indentation // + tonal de-emphasis, not an expand/collapse chevron tree. Flatten the built // hierarchy (depth-first = document order) into a single ordered list. function flattenToc(items: TocItem[]): TocItem[] { const out: TocItem[] = []; const walk = (list: TocItem[]) => { for (const it of list) { out.push(it); if (it.children.length) walk(it.children); } }; walk(items); return out; } // Indentation + tonal de-emphasis by heading level (prototype style): // H1 flush + near-full strength, H2/H3 indented and dimmed to muted. Active row // is a soft neutral surface tint (not a loud primary fill). function itemClasses(level: number, isActive: boolean): string { const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : 'ml-6'; const tone = isActive ? 'bg-surface-1 text-foreground' : level <= 1 ? 'text-foreground/80 hover:bg-surface-1/70' : 'text-muted-foreground hover:bg-surface-1/70'; return `${indent} ${tone}`; } export function TableOfContents({ blocks, annotations, activeId, onNavigate, className = '', style, linkedDocFilepath, onLinkedDocBack, backLabel, }: TableOfContentsProps) { // Annotation count per section (kept — production feature). const annotationCounts = useMemo( () => getAnnotationCountBySection(blocks, annotations), [blocks, annotations] ); // Build the hierarchy (filters to heading levels ≤ 3 and attaches counts), // then flatten to a plain list. const tocItems = useMemo( () => flattenToc(buildTocHierarchy(blocks, annotationCounts)), [blocks, annotationCounts] ); // The real scroll element is the OverlayScrollArea viewport, not
. const scrollViewport = useScrollViewport(); // Smooth scroll-to-heading, accounting for the sticky header. const handleNavigate = useCallback( (blockId: string) => { onNavigate(blockId); const target = (scrollViewport ?? document).querySelector(`[data-block-id="${blockId}"]`); if (target && scrollViewport) { const scrollContainer = scrollViewport; const headerOffset = 80; // sticky header (h-12) + breathing room const containerRect = getScrollViewportRect(scrollContainer); const targetRect = target.getBoundingClientRect(); const offsetPosition = getScrollViewportTop(scrollContainer) + (targetRect.top - containerRect.top) - headerOffset; scrollViewportTo(scrollContainer, { top: offsetPosition, behavior: 'smooth' }); } }, [onNavigate, scrollViewport] ); // A linked document with no headings of its own — most obviously a raw-HTML // one, which is never parsed into blocks — still needs the "Viewing / Back // to …" header, which lives here. Without it, opening such a document from // a link leaves no visible way back. if (tocItems.length === 0 && !linkedDocFilepath) { return null; } return ( ); }