From 384cffec48851f038eb5efe79c869f20e15289e6 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Sat, 28 Feb 2026 12:41:57 +0100 Subject: [PATCH] feat: zoom in/out keyboard shortcuts (Cmd+=, Cmd+-, Cmd+0) (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: zoom in/out keyboard shortcuts (Cmd+=, Cmd+-, Cmd+0) Add zoom control with keyboard shortcuts, native menu items, command palette integration, and StatusBar zoom indicator with localStorage persistence (80-150%, 10% step). Co-Authored-By: Claude Opus 4.6 * docs: add zoom-shortcuts design file with StatusBar zoom indicator Co-Authored-By: Claude Opus 4.6 * design: zoom-shortcuts — StatusBar zoom indicator + Command Palette entries --------- Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- design/zoom-shortcuts.pen | 202 +++++++++++++++++++++++++++ src-tauri/src/menu.rs | 25 ++++ src/App.tsx | 6 +- src/components/StatusBar.tsx | 61 +++++--- src/hooks/useAppCommands.ts | 14 ++ src/hooks/useAppKeyboard.test.ts | 31 ++++ src/hooks/useAppKeyboard.ts | 11 +- src/hooks/useCommandRegistry.test.ts | 34 +++++ src/hooks/useCommandRegistry.ts | 15 +- src/hooks/useMenuEvents.test.ts | 21 +++ src/hooks/useMenuEvents.ts | 35 +++-- src/hooks/useZoom.test.ts | 113 +++++++++++++++ src/hooks/useZoom.ts | 61 ++++++++ 13 files changed, 591 insertions(+), 38 deletions(-) create mode 100644 design/zoom-shortcuts.pen create mode 100644 src/hooks/useZoom.test.ts create mode 100644 src/hooks/useZoom.ts diff --git a/design/zoom-shortcuts.pen b/design/zoom-shortcuts.pen new file mode 100644 index 00000000..78ec4921 --- /dev/null +++ b/design/zoom-shortcuts.pen @@ -0,0 +1,202 @@ +{ + "children": [ + { + "type": "frame", + "id": "zs_normal", + "name": "Zoom Shortcuts — StatusBar (100% / default)", + "x": 0, + "y": 0, + "theme": { "Mode": "Light" }, + "width": 800, + "height": 32, + "fill": "$--background", + "layout": "horizontal", + "gap": 0, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zs_normal_desc", + "name": "description", + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "zs_normal_desc_text", + "fill": "$--muted-foreground", + "content": "StatusBar at 100% zoom — zoom indicator is HIDDEN. Cmd+= zooms in, Cmd+- zooms out, Cmd+0 resets to 100%.", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "zs_zoomed", + "name": "Zoom Shortcuts — StatusBar (150% / zoomed in)", + "x": 0, + "y": 60, + "theme": { "Mode": "Light" }, + "width": 800, + "height": 32, + "fill": "$--background", + "layout": "horizontal", + "gap": 0, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zs_statusbar_zoomed", + "name": "StatusBar — 150% (zoom indicator visible)", + "layout": "horizontal", + "gap": 8, + "alignItems": "center", + "padding": [0, 8], + "children": [ + { + "type": "text", + "id": "zs_wordcount_z", + "fill": "$--muted-foreground", + "content": "1,247 words", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + }, + { + "type": "frame", + "id": "zs_zoom_badge", + "name": "Zoom % Badge", + "fill": "$--muted", + "cornerRadius": 4, + "padding": [2, 6], + "alignItems": "center", + "gap": 4, + "children": [ + { + "type": "text", + "id": "zs_zoom_label", + "fill": "$--foreground", + "content": "150%", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zs_cmd_palette", + "name": "Zoom Shortcuts — Command Palette entries", + "x": 0, + "y": 120, + "theme": { "Mode": "Light" }, + "width": 540, + "height": 120, + "fill": "$--popover", + "cornerRadius": 12, + "layout": "vertical", + "gap": 0, + "children": [ + { + "type": "frame", + "id": "zs_cmd_row1", + "name": "Command — Zoom In (⌘=)", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "alignItems": "center", + "fill": "$--accent", + "children": [ + { + "type": "text", + "id": "zs_cmd_row1_label", + "fill": "$--accent-foreground", + "content": "Zoom In", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "width": "fill_container" + }, + { + "type": "text", + "id": "zs_cmd_row1_shortcut", + "fill": "$--muted-foreground", + "content": "Command+=", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "zs_cmd_row2", + "name": "Command — Zoom Out (⌘-)", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zs_cmd_row2_label", + "fill": "$--foreground", + "content": "Zoom Out", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "width": "fill_container" + }, + { + "type": "text", + "id": "zs_cmd_row2_shortcut", + "fill": "$--muted-foreground", + "content": "Command+-", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "zs_cmd_row3", + "name": "Command — Reset Zoom (⌘0)", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zs_cmd_row3_label", + "fill": "$--foreground", + "content": "Reset Zoom", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "width": "fill_container" + }, + { + "type": "text", + "id": "zs_cmd_row3_shortcut", + "fill": "$--muted-foreground", + "content": "Command+0", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 5dce3098..55466446 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -14,6 +14,9 @@ const VIEW_EDITOR_LIST: &str = "view-editor-list"; const VIEW_ALL: &str = "view-all"; const VIEW_TOGGLE_INSPECTOR: &str = "view-toggle-inspector"; const VIEW_COMMAND_PALETTE: &str = "view-command-palette"; +const VIEW_ZOOM_IN: &str = "view-zoom-in"; +const VIEW_ZOOM_OUT: &str = "view-zoom-out"; +const VIEW_ZOOM_RESET: &str = "view-zoom-reset"; const CUSTOM_IDS: &[&str] = &[ APP_SETTINGS, @@ -26,6 +29,9 @@ const CUSTOM_IDS: &[&str] = &[ VIEW_ALL, VIEW_TOGGLE_INSPECTOR, VIEW_COMMAND_PALETTE, + VIEW_ZOOM_IN, + VIEW_ZOOM_OUT, + VIEW_ZOOM_RESET, ]; /// IDs of menu items that should be disabled when no note tab is active. @@ -115,6 +121,18 @@ fn build_view_menu(app: &App) -> MenuResult { .id(VIEW_COMMAND_PALETTE) .accelerator("CmdOrCtrl+K") .build(app)?; + let zoom_in = MenuItemBuilder::new("Zoom In") + .id(VIEW_ZOOM_IN) + .accelerator("CmdOrCtrl+=") + .build(app)?; + let zoom_out = MenuItemBuilder::new("Zoom Out") + .id(VIEW_ZOOM_OUT) + .accelerator("CmdOrCtrl+-") + .build(app)?; + let zoom_reset = MenuItemBuilder::new("Actual Size") + .id(VIEW_ZOOM_RESET) + .accelerator("CmdOrCtrl+0") + .build(app)?; Ok(SubmenuBuilder::new(app, "View") .item(&editor_only) @@ -123,6 +141,10 @@ fn build_view_menu(app: &App) -> MenuResult { .separator() .item(&toggle_inspector) .separator() + .item(&zoom_in) + .item(&zoom_out) + .item(&zoom_reset) + .separator() .item(&command_palette) .build()?) } @@ -192,6 +214,9 @@ mod tests { "view-all", "view-toggle-inspector", "view-command-palette", + "view-zoom-in", + "view-zoom-out", + "view-zoom-reset", ]; for id in &expected { assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}"); diff --git a/src/App.tsx b/src/App.tsx index 7c413fce..5cd14a4a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { useGitHistory } from './hooks/useGitHistory' import { useUpdater } from './hooks/useUpdater' import { useNavigationHistory } from './hooks/useNavigationHistory' import { useAutoSync } from './hooks/useAutoSync' +import { useZoom } from './hooks/useZoom' import { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' import { extractOutgoingLinks } from './utils/wikilinks' @@ -250,6 +251,7 @@ function App() { const bulkActions = useBulkActions(entryActions, setToastMessage) const { setViewMode, sidebarVisible, noteListVisible } = useViewMode() + const zoom = useZoom() const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, @@ -264,6 +266,8 @@ function App() { onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode, onToggleInspector: () => layout.setInspectorCollapsed(c => !c), + onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, + zoomLevel: zoom.zoomLevel, onSelect: setSelection, onCloseTab: notes.handleCloseTab, onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, @@ -336,7 +340,7 @@ function App() { /> - setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} /> + setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 9e1d8f20..16e8bb0f 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -24,6 +24,8 @@ interface StatusBarProps { conflictCount?: number lastCommitInfo?: LastCommitInfo | null onTriggerSync?: () => void + zoomLevel?: number + onZoomReset?: () => void } function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) { @@ -111,6 +113,7 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const const SEP_STYLE = { color: 'var(--border)' } as const +const SYNC_ICON_MAP: Record = { syncing: Loader2, conflict: AlertTriangle } function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string { if (status === 'syncing') return 'Syncing…' @@ -129,7 +132,30 @@ function syncIconColor(status: SyncStatus): string { return 'var(--accent-green)' } -export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync }: StatusBarProps) { +function CommitBadge({ info }: { info: LastCommitInfo }) { + if (info.commitUrl) { + return ( + openExternalUrl(info.commitUrl!)} + style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }} + title={`Open commit ${info.shortHash} on GitHub`} + data-testid="status-commit-link" + onMouseEnter={e => { e.currentTarget.style.color = 'var(--foreground)' }} + onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted-foreground)' }} + > + {info.shortHash} + + ) + } + return ( + + {info.shortHash} + + ) +} + +export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset }: StatusBarProps) { // Force re-render every 30s to keep relative time label fresh const [, setTick] = useState(0) useEffect(() => { @@ -138,7 +164,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS }, []) const syncLabel = formatSyncLabel(syncStatus, lastSyncTime) - const SyncIcon = syncStatus === 'syncing' ? Loader2 : syncStatus === 'conflict' ? AlertTriangle : RefreshCw + const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw return (