summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-18 11:51:47 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-18 11:51:47 +0200
commitf8ccb673bfd327e9001d5b7ba1ff77f303c2ec49 (patch)
tree2352bfdb3598c88c9e1ffcbcc04a213dcd73854c /apps
parent74e7d427aaed3517ce578c2192f783dacffc02fc (diff)
downloadopencut-f8ccb673bfd327e9001d5b7ba1ff77f303c2ec49.tar.gz
style: match editing experience to match capcut
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/components/keybinding-editor.tsx425
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx187
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts8
3 files changed, 138 insertions, 482 deletions
diff --git a/apps/web/src/components/keybinding-editor.tsx b/apps/web/src/components/keybinding-editor.tsx
deleted file mode 100644
index bc98c5fd..00000000
--- a/apps/web/src/components/keybinding-editor.tsx
+++ /dev/null
@@ -1,425 +0,0 @@
-"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 0481b1dd..91cfb01a 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -1,7 +1,7 @@
"use client";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import {
Dialog,
@@ -12,13 +12,13 @@ import {
DialogTrigger,
} from "./ui/dialog";
import { getPlatformSpecialKey } from "@/lib/utils";
-import { Badge } from "./ui/badge";
-import { Keyboard, Settings } from "lucide-react";
+import { Keyboard } from "lucide-react";
import {
useKeyboardShortcutsHelp,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts-help";
-import { KeybindingEditor } from "./keybinding-editor";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { toast } from "sonner";
const modifier: {
[key: string]: string;
@@ -37,7 +37,15 @@ function getKeyWithModifier(key: string) {
return modifier[key] || key;
}
-const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => {
+const ShortcutItem = ({
+ shortcut,
+ recordingKey,
+ onStartRecording
+}: {
+ shortcut: KeyboardShortcut;
+ recordingKey: string | null;
+ onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void;
+}) => {
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => {
if (
@@ -61,11 +69,21 @@ const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => {
{displayKeys.map((key: string, index: number) => (
<div key={index} className="flex items-center gap-1">
<div className="flex items-center">
- {key.split("+").map((keyPart: string, partIndex: number) => (
- <ShortcutKey key={partIndex}>
- {getKeyWithModifier(keyPart)}
- </ShortcutKey>
- ))}
+ {key.split("+").map((keyPart: string, partIndex: number) => {
+ const keyId = `${shortcut.id}-${index}-${partIndex}`;
+ return (
+ <EditableShortcutKey
+ key={partIndex}
+ keyId={keyId}
+ originalKey={key}
+ shortcut={shortcut}
+ isRecording={recordingKey === keyId}
+ onStartRecording={() => onStartRecording(keyId, shortcut)}
+ >
+ {getKeyWithModifier(keyPart)}
+ </EditableShortcutKey>
+ );
+ })}
</div>
{index < displayKeys.length - 1 && (
<span className="text-xs text-muted-foreground">or</span>
@@ -77,33 +95,110 @@ const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => {
);
};
+const EditableShortcutKey = ({
+ children,
+ keyId,
+ originalKey,
+ shortcut,
+ isRecording,
+ onStartRecording
+}: {
+ children: React.ReactNode;
+ keyId: string;
+ originalKey: string;
+ shortcut: KeyboardShortcut;
+ isRecording: boolean;
+ onStartRecording: () => void;
+}) => {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onStartRecording();
+ };
+
+ return (
+ <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 cursor-pointer hover:bg-opacity-80 ${
+ isRecording
+ ? "border-primary bg-primary/10"
+ : "border-white/10 bg-black/20"
+ }`}
+ onClick={handleClick}
+ title={isRecording ? "Press any key combination..." : "Click to edit shortcut"}
+ >
+ {children}
+ </kbd>
+ );
+};
+
export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false);
- const [showEditor, setShowEditor] = useState(false);
+ const [recordingKey, setRecordingKey] = useState<string | null>(null);
+ const [recordingShortcut, setRecordingShortcut] = useState<KeyboardShortcut | null>(null);
+
+ const {
+ updateKeybinding,
+ removeKeybinding,
+ getKeybindingString,
+ validateKeybinding,
+ getKeybindingsForAction,
+ } = useKeybindingsStore();
// 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>
- );
- }
+ useEffect(() => {
+ if (!recordingKey || !recordingShortcut) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const keyString = getKeybindingString(e);
+ if (keyString) {
+ // Auto-save the new keybinding
+ const conflict = validateKeybinding(keyString, recordingShortcut.action);
+ if (conflict) {
+ toast.error(
+ `Key "${keyString}" is already bound to "${conflict.existingAction}"`
+ );
+ setRecordingKey(null);
+ setRecordingShortcut(null);
+ return;
+ }
+
+ // Remove old keybindings for this action
+ const oldKeys = getKeybindingsForAction(recordingShortcut.action);
+ oldKeys.forEach((key) => removeKeybinding(key));
+
+ // Add new keybinding
+ updateKeybinding(keyString, recordingShortcut.action);
+
+ setRecordingKey(null);
+ setRecordingShortcut(null);
+ }
+ };
+
+ const handleClickOutside = (e: MouseEvent) => {
+ setRecordingKey(null);
+ setRecordingShortcut(null);
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("click", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [recordingKey, recordingShortcut, getKeybindingString, updateKeybinding, removeKeybinding, validateKeybinding, getKeybindingsForAction]);
+
+ const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => {
+ setRecordingKey(keyId);
+ setRecordingShortcut(shortcut);
+ };
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -121,21 +216,10 @@ export const KeyboardShortcutsHelp = () => {
</DialogTitle>
<DialogDescription>
Speed up your video editing workflow with these keyboard shortcuts.
- Most shortcuts work when the timeline is focused.
+ Click any shortcut key to edit it.
</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">
@@ -146,7 +230,12 @@ export const KeyboardShortcutsHelp = () => {
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut, index) => (
- <ShortcutItem key={index} shortcut={shortcut} />
+ <ShortcutItem
+ key={index}
+ shortcut={shortcut}
+ recordingKey={recordingKey}
+ onStartRecording={handleStartRecording}
+ />
))}
</div>
</div>
@@ -156,17 +245,3 @@ export const KeyboardShortcutsHelp = () => {
</Dialog>
);
};
-
-function ShortcutKey({ children }: { children: React.ReactNode }) {
- return (
- <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)",
- }}
- >
- {children}
- </kbd>
- );
-}
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
index bd8beffc..c7de8466 100644
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -114,7 +114,13 @@ export const useKeyboardShortcutsHelp = () => {
}
});
- return result;
+ // Sort shortcuts by category first, then by description to ensure consistent ordering
+ return result.sort((a, b) => {
+ if (a.category !== b.category) {
+ return a.category.localeCompare(b.category);
+ }
+ return a.description.localeCompare(b.description);
+ });
}, [keybindings]);
return {