import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { ListBullets, TextHOne, TextHThree, TextHTwo, X } from '@phosphor-icons/react' import { Button } from '@/components/ui/button' import { trackEvent } from '../lib/telemetry' import type { AppLocale } from '../lib/i18n' import { translate } from '../lib/i18n' import type { VaultEntry } from '../types' import { buildTableOfContents, resolveTocItemBlockId, type TocItem, } from './tableOfContentsModel' import { buildTableOfContentsInWorker, TOC_BUILD_DEBOUNCE_MS } from './tableOfContentsWorkerClient' import { FOLDER_ROW_CONTENT_INSET, getFolderConnectorLeft, getFolderDepthIndent, } from './folder-tree/folderTreeLayout' interface TableOfContentsEditor { document?: unknown[] focus?: () => void setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void } interface TableOfContentsPanelProps { editor: TableOfContentsEditor entry: VaultEntry | null locale?: AppLocale onClose: () => void sourceContent?: string | null } interface TocState { noteKey: string toc: TocItem } interface DebouncedTocOptions { editor: TableOfContentsEditor noteKey: string sourceContent?: string | null title: string titleOnlyToc: TocItem } function HeadingIcon({ level }: { level: TocItem['level'] }) { const className = 'size-[17px] shrink-0 text-muted-foreground' if (level === 1) return if (level === 2) return return } function buildTitleOnlyToc(title: string): TocItem { return { id: 'toc-title', level: 1, title, children: [] } } function noteKeyForEntry(entry: VaultEntry | null, title: string): string { return `${entry?.path ?? ''}:${title}` } function cssAttributeValue(value: string): string { return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&') } function scrollBlockIntoView(blockId: string) { requestAnimationFrame(() => { document .querySelector(`[data-id="${cssAttributeValue(blockId)}"]`) ?.scrollIntoView?.({ block: 'center' }) }) } function useDebouncedToc({ editor, noteKey, sourceContent, title, titleOnlyToc, }: DebouncedTocOptions): TocItem { const [tocState, setTocState] = useState(() => ({ noteKey, toc: titleOnlyToc, })) useEffect(() => { if (sourceContent === undefined) return undefined let cancelled = false const timeout = window.setTimeout(() => { void buildTableOfContentsInWorker(title, sourceContent ?? '') .then((nextToc) => { if (!cancelled) setTocState({ noteKey, toc: nextToc }) }) .catch(() => { if (!cancelled) setTocState({ noteKey, toc: titleOnlyToc }) }) }, TOC_BUILD_DEBOUNCE_MS) return () => { cancelled = true window.clearTimeout(timeout) } }, [noteKey, sourceContent, title, titleOnlyToc]) useEffect(() => { if (sourceContent !== undefined) return undefined const timeout = window.setTimeout(() => { setTocState({ noteKey, toc: buildTableOfContents(title, editor.document ?? []) }) }, TOC_BUILD_DEBOUNCE_MS) return () => window.clearTimeout(timeout) }, [editor.document, noteKey, sourceContent, title]) return tocState.noteKey === noteKey ? tocState.toc : titleOnlyToc } function useTocNavigation(editor: TableOfContentsEditor, title: string) { return useCallback((item: TocItem) => { const blockId = resolveTocItemBlockId(title, item, editor.document ?? []) if (!blockId) return try { editor.setTextCursorPosition?.(blockId, 'start') } catch { // BlockNote can transiently reject selection while a note swap settles. } editor.focus?.() scrollBlockIntoView(blockId) trackEvent('table_of_contents_heading_selected') }, [editor, title]) } function TocRow({ depth, item, onNavigate, }: { depth: number item: TocItem onNavigate: (item: TocItem) => void }) { const hasChildren = item.children.length > 0 const depthIndent = getFolderDepthIndent(depth) const contentInset = FOLDER_ROW_CONTENT_INSET return ( onNavigate(item)} > {item.title} ) } function TocChildren({ depth, item, onNavigate, }: { depth: number item: TocItem onNavigate: (item: TocItem) => void }) { if (item.children.length === 0) return null return ( {item.children.map((child) => ( ))} ) } function TocItemNode({ depth, item, onNavigate, }: { depth: number item: TocItem onNavigate: (item: TocItem) => void }) { return ( <> > ) } function TableOfContentsHeader({ locale = 'en', onClose }: Pick) { return ( {translate(locale, 'tableOfContents.title')} ) } export const TableOfContentsPanel = memo(function TableOfContentsPanel({ editor, entry, locale = 'en', onClose, sourceContent, }: TableOfContentsPanelProps) { const title = entry?.title ?? translate(locale, 'tableOfContents.untitledHeading') const noteKey = noteKeyForEntry(entry, title) const titleOnlyToc = useMemo(() => buildTitleOnlyToc(title), [title]) const toc = useDebouncedToc({ editor, noteKey, sourceContent, title, titleOnlyToc, }) const navigateToItem = useTocNavigation(editor, title) return ( ) })