style: match editing experience to match capcut

This commit is contained in:
Maze Winther
2025-07-18 11:51:47 +02:00
parent 74e7d427aa
commit f8ccb673bf
3 changed files with 138 additions and 482 deletions
@@ -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>
);
};
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
Dialog, Dialog,
@@ -12,13 +12,13 @@ import {
DialogTrigger, DialogTrigger,
} from "./ui/dialog"; } from "./ui/dialog";
import { getPlatformSpecialKey } from "@/lib/utils"; import { getPlatformSpecialKey } from "@/lib/utils";
import { Badge } from "./ui/badge"; import { Keyboard } from "lucide-react";
import { Keyboard, Settings } from "lucide-react";
import { import {
useKeyboardShortcutsHelp, useKeyboardShortcutsHelp,
KeyboardShortcut, KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts-help"; } from "@/hooks/use-keyboard-shortcuts-help";
import { KeybindingEditor } from "./keybinding-editor"; import { useKeybindingsStore } from "@/stores/keybindings-store";
import { toast } from "sonner";
const modifier: { const modifier: {
[key: string]: string; [key: string]: string;
@@ -37,7 +37,15 @@ function getKeyWithModifier(key: string) {
return modifier[key] || key; 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" // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => { const displayKeys = shortcut.keys.filter((key: string) => {
if ( if (
@@ -61,11 +69,21 @@ const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => {
{displayKeys.map((key: string, index: number) => ( {displayKeys.map((key: string, index: number) => (
<div key={index} className="flex items-center gap-1"> <div key={index} className="flex items-center gap-1">
<div className="flex items-center"> <div className="flex items-center">
{key.split("+").map((keyPart: string, partIndex: number) => ( {key.split("+").map((keyPart: string, partIndex: number) => {
<ShortcutKey key={partIndex}> 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)} {getKeyWithModifier(keyPart)}
</ShortcutKey> </EditableShortcutKey>
))} );
})}
</div> </div>
{index < displayKeys.length - 1 && ( {index < displayKeys.length - 1 && (
<span className="text-xs text-muted-foreground">or</span> <span className="text-xs text-muted-foreground">or</span>
@@ -77,34 +95,111 @@ 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 = () => { export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false); 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 // Get shortcuts from centralized hook
const { shortcuts } = useKeyboardShortcutsHelp(); const { shortcuts } = useKeyboardShortcutsHelp();
const categories = Array.from(new Set(shortcuts.map((s) => s.category))); const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
if (showEditor) { useEffect(() => {
return ( if (!recordingKey || !recordingShortcut) return;
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> const handleKeyDown = (e: KeyboardEvent) => {
<Button variant="text" size="sm" className="gap-2"> e.preventDefault();
<Keyboard className="w-4 h-4" /> e.stopPropagation();
Shortcuts
</Button> const keyString = getKeybindingString(e);
</DialogTrigger> if (keyString) {
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> // Auto-save the new keybinding
<KeybindingEditor const conflict = validateKeybinding(keyString, recordingShortcut.action);
shortcuts={shortcuts} if (conflict) {
onClose={() => setShowEditor(false)} toast.error(
/> `Key "${keyString}" is already bound to "${conflict.existingAction}"`
</DialogContent>
</Dialog>
); );
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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -121,21 +216,10 @@ export const KeyboardShortcutsHelp = () => {
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Speed up your video editing workflow with these keyboard shortcuts. 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> </DialogDescription>
</DialogHeader> </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"> <div className="space-y-6">
{categories.map((category) => ( {categories.map((category) => (
<div key={category} className="flex flex-col gap-1"> <div key={category} className="flex flex-col gap-1">
@@ -146,7 +230,12 @@ export const KeyboardShortcutsHelp = () => {
{shortcuts {shortcuts
.filter((shortcut) => shortcut.category === category) .filter((shortcut) => shortcut.category === category)
.map((shortcut, index) => ( .map((shortcut, index) => (
<ShortcutItem key={index} shortcut={shortcut} /> <ShortcutItem
key={index}
shortcut={shortcut}
recordingKey={recordingKey}
onStartRecording={handleStartRecording}
/>
))} ))}
</div> </div>
</div> </div>
@@ -156,17 +245,3 @@ export const KeyboardShortcutsHelp = () => {
</Dialog> </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>
);
}
@@ -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]); }, [keybindings]);
return { return {