Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -0,0 +1,793 @@
|
||||
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<string, { bg: string; border: string; text: string; dot: string }> = {
|
||||
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<ActionId | null>(null)
|
||||
const [showPresetDropdown, setShowPresetDropdown] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [savePresetName, setSavePresetName] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const recordRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Visual keyboard state
|
||||
const [kbModCtrl, setKbModCtrl] = useState(false)
|
||||
const [kbModShift, setKbModShift] = useState(false)
|
||||
const [kbModAlt, setKbModAlt] = useState(false)
|
||||
const [hoveredActionId, setHoveredActionId] = useState<ActionId | null>(null)
|
||||
const [draggedActionId, setDraggedActionId] = useState<ActionId | null>(null)
|
||||
const [dropTargetKey, setDropTargetKey] = useState<string | null>(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<string, { actionId: ActionId; action: ActionDefinition }>()
|
||||
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<string>()
|
||||
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<ActionId | null>(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<string, ActionDefinition[]> = {}
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !recordingAction) setEditorOpen(false) }}
|
||||
>
|
||||
<div className="w-[880px] max-h-[90vh] bg-zinc-900 rounded-xl border border-zinc-700/80 shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-zinc-800 bg-zinc-900/95">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<Keyboard className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-sm font-semibold text-white">Keyboard Shortcuts</h2>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">Customize keybindings — drag actions onto keys or click Edit</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKeyboard(!showKeyboard)}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors ${
|
||||
showKeyboard ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{showKeyboard ? 'Hide Keyboard' : 'Show Keyboard'}
|
||||
</button>
|
||||
<button onClick={() => setEditorOpen(false)} className="text-zinc-500 hover:text-white transition-colors p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Preset selector + Search + Actions */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800/80 bg-zinc-950/50">
|
||||
{/* Preset dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPresetDropdown(!showPresetDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-md border border-zinc-700/60 text-[11px] text-zinc-300 transition-colors"
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{activePreset?.name || 'Custom'}</span>
|
||||
<ChevronDown className="h-3 w-3 text-zinc-500" />
|
||||
</button>
|
||||
{showPresetDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 overflow-hidden">
|
||||
{presets.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center gap-1 hover:bg-zinc-700 transition-colors ${
|
||||
p.id === activePresetId ? 'bg-blue-600/15' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
switchPreset(p.id)
|
||||
setShowPresetDropdown(false)
|
||||
}}
|
||||
className={`flex-1 text-left px-3 py-2 text-[11px] ${
|
||||
p.id === activePresetId ? 'text-blue-300' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5">{p.description}</div>
|
||||
</button>
|
||||
{/* Delete button — only for user-created (non-built-in) presets */}
|
||||
{!p.builtIn && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete preset "${p.name}"?`)) {
|
||||
deleteCustomPreset(p.id)
|
||||
if (presets.filter(pr => pr.id !== p.id).length > 0) {
|
||||
setShowPresetDropdown(true)
|
||||
} else {
|
||||
setShowPresetDropdown(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="p-1.5 mr-1.5 rounded text-zinc-600 hover:text-red-400 hover:bg-red-600/10 transition-colors"
|
||||
title={`Delete "${p.name}"`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actions or keys..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<button
|
||||
onClick={() => resetToPreset(activePresetId)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
|
||||
title="Reset all shortcuts to the selected preset"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
</button>
|
||||
|
||||
{/* Save as custom */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
|
||||
title="Save current layout as a custom preset"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
Save As
|
||||
</button>
|
||||
{showSaveDialog && (
|
||||
<div className="absolute top-full right-0 mt-1 w-52 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 p-3">
|
||||
<p className="text-[10px] text-zinc-400 mb-2">Save as custom preset:</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Preset name..."
|
||||
value={savePresetName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (savePresetName.trim()) {
|
||||
saveAsCustomPreset(savePresetName.trim())
|
||||
setSavePresetName('')
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
}}
|
||||
disabled={!savePresetName.trim()}
|
||||
className="w-full py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-[11px] font-medium rounded transition-colors"
|
||||
>
|
||||
Save Preset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflicts.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-950/30 border-b border-amber-800/30">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-[11px] text-amber-400/90">
|
||||
{conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════════ Visual Keyboard ══════════════ */}
|
||||
{showKeyboard && (
|
||||
<div className="border-b border-zinc-800/80 bg-zinc-950/60 px-4 py-3">
|
||||
{/* Modifier toggles */}
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<span className="text-[10px] text-zinc-500 font-medium mr-1">Modifiers:</span>
|
||||
{([
|
||||
{ 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 => (
|
||||
<button
|
||||
key={mod.label}
|
||||
onClick={mod.toggle}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold transition-all ${
|
||||
mod.active
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20'
|
||||
: 'bg-zinc-800 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 border border-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
{mod.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{/* Category legend */}
|
||||
<div className="flex items-center gap-3">
|
||||
{categories.map(cat => {
|
||||
const c = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1">
|
||||
<div className={`w-2 h-2 rounded-sm ${c.dot}`} />
|
||||
<span className="text-[9px] text-zinc-500">{cat}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard rows */}
|
||||
<div className="flex flex-col items-center gap-[2px]">
|
||||
{KB_ROWS.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="flex gap-[2px]">
|
||||
{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 (
|
||||
<div
|
||||
key={kbKey.id}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center rounded-md
|
||||
border text-center select-none transition-all duration-100
|
||||
${isModifier
|
||||
? 'bg-zinc-800/60 border-zinc-700/40 cursor-default'
|
||||
: isDropTarget
|
||||
? 'bg-blue-600/40 border-blue-400 ring-1 ring-blue-400 scale-105'
|
||||
: isHighlighted
|
||||
? 'bg-blue-600/30 border-blue-500 ring-1 ring-blue-400/50'
|
||||
: hasConflict
|
||||
? 'bg-amber-900/30 border-amber-600/60'
|
||||
: assigned && catColors
|
||||
? `${catColors.bg} ${catColors.border}`
|
||||
: isDragging && !isModifier
|
||||
? 'bg-zinc-800/80 border-zinc-600/80 border-dashed hover:border-blue-500/60 hover:bg-zinc-700/50'
|
||||
: 'bg-zinc-850 border-zinc-700/50 hover:border-zinc-600'
|
||||
}
|
||||
${!isModifier ? 'cursor-pointer' : ''}
|
||||
`}
|
||||
style={{
|
||||
width: kbKey.w * KEY_UNIT + (kbKey.w - 1) * KEY_GAP,
|
||||
height: KEY_H,
|
||||
}}
|
||||
data-kb-key={kbKey.id}
|
||||
onClick={() => handleKeyClick(kbKey.id)}
|
||||
title={
|
||||
assigned
|
||||
? `${assigned.action.label} (${assigned.action.category})`
|
||||
: isModifier
|
||||
? kbKey.label
|
||||
: 'Unassigned — drag an action here'
|
||||
}
|
||||
>
|
||||
{/* Key label */}
|
||||
<span className={`text-[9px] font-medium leading-none ${
|
||||
isModifier ? 'text-zinc-600' :
|
||||
assigned && catColors ? catColors.text :
|
||||
isHighlighted ? 'text-blue-300' :
|
||||
'text-zinc-500'
|
||||
}`}>
|
||||
{kbKey.label}
|
||||
</span>
|
||||
{/* Assigned action label (truncated) */}
|
||||
{assigned && !isModifier && (
|
||||
<span className={`text-[7px] leading-tight mt-0.5 max-w-full px-0.5 truncate ${
|
||||
catColors ? catColors.text : 'text-zinc-400'
|
||||
}`} style={{ opacity: 0.8 }}>
|
||||
{assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')}
|
||||
</span>
|
||||
)}
|
||||
{/* Conflict indicator */}
|
||||
{hasConflict && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{draggedActionId && (
|
||||
<div className="text-center mt-2 text-[10px] text-blue-400 animate-pulse">
|
||||
Drop on a key to assign — current modifiers: {
|
||||
[kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-zinc-800/60 bg-zinc-950/30">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors ${
|
||||
!selectedCategory ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const c = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat === selectedCategory ? null : cat)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors flex items-center gap-1.5 ${
|
||||
selectedCategory === cat ? `${c.bg} ${c.text}` : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0" ref={recordRef}>
|
||||
{categories.map(cat => {
|
||||
const actions = groupedActions[cat]
|
||||
if (!actions || actions.length === 0) return null
|
||||
const catColor = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<div key={cat}>
|
||||
{/* Category header */}
|
||||
<div className="sticky top-0 z-10 px-4 py-1.5 bg-zinc-950/95 border-b border-zinc-800/50 flex items-center gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${catColor.dot}`} />
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">{cat}</span>
|
||||
</div>
|
||||
{/* 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 (
|
||||
<div
|
||||
key={action.id}
|
||||
onMouseDown={(e) => { 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 && (
|
||||
<GripVertical className={`h-3 w-3 flex-shrink-0 ${
|
||||
draggedActionId === action.id ? 'text-blue-400 cursor-grabbing' : 'text-zinc-700 cursor-grab'
|
||||
}`} />
|
||||
)}
|
||||
|
||||
{/* Category dot */}
|
||||
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${catColor.dot}`} />
|
||||
|
||||
{/* Action label */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`text-[12px] ${hasConflict ? 'text-amber-300' : 'text-zinc-300'}`}>
|
||||
{action.label}
|
||||
</span>
|
||||
{action.description && (
|
||||
<span className="text-[10px] text-zinc-600 ml-2">{action.description}</span>
|
||||
)}
|
||||
{hasConflict && (
|
||||
<AlertTriangle className="inline-block h-3 w-3 text-amber-400 ml-1.5 -mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current binding(s) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-600/20 border border-blue-500/50 rounded-md animate-pulse">
|
||||
<span className="text-[11px] text-blue-300">Press a key...</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRecordingAction(null) }}
|
||||
className="text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{combos.length === 0 ? (
|
||||
<span className="text-[11px] text-zinc-600 italic">Unassigned</span>
|
||||
) : (
|
||||
combos.map((combo, i) => {
|
||||
const comboKey = formatKeyCombo(combo)
|
||||
const conflicting = conflicts.has(comboKey)
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-mono font-medium ${
|
||||
conflicting
|
||||
? 'bg-amber-900/30 text-amber-300 border border-amber-700/50'
|
||||
: `${catColor.bg} ${catColor.text} border ${catColor.border}`
|
||||
}`}
|
||||
>
|
||||
{comboKey}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit / Clear buttons */}
|
||||
{!isRecording && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
onClick={() => setRecordingAction(action.id)}
|
||||
className="px-2 py-0.5 text-[10px] text-zinc-500 hover:text-blue-400 hover:bg-blue-600/10 rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{combos.length > 0 && (
|
||||
<button
|
||||
onClick={() => updateBinding(action.id, [])}
|
||||
className="px-2 py-0.5 text-[10px] text-zinc-600 hover:text-red-400 hover:bg-red-600/10 rounded transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-zinc-800 bg-zinc-950/80">
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{ACTION_REGISTRY.length} actions · Drag actions onto keys or click "Edit" to record
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditorOpen(false)}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-medium rounded-md transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user