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:
202
design/zoom-shortcuts.pen
Normal file
202
design/zoom-shortcuts.pen
Normal 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": {}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
113
src/hooks/useZoom.test.ts
Normal 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
61
src/hooks/useZoom.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user