feat: zoom in/out keyboard shortcuts (Cmd+=, Cmd+-, Cmd+0) (#134)

* 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 <noreply@anthropic.com>

* docs: add zoom-shortcuts design file with StatusBar zoom indicator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: zoom-shortcuts — StatusBar zoom indicator + Command Palette entries

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-28 12:41:57 +01:00
committed by GitHub
parent 8388029cfb
commit fb4896f49f
13 changed files with 591 additions and 38 deletions

202
design/zoom-shortcuts.pen Normal file
View File

@@ -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": {}
}

View File

@@ -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}");

View File

@@ -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() {
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />

View File

@@ -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<string, typeof RefreshCw> = { 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 (
<span
role="button"
onClick={() => 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)' }}
>
<GitCommitHorizontal size={13} />{info.shortHash}
</span>
)
}
return (
<span style={ICON_STYLE} data-testid="status-commit-hash">
<GitCommitHorizontal size={13} />{info.shortHash}
</span>
)
}
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 (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
@@ -156,25 +182,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
>
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
</span>
{lastCommitInfo && (
lastCommitInfo.commitUrl ? (
<span
role="button"
onClick={() => openExternalUrl(lastCommitInfo.commitUrl!)}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={`Open commit ${lastCommitInfo.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)' }}
>
<GitCommitHorizontal size={13} />{lastCommitInfo.shortHash}
</span>
) : (
<span style={ICON_STYLE} data-testid="status-commit-hash">
<GitCommitHorizontal size={13} />{lastCommitInfo.shortHash}
</span>
)
)}
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
{conflictCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
@@ -201,6 +209,17 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
{zoomLevel !== 100 && (
<span
role="button"
onClick={onZoomReset}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Reset zoom (⌘0)"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-zoom"
>{zoomLevel}%</span>
)}
<span style={DISABLED_STYLE} title="Coming soon"><Bell size={14} /></span>
<span
role="button"

View File

@@ -29,6 +29,10 @@ interface AppCommandsConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onCloseTab: (path: string) => void
onSwitchTab: (path: string) => void
@@ -52,6 +56,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onTrashNote: config.onTrashNote,
onArchiveNote: config.onArchiveNote,
onSetViewMode: config.onSetViewMode,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
activeTabPathRef: config.activeTabPathRef,
@@ -66,6 +73,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onOpenSettings: config.onOpenSettings,
onToggleInspector: config.onToggleInspector,
onCommandPalette: config.onCommandPalette,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -85,6 +95,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommitPush: config.onCommitPush,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
onCloseTab: config.onCloseTab,
onGoBack: config.onGoBack,

View File

@@ -26,6 +26,9 @@ function makeActions() {
onTrashNote: vi.fn(),
onArchiveNote: vi.fn(),
onSetViewMode: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
}
@@ -143,4 +146,32 @@ describe('useAppKeyboard', () => {
expect(actions.onCommandPalette).toHaveBeenCalled()
})
})
it('Cmd+= triggers zoom in', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('=', { metaKey: true })
expect(actions.onZoomIn).toHaveBeenCalled()
})
it('Cmd++ triggers zoom in', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('+', { metaKey: true })
expect(actions.onZoomIn).toHaveBeenCalled()
})
it('Cmd+- triggers zoom out', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('-', { metaKey: true })
expect(actions.onZoomOut).toHaveBeenCalled()
})
it('Cmd+0 triggers zoom reset', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('0', { metaKey: true })
expect(actions.onZoomReset).toHaveBeenCalled()
})
})

View File

@@ -11,6 +11,9 @@ interface KeyboardActions {
onTrashNote: (path: string) => void
onArchiveNote: (path: string) => void
onSetViewMode: (mode: ViewMode) => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onGoBack?: () => void
onGoForward?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
@@ -58,7 +61,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -78,6 +81,10 @@ export function useAppKeyboard({
Delete: withActiveTab(onTrashNote),
'[': () => onGoBack?.(),
']': () => onGoForward?.(),
'=': onZoomIn,
'+': onZoomIn,
'-': onZoomOut,
'0': onZoomReset,
}
const handleKeyDown = (e: KeyboardEvent) => {
@@ -93,5 +100,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onGoBack, onGoForward])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
}

View File

@@ -45,6 +45,10 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onCommitPush: vi.fn(),
onSetViewMode: vi.fn(),
onToggleInspector: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
zoomLevel: 100,
onSelect: vi.fn(),
onCloseTab: vi.fn(),
...overrides,
@@ -140,6 +144,36 @@ describe('useCommandRegistry', () => {
result.current.find(c => c.id === 'view-editor')!.execute()
expect(onSetViewMode).toHaveBeenCalledWith('editor-only')
})
it('zoom-in is enabled when below max zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(true)
})
it('zoom-in is disabled at max zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 150 })))
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(false)
})
it('zoom-out is disabled at min zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 80 })))
expect(result.current.find(c => c.id === 'zoom-out')!.enabled).toBe(false)
})
it('zoom-reset is disabled at 100%', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(false)
})
it('zoom-reset is enabled when not at 100%', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(true)
})
it('zoom-in label shows current zoom level', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
})
describe('groupSortKey', () => {

View File

@@ -29,6 +29,10 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onCloseTab: (path: string) => void
onGoBack?: () => void
@@ -48,7 +52,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab,
onCommitPush, onSetViewMode, onToggleInspector,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
} = config
@@ -92,6 +98,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
@@ -102,7 +111,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
hasActiveNote, activeTabPath, isArchived, modifiedCount,
onQuickOpen, onCreateNote, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab,
onCommitPush, onSetViewMode, onToggleInspector,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
])
}

View File

@@ -10,6 +10,9 @@ function makeHandlers(): MenuEventHandlers {
onOpenSettings: vi.fn(),
onToggleInspector: vi.fn(),
onCommandPalette: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
@@ -84,6 +87,24 @@ describe('dispatchMenuEvent', () => {
expect(h.onCommandPalette).toHaveBeenCalled()
})
it('view-zoom-in triggers zoom in', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-in', h)
expect(h.onZoomIn).toHaveBeenCalled()
})
it('view-zoom-out triggers zoom out', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-out', h)
expect(h.onZoomOut).toHaveBeenCalled()
})
it('view-zoom-reset triggers zoom reset', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-reset', h)
expect(h.onZoomReset).toHaveBeenCalled()
})
it('unknown event ID does nothing', () => {
const h = makeHandlers()
dispatchMenuEvent('unknown-event', h)

View File

@@ -10,6 +10,9 @@ export interface MenuEventHandlers {
onOpenSettings: () => void
onToggleInspector: () => void
onCommandPalette: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -21,23 +24,31 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-all': 'all',
}
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset'
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'file-new-note': 'onCreateNote',
'file-quick-open': 'onQuickOpen',
'file-save': 'onSave',
'app-settings': 'onOpenSettings',
'view-toggle-inspector': 'onToggleInspector',
'view-command-palette': 'onCommandPalette',
'view-zoom-in': 'onZoomIn',
'view-zoom-out': 'onZoomOut',
'view-zoom-reset': 'onZoomReset',
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
const viewMode = VIEW_MODE_MAP[id]
if (viewMode) { h.onSetViewMode(viewMode); return }
switch (id) {
case 'file-new-note': h.onCreateNote(); break
case 'file-quick-open': h.onQuickOpen(); break
case 'file-save': h.onSave(); break
case 'file-close-tab': {
const path = h.activeTabPathRef.current
if (path) h.handleCloseTabRef.current(path)
break
}
case 'app-settings': h.onOpenSettings(); break
case 'view-toggle-inspector': h.onToggleInspector(); break
case 'view-command-palette': h.onCommandPalette(); break
const simple = SIMPLE_EVENT_MAP[id]
if (simple) { h[simple](); return }
if (id === 'file-close-tab') {
const path = h.activeTabPathRef.current
if (path) h.handleCloseTabRef.current(path)
}
}

113
src/hooks/useZoom.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useZoom } from './useZoom'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
describe('useZoom', () => {
beforeEach(() => {
localStorageMock.clear()
document.documentElement.style.removeProperty('zoom')
})
it('initializes at 100% by default', () => {
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('restores persisted zoom level from localStorage', () => {
localStorageMock.setItem('laputa:zoom-level', '120')
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(120)
})
it('ignores invalid persisted values', () => {
localStorageMock.setItem('laputa:zoom-level', 'banana')
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('ignores out-of-range persisted values', () => {
localStorageMock.setItem('laputa:zoom-level', '200')
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('zoomIn increases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(110)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('110')
})
it('zoomOut decreases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(90)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('90')
})
it('zoomIn clamps at 150', () => {
localStorageMock.setItem('laputa:zoom-level', '150')
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(150)
})
it('zoomOut clamps at 80', () => {
localStorageMock.setItem('laputa:zoom-level', '80')
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(80)
})
it('zoomReset returns to 100', () => {
localStorageMock.setItem('laputa:zoom-level', '130')
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomReset())
expect(result.current.zoomLevel).toBe(100)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('100')
})
it('applies CSS zoom property to document element', () => {
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
const { result } = renderHook(() => useZoom())
spy.mockClear() // clear the mount call
act(() => result.current.zoomIn())
expect(spy).toHaveBeenCalledWith('zoom', '110%')
spy.mockRestore()
})
it('zoomIn and zoomOut are stable callbacks', () => {
const { result, rerender } = renderHook(() => useZoom())
const { zoomIn: a, zoomOut: b, zoomReset: c } = result.current
rerender()
expect(result.current.zoomIn).toBe(a)
expect(result.current.zoomOut).toBe(b)
expect(result.current.zoomReset).toBe(c)
})
it('successive zoomIn calls accumulate', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
act(() => result.current.zoomIn())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(130)
})
it('handles localStorage getItem throwing', () => {
localStorageMock.getItem.mockImplementationOnce(() => { throw new Error('no storage') })
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
})

61
src/hooks/useZoom.ts Normal file
View File

@@ -0,0 +1,61 @@
import { useState, useEffect, useCallback } from 'react'
const ZOOM_KEY = 'laputa:zoom-level'
const MIN_ZOOM = 80
const MAX_ZOOM = 150
const STEP = 10
const DEFAULT_ZOOM = 100
function loadPersistedZoom(): number {
try {
const stored = localStorage.getItem(ZOOM_KEY)
if (stored !== null) {
const val = Number(stored)
if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val
}
} catch { /* localStorage unavailable */ }
return DEFAULT_ZOOM
}
function applyZoomToDocument(level: number): void {
document.documentElement.style.setProperty('zoom', `${level}%`)
}
function persistZoom(level: number): void {
try { localStorage.setItem(ZOOM_KEY, String(level)) } catch { /* ignore */ }
}
export function useZoom() {
const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom)
// Apply persisted zoom on mount
useEffect(() => {
applyZoomToDocument(zoomLevel)
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
const zoomIn = useCallback(() => {
setZoomLevel(prev => {
const next = Math.min(MAX_ZOOM, prev + STEP)
applyZoomToDocument(next)
persistZoom(next)
return next
})
}, [])
const zoomOut = useCallback(() => {
setZoomLevel(prev => {
const next = Math.max(MIN_ZOOM, prev - STEP)
applyZoomToDocument(next)
persistZoom(next)
return next
})
}, [])
const zoomReset = useCallback(() => {
setZoomLevel(DEFAULT_ZOOM)
applyZoomToDocument(DEFAULT_ZOOM)
persistZoom(DEFAULT_ZOOM)
}, [])
return { zoomLevel, zoomIn, zoomOut, zoomReset }
}