summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-18 11:37:48 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-18 11:37:48 +0200
commit74e7d427aaed3517ce578c2192f783dacffc02fc (patch)
treec6ec27070940778e64ca3ad50cd937227f87f3ac /apps/web/src/components
parentc4e6a9a45ab0f0afdde4f0c9991e39cd5d6205d0 (diff)
parent23001f8442e60999e34c0d6c348fecf0867b6cad (diff)
downloadopencut-74e7d427aaed3517ce578c2192f783dacffc02fc.tar.gz
Merge branch 'feat/shortcut-actions' into staging
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor-provider.tsx25
-rw-r--r--apps/web/src/components/keybinding-editor.tsx425
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx53
3 files changed, 490 insertions, 13 deletions
diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx
index d970f86d..25d040db 100644
--- a/apps/web/src/components/editor-provider.tsx
+++ b/apps/web/src/components/editor-provider.tsx
@@ -3,7 +3,11 @@
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
-import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import {
+ useKeybindingsListener,
+ useKeybindingDisabler,
+} from "@/hooks/use-keybindings";
+import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
@@ -11,11 +15,22 @@ interface EditorProviderProps {
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
+ const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
- useKeyboardShortcuts({
- enabled: !isInitializing && isPanelsReady,
- context: "editor",
- });
+ // Set up action handlers
+ useEditorActions();
+
+ // Set up keybinding listener
+ useKeybindingsListener();
+
+ // Disable keybindings when initializing
+ useEffect(() => {
+ if (isInitializing || !isPanelsReady) {
+ disableKeybindings();
+ } else {
+ enableKeybindings();
+ }
+ }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
diff --git a/apps/web/src/components/keybinding-editor.tsx b/apps/web/src/components/keybinding-editor.tsx
new file mode 100644
index 00000000..bc98c5fd
--- /dev/null
+++ b/apps/web/src/components/keybinding-editor.tsx
@@ -0,0 +1,425 @@
+"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);
+ // 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: ${error}`);
+ }
+ };
+ 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 a48b3c21..0481b1dd 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -1,7 +1,6 @@
"use client";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
-import { Keyboard } from "lucide-react";
import { useState } from "react";
import { Button } from "./ui/button";
import {
@@ -13,6 +12,13 @@ import {
DialogTrigger,
} from "./ui/dialog";
import { getPlatformSpecialKey } from "@/lib/utils";
+import { Badge } from "./ui/badge";
+import { Keyboard, Settings } from "lucide-react";
+import {
+ useKeyboardShortcutsHelp,
+ KeyboardShortcut,
+} from "@/hooks/use-keyboard-shortcuts-help";
+import { KeybindingEditor } from "./keybinding-editor";
const modifier: {
[key: string]: string;
@@ -31,7 +37,7 @@ function getKeyWithModifier(key: string) {
return modifier[key] || key;
}
-const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
+const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => {
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => {
if (
@@ -73,12 +79,32 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false);
+ const [showEditor, setShowEditor] = useState(false);
- // Get shortcuts from centralized hook (disabled so it doesn't add event listeners)
- const { shortcuts } = useKeyboardShortcuts({ enabled: 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>
@@ -87,7 +113,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" />
@@ -99,6 +125,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">
@@ -122,14 +159,14 @@ export const KeyboardShortcutsHelp = () => {
function ShortcutKey({ children }: { children: React.ReactNode }) {
return (
- <kbd
+ <kbd
className="inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1"
style={{
backgroundColor: "rgba(0, 0, 0, 0.2)",
- borderColor: "rgba(255, 255, 255, 0.1)"
+ borderColor: "rgba(255, 255, 255, 0.1)",
}}
>
{children}
</kbd>
);
-};
+}