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'
|
2026-05-04 06:00:16 +02:00
|
|
|
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'
|
2026-05-04 06:00:16 +02:00
|
|
|
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'
|
2026-05-04 06:00:16 +02:00
|
|
|
|
|
|
|
|
interface TableOfContentsEditor {
|
2026-05-04 12:37:30 +02:00
|
|
|
document?: unknown[]
|
|
|
|
|
focus?: () => void
|
|
|
|
|
setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void
|
2026-05-04 06:00:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TableOfContentsPanelProps {
|
|
|
|
|
editor: TableOfContentsEditor
|
2026-05-04 12:37:30 +02:00
|
|
|
entry: VaultEntry | null
|
2026-05-04 06:00:16 +02:00
|
|
|
locale?: AppLocale
|
|
|
|
|
onClose: () => void
|
2026-05-04 12:37:30 +02:00
|
|
|
sourceContent?: string | null
|
2026-05-04 06:00:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
interface TocState {
|
|
|
|
|
noteKey: string
|
|
|
|
|
toc: TocItem
|
|
|
|
|
}
|
2026-05-04 06:00:16 +02:00
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
interface DebouncedTocOptions {
|
|
|
|
|
editor: TableOfContentsEditor
|
|
|
|
|
noteKey: string
|
|
|
|
|
sourceContent?: string | null
|
|
|
|
|
title: string
|
|
|
|
|
titleOnlyToc: TocItem
|
2026-05-04 06:00:16 +02:00
|
|
|
}
|
|
|
|
|
|
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 06:00:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
function buildTitleOnlyToc(title: string): TocItem {
|
|
|
|
|
return { id: 'toc-title', level: 1, title, children: [] }
|
2026-05-04 06:00:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
function noteKeyForEntry(entry: VaultEntry | null, title: string): string {
|
|
|
|
|
return `${entry?.path ?? ''}:${title}`
|
2026-05-04 06:00:16 +02:00
|
|
|
}
|
|
|
|
|
|
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 06:00:16 +02:00
|
|
|
}: {
|
2026-05-04 12:37:30 +02:00
|
|
|
depth: number
|
|
|
|
|
item: TocItem
|
|
|
|
|
onNavigate: (item: TocItem) => void
|
2026-05-04 06:00:16 +02:00
|
|
|
}) {
|
2026-05-04 12:37:30 +02:00
|
|
|
const hasChildren = item.children.length > 0
|
|
|
|
|
const depthIndent = getFolderDepthIndent(depth)
|
|
|
|
|
const contentInset = FOLDER_ROW_CONTENT_INSET
|
2026-05-04 06:00:16 +02:00
|
|
|
|
|
|
|
|
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 }}
|
2026-05-04 06:00:16 +02:00
|
|
|
>
|
|
|
|
|
<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 06:00:16 +02:00
|
|
|
>
|
2026-05-04 12:37:30 +02:00
|
|
|
<HeadingIcon level={item.level} />
|
|
|
|
|
<span className="truncate">{item.title}</span>
|
2026-05-04 06:00:16 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
function TocChildren({
|
|
|
|
|
depth,
|
2026-05-04 06:00:16 +02:00
|
|
|
item,
|
2026-05-04 12:37:30 +02:00
|
|
|
onNavigate,
|
2026-05-04 06:00:16 +02:00
|
|
|
}: {
|
2026-05-04 12:37:30 +02:00
|
|
|
depth: number
|
|
|
|
|
item: TocItem
|
|
|
|
|
onNavigate: (item: TocItem) => void
|
2026-05-04 06:00:16 +02:00
|
|
|
}) {
|
2026-05-04 12:37:30 +02:00
|
|
|
if (item.children.length === 0) return null
|
2026-05-04 06:00:16 +02:00
|
|
|
|
|
|
|
|
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 06:00:16 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
function TocItemNode({
|
2026-05-04 06:00:16 +02:00
|
|
|
depth,
|
|
|
|
|
item,
|
2026-05-04 12:37:30 +02:00
|
|
|
onNavigate,
|
2026-05-04 06:00:16 +02:00
|
|
|
}: {
|
|
|
|
|
depth: number
|
2026-05-04 12:37:30 +02:00
|
|
|
item: TocItem
|
|
|
|
|
onNavigate: (item: TocItem) => void
|
2026-05-04 06:00:16 +02:00
|
|
|
}) {
|
|
|
|
|
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 06:00:16 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
function TableOfContentsHeader({ locale = 'en', onClose }: Pick<TableOfContentsPanelProps, 'locale' | 'onClose'>) {
|
2026-05-04 06:00:16 +02:00
|
|
|
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 06:00:16 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:37:30 +02:00
|
|
|
export const TableOfContentsPanel = memo(function TableOfContentsPanel({
|
2026-05-04 06:00:16 +02:00
|
|
|
editor,
|
2026-05-04 12:37:30 +02:00
|
|
|
entry,
|
2026-05-04 06:00:16 +02:00
|
|
|
locale = 'en',
|
|
|
|
|
onClose,
|
2026-05-04 12:37:30 +02:00
|
|
|
sourceContent,
|
2026-05-04 06:00:16 +02:00
|
|
|
}: 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)
|
2026-05-04 06:00:16 +02:00
|
|
|
|
|
|
|
|
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">
|
2026-05-04 06:00:16 +02:00
|
|
|
<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">
|
2026-05-12 06:57:42 +02:00
|
|
|
<NoteInfoPanel entry={entry} content={sourceContent ?? null} locale={locale} />
|
2026-05-06 16:45:18 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-04 06:00:16 +02:00
|
|
|
</aside>
|
|
|
|
|
)
|
2026-05-04 12:37:30 +02:00
|
|
|
})
|