diff --git a/dist-electron/main.js b/dist-electron/main.js index 39a5ce5..c9416de 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,4 +1,4 @@ -import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron"; +import { ipcMain, screen, BrowserWindow, app, desktopCapturer, shell, dialog, nativeImage, Tray, Menu } from "electron"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs/promises"; @@ -126,6 +126,7 @@ function createSourceSelectorWindow() { } return win; } +const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); let selectedSource = null; function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { ipcMain.handle("get-sources", async (_, opts) => { @@ -298,6 +299,23 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("get-platform", () => { return process.platform; }); + ipcMain.handle("get-shortcuts", async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + return JSON.parse(data); + } catch { + return null; + } + }); + ipcMain.handle("save-shortcuts", async (_, shortcuts) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + return { success: false, error: String(error) }; + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index cb59604..6355b7b 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -59,5 +59,11 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { }, getPlatform: () => { return electron.ipcRenderer.invoke("get-platform"); + }, + getShortcuts: () => { + return electron.ipcRenderer.invoke("get-shortcuts"); + }, + saveShortcuts: (shortcuts) => { + return electron.ipcRenderer.invoke("save-shortcuts", shortcuts); } }); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dda3d8d..a489699 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -41,6 +41,8 @@ interface Window { getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> clearCurrentVideoPath: () => Promise<{ success: boolean }> getPlatform: () => Promise + getShortcuts: () => Promise | null> + saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }> hudOverlayHide: () => void; hudOverlayClose: () => void; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 24a63a1..cec3ed9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -4,6 +4,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' +const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json') + let selectedSource: any = null let currentVideoPath: string | null = null @@ -328,4 +330,23 @@ export function registerIpcHandlers( ipcMain.handle('get-platform', () => { return process.platform; }); + + ipcMain.handle('get-shortcuts', async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } + }); + + ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8'); + return { success: true }; + } catch (error) { + console.error('Failed to save shortcuts:', error); + return { success: false, error: String(error) }; + } + }); } diff --git a/electron/preload.ts b/electron/preload.ts index f58d8a8..a609e7b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -66,4 +66,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getPlatform: () => { return ipcRenderer.invoke('get-platform') }, + getShortcuts: () => { + return ipcRenderer.invoke('get-shortcuts') + }, + saveShortcuts: (shortcuts: unknown) => { + return ipcRenderer.invoke('save-shortcuts', shortcuts) + }, }) \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ad94efb..cc6742c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import VideoEditor from "./components/video-editor/VideoEditor"; import { loadAllCustomFonts } from "./lib/customFonts"; +import { ShortcutsProvider } from "./contexts/ShortcutsContext"; +import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; export default function App() { const [windowType, setWindowType] = useState(''); @@ -29,7 +31,12 @@ export default function App() { case 'source-selector': return ; case 'editor': - return ; + return ( + + + + + ); default: return (
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 3edbd04..b48896f 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,65 +1,62 @@ -import { HelpCircle } from "lucide-react"; +import { HelpCircle, Settings2 } from "lucide-react"; import { useState, useEffect } from "react"; import { formatShortcut } from "@/utils/platformUtils"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts"; export function KeyboardShortcutsHelp() { - const [shortcuts, setShortcuts] = useState({ - delete: 'Ctrl + D', - pan: 'Shift + Ctrl + Scroll', - zoom: 'Ctrl + Scroll' - }); + const { shortcuts, isMac, openConfig } = useShortcuts(); + + const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' }); useEffect(() => { Promise.all([ - formatShortcut(['mod', 'D']), formatShortcut(['shift', 'mod', 'Scroll']), - formatShortcut(['mod', 'Scroll']) - ]).then(([deleteKey, panKey, zoomKey]) => { - setShortcuts({ - delete: deleteKey, - pan: panKey, - zoom: zoomKey - }); - }); + formatShortcut(['mod', 'Scroll']), + ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); }, []); return (
+
-
Keyboard Shortcuts
+
+ Keyboard Shortcuts + +
+
-
- Add Zoom - Z -
-
- Add Annotation - A -
-
- Add Keyframe - F -
-
- Add Trim - T -
-
- Delete Selected - {shortcuts.delete} -
-
- Pan Timeline - {shortcuts.pan} -
-
- Zoom Timeline - {shortcuts.zoom} -
-
- Pause/Play - Space + {SHORTCUT_ACTIONS.map((action) => ( +
+ {SHORTCUT_LABELS[action]} + + {formatBinding(shortcuts[action], isMac)} + +
+ ))} + +
+
+ Pan Timeline + {scrollLabels.pan} +
+
+ Zoom Timeline + {scrollLabels.zoom} +
+
+ Cycle Annotations + Tab +
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx new file mode 100644 index 0000000..e524e38 --- /dev/null +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Keyboard, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + DEFAULT_SHORTCUTS, + FIXED_SHORTCUTS, + SHORTCUT_ACTIONS, + SHORTCUT_LABELS, + findConflict, + formatBinding, + type ShortcutAction, + type ShortcutBinding, + type ShortcutConflict, + type ShortcutsConfig, +} from '@/lib/shortcuts'; +import { useShortcuts } from '@/contexts/ShortcutsContext'; + +const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +export function ShortcutsConfigDialog() { + const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = + useShortcuts(); + + const [draft, setDraft] = useState(shortcuts); + const [captureFor, setCaptureFor] = useState(null); + const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null); + + useEffect(() => { + if (isConfigOpen) { + setDraft(shortcuts); + setCaptureFor(null); + setConflict(null); + } + }, [isConfigOpen, shortcuts]); + + useEffect(() => { + if (!captureFor) return; + + const handleCapture = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + setCaptureFor(null); + return; + } + + if (MODIFIER_KEYS.has(e.key)) return; + + const binding: ShortcutBinding = { + key: e.key.toLowerCase(), + ...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}), + ...(e.shiftKey ? { shift: true } : {}), + ...(e.altKey ? { alt: true } : {}), + }; + + const found = findConflict(binding, captureFor, draft); + setCaptureFor(null); + + if (found?.type === 'fixed') { + toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`); + return; + } + + if (found?.type === 'configurable') { + setConflict({ forAction: captureFor, pending: binding, conflictWith: found }); + return; + } + + setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + }; + + window.addEventListener('keydown', handleCapture, { capture: true }); + return () => window.removeEventListener('keydown', handleCapture, { capture: true }); + }, [captureFor]); + + const handleSwap = useCallback(() => { + if (!conflict || conflict.conflictWith.type !== 'configurable') return; + const { forAction, pending, conflictWith } = conflict; + setDraft((prev: ShortcutsConfig) => ({ + ...prev, + [forAction]: pending, + [conflictWith.action]: prev[forAction], + })); + setConflict(null); + }, [conflict]); + + const handleCancelConflict = useCallback(() => setConflict(null), []); + + const handleSave = useCallback(async () => { + setShortcuts(draft); + await persistShortcuts(draft); + toast.success('Keyboard shortcuts saved'); + closeConfig(); + }, [draft, setShortcuts, persistShortcuts, closeConfig]); + + const handleReset = useCallback(() => { + setDraft({ ...DEFAULT_SHORTCUTS }); + toast.info('Reset to default shortcuts — click Save to apply'); + }, []); + + const handleClose = useCallback(() => { + setCaptureFor(null); + setConflict(null); + closeConfig(); + }, [closeConfig]); + + return ( + { if (!open) handleClose(); }}> + + + + + Keyboard Shortcuts + + + +
+

Configurable

+ {SHORTCUT_ACTIONS.map((action) => { + const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; + return ( +
+
+ {SHORTCUT_LABELS[action]} + +
+ {hasConflict && conflict?.conflictWith.type === 'configurable' && ( +
+ + ⚠ Already used by {SHORTCUT_LABELS[conflict.conflictWith.action]} + +
+ + +
+
+ )} +
+ ); + })} +
+ +
+

Fixed

+ {FIXED_SHORTCUTS.map(({ label, display }) => ( +
+ {label} + + {display} + +
+ ))} +
+ +

+ Click a shortcut then press the new key combination. Press{' '} + Esc to cancel. +

+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index d418950..d1515ed 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -32,6 +32,8 @@ import { import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { getAssetPath } from "@/lib/assetPath"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { matchesShortcut } from "@/lib/shortcuts"; const WALLPAPER_COUNT = 18; const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); @@ -71,6 +73,8 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); + + const { shortcuts, isMac } = useShortcuts(); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); @@ -459,7 +463,7 @@ export default function VideoEditor() { e.preventDefault(); } - if (e.key === ' ' || e.code === 'Space') { + if (matchesShortcut(e, shortcuts.playPause, isMac)) { // Allow space only in inputs/textareas if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; @@ -479,7 +483,7 @@ export default function VideoEditor() { window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, []); + }, [shortcuts, isMac]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b15333..2d0ced8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -20,6 +20,8 @@ import { import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; import { TutorialHelp } from "../TutorialHelp"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { matchesShortcut } from "@/lib/shortcuts"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; @@ -558,16 +560,17 @@ export default function TimelineEditor({ const [range, setRange] = useState(() => createInitialRange(totalMs)); const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); - const [shortcuts, setShortcuts] = useState({ + const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' }); const timelineContainerRef = useRef(null); + const { shortcuts: keyShortcuts, isMac } = useShortcuts(); useEffect(() => { formatShortcut(['shift', 'mod', 'Scroll']).then(pan => { formatShortcut(['mod', 'Scroll']).then(zoom => { - setShortcuts({ pan, zoom }); + setScrollLabels({ pan, zoom }); }); }); }, []); @@ -860,16 +863,16 @@ export default function TimelineEditor({ return; } - if (e.key === 'f' || e.key === 'F') { + if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) { addKeyframe(); } - if (e.key === 'z' || e.key === 'Z') { + if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { handleAddZoom(); } - if (e.key === 't' || e.key === 'T') { + if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) { handleAddTrim(); } - if (e.key === 'a' || e.key === 'A') { + if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } @@ -896,7 +899,7 @@ export default function TimelineEditor({ } } // Delete key or Ctrl+D / Cmd+D - if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) { + if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) { if (selectedKeyframeId) { deleteSelectedKeyframe(); } else if (selectedZoomId) { @@ -910,7 +913,7 @@ export default function TimelineEditor({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]); + }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]); const clampedRange = useMemo(() => { if (totalMs === 0) { @@ -1070,11 +1073,11 @@ export default function TimelineEditor({
- {shortcuts.pan} + {scrollLabels.pan} Pan - {shortcuts.zoom} + {scrollLabels.zoom} Zoom
diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx new file mode 100644 index 0000000..bc6a57b --- /dev/null +++ b/src/contexts/ShortcutsContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts'; +import { isMac as getIsMac } from '@/utils/platformUtils'; + +interface ShortcutsContextValue { + shortcuts: ShortcutsConfig; + isMac: boolean; + setShortcuts: (config: ShortcutsConfig) => void; + persistShortcuts: (config?: ShortcutsConfig) => Promise; + isConfigOpen: boolean; + openConfig: () => void; + closeConfig: () => void; +} + +const ShortcutsContext = createContext(null); + +export function useShortcuts(): ShortcutsContextValue { + const ctx = useContext(ShortcutsContext); + if (!ctx) throw new Error('useShortcuts must be used within '); + return ctx; +} + +export function ShortcutsProvider({ children }: { children: ReactNode }) { + const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [isMac, setIsMac] = useState(false); + const [isConfigOpen, setIsConfigOpen] = useState(false); + + useEffect(() => { + getIsMac().then(setIsMac).catch(() => {}); + + window.electronAPI.getShortcuts?.() + .then((saved) => { + if (saved) { + setShortcuts(mergeWithDefaults(saved as Partial)); + } + }) + .catch(() => {}); + }, []); + + const persistShortcuts = useCallback( + async (config?: ShortcutsConfig) => { + await window.electronAPI.saveShortcuts?.(config ?? shortcuts); + }, + [shortcuts], + ); + + const openConfig = useCallback(() => setIsConfigOpen(true), []); + const closeConfig = useCallback(() => setIsConfigOpen(false), []); + + const value = useMemo( + () => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }), + [shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig], + ); + + return ( + + {children} + + ); +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000..75ccce9 --- /dev/null +++ b/src/lib/shortcuts.ts @@ -0,0 +1,122 @@ +export const SHORTCUT_ACTIONS = [ + 'addZoom', + 'addTrim', + 'addAnnotation', + 'addKeyframe', + 'deleteSelected', + 'playPause', +] as const; + +export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; + +export interface ShortcutBinding { + key: string; + /** Maps to Cmd on macOS, Ctrl on Windows/Linux */ + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +} + +export type ShortcutsConfig = Record; + +export interface FixedShortcut { + label: string; + display: string; + bindings: ShortcutBinding[]; +} + +export const FIXED_SHORTCUTS: FixedShortcut[] = [ + { label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] }, + { label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] }, + { label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] }, + { label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] }, + { label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] }, +]; + +export type ShortcutConflict = + | { type: 'configurable'; action: ShortcutAction } + | { type: 'fixed'; label: string }; + +export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean { + return ( + a.key.toLowerCase() === b.key.toLowerCase() && + !!a.ctrl === !!b.ctrl && + !!a.shift === !!b.shift && + !!a.alt === !!b.alt + ); +} + +export function findConflict( + binding: ShortcutBinding, + forAction: ShortcutAction, + config: ShortcutsConfig, +): ShortcutConflict | null { + for (const fixed of FIXED_SHORTCUTS) { + if (fixed.bindings.some((b) => bindingsEqual(b, binding))) { + return { type: 'fixed', label: fixed.label }; + } + } + for (const action of SHORTCUT_ACTIONS) { + if (action !== forAction && bindingsEqual(config[action], binding)) { + return { type: 'configurable', action }; + } + } + return null; +} + +export const DEFAULT_SHORTCUTS: ShortcutsConfig = { + addZoom: { key: 'z' }, + addTrim: { key: 't' }, + addAnnotation: { key: 'a' }, + addKeyframe: { key: 'f' }, + deleteSelected: { key: 'd', ctrl: true }, + playPause: { key: ' ' }, +}; + +export const SHORTCUT_LABELS: Record = { + addZoom: 'Add Zoom', + addTrim: 'Add Trim', + addAnnotation: 'Add Annotation', + addKeyframe: 'Add Keyframe', + deleteSelected: 'Delete Selected', + playPause: 'Play / Pause', +}; + +export function matchesShortcut( + e: KeyboardEvent, + binding: ShortcutBinding, + isMacPlatform: boolean, +): boolean { + if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false; + + const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey; + if (primaryMod !== !!binding.ctrl) return false; + if (e.shiftKey !== !!binding.shift) return false; + if (e.altKey !== !!binding.alt) return false; + + return true; +} + +const KEY_LABELS: Record = { + ' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc', + 'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→', +}; + +export function formatBinding(binding: ShortcutBinding, isMac: boolean): string { + const parts: string[] = []; + if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl'); + if (binding.shift) parts.push(isMac ? '⇧' : 'Shift'); + if (binding.alt) parts.push(isMac ? '⌥' : 'Alt'); + parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase()); + return parts.join(' + '); +} + +export function mergeWithDefaults(partial: Partial): ShortcutsConfig { + const merged = { ...DEFAULT_SHORTCUTS }; + for (const action of SHORTCUT_ACTIONS) { + if (partial[action]) { + merged[action] = partial[action] as ShortcutBinding; + } + } + return merged; +}