diff --git a/src/App.tsx b/src/App.tsx index 08a3e294..5e73b355 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 539b96f0..c72ff8ac 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -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() + 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() diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 51b50998..6495f760 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -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 ( diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index d4688991..761ec4bd 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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 ( { 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( + , + ) + + expect(screen.getByText('Info')).toBeInTheDocument() + expect(screen.getByText('Words')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('2.0 KB')).toBeInTheDocument() + }) }) diff --git a/src/components/TableOfContentsPanel.tsx b/src/components/TableOfContentsPanel.tsx index e0f38530..7e3be687 100644 --- a/src/components/TableOfContentsPanel.tsx +++ b/src/components/TableOfContentsPanel.tsx @@ -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} /> + {entry && ( +
+ +
+ )} ) }) diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index 11cdb7b5..476b8c28 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -45,6 +45,7 @@ type SimpleHandlerKey = | 'onToggleDiff' | 'onToggleInspector' | 'onToggleAIChat' + | 'onToggleTableOfContents' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 12b61d03..9903803e 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -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) diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index a4077504..44ad9e38 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -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()], diff --git a/src/hooks/appKeyboardShortcuts.ts b/src/hooks/appKeyboardShortcuts.ts index 4290fbfa..aa7f4cf5 100644 --- a/src/hooks/appKeyboardShortcuts.ts +++ b/src/hooks/appKeyboardShortcuts.ts @@ -28,6 +28,7 @@ export type KeyboardActions = Pick< | 'onGoBack' | 'onGoForward' | 'onToggleAIChat' + | 'onToggleTableOfContents' | 'onToggleRawEditor' | 'onToggleInspector' | 'onToggleFavorite' diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index 971234ab..11e120a4 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -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), diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 4f89d731..2c8111df 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, } } diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 0c592fd3..d6b353de 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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, diff --git a/src/shared/appCommandManifest.json b/src/shared/appCommandManifest.json index 4aed67e1..239f9ffe 100644 --- a/src/shared/appCommandManifest.json +++ b/src/shared/appCommandManifest.json @@ -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" } ],