summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorAnwarul Islam <anwaarulislaam@gmail.com>2025-07-18 07:38:32 +0600
committerAnwarul Islam <anwaarulislaam@gmail.com>2025-07-18 07:38:32 +0600
commitcdae10ca0aeaf23d49591cb81fe9dac7b83ff642 (patch)
tree5d57208b23dc2513dddee24bd479e3f9ca662439 /apps/web/src
parent33531fb3bf2628d5c5c79d43e0e37b543c9384e1 (diff)
downloadopencut-cdae10ca0aeaf23d49591cb81fe9dac7b83ff642.tar.gz
feat: refactor keybinding management and add keybinding editor component
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor-provider.tsx2
-rw-r--r--apps/web/src/components/keybinding-editor.tsx414
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx36
-rw-r--r--apps/web/src/hooks/use-keybinding-conflicts.ts59
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts8
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,