import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { X, Search, Keyboard, RotateCcw, Save, AlertTriangle, ChevronDown, GripVertical, Trash2 } from 'lucide-react' import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutsContext' import { ACTION_REGISTRY, ActionId, KeyCombo, formatKeyCombo, findConflicts, ActionDefinition, } from '../lib/keyboard-shortcuts' // ── Visual keyboard layout (US QWERTY) ── // Each key: { id: lowercase key id matching KeyCombo.key, label: display text, w: width units (1 = standard key) } interface KBKey { id: string; label: string; w: number } const KB_ROWS: KBKey[][] = [ // Row 0: Number row [ { id: '`', label: '`', w: 1 }, { id: '1', label: '1', w: 1 }, { id: '2', label: '2', w: 1 }, { id: '3', label: '3', w: 1 }, { id: '4', label: '4', w: 1 }, { id: '5', label: '5', w: 1 }, { id: '6', label: '6', w: 1 }, { id: '7', label: '7', w: 1 }, { id: '8', label: '8', w: 1 }, { id: '9', label: '9', w: 1 }, { id: '0', label: '0', w: 1 }, { id: '-', label: '-', w: 1 }, { id: '=', label: '=', w: 1 }, { id: 'backspace', label: 'Bksp', w: 2 }, ], // Row 1: QWERTY [ { id: 'tab', label: 'Tab', w: 1.5 }, { id: 'q', label: 'Q', w: 1 }, { id: 'w', label: 'W', w: 1 }, { id: 'e', label: 'E', w: 1 }, { id: 'r', label: 'R', w: 1 }, { id: 't', label: 'T', w: 1 }, { id: 'y', label: 'Y', w: 1 }, { id: 'u', label: 'U', w: 1 }, { id: 'i', label: 'I', w: 1 }, { id: 'o', label: 'O', w: 1 }, { id: 'p', label: 'P', w: 1 }, { id: '[', label: '[', w: 1 }, { id: ']', label: ']', w: 1 }, { id: '\\', label: '\\', w: 1.5 }, ], // Row 2: Home row [ { id: 'capslock', label: 'Caps', w: 1.8 }, { id: 'a', label: 'A', w: 1 }, { id: 's', label: 'S', w: 1 }, { id: 'd', label: 'D', w: 1 }, { id: 'f', label: 'F', w: 1 }, { id: 'g', label: 'G', w: 1 }, { id: 'h', label: 'H', w: 1 }, { id: 'j', label: 'J', w: 1 }, { id: 'k', label: 'K', w: 1 }, { id: 'l', label: 'L', w: 1 }, { id: ';', label: ';', w: 1 }, { id: "'", label: "'", w: 1 }, { id: 'enter', label: 'Enter', w: 2.2 }, ], // Row 3: Shift row [ { id: 'shift-l', label: 'Shift', w: 2.5 }, { id: 'z', label: 'Z', w: 1 }, { id: 'x', label: 'X', w: 1 }, { id: 'c', label: 'C', w: 1 }, { id: 'v', label: 'V', w: 1 }, { id: 'b', label: 'B', w: 1 }, { id: 'n', label: 'N', w: 1 }, { id: 'm', label: 'M', w: 1 }, { id: ',', label: ',', w: 1 }, { id: '.', label: '.', w: 1 }, { id: '/', label: '/', w: 1 }, { id: 'shift-r', label: 'Shift', w: 2.5 }, ], // Row 4: Bottom row [ { id: 'ctrl-l', label: 'Ctrl', w: 1.5 }, { id: 'alt-l', label: 'Alt', w: 1.5 }, { id: ' ', label: 'Space', w: 7 }, { id: 'alt-r', label: 'Alt', w: 1.5 }, { id: 'ctrl-r', label: 'Ctrl', w: 1.5 }, ], // Row 5: Navigation cluster [ { id: 'escape', label: 'Esc', w: 1 }, { id: 'delete', label: 'Del', w: 1 }, { id: 'home', label: 'Home', w: 1 }, { id: 'end', label: 'End', w: 1 }, { id: 'f9', label: 'F9', w: 1 }, { id: 'f10', label: 'F10', w: 1 }, { id: 'f11', label: 'F11', w: 1 }, { id: 'arrowleft', label: '\u2190', w: 1 }, { id: 'arrowup', label: '\u2191', w: 1 }, { id: 'arrowdown', label: '\u2193', w: 1 }, { id: 'arrowright', label: '\u2192', w: 1 }, ], ] // Category → color mapping const CATEGORY_COLORS: Record = { Tools: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' }, Transport: { bg: 'bg-emerald-600/30', border: 'border-emerald-500/60', text: 'text-emerald-300', dot: 'bg-emerald-400' }, Editing: { bg: 'bg-amber-600/30', border: 'border-amber-500/60', text: 'text-amber-300', dot: 'bg-amber-400' }, Marking: { bg: 'bg-rose-600/30', border: 'border-rose-500/60', text: 'text-rose-300', dot: 'bg-rose-400' }, Timeline: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' }, } // Keys that are modifier indicators (not assignable targets) const MODIFIER_KEY_IDS = new Set(['shift-l', 'shift-r', 'ctrl-l', 'ctrl-r', 'alt-l', 'alt-r', 'capslock', 'tab']) export function KeyboardShortcutsModal() { const { activeLayout, activePresetId, presets, switchPreset, updateBinding, resetToPreset, saveAsCustomPreset, deleteCustomPreset, isEditorOpen, setEditorOpen, } = useKeyboardShortcuts() const [searchQuery, setSearchQuery] = useState('') const [recordingAction, setRecordingAction] = useState(null) const [showPresetDropdown, setShowPresetDropdown] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false) const [savePresetName, setSavePresetName] = useState('') const [selectedCategory, setSelectedCategory] = useState(null) const recordRef = useRef(null) // Visual keyboard state const [kbModCtrl, setKbModCtrl] = useState(false) const [kbModShift, setKbModShift] = useState(false) const [kbModAlt, setKbModAlt] = useState(false) const [hoveredActionId, setHoveredActionId] = useState(null) const [draggedActionId, setDraggedActionId] = useState(null) const [dropTargetKey, setDropTargetKey] = useState(null) const [showKeyboard, setShowKeyboard] = useState(true) const conflicts = findConflicts(activeLayout) const activePreset = presets.find(p => p.id === activePresetId) // ── Reverse lookup: given current modifier state, map keyId → ActionId ── const keyToAction = useMemo(() => { const map = new Map() for (const [actionId, combos] of Object.entries(activeLayout)) { if (!combos) continue for (const combo of combos) { // Check if this combo matches the current modifier filter const comboCtrl = !!combo.ctrl || !!combo.meta const comboShift = !!combo.shift const comboAlt = !!combo.alt if (comboCtrl === kbModCtrl && comboShift === kbModShift && comboAlt === kbModAlt) { const def = ACTION_REGISTRY.find(a => a.id === actionId) if (def) { map.set(combo.key, { actionId: actionId as ActionId, action: def }) } } } } return map }, [activeLayout, kbModCtrl, kbModShift, kbModAlt]) // ── Keys assigned to the hovered action (for highlighting) ── const hoveredKeys = useMemo(() => { if (!hoveredActionId) return new Set() const combos = activeLayout[hoveredActionId] || [] return new Set(combos.map(c => c.key)) }, [hoveredActionId, activeLayout]) // Close on Escape (only when not recording) useEffect(() => { if (!isEditorOpen) return const handler = (e: KeyboardEvent) => { if (e.key === 'Escape' && !recordingAction) { setEditorOpen(false) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [isEditorOpen, recordingAction, setEditorOpen]) // Recording mode: capture the next keystroke as a new binding const handleRecordKeyDown = useCallback((e: KeyboardEvent) => { if (!recordingAction) return e.preventDefault() e.stopPropagation() // Ignore modifier-only presses if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return const combo: KeyCombo = { key: e.key.toLowerCase(), ...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}), ...(e.shiftKey ? { shift: true } : {}), ...(e.altKey ? { alt: true } : {}), } // If Escape pressed alone, cancel recording if (combo.key === 'escape' && !combo.ctrl && !combo.shift && !combo.alt) { setRecordingAction(null) return } // Set the new binding (replace existing) updateBinding(recordingAction, [combo]) setRecordingAction(null) }, [recordingAction, updateBinding]) useEffect(() => { if (recordingAction) { window.addEventListener('keydown', handleRecordKeyDown, true) return () => window.removeEventListener('keydown', handleRecordKeyDown, true) } }, [recordingAction, handleRecordKeyDown]) // ── Drag and drop via React state (avoids HTML5 DnD issues in Electron) ── // We track the dragged action in a ref so it's always current const draggedActionRef = useRef(null) // Mouse-based drag: user mousedowns on an action, moves to a key, mouseups to drop const handleActionMouseDown = useCallback((e: React.MouseEvent, actionId: ActionId) => { // Only left-click if (e.button !== 0) return e.preventDefault() draggedActionRef.current = actionId setDraggedActionId(actionId) }, []) // Global mouse handlers for drag (attached when dragging) useEffect(() => { if (!draggedActionId) return const handleMouseMove = (e: MouseEvent) => { // Find which keyboard key the mouse is over const el = document.elementFromPoint(e.clientX, e.clientY) const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null if (keyEl) { const keyId = keyEl.getAttribute('data-kb-key') if (keyId && !MODIFIER_KEY_IDS.has(keyId)) { setDropTargetKey(keyId) } else { setDropTargetKey(null) } } else { setDropTargetKey(null) } } const handleMouseUp = (e: MouseEvent) => { const actionId = draggedActionRef.current const el = document.elementFromPoint(e.clientX, e.clientY) const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null if (actionId && keyEl) { const keyId = keyEl.getAttribute('data-kb-key') if (keyId && !MODIFIER_KEY_IDS.has(keyId)) { // Build combo from current modifier state + dropped key const combo: KeyCombo = { key: keyId, ...(kbModCtrl ? { ctrl: true } : {}), ...(kbModShift ? { shift: true } : {}), ...(kbModAlt ? { alt: true } : {}), } updateBinding(actionId, [combo]) } } draggedActionRef.current = null setDraggedActionId(null) setDropTargetKey(null) } window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } }, [draggedActionId, kbModCtrl, kbModShift, kbModAlt, updateBinding]) // Click on a key to select the action assigned to it const handleKeyClick = useCallback((keyId: string) => { if (MODIFIER_KEY_IDS.has(keyId)) return // Don't treat mouseup from a drag as a click if (draggedActionRef.current) return const assigned = keyToAction.get(keyId) if (assigned) { setSelectedCategory(null) setSearchQuery(assigned.action.label) } }, [keyToAction]) if (!isEditorOpen) return null // Filter and group actions const categories = ['Tools', 'Transport', 'Editing', 'Marking', 'Timeline'] as const const filteredActions = ACTION_REGISTRY.filter(a => { if (selectedCategory && a.category !== selectedCategory) return false if (searchQuery) { const q = searchQuery.toLowerCase() const combos = activeLayout[a.id] const comboStr = combos?.map(formatKeyCombo).join(' ') || '' return a.label.toLowerCase().includes(q) || a.category.toLowerCase().includes(q) || comboStr.toLowerCase().includes(q) } return true }) const groupedActions: Record = {} for (const a of filteredActions) { if (!groupedActions[a.category]) groupedActions[a.category] = [] groupedActions[a.category].push(a) } // Unit size for keyboard keys const KEY_UNIT = 42 // px per 1 unit of key width const KEY_GAP = 2 // px gap between keys const KEY_H = 36 // px key height return (
{ if (e.target === e.currentTarget && !recordingAction) setEditorOpen(false) }} >
{/* Header */}

