From cdae10ca0aeaf23d49591cb81fe9dac7b83ff642 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:38:32 +0600 Subject: feat: refactor keybinding management and add keybinding editor component --- apps/web/src/components/keybinding-editor.tsx | 414 ++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 apps/web/src/components/keybinding-editor.tsx (limited to 'apps/web/src/components/keybinding-editor.tsx') 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 ( +
+ + {isRecording ? ( +
+ + +
+ ) : ( + + )} +
+ ); +}; + +export const KeybindingEditor = ({ + shortcuts, + onClose, +}: KeybindingEditorProps) => { + const { + keybindings, + updateKeybinding, + removeKeybinding, + resetToDefaults, + isCustomized, + validateKeybinding, + getKeybindingsForAction, + exportKeybindings, + importKeybindings, + } = useKeybindingsStore(); + + const [editingShortcut, setEditingShortcut] = useState(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 + ) => { + 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 ( +
+
+
+ +

+ Customize Keyboard Shortcuts +

+ {isCustomized && ( + + Modified + + )} +
+
+ + + + + +
+
+ +
+ setSearchTerm(e.target.value)} + className="flex-1" + /> + + + {categories.map((category) => ( + + {category === "all" ? "All" : category} + + ))} + + +
+ +
+ {filteredShortcuts.map((shortcut) => ( + + +
+
+
+
+

{shortcut.description}

+

+ {shortcut.category} +

+
+
+
+
+
+ {editingShortcut === shortcut.id ? ( + + ) : ( +
+ {shortcut.keys.map((key, index) => ( + + {key} + + ))} + {shortcut.keys.length === 0 && ( + + No binding + + )} +
+ )} +
+
+ {editingShortcut === shortcut.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+
+ ))} +
+ + + + + + + Reset Keyboard Shortcuts? + + + This will reset all keyboard shortcuts to their default values. + Any custom keybindings will be lost. + + + + Cancel + + Reset to Defaults + + + + +
+ ); +}; -- cgit v1.3.1 From c3caefc001e1ea6a7e5626acb1fe1054fa00a56a Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 08:30:42 +0600 Subject: feat: enhance keybinding import validation and improve error handling --- apps/web/src/components/keybinding-editor.tsx | 13 ++++++++++++- apps/web/src/constants/actions.ts | 22 +++++++++++----------- apps/web/src/stores/keybindings-store.ts | 8 ++++++++ 3 files changed, 31 insertions(+), 12 deletions(-) (limited to 'apps/web/src/components/keybinding-editor.tsx') diff --git a/apps/web/src/components/keybinding-editor.tsx b/apps/web/src/components/keybinding-editor.tsx index 8735c504..bc98c5fd 100644 --- a/apps/web/src/components/keybinding-editor.tsx +++ b/apps/web/src/components/keybinding-editor.tsx @@ -224,10 +224,21 @@ export const KeybindingEditor = ({ reader.onload = (e) => { try { const config = JSON.parse(e.target?.result as string); + // Validate config structure + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration format"); + } + + // Validate each keybinding + for (const [key, action] of Object.entries(config)) { + if (typeof key !== "string" || typeof action !== "string") { + throw new Error(`Invalid keybinding: ${key} -> ${action}`); + } + } importKeybindings(config); toast.success("Keybindings imported successfully"); } catch (error) { - toast.error("Failed to import keybindings file"); + toast.error(`Failed to import keybindings: ${error}`); } }; reader.readAsText(file); diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index 95a1e645..5f1883f1 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -233,19 +233,19 @@ export function useActionHandler( // Handle ref-based isActive changes useEffect(() => { if (isActive && typeof isActive === "object" && "current" in isActive) { - const checkActive = () => { + // Poll for ref changes + const interval = setInterval(() => { const shouldBind = isActive.current; - if (shouldBind && !isBound) { - bindAction(action, stableHandler); - setIsBound(true); - } else if (!shouldBind && isBound) { - unbindAction(action, stableHandler); - setIsBound(false); + if (shouldBind !== isBound) { + if (shouldBind) { + bindAction(action, stableHandler); + } else { + unbindAction(action, stableHandler); + } + setIsBound(shouldBind); } - }; - - // Initial check - checkActive(); + }, 100); + return () => clearInterval(interval); } }, [action, stableHandler, isActive, isBound]); } diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 6a72423f..6d9f5d54 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -112,6 +112,13 @@ export const useKeybindingsStore = create()( }, importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key, action] of Object.entries(config)) { + // Validate the key format + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } set({ keybindings: { ...config }, isCustomized: true, @@ -198,6 +205,7 @@ function getPressedKey(ev: KeyboardEvent): string | null { // Check for special keys if (key === "tab") return "tab"; + if (key === " " || key === "space") return "space"; if (key === "home") return "home"; if (key === "end") return "end"; if (key === "delete") return "delete"; -- cgit v1.3.1