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 (
+
+
+ {isRecording ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+ Record
+
+ )}
+
+ );
+};
+
+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
+
+ )}
+
+
+
+
+ Export
+
+
+
+ document.getElementById("import-keybindings")?.click()
+ }
+ >
+
+ Import
+
+ setShowResetDialog(true)}
+ disabled={!isCustomized}
+ >
+
+ Reset to Defaults
+
+
+
+ Close
+
+
+
+
+
+ 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 ? (
+ <>
+
+ Save
+
+
+ Cancel
+
+ >
+ ) : (
+ <>
+ handleEditShortcut(shortcut)}
+ >
+ Edit
+
+ handleRemoveShortcut(shortcut)}
+ disabled={shortcut.keys.length === 0}
+ >
+ Remove
+
+ >
+ )}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ Reset Keyboard Shortcuts?
+
+
+ This will reset all keyboard shortcuts to their default values.
+ Any custom keybindings will be lost.
+
+
+
+ Cancel
+
+ Reset to Defaults
+
+
+
+
+
+ );
+};
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 (
+
+
+
+
+ Shortcuts
+
+
+
+ setShowEditor(false)}
+ />
+
+
+ );
+ }
+
return (
@@ -102,7 +123,7 @@ export const KeyboardShortcutsHelp = () => {
Shortcuts
-
+
@@ -114,6 +135,17 @@ export const KeyboardShortcutsHelp = () => {
+
+ setShowEditor(true)}
+ >
+
+ Customize
+
+
+
{categories.map((category) => (
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
= {};
+ 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 = {} 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,