diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index f7d6074..e524e38 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -6,36 +6,33 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from ' 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']); -const FIXED_SHORTCUTS = [ - { label: 'Cycle Annotations Forward', display: 'Tab' }, - { label: 'Cycle Annotations Backward', display: 'Shift + Tab' }, - { label: 'Delete Selected (alt)', display: 'Del / ⌫' }, - { label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll' }, - { label: 'Zoom Timeline', display: 'Ctrl + Scroll' }, -] as const; - 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]); @@ -60,14 +57,39 @@ export function ShortcutsConfigDialog() { ...(e.altKey ? { alt: true } : {}), }; - setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + 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); @@ -82,6 +104,7 @@ export function ShortcutsConfigDialog() { const handleClose = useCallback(() => { setCaptureFor(null); + setConflict(null); closeConfig(); }, [closeConfig]); @@ -99,25 +122,53 @@ export function ShortcutsConfigDialog() {

Configurable

{SHORTCUT_ACTIONS.map((action) => { const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; return ( -
- {SHORTCUT_LABELS[action]} - +
+
+ {SHORTCUT_LABELS[action]} + +
+ {hasConflict && conflict?.conflictWith.type === 'configurable' && ( +
+ + ⚠ Already used by {SHORTCUT_LABELS[conflict.conflictWith.action]} + +
+ + +
+
+ )}
); })} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 4b81444..75ccce9 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -19,6 +19,51 @@ export interface ShortcutBinding { 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' },