diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 694c441d..ed7f2af3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -207,7 +207,7 @@ flowchart TD - **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. - **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. -- **Inspector / Table of Contents / AI Agent** (200-500px or 40px collapsed): `EditorRightPanel` selects between Inspector (frontmatter, relationships, instances, backlinks, git history), Table of Contents (live BlockNote heading outline with collapsible nested headings and cursor navigation), and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles AI; the List Bullets icon toggles the TOC and moves into the breadcrumb overflow menu when horizontal space is tight. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). +- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. diff --git a/docs/adr/0109-debounced-worker-derived-editor-indexes.md b/docs/adr/0109-debounced-worker-derived-editor-indexes.md new file mode 100644 index 00000000..c52504ef --- /dev/null +++ b/docs/adr/0109-debounced-worker-derived-editor-indexes.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0109" +title: "Debounced worker-derived editor indexes" +status: active +date: 2026-05-04 +--- + +## Context + +Right side panels can need derived indexes of the active note, such as the Table of Contents hierarchy. These indexes are useful while editing, but rebuilding them synchronously during note opening or on every keystroke competes with the editor's main-thread work and violates ADR-0105's responsiveness contract. + +The Table of Contents also needs live BlockNote block IDs for navigation, while the fastest and most stable source for the outline itself is the active note's Markdown content. Binding the outline rebuild directly to BlockNote document mutations makes typing and note swaps more expensive than necessary. + +## Decision + +**Derived editor indexes that are not required for the editor surface itself must be lazy, debounced, and built off the main thread when they can be derived from Markdown.** The Table of Contents does not build while its panel is closed; once opened, it uses a Web Worker to build its Markdown-derived H1/H2/H3 tree after a debounce, while live BlockNote block IDs are resolved only at click time for navigation. + +## Options considered + +- **Lazy debounced Web Worker for Markdown-derived indexes** (chosen): avoids any TOC work while the panel is closed, keeps outline parsing away from typing and note-opening work once opened, cancels stale panel updates, and lets the rendered editor remain the only editor surface. Cons: adds a small worker/client path and a title-only interim state. +- **Main-thread deferred rebuild with `setTimeout`**: avoids blocking the first render, but still runs on the UI thread and can still rebuild too often during active edits. +- **Synchronous rebuild from the BlockNote document**: simplest and gives immediate block IDs, but makes every BlockNote document update a potential side-panel rebuild. +- **Never update the TOC while editing**: safest for typing performance, but stale outlines make the panel misleading for active authoring. + +## Consequences + +- TOC tree state is driven by note identity plus debounced Markdown content, not by BlockNote document churn. +- Closing the TOC panel unmounts the panel and cancels pending debounce callbacks; no worker request is scheduled while the panel is closed. +- The TOC may briefly show only the note title after a note switch or edit burst; the full tree appears when the debounced worker result returns. +- Navigation remains tied to the live editor: block IDs are resolved from the current BlockNote document at click time and scrolled/focused then. +- Future derived side-panel indexes should follow the same pattern when they parse or scan note content and are not needed to render the editor itself. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1578aeb4..f5036ca7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -162,3 +162,4 @@ proposed → active → superseded | [0107](0107-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active | | [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active | | [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active | +| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active | diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx index 8f75563c..67e8715d 100644 --- a/src/components/AiPanelChrome.tsx +++ b/src/components/AiPanelChrome.tsx @@ -200,6 +200,7 @@ export const AiPanelHeader = memo(function AiPanelHeader({ type="button" variant="ghost" size="icon-xs" + className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4" onClick={onNewChat} aria-label={t('ai.panel.newChat')} title={t('ai.panel.newChat')} @@ -210,6 +211,7 @@ export const AiPanelHeader = memo(function AiPanelHeader({ type="button" variant="ghost" size="icon-xs" + className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4" onClick={onClose} aria-label={t('ai.panel.close')} title={t('ai.panel.close')} diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 9326ea78..f93ffc37 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -538,9 +538,9 @@ describe('Editor', () => { expect(screen.getAllByText('Properties').length).toBeGreaterThan(0) }) - it('renders the table of contents panel from the editor document', () => { + it('renders the table of contents panel from the active note content', async () => { mockEditor.document = [ - { id: 'toc-heading', type: 'heading', content: 'Table Heading', props: { level: 1 }, children: [] }, + { id: 'toc-heading', type: 'heading', content: [{ type: 'text', text: 'Table Heading' }], props: { level: 2 }, children: [] }, ] render( @@ -549,13 +549,14 @@ describe('Editor', () => { tabs={[mockTab]} activeTabPath={mockEntry.path} inspectorEntry={mockEntry} + inspectorContent={`${mockContent}\n\n## Table Heading`} /> ) fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' })) expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Table Heading' })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument() }) // Regression: editor content did not appear on first load because BlockNote's diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index e4b988d0..cddaf651 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -8,7 +8,6 @@ import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/ import type { AiTarget } from '../lib/aiTargets' import { translate, type AppLocale } from '../lib/i18n' import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' -import { trackEvent } from '../lib/telemetry' import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types' import type { NoteListItem } from '../utils/ai-context' import type { FrontmatterValue } from './Inspector' @@ -22,6 +21,7 @@ import { EditorContent } from './EditorContent' import { EditorMemoryProbe } from './EditorMemoryProbe' import { FilePreview } from './FilePreview' import { schema } from './editorSchema' +import { useRightPanelExclusion } from './useRightPanelExclusion' import type { RawEditorFindRequest } from './RawEditorFindBar' import { applyPendingRawExitContent, @@ -324,6 +324,8 @@ function EditorLayout({ showDiffToggle, showAIChat, onToggleAIChat, + showTableOfContents, + onToggleTableOfContents, inspectorCollapsed, onToggleInspector, onNavigateWikilink, @@ -389,6 +391,8 @@ function EditorLayout({ showDiffToggle: boolean showAIChat?: boolean onToggleAIChat?: () => void + showTableOfContents?: boolean + onToggleTableOfContents?: () => void inspectorCollapsed: boolean onToggleInspector: () => void onNavigateWikilink: (target: string) => void @@ -437,27 +441,6 @@ function EditorLayout({ }) { const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null const showEmptyState = tabs.length === 0 && activeTabPath === null && !isVaultLoading - const [showTableOfContents, setShowTableOfContents] = useState(false) - const [tableOfContentsRevision, setTableOfContentsRevision] = useState(0) - const handleEditorChangeWithToc = useCallback(() => { - handleEditorChange() - setTableOfContentsRevision((revision) => revision + 1) - }, [handleEditorChange]) - const handleToggleAIChatExclusive = useCallback(() => { - if (!showAIChat) setShowTableOfContents(false) - onToggleAIChat?.() - }, [onToggleAIChat, showAIChat]) - const handleToggleTableOfContents = useCallback(() => { - const opening = !showTableOfContents - if (opening && showAIChat) onToggleAIChat?.() - setShowTableOfContents(opening) - trackEvent('table_of_contents_toggled', { open: opening ? 1 : 0 }) - }, [onToggleAIChat, showAIChat, showTableOfContents]) - const handleTableOfContentsHeadingSelected = useCallback(() => { - trackEvent('table_of_contents_heading_selected') - }, []) - - const visibleTableOfContents = showTableOfContents && !showAIChat return (
@@ -491,13 +474,13 @@ function EditorLayout({ activeStatus={activeStatus} showDiffToggle={showDiffToggle} showAIChat={showAIChat} - onToggleAIChat={handleToggleAIChatExclusive} - showTableOfContents={visibleTableOfContents} - onToggleTableOfContents={handleToggleTableOfContents} + onToggleAIChat={onToggleAIChat} + showTableOfContents={showTableOfContents} + onToggleTableOfContents={onToggleTableOfContents} inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} onNavigateWikilink={onNavigateWikilink} - onEditorChange={handleEditorChangeWithToc} + onEditorChange={handleEditorChange} onToggleFavorite={onToggleFavorite} onToggleOrganized={onToggleOrganized} onRevealFile={onRevealFile} @@ -518,14 +501,13 @@ function EditorLayout({ locale={locale} /> } - {(showAIChat || visibleTableOfContents || !inspectorCollapsed) && } + {(showAIChat || showTableOfContents || !inspectorCollapsed) && } + const rightPanel = useRightPanelExclusion(props) + + return ( + + ) }) diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 86637400..3fbd3b78 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -17,7 +17,6 @@ interface EditorRightPanelProps { inspectorCollapsed: boolean inspectorWidth: number editor: ReturnType - tableOfContentsRevision: number defaultAiAgent?: AiAgentId defaultAiTarget?: AiTarget defaultAiAgentReadiness?: AiAgentReadiness @@ -33,7 +32,6 @@ interface EditorRightPanelProps { onToggleInspector: () => void onToggleAIChat?: () => void onToggleTableOfContents?: () => void - onTableOfContentsHeadingSelected?: () => void onNavigateWikilink: (target: string) => void onViewCommitDiff: (commitHash: string) => Promise onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise @@ -52,12 +50,12 @@ interface EditorRightPanelProps { export function EditorRightPanel({ showAIChat, showTableOfContents, inspectorCollapsed, inspectorWidth, - editor, tableOfContentsRevision, + editor, defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true, onUnsupportedAiPaste, inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, noteList, noteListFilter, - onToggleInspector, onToggleAIChat, onToggleTableOfContents, onTableOfContentsHeadingSelected, onNavigateWikilink, onViewCommitDiff, + onToggleInspector, onToggleAIChat, onToggleTableOfContents, onNavigateWikilink, onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote, onFileCreated, onFileModified, onVaultChanged, locale, @@ -90,6 +88,52 @@ export function EditorRightPanel({ return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat) }, [handleNewChat]) + if (!inspectorCollapsed) { + return ( +
+ +
+ ) + } + + if (showTableOfContents) { + return ( +
+ onToggleTableOfContents?.()} + sourceContent={inspectorContent} + /> +
+ ) + } + if (showAIChat) { return (
- onToggleTableOfContents?.()} - onHeadingSelected={() => onTableOfContentsHeadingSelected?.()} - /> -
- ) - } - - if (inspectorCollapsed) return null - - return ( -
- -
- ) + return null } diff --git a/src/components/TableOfContentsPanel.test.tsx b/src/components/TableOfContentsPanel.test.tsx index cbc30269..099e917b 100644 --- a/src/components/TableOfContentsPanel.test.tsx +++ b/src/components/TableOfContentsPanel.test.tsx @@ -1,158 +1,143 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { TableOfContentsPanel } from './TableOfContentsPanel' import type { VaultEntry } from '../types' +import { TableOfContentsPanel } from './TableOfContentsPanel' +import { buildTableOfContents, buildTableOfContentsFromMarkdown } from './tableOfContentsModel' -const baseEntry: VaultEntry = { - path: '/vault/project/test.md', - filename: 'test.md', - title: 'Test Project', - isA: 'Project', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 100, - snippet: '', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - outgoingLinks: [], - template: null, - sort: null, - sidebarLabel: null, - view: null, - visible: null, - properties: {}, - organized: false, - favorite: false, - favoriteIndex: null, - listPropertiesDisplay: [], - hasH1: false, -} +const entry = { + title: 'The Compounding Software Factory', +} as VaultEntry -function createEditor(documentBlocks: unknown[]) { - return { - document: documentBlocks, - focus: vi.fn(), - setTextCursorPosition: vi.fn(), - } -} +const blocks = [ + { id: 'h1', type: 'heading', props: { level: 1 }, content: [{ type: 'text', text: 'The default path is degradation' }] }, + { id: 'h2', type: 'heading', props: { level: 2 }, content: [{ type: 'text', text: 'What causes teams to degrade' }] }, + { id: 'h3', type: 'heading', props: { level: 3 }, content: [{ type: 'text', text: 'Poor coding hygiene' }] }, + { id: 'ignored', type: 'paragraph', props: {}, content: [{ type: 'text', text: 'Body' }] }, +] describe('TableOfContentsPanel', () => { - it('renders a nested heading tree and collapses child headings', () => { - const editor = createEditor([ - { id: 'intro', type: 'heading', props: { level: 1 }, content: 'Intro' }, - { id: 'setup', type: 'heading', props: { level: 2 }, content: 'Setup' }, - { id: 'details', type: 'heading', props: { level: 3 }, content: 'Details' }, - { id: 'usage', type: 'heading', props: { level: 1 }, content: 'Usage' }, - ]) + it('builds a title-rooted H1/H2/H3 hierarchy', () => { + const toc = buildTableOfContents(entry.title, blocks) + expect(toc.title).toBe('The Compounding Software Factory') + expect(toc.children[0].title).toBe('The default path is degradation') + expect(toc.children[0].children[0].title).toBe('What causes teams to degrade') + expect(toc.children[0].children[0].children[0].title).toBe('Poor coding hygiene') + }) + + it('does not duplicate the note title when the first markdown H1 matches it', () => { + const toc = buildTableOfContentsFromMarkdown( + 'Introducing Tolaria', + '# Introducing Tolaria\n\n## Tolaria + Refactoring\n\n## Principles', + ) + + expect(toc.title).toBe('Introducing Tolaria') + expect(toc.children.map((item) => item.title)).toEqual(['Tolaria + Refactoring', 'Principles']) + }) + + it('keeps navigation ids after removing a duplicate markdown title H1', async () => { + const setTextCursorPosition = vi.fn() render( , ) - expect(screen.getByRole('heading', { name: 'Table of Contents' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Intro' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Setup' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Details' })).toBeInTheDocument() + fireEvent.click(await screen.findByRole('button', { name: /Introducing Tolaria/ })) + expect(setTextCursorPosition).toHaveBeenCalledWith('title-block', 'start') - fireEvent.click(screen.getByRole('button', { name: 'Collapse Intro' })) - - expect(screen.getByRole('button', { name: 'Intro' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Setup' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Usage' })).toBeInTheDocument() + fireEvent.click(await screen.findByRole('button', { name: /Tolaria \+ Refactoring/ })) + expect(setTextCursorPosition).toHaveBeenCalledWith('section-block', 'start') }) - it('moves the editor cursor to the selected heading', () => { - const editor = createEditor([ - { id: 'target-heading', type: 'heading', props: { level: 1 }, content: 'Target' }, - ]) - const onHeadingSelected = vi.fn() - + it('resolves navigation ids on click after the async TOC build starts without ids', async () => { + const setTextCursorPosition = vi.fn() + const editor = { + document: [] as unknown[], + setTextCursorPosition, + } render( , ) - fireEvent.click(screen.getByRole('button', { name: 'Target' })) + await screen.findByRole('button', { name: /New Heading/ }) + editor.document = [ + { id: 'title-block', type: 'heading', props: { level: 1 }, content: [{ type: 'text', text: 'New Note' }] }, + { id: 'new-heading-block', type: 'heading', props: { level: 2 }, content: [{ type: 'text', text: 'New Heading' }] }, + ] - expect(editor.focus).toHaveBeenCalledOnce() - expect(editor.setTextCursorPosition).toHaveBeenCalledWith('target-heading', 'start') - expect(onHeadingSelected).toHaveBeenCalledWith(expect.objectContaining({ id: 'target-heading' })) + fireEvent.click(screen.getByRole('button', { name: /New Heading/ })) + expect(setTextCursorPosition).toHaveBeenCalledWith('new-heading-block', 'start') }) - it('updates when the editor document revision changes', () => { - const editor = createEditor([ - { id: 'intro', type: 'heading', props: { level: 1 }, content: 'Intro' }, - ]) + it('updates from source content even when the editor document is stale', async () => { const { rerender } = render( , ) - editor.document = [ - { id: 'intro', type: 'heading', props: { level: 1 }, content: 'Intro' }, - { id: 'live-update', type: 'heading', props: { level: 2 }, content: 'Live update' }, - ] + await screen.findByRole('button', { name: /Old Heading/ }) rerender( , ) - expect(screen.getByRole('button', { name: 'Live update' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /New Note/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Old Heading/ })).not.toBeInTheDocument() + expect(await screen.findByRole('button', { name: /New Heading/ })).toBeInTheDocument() }) - it('shows an empty state when the note has no headings', () => { + it('does not show stale editor headings while new note source content is loading', () => { render( , ) - expect(screen.getByText('No headings in this note')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /New Note/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /The default path is degradation/ })).not.toBeInTheDocument() }) - it('keeps the close action in the panel header', () => { - const onClose = vi.fn() + it('renders heading icons, nesting guides, and navigates to clicked headings', async () => { + const setTextCursorPosition = vi.fn() render( , ) - const panel = screen.getByTestId('table-of-contents-panel') - fireEvent.click(within(panel).getByRole('button', { name: 'Close table of contents' })) + expect(screen.getByText('Table of Contents')).toBeInTheDocument() + expect(await screen.findByTestId('toc-connector:toc-title')).toBeInTheDocument() + expect(screen.getByTestId('toc-connector:h1')).toBeInTheDocument() - expect(onClose).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: /What causes teams to degrade/ })) + expect(setTextCursorPosition).toHaveBeenCalledWith('h2', 'start') }) }) diff --git a/src/components/TableOfContentsPanel.tsx b/src/components/TableOfContentsPanel.tsx index cc2202d6..e0f38530 100644 --- a/src/components/TableOfContentsPanel.tsx +++ b/src/components/TableOfContentsPanel.tsx @@ -1,82 +1,252 @@ -import { useCallback, useMemo, useState } from 'react' -import { CaretDown, CaretRight, ListBullets, X } from '@phosphor-icons/react' +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 { cn } from '@/lib/utils' -import { useDragRegion } from '../hooks/useDragRegion' -import { translate, type AppLocale } from '../lib/i18n' +import { trackEvent } from '../lib/telemetry' +import type { AppLocale } from '../lib/i18n' +import { translate } from '../lib/i18n' import type { VaultEntry } from '../types' -import { extractTableOfContents, type TableOfContentsItem } from '../utils/tableOfContents' +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: (blockId: string, placement: 'start' | 'end') => void + document?: unknown[] + focus?: () => void + setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void } interface TableOfContentsPanelProps { - activeEntry: VaultEntry | null - documentRevision: number editor: TableOfContentsEditor + entry: VaultEntry | null locale?: AppLocale onClose: () => void - onHeadingSelected?: (item: TableOfContentsItem) => void + sourceContent?: string | null } -const EMPTY_COLLAPSED_IDS = new Set() - -function headingLabel(item: TableOfContentsItem, locale: AppLocale): string { - return item.text || translate(locale, 'tableOfContents.untitledHeading') +interface TocState { + noteKey: string + toc: TocItem } -function findBlockElement(blockId: string): HTMLElement | null { - const candidates = document.querySelectorAll('[data-id], [data-block-id]') - for (const candidate of candidates) { - if (candidate.dataset.id === blockId) return candidate - if (candidate.dataset.blockId === blockId) return candidate - } - return null +interface DebouncedTocOptions { + editor: TableOfContentsEditor + noteKey: string + sourceContent?: string | null + title: string + titleOnlyToc: TocItem } -function scrollHeadingIntoView(blockId: string) { - findBlockElement(blockId)?.scrollIntoView({ block: 'start', behavior: 'smooth' }) +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 EmptyTableOfContents({ children }: { children: string }) { +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 ( -
- {children} +
+
) } -function TableOfContentsHeader({ - locale, - onClose, +function TocChildren({ + depth, + item, + onNavigate, }: { - locale: AppLocale - onClose: () => void + depth: number + item: TocItem + onNavigate: (item: TocItem) => void }) { - const { onMouseDown } = useDragRegion() + 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')} -

+ @@ -84,219 +254,35 @@ function TableOfContentsHeader({ ) } -function ToggleChildrenButton({ - collapsed, - item, - locale, - onToggle, -}: { - collapsed: boolean - item: TableOfContentsItem - locale: AppLocale - onToggle: (itemId: string) => void -}) { - const label = headingLabel(item, locale) - - return ( - - ) -} - -function TogglePlaceholder() { - return