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";
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>
);
}
@@ -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 {