diff options
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/src/components/editor-provider.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/keybinding-editor.tsx | 414 | ||||
| -rw-r--r-- | apps/web/src/components/keyboard-shortcuts-help.tsx | 36 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keybinding-conflicts.ts | 59 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 8 |
5 files changed, 513 insertions, 6 deletions
diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index ae1bb1a5..25d040db 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -6,7 +6,7 @@ import { useEditorStore } from "@/stores/editor-store"; import {
useKeybindingsListener,
useKeybindingDisabler,
-} from "@/constants/keybindings";
+} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
diff --git a/apps/web/src/components/keybinding-editor.tsx b/apps/web/src/components/keybinding-editor.tsx new file mode 100644 index 00000000..8735c504 --- /dev/null +++ b/apps/web/src/components/keybinding-editor.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { Action } from "@/constants/actions"; +import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts-help"; +import { + Settings, + RotateCcw, + Download, + Upload, + X, + Check, + AlertTriangle, +} from "lucide-react"; +import { toast } from "sonner"; + +interface KeybindingEditorProps { + shortcuts: KeyboardShortcut[]; + onClose: () => void; +} + +interface KeyRecorderProps { + value: string; + onValueChange: (value: string) => void; + onCancel: () => void; +} + +const KeyRecorder = ({ value, onValueChange, onCancel }: KeyRecorderProps) => { + const [isRecording, setIsRecording] = useState(false); + const [recordedKey, setRecordedKey] = useState(""); + const { getKeybindingString } = useKeybindingsStore(); + + useEffect(() => { + if (!isRecording) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + + const keyString = getKeybindingString(e); + if (keyString) { + setRecordedKey(keyString); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isRecording, getKeybindingString]); + + const handleStartRecording = () => { + setIsRecording(true); + setRecordedKey(""); + }; + + const handleConfirm = () => { + onValueChange(recordedKey); + setIsRecording(false); + setRecordedKey(""); + }; + + const handleCancel = () => { + setIsRecording(false); + setRecordedKey(""); + onCancel(); + }; + + const displayKey = recordedKey || value; + + return ( + <div className="flex items-center gap-2"> + <Input + value={displayKey} + readOnly + placeholder={isRecording ? "Press keys..." : "Click to record"} + className="font-mono text-sm" + onClick={handleStartRecording} + /> + {isRecording ? ( + <div className="flex gap-1"> + <Button + size="sm" + variant="outline" + onClick={handleConfirm} + disabled={!recordedKey} + > + <Check className="w-4 h-4" /> + </Button> + <Button size="sm" variant="outline" onClick={handleCancel}> + <X className="w-4 h-4" /> + </Button> + </div> + ) : ( + <Button size="sm" variant="outline" onClick={handleStartRecording}> + Record + </Button> + )} + </div> + ); +}; + +export const KeybindingEditor = ({ + shortcuts, + onClose, +}: KeybindingEditorProps) => { + const { + keybindings, + updateKeybinding, + removeKeybinding, + resetToDefaults, + isCustomized, + validateKeybinding, + getKeybindingsForAction, + exportKeybindings, + importKeybindings, + } = useKeybindingsStore(); + + const [editingShortcut, setEditingShortcut] = useState<string | null>(null); + const [newKeyBinding, setNewKeyBinding] = useState(""); + const [showResetDialog, setShowResetDialog] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + + const categories = [ + "all", + ...Array.from(new Set(shortcuts.map((s) => s.category))), + ]; + + const filteredShortcuts = shortcuts.filter((shortcut) => { + const matchesSearch = + shortcut.description.toLowerCase().includes(searchTerm.toLowerCase()) || + shortcut.keys.some((key) => + key.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const matchesCategory = + selectedCategory === "all" || shortcut.category === selectedCategory; + return matchesSearch && matchesCategory; + }); + + const handleEditShortcut = (shortcut: KeyboardShortcut) => { + setEditingShortcut(shortcut.id); + setNewKeyBinding(shortcut.keys[0] || ""); + }; + + const handleSaveShortcut = () => { + if (!editingShortcut || !newKeyBinding) return; + + const shortcut = shortcuts.find((s) => s.id === editingShortcut); + if (!shortcut) return; + + // Validate the new keybinding + const conflict = validateKeybinding(newKeyBinding, shortcut.action); + if (conflict) { + toast.error( + `Key "${newKeyBinding}" is already bound to "${conflict.existingAction}"` + ); + return; + } + + // Remove old keybindings for this action + const oldKeys = getKeybindingsForAction(shortcut.action); + oldKeys.forEach((key) => removeKeybinding(key)); + + // Add new keybinding + updateKeybinding(newKeyBinding, shortcut.action); + + setEditingShortcut(null); + setNewKeyBinding(""); + toast.success("Keybinding updated successfully"); + }; + + const handleCancelEdit = () => { + setEditingShortcut(null); + setNewKeyBinding(""); + }; + + const handleRemoveShortcut = (shortcut: KeyboardShortcut) => { + const keys = getKeybindingsForAction(shortcut.action); + keys.forEach((key) => removeKeybinding(key)); + toast.success("Keybinding removed"); + }; + + const handleResetToDefaults = () => { + resetToDefaults(); + setShowResetDialog(false); + toast.success("Keybindings reset to defaults"); + }; + + const handleExportKeybindings = () => { + const config = exportKeybindings(); + const blob = new Blob([JSON.stringify(config, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "opencut-keybindings.json"; + a.click(); + URL.revokeObjectURL(url); + toast.success("Keybindings exported"); + }; + + const handleImportKeybindings = ( + event: React.ChangeEvent<HTMLInputElement> + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const config = JSON.parse(e.target?.result as string); + importKeybindings(config); + toast.success("Keybindings imported successfully"); + } catch (error) { + toast.error("Failed to import keybindings file"); + } + }; + reader.readAsText(file); + }; + + return ( + <div className="max-w-4xl mx-auto p-6 space-y-6"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Settings className="w-5 h-5" /> + <h2 className="text-xl font-semibold"> + Customize Keyboard Shortcuts + </h2> + {isCustomized && ( + <Badge variant="secondary" className="ml-2"> + Modified + </Badge> + )} + </div> + <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" onClick={handleExportKeybindings}> + <Download className="w-4 h-4 mr-2" /> + Export + </Button> + <input + type="file" + accept=".json" + onChange={handleImportKeybindings} + className="hidden" + id="import-keybindings" + /> + <Button + variant="outline" + size="sm" + onClick={() => + document.getElementById("import-keybindings")?.click() + } + > + <Upload className="w-4 h-4 mr-2" /> + Import + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setShowResetDialog(true)} + disabled={!isCustomized} + > + <RotateCcw className="w-4 h-4 mr-2" /> + Reset to Defaults + </Button> + <Button variant="outline" size="sm" onClick={onClose}> + <X className="w-4 h-4 mr-2" /> + Close + </Button> + </div> + </div> + + <div className="flex gap-4 items-center"> + <Input + placeholder="Search shortcuts..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="flex-1" + /> + <Tabs value={selectedCategory} onValueChange={setSelectedCategory}> + <TabsList> + {categories.map((category) => ( + <TabsTrigger key={category} value={category}> + {category === "all" ? "All" : category} + </TabsTrigger> + ))} + </TabsList> + </Tabs> + </div> + + <div className="space-y-4"> + {filteredShortcuts.map((shortcut) => ( + <Card key={shortcut.id}> + <CardContent className="p-4"> + <div className="flex items-center justify-between"> + <div className="flex-1"> + <div className="flex items-center gap-3"> + <div> + <h3 className="font-medium">{shortcut.description}</h3> + <p className="text-sm text-muted-foreground"> + {shortcut.category} + </p> + </div> + </div> + </div> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + {editingShortcut === shortcut.id ? ( + <KeyRecorder + value={newKeyBinding} + onValueChange={setNewKeyBinding} + onCancel={handleCancelEdit} + /> + ) : ( + <div className="flex items-center gap-1"> + {shortcut.keys.map((key, index) => ( + <Badge + key={index} + variant="secondary" + className="font-mono text-xs" + > + {key} + </Badge> + ))} + {shortcut.keys.length === 0 && ( + <Badge + variant="outline" + className="text-muted-foreground" + > + No binding + </Badge> + )} + </div> + )} + </div> + <div className="flex items-center gap-2"> + {editingShortcut === shortcut.id ? ( + <> + <Button size="sm" onClick={handleSaveShortcut}> + Save + </Button> + <Button + size="sm" + variant="outline" + onClick={handleCancelEdit} + > + Cancel + </Button> + </> + ) : ( + <> + <Button + size="sm" + variant="outline" + onClick={() => handleEditShortcut(shortcut)} + > + Edit + </Button> + <Button + size="sm" + variant="outline" + onClick={() => handleRemoveShortcut(shortcut)} + disabled={shortcut.keys.length === 0} + > + Remove + </Button> + </> + )} + </div> + </div> + </div> + </CardContent> + </Card> + ))} + </div> + + <AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertTriangle className="w-5 h-5 text-amber-500" /> + Reset Keyboard Shortcuts? + </AlertDialogTitle> + <AlertDialogDescription> + This will reset all keyboard shortcuts to their default values. + Any custom keybindings will be lost. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction onClick={handleResetToDefaults}> + Reset to Defaults + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ); +}; diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index c908b9ce..c40e5eb4 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -11,11 +11,12 @@ import { DialogTrigger, } from "./ui/dialog"; import { Badge } from "./ui/badge"; -import { Keyboard } from "lucide-react"; +import { Keyboard, Settings } from "lucide-react"; import { useKeyboardShortcutsHelp, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts-help"; +import { KeybindingEditor } from "./keybinding-editor"; const KeyBadge = ({ keyName }: { keyName: string }) => { // Replace common key names with symbols or friendly names @@ -88,12 +89,32 @@ const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => { export const KeyboardShortcutsHelp = () => { const [open, setOpen] = useState(false); + const [showEditor, setShowEditor] = useState(false); // Get shortcuts from centralized hook const { shortcuts } = useKeyboardShortcutsHelp(); const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + if (showEditor) { + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="text" size="sm" className="gap-2"> + <Keyboard className="w-4 h-4" /> + Shortcuts + </Button> + </DialogTrigger> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <KeybindingEditor + shortcuts={shortcuts} + onClose={() => setShowEditor(false)} + /> + </DialogContent> + </Dialog> + ); + } + return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> @@ -102,7 +123,7 @@ export const KeyboardShortcutsHelp = () => { Shortcuts </Button> </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-hidden flex"> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Keyboard className="w-5 h-5" /> @@ -114,6 +135,17 @@ export const KeyboardShortcutsHelp = () => { </DialogDescription> </DialogHeader> + <div className="flex justify-end mb-4"> + <Button + variant="outline" + size="sm" + onClick={() => setShowEditor(true)} + > + <Settings className="w-4 h-4 mr-2" /> + Customize + </Button> + </div> + <div className="space-y-6"> {categories.map((category) => ( <div key={category} className="flex flex-col gap-1"> diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts new file mode 100644 index 00000000..cc5dd340 --- /dev/null +++ b/apps/web/src/hooks/use-keybinding-conflicts.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useMemo } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { ActionWithOptionalArgs } from "@/constants/actions"; + +export interface KeybindingConflictInfo { + key: string; + actions: ActionWithOptionalArgs[]; + isConflict: boolean; +} + +export const useKeybindingConflicts = () => { + const { keybindings } = useKeybindingsStore(); + + const conflicts = useMemo(() => { + const keyToActions: Record<string, ActionWithOptionalArgs[]> = {}; + const conflictList: KeybindingConflictInfo[] = []; + + // Group actions by key + Object.entries(keybindings).forEach(([key, action]) => { + if (!keyToActions[key]) { + keyToActions[key] = []; + } + keyToActions[key].push(action); + }); + + // Find conflicts + Object.entries(keyToActions).forEach(([key, actions]) => { + const uniqueActions = [...new Set(actions)]; + conflictList.push({ + key, + actions: uniqueActions, + isConflict: uniqueActions.length > 1, + }); + }); + + return conflictList.filter((item) => item.isConflict); + }, [keybindings]); + + const hasConflicts = conflicts.length > 0; + + const getConflictsForKey = (key: string): KeybindingConflictInfo | null => { + return conflicts.find((conflict) => conflict.key === key) || null; + }; + + const getConflictsForAction = ( + action: ActionWithOptionalArgs + ): KeybindingConflictInfo[] => { + return conflicts.filter((conflict) => conflict.actions.includes(action)); + }; + + return { + conflicts, + hasConflicts, + getConflictsForKey, + getConflictsForAction, + }; +}; diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index cac68524..bd8beffc 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { bindings } from "@/constants/keybindings"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; import { Action } from "@/constants/actions"; export interface KeyboardShortcut { @@ -83,13 +83,15 @@ const formatKey = (key: string): string => { }; export const useKeyboardShortcutsHelp = () => { + const { keybindings } = useKeybindingsStore(); + const shortcuts = useMemo(() => { const result: KeyboardShortcut[] = []; // Group keybindings by action const actionToKeys: Record<Action, string[]> = {} as any; - Object.entries(bindings).forEach(([key, action]) => { + Object.entries(keybindings).forEach(([key, action]) => { if (action) { if (!actionToKeys[action]) { actionToKeys[action] = []; @@ -113,7 +115,7 @@ export const useKeyboardShortcutsHelp = () => { }); return result; - }, []); + }, [keybindings]); return { shortcuts, |