Keyboard Shortcuts

Customize keybindings — drag actions onto keys or click Edit

{/* Toolbar: Preset selector + Search + Actions */}
{/* Preset dropdown */}
{showPresetDropdown && (
{presets.map(p => (
{/* Delete button — only for user-created (non-built-in) presets */} {!p.builtIn && ( )}
))}
)}
{/* Search */}
setSearchQuery(e.target.value)} className="w-full pl-8 pr-3 py-1.5 bg-zinc-800 rounded-md text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700/40 focus:border-blue-500/50 transition-colors" />
{/* Reset button */} {/* Save as custom */}
{showSaveDialog && (

Save as custom preset:

setSavePresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && savePresetName.trim()) { saveAsCustomPreset(savePresetName.trim()) setSavePresetName('') setShowSaveDialog(false) } }} className="w-full px-2 py-1.5 bg-zinc-900 rounded text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700 focus:border-blue-500 mb-2" autoFocus />
)}
{/* Conflict warning */} {conflicts.size > 0 && (
{conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions
)} {/* ══════════════ Visual Keyboard ══════════════ */} {showKeyboard && (
{/* Modifier toggles */}
Modifiers: {([ { label: 'Ctrl', active: kbModCtrl, toggle: () => setKbModCtrl(v => !v) }, { label: 'Shift', active: kbModShift, toggle: () => setKbModShift(v => !v) }, { label: 'Alt', active: kbModAlt, toggle: () => setKbModAlt(v => !v) }, ] as const).map(mod => ( ))}
{/* Category legend */}
{categories.map(cat => { const c = CATEGORY_COLORS[cat] return (
{cat}
) })}
{/* Keyboard rows */}
{KB_ROWS.map((row, rowIdx) => (
{row.map((kbKey) => { const isModifier = MODIFIER_KEY_IDS.has(kbKey.id) const assigned = keyToAction.get(kbKey.id) const catColors = assigned ? CATEGORY_COLORS[assigned.action.category] : null const isHighlighted = hoveredKeys.has(kbKey.id) const isDropTarget = dropTargetKey === kbKey.id const isDragging = !!draggedActionId // Check if this key has a conflict const comboStr = assigned ? formatKeyCombo({ key: kbKey.id, ...(kbModCtrl ? { ctrl: true } : {}), ...(kbModShift ? { shift: true } : {}), ...(kbModAlt ? { alt: true } : {}), }) : null const hasConflict = comboStr ? conflicts.has(comboStr) : false return (
handleKeyClick(kbKey.id)} title={ assigned ? `${assigned.action.label} (${assigned.action.category})` : isModifier ? kbKey.label : 'Unassigned — drag an action here' } > {/* Key label */} {kbKey.label} {/* Assigned action label (truncated) */} {assigned && !isModifier && ( {assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')} )} {/* Conflict indicator */} {hasConflict && (
)}
) })}
))}
{/* Drag hint */} {draggedActionId && (
Drop on a key to assign — current modifiers: { [kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None' }
)}
)} {/* Category tabs */}
{categories.map(cat => { const c = CATEGORY_COLORS[cat] return ( ) })}
{/* Actions list */}
{categories.map(cat => { const actions = groupedActions[cat] if (!actions || actions.length === 0) return null const catColor = CATEGORY_COLORS[cat] return (
{/* Category header */}
{cat}
{/* Action rows */} {actions.map(action => { const combos = activeLayout[action.id] || [] const isRecording = recordingAction === action.id const hasConflict = combos.some(c => { const key = formatKeyCombo(c) return conflicts.has(key) }) return (
{ if (!isRecording) handleActionMouseDown(e, action.id) }} onMouseEnter={() => setHoveredActionId(action.id)} onMouseLeave={() => setHoveredActionId(null)} className={`flex items-center gap-3 px-4 py-2 border-b border-zinc-800/30 transition-colors select-none ${ isRecording ? 'bg-blue-950/30' : draggedActionId === action.id ? 'bg-blue-600/20 ring-1 ring-blue-500/50' : hoveredActionId === action.id ? 'bg-zinc-800/40' : 'hover:bg-zinc-800/30' } ${!isRecording ? 'cursor-grab active:cursor-grabbing' : ''}`} > {/* Drag handle */} {!isRecording && ( )} {/* Category dot */}
{/* Action label */}
{action.label} {action.description && ( {action.description} )} {hasConflict && ( )}
{/* Current binding(s) */}
{isRecording ? (
Press a key...
) : ( <> {combos.length === 0 ? ( Unassigned ) : ( combos.map((combo, i) => { const comboKey = formatKeyCombo(combo) const conflicting = conflicts.has(comboKey) return ( {comboKey} ) }) )} )}
{/* Edit / Clear buttons */} {!isRecording && (
{combos.length > 0 && ( )}
)}
) })}
) })}
{/* Footer */}
{ACTION_REGISTRY.length} actions · Drag actions onto keys or click "Edit" to record
) }