diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-18 11:51:47 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-18 11:51:47 +0200 |
| commit | f8ccb673bfd327e9001d5b7ba1ff77f303c2ec49 (patch) | |
| tree | 2352bfdb3598c88c9e1ffcbcc04a213dcd73854c /apps/web | |
| parent | 74e7d427aaed3517ce578c2192f783dacffc02fc (diff) | |
| download | opencut-f8ccb673bfd327e9001d5b7ba1ff77f303c2ec49.tar.gz | |
style: match editing experience to match capcut
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/src/components/keybinding-editor.tsx | 425 | ||||
| -rw-r--r-- | apps/web/src/components/keyboard-shortcuts-help.tsx | 187 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 8 |
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 { |
