Files
tolaria/src/components/TableOfContentsPanel.tsx

295 lines
8.1 KiB
TypeScript
Raw Normal View History

2026-05-04 12:37:30 +02:00
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { ListBullets, TextHOne, TextHThree, TextHTwo, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
2026-05-04 12:37:30 +02:00
import { trackEvent } from '../lib/telemetry'
import type { AppLocale } from '../lib/i18n'
import { translate } from '../lib/i18n'
import type { VaultEntry } from '../types'
2026-05-04 12:37:30 +02:00
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'
2026-05-06 16:45:18 +02:00
import { NoteInfoPanel } from './inspector/NoteInfoPanel'
interface TableOfContentsEditor {
2026-05-04 12:37:30 +02:00
document?: unknown[]
focus?: () => void
setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void
}
interface TableOfContentsPanelProps {
editor: TableOfContentsEditor
2026-05-04 12:37:30 +02:00
entry: VaultEntry | null
locale?: AppLocale
onClose: () => void
2026-05-04 12:37:30 +02:00
sourceContent?: string | null
}
2026-05-04 12:37:30 +02:00
interface TocState {
noteKey: string
toc: TocItem
}
2026-05-04 12:37:30 +02:00
interface DebouncedTocOptions {
editor: TableOfContentsEditor
noteKey: string
sourceContent?: string | null
title: string
titleOnlyToc: TocItem
}
2026-05-04 12:37:30 +02:00
function HeadingIcon({ level }: { level: TocItem['level'] }) {
const className = 'size-[17px] shrink-0 text-muted-foreground'
if (level === 1) return <TextHOne size={17} className={className} />
if (level === 2) return <TextHTwo size={17} className={className} />
return <TextHThree size={17} className={className} />
}
2026-05-04 12:37:30 +02:00
function buildTitleOnlyToc(title: string): TocItem {
return { id: 'toc-title', level: 1, title, children: [] }
}
2026-05-04 12:37:30 +02:00
function noteKeyForEntry(entry: VaultEntry | null, title: string): string {
return `${entry?.path ?? ''}:${title}`
}
2026-05-04 12:37:30 +02:00
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<HTMLElement>(`[data-id="${cssAttributeValue(blockId)}"]`)
?.scrollIntoView?.({ block: 'center' })
})
}
function useDebouncedToc({
editor,
noteKey,
sourceContent,
title,
titleOnlyToc,
}: DebouncedTocOptions): TocItem {
const [tocState, setTocState] = useState<TocState>(() => ({
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,
}: {
2026-05-04 12:37:30 +02:00
depth: number
item: TocItem
onNavigate: (item: TocItem) => void
}) {
2026-05-04 12:37:30 +02:00
const hasChildren = item.children.length > 0
const depthIndent = getFolderDepthIndent(depth)
const contentInset = FOLDER_ROW_CONTENT_INSET
return (
<div
2026-05-04 12:37:30 +02:00
className="group relative flex items-center gap-1 rounded text-foreground transition-colors hover:bg-accent"
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
>
<Button
type="button"
variant="ghost"
2026-05-04 12:37:30 +02:00
className="h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium text-foreground hover:bg-transparent hover:text-foreground"
style={{
paddingTop: 6,
paddingBottom: 6,
paddingLeft: contentInset,
paddingRight: 16,
}}
title={item.title}
aria-expanded={hasChildren ? true : undefined}
onClick={() => onNavigate(item)}
>
2026-05-04 12:37:30 +02:00
<HeadingIcon level={item.level} />
<span className="truncate">{item.title}</span>
</Button>
</div>
)
}
2026-05-04 12:37:30 +02:00
function TocChildren({
depth,
item,
2026-05-04 12:37:30 +02:00
onNavigate,
}: {
2026-05-04 12:37:30 +02:00
depth: number
item: TocItem
onNavigate: (item: TocItem) => void
}) {
2026-05-04 12:37:30 +02:00
if (item.children.length === 0) return null
return (
2026-05-04 12:37:30 +02:00
<div className="relative" data-testid={`toc-children:${item.id}`}>
<div
className="absolute top-0 bottom-0 bg-border"
data-testid={`toc-connector:${item.id}`}
style={{ left: getFolderConnectorLeft(depth), width: 1 }}
/>
{item.children.map((child) => (
<TocItemNode
key={child.id}
depth={depth + 1}
item={child}
onNavigate={onNavigate}
/>
))}
</div>
)
}
2026-05-04 12:37:30 +02:00
function TocItemNode({
depth,
item,
2026-05-04 12:37:30 +02:00
onNavigate,
}: {
depth: number
2026-05-04 12:37:30 +02:00
item: TocItem
onNavigate: (item: TocItem) => void
}) {
return (
2026-05-04 12:37:30 +02:00
<>
<TocRow
depth={depth}
item={item}
onNavigate={onNavigate}
/>
<TocChildren
depth={depth}
item={item}
onNavigate={onNavigate}
/>
</>
)
}
2026-05-04 12:37:30 +02:00
function TableOfContentsHeader({ locale = 'en', onClose }: Pick<TableOfContentsPanelProps, 'locale' | 'onClose'>) {
return (
2026-05-04 12:37:30 +02:00
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '6px 12px', gap: 8, cursor: 'default' }}
>
<ListBullets size={16} className="shrink-0 text-muted-foreground" />
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{translate(locale, 'tableOfContents.title')}
</span>
<span className="flex-1" />
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={onClose}
aria-label={translate(locale, 'tableOfContents.close')}
title={translate(locale, 'tableOfContents.close')}
>
<X size={16} />
</Button>
</div>
)
}
2026-05-04 12:37:30 +02:00
export const TableOfContentsPanel = memo(function TableOfContentsPanel({
editor,
2026-05-04 12:37:30 +02:00
entry,
locale = 'en',
onClose,
2026-05-04 12:37:30 +02:00
sourceContent,
}: TableOfContentsPanelProps) {
2026-05-04 12:37:30 +02:00
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 (
2026-05-04 12:37:30 +02:00
<aside className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground">
<TableOfContentsHeader locale={locale} onClose={onClose} />
2026-05-04 12:37:30 +02:00
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3" data-testid="table-of-contents-panel">
<TocItemNode
depth={0}
item={toc}
onNavigate={navigateToItem}
/>
</div>
2026-05-06 16:45:18 +02:00
{entry && (
<div className="shrink-0 border-t border-border p-3">
<NoteInfoPanel entry={entry} content={sourceContent ?? null} locale={locale} />
2026-05-06 16:45:18 +02:00
</div>
)}
</aside>
)
2026-05-04 12:37:30 +02:00
})