feat: add table of contents shortcut

This commit is contained in:
lucaronin
2026-05-06 16:45:18 +02:00
parent 74eecfda02
commit e19db5cab7
14 changed files with 110 additions and 6 deletions

View File

@@ -1237,6 +1237,7 @@ function App() {
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
const rawToggleRef = useRef<() => void>(() => {})
const tableOfContentsToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
@@ -1441,6 +1442,9 @@ function App() {
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
)
const toggleTableOfContentsCommand = useCallback(() => {
if (notes.activeTabPath) tableOfContentsToggleRef.current()
}, [notes.activeTabPath])
const findInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: false })
}, [])
@@ -1558,6 +1562,7 @@ function App() {
onToggleInspector: handleToggleInspector,
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
onToggleTableOfContents: toggleTableOfContentsCommand,
noteWidth: activeNoteWidth,
defaultNoteWidth,
onSetNoteWidth: handleSetActiveNoteWidth,
@@ -1785,6 +1790,7 @@ function App() {
noteWidth={activeNoteWidth}
onToggleNoteWidth={handleToggleNoteWidth}
rawToggleRef={rawToggleRef}
tableOfContentsToggleRef={tableOfContentsToggleRef}
findInNoteRef={findInNoteRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}

View File

@@ -562,6 +562,15 @@ describe('BreadcrumbBar — table of contents toggle', () => {
expect(screen.getByRole('button', { name: 'Close table of contents' })).toBeInTheDocument()
})
it('shows the table of contents shortcut in the button tooltip', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleTableOfContents={vi.fn()} />)
await expectTooltip(
screen.getByRole('button', { name: 'Open table of contents' }),
'Open table of contents',
formatShortcutDisplay({ display: '⌘⇧T' }),
)
})
it('offers the table of contents action from the overflow menu', async () => {
const onToggleTableOfContents = vi.fn()
const restoreMeasurement = mockCollapsedBreadcrumbOverflow()

View File

@@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { APP_COMMAND_IDS, formatShortcutDisplay, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
@@ -353,7 +353,10 @@ function TableOfContentsAction({
return (
<IconActionButton
copy={{ label: translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents') }}
copy={{
label: translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents'),
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleTableOfContents),
}}
onClick={onToggleTableOfContents}
className={cn(showTableOfContents ? 'text-foreground' : 'hover:text-foreground')}
>

View File

@@ -103,6 +103,8 @@ interface EditorProps {
findInNoteRef?: React.MutableRefObject<((options?: { replace?: boolean }) => void) | null>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers its table-of-contents toggle into, for app shortcuts and menus. */
tableOfContentsToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
@@ -601,6 +603,12 @@ export const Editor = memo(function Editor(props: EditorProps) {
flushPendingRawContentRef: props.flushPendingRawContentRef,
})
const rightPanel = useRightPanelExclusion(props)
const { tableOfContentsToggleRef } = props
useEffect(() => {
if (tableOfContentsToggleRef) {
tableOfContentsToggleRef.current = rightPanel.handleToggleTableOfContents
}
}, [tableOfContentsToggleRef, rightPanel.handleToggleTableOfContents])
return (
<EditorLayout

View File

@@ -6,6 +6,9 @@ import { buildTableOfContents, buildTableOfContentsFromMarkdown } from './tableO
const entry = {
title: 'The Compounding Software Factory',
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 2048,
} as VaultEntry
const blocks = [
@@ -140,4 +143,20 @@ describe('TableOfContentsPanel', () => {
fireEvent.click(screen.getByRole('button', { name: /What causes teams to degrade/ }))
expect(setTextCursorPosition).toHaveBeenCalledWith('h2', 'start')
})
it('shows note info at the bottom of the table of contents', () => {
render(
<TableOfContentsPanel
editor={{ document: blocks, setTextCursorPosition: vi.fn() }}
entry={entry}
sourceContent="One two three"
onClose={vi.fn()}
/>,
)
expect(screen.getByText('Info')).toBeInTheDocument()
expect(screen.getByText('Words')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
})
})

View File

@@ -16,6 +16,7 @@ import {
getFolderConnectorLeft,
getFolderDepthIndent,
} from './folder-tree/folderTreeLayout'
import { NoteInfoPanel } from './inspector/NoteInfoPanel'
interface TableOfContentsEditor {
document?: unknown[]
@@ -283,6 +284,11 @@ export const TableOfContentsPanel = memo(function TableOfContentsPanel({
onNavigate={navigateToItem}
/>
</div>
{entry && (
<div className="shrink-0 border-t border-border p-3">
<NoteInfoPanel entry={entry} content={sourceContent ?? null} locale={locale} />
</div>
)}
</aside>
)
})

View File

@@ -45,6 +45,7 @@ type SimpleHandlerKey =
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'

View File

@@ -48,6 +48,7 @@ function makeHandlers(): AppCommandHandlers {
onToggleRawEditor: vi.fn(),
onToggleDiff: vi.fn(),
onToggleAIChat: vi.fn(),
onToggleTableOfContents: vi.fn(),
onPastePlainText: vi.fn(),
onGoBack: vi.fn(),
onGoForward: vi.fn(),
@@ -134,6 +135,7 @@ describe('appCommandDispatcher', () => {
expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen)
expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor)
expect(findShortcutCommandId('command-or-ctrl-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
expect(findShortcutCommandId('command-or-ctrl-shift', 'T', 'KeyT')).toBe(APP_COMMAND_IDS.viewToggleTableOfContents)
expect(findShortcutCommandId('command-or-ctrl-shift', 'v', 'KeyV')).toBe(APP_COMMAND_IDS.editPastePlainText)
})
@@ -196,6 +198,7 @@ describe('appCommandDispatcher', () => {
expectShortcutEventCommand({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true }, APP_COMMAND_IDS.viewGoBack)
expectShortcutEventCommand({ key: 'ArrowRight', code: 'ArrowRight', metaKey: true }, APP_COMMAND_IDS.viewGoForward)
expectShortcutEventCommand({ key: 'l', code: 'KeyL', ctrlKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleAiChat)
expectShortcutEventCommand({ key: 'T', code: 'KeyT', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleTableOfContents)
expectShortcutEventCommand({ key: 'V', code: 'KeyV', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.editPastePlainText)
})
@@ -227,6 +230,12 @@ describe('appCommandDispatcher', () => {
expect(handlers.onToggleAIChat).toHaveBeenCalled()
})
it('dispatches table of contents toggle through the shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.viewToggleTableOfContents, handlers)).toBe(true)
expect(handlers.onToggleTableOfContents).toHaveBeenCalled()
})
it('dispatches plain-text paste through the shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.editPastePlainText, handlers)).toBe(true)

View File

@@ -50,6 +50,7 @@ export interface AppCommandHandlers {
onToggleRawEditor?: () => void
onToggleDiff?: () => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
onGoBack?: () => void
onGoForward?: () => void
onCheckForUpdates?: () => void
@@ -87,6 +88,7 @@ type SimpleHandlerKey = keyof Pick<
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
@@ -131,6 +133,7 @@ const SIMPLE_HANDLER_EXECUTORS: readonly [SimpleHandlerKey, SimpleHandlerExecuto
['onToggleDiff', (handlers) => handlers.onToggleDiff?.()],
['onToggleInspector', (handlers) => handlers.onToggleInspector()],
['onToggleAIChat', (handlers) => handlers.onToggleAIChat?.()],
['onToggleTableOfContents', (handlers) => handlers.onToggleTableOfContents?.()],
['onCommandPalette', (handlers) => handlers.onCommandPalette()],
['onZoomIn', (handlers) => handlers.onZoomIn()],
['onZoomOut', (handlers) => handlers.onZoomOut()],

View File

@@ -28,6 +28,7 @@ export type KeyboardActions = Pick<
| 'onGoBack'
| 'onGoForward'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
| 'onToggleRawEditor'
| 'onToggleInspector'
| 'onToggleFavorite'

View File

@@ -29,6 +29,7 @@ interface ViewCommandsConfig {
onSetNoteWidth?: (mode: NoteWidthMode) => void
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
zoomLevel: number
onZoomIn: () => void
onZoomOut: () => void
@@ -92,12 +93,27 @@ function buildMoveSavedViewCommand(
}
}
function buildToggleTableOfContentsCommand(
hasActiveNote: boolean,
onToggleTableOfContents?: () => void,
): CommandAction {
return {
id: 'toggle-table-of-contents',
label: 'Toggle Table of Contents',
group: 'View',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleTableOfContents),
keywords: ['toc', 'outline', 'headings', 'contents', 'panel'],
enabled: hasActiveNote && !!onToggleTableOfContents,
execute: () => onToggleTableOfContents?.(),
}
}
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor,
noteWidth = DEFAULT_NOTE_WIDTH_MODE, defaultNoteWidth = DEFAULT_NOTE_WIDTH_MODE,
onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onToggleTableOfContents,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
@@ -116,6 +132,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
buildSetDefaultNoteWidthCommand('normal', defaultNoteWidth, onSetDefaultNoteWidth),
buildSetDefaultNoteWidthCommand('wide', defaultNoteWidth, onSetDefaultNoteWidth),
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
buildToggleTableOfContentsCommand(hasActiveNote, onToggleTableOfContents),
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
buildMoveSavedViewCommand('Up', selectedViewName, onMoveSelectedViewUp, canMoveSelectedViewUp),

View File

@@ -75,6 +75,7 @@ interface AppCommandsConfig {
onInitializeGit?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
onCheckForUpdates?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
@@ -176,6 +177,7 @@ type CommandRegistryCoreActions = Pick<
| 'onSetNoteWidth'
| 'onSetDefaultNoteWidth'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
>
type CommandRegistryVaultActions = Pick<
CommandRegistryConfig,
@@ -256,6 +258,7 @@ function createKeyboardActions(
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onToggleAIChat: config.onToggleAIChat,
onToggleTableOfContents: config.onToggleTableOfContents,
onToggleRawEditor: config.onToggleRawEditor,
onToggleInspector: config.onToggleInspector,
onToggleFavorite: config.onToggleFavorite,
@@ -302,6 +305,7 @@ function createMenuEventActionHandlers(
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
| 'onToggleOrganized'
| 'onGoBack'
| 'onGoForward'
@@ -328,6 +332,7 @@ function createMenuEventActionHandlers(
onToggleRawEditor: config.onToggleRawEditor,
onToggleDiff: config.onToggleDiff,
onToggleAIChat: config.onToggleAIChat,
onToggleTableOfContents: config.onToggleTableOfContents,
onToggleOrganized: config.onToggleOrganized,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
@@ -452,6 +457,7 @@ function createCommandRegistryCoreConfig(
onSetNoteWidth: config.onSetNoteWidth,
onSetDefaultNoteWidth: config.onSetDefaultNoteWidth,
onToggleAIChat: config.onToggleAIChat,
onToggleTableOfContents: config.onToggleTableOfContents,
}
}

View File

@@ -98,6 +98,7 @@ interface CommandRegistryConfig {
onSetNoteWidth?: (mode: NoteWidthMode) => void
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
activeNoteModified: boolean
onCheckForUpdates?: () => void
onZoomIn: () => void
@@ -129,7 +130,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onPastePlainText, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote,
noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onOpenVault, onCreateEmptyVault,
noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onToggleTableOfContents, onOpenVault, onCreateEmptyVault,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
@@ -224,12 +225,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const viewCommands = useMemo(() => buildViewCommands({
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onToggleTableOfContents, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
}), [
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onToggleTableOfContents,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,

View File

@@ -182,6 +182,19 @@
"requiresManualNativeAcceleratorQa": true
}
},
"viewToggleTableOfContents": {
"id": "view-toggle-table-of-contents",
"route": { "kind": "handler", "handler": "onToggleTableOfContents" },
"menuOwned": true,
"shortcut": {
"combo": "command-or-ctrl-shift",
"key": "t",
"code": "KeyT",
"display": "⌘⇧T",
"accelerator": "CmdOrCtrl+Shift+T",
"requiresManualNativeAcceleratorQa": true
}
},
"viewToggleBacklinks": {
"id": "view-toggle-backlinks",
"route": { "kind": "handler", "handler": "onToggleInspector" },
@@ -474,6 +487,7 @@
{ "kind": "command", "command": "noteOpenInNewWindow", "label": "Open in New Window" },
{ "kind": "separator" },
{ "kind": "command", "command": "editToggleRawEditor", "label": "Toggle Raw Editor" },
{ "kind": "command", "command": "viewToggleTableOfContents", "label": "Toggle Table of Contents" },
{ "kind": "command", "command": "viewToggleAiChat", "label": "Toggle AI Panel" },
{ "kind": "command", "command": "viewToggleBacklinks", "label": "Toggle Backlinks" }
]
@@ -510,6 +524,7 @@
{ "command": "noteDelete" },
{ "command": "editToggleRawEditor" },
{ "command": "editToggleDiff" },
{ "command": "viewToggleTableOfContents" },
{ "command": "viewToggleBacklinks" },
{ "command": "noteOpenInNewWindow" }
],