summaryrefslogtreecommitdiff
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
parentc4e6a9a45ab0f0afdde4f0c9991e39cd5d6205d0 (diff)
parent23001f8442e60999e34c0d6c348fecf0867b6cad (diff)
downloadopencut-74e7d427aaed3517ce578c2192f783dacffc02fc.tar.gz
Merge branch 'feat/shortcut-actions' into staging
-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
-rw-r--r--apps/web/src/constants/actions.ts293
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts162
-rw-r--r--apps/web/src/hooks/use-keybinding-conflicts.ts59
-rw-r--r--apps/web/src/hooks/use-keybindings.ts64
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts123
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts.ts338
-rw-r--r--apps/web/src/lib/utils.ts19
-rw-r--r--apps/web/src/stores/keybindings-store.ts241
-rw-r--r--apps/web/src/types/keybinding.ts35
12 files changed, 1486 insertions, 351 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>
);
-};
+}
diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts
new file mode 100644
index 00000000..5f1883f1
--- /dev/null
+++ b/apps/web/src/constants/actions.ts
@@ -0,0 +1,293 @@
+/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut.
+ * For example, toggling playback or seeking.
+ */
+
+import {
+ useEffect,
+ useRef,
+ useState,
+ useCallback,
+ MutableRefObject,
+} from "react";
+
+// Simple event emitter for action changes
+class ActionEmitter {
+ private listeners: Array<(actions: Action[]) => void> = [];
+
+ subscribe(listener: (actions: Action[]) => void) {
+ this.listeners.push(listener);
+ return () => {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ };
+ }
+
+ emit(actions: Action[]) {
+ this.listeners.forEach((listener) => listener(actions));
+ }
+}
+
+const actionEmitter = new ActionEmitter();
+
+export type Action =
+ | "toggle-play" // Toggle play/pause state
+ | "stop-playback" // Stop playback
+ | "seek-forward" // Seek forward in playback
+ | "seek-backward" // Seek backward in playback
+ | "frame-step-forward" // Step forward by one frame
+ | "frame-step-backward" // Step backward by one frame
+ | "jump-forward" // Jump forward by 5 seconds
+ | "jump-backward" // Jump backward by 5 seconds
+ | "goto-start" // Go to timeline start
+ | "goto-end" // Go to timeline end
+ | "split-element" // Split element at current time
+ | "delete-selected" // Delete selected elements
+ | "select-all" // Select all elements
+ | "duplicate-selected" // Duplicate selected element
+ | "toggle-snapping" // Toggle snapping
+ | "undo" // Undo last action
+ | "redo"; // Redo last undone action
+
+/**
+ * Defines the arguments, if present for a given type that is required to be passed on
+ * invocation and will be passed to action handlers.
+ *
+ * This type is supposed to be an object with the key being one of the actions mentioned above.
+ * The value to the key can be anything.
+ * If an action has no argument, you do not need to add it to this type.
+ *
+ * NOTE: We can't enforce type checks to make sure the key is Action, you
+ * will know if you got something wrong if there is a type error in this file
+ */
+type ActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1)
+ "seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1)
+ "jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5)
+ "jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5)
+};
+
+type KeysWithValueUndefined<T> = {
+ [K in keyof T]: undefined extends T[K] ? K : never;
+}[keyof T];
+
+/**
+ * Actions which require arguments for their invocation
+ */
+export type ActionWithArgs = keyof ActionArgsMap;
+
+/**
+ * Actions which optionally takes in arguments for their invocation
+ */
+
+export type ActionWithOptionalArgs =
+ | ActionWithNoArgs
+ | KeysWithValueUndefined<ActionArgsMap>;
+
+/**
+ * Actions which do not require arguments for their invocation
+ */
+export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
+
+/**
+ * Resolves the argument type for a given Action
+ */
+type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
+ ? ActionArgsMap[A]
+ : undefined;
+
+/**
+ * Resolves the action function for a given Action, used by action handler function defs
+ */
+type ActionFunc<A extends Action> = A extends ActionWithArgs
+ ? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
+ : (_?: undefined, trigger?: InvocationTriggers) => void;
+
+type BoundActionList = {
+ [A in Action]?: Array<ActionFunc<A>>;
+};
+
+const boundActions: BoundActionList = {};
+
+let currentActiveActions: Action[] = [];
+
+function updateActiveActions() {
+ const newActions = Object.keys(boundActions) as Action[];
+ currentActiveActions = newActions;
+ actionEmitter.emit(newActions);
+}
+
+export function bindAction<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>
+) {
+ if (boundActions[action]) {
+ boundActions[action]?.push(handler);
+ } else {
+ // 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
+ boundActions[action] = [handler] as any;
+ }
+
+ updateActiveActions();
+}
+
+export type InvocationTriggers = "keypress" | "mouseclick";
+
+type InvokeActionFunc = {
+ (
+ action: ActionWithOptionalArgs,
+ args?: undefined,
+ trigger?: InvocationTriggers
+ ): void;
+ <A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void;
+};
+
+/**
+ * Invokes an action, triggering action handlers if any registered.
+ * The second and third arguments are optional
+ * @param action The action to fire
+ * @param args The argument passed to the action handler. Optional if action has no args required
+ * @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
+ */
+export const invokeAction: InvokeActionFunc = <A extends Action>(
+ action: A,
+ args?: ArgOfHoppAction<A>,
+ trigger?: InvocationTriggers
+) => {
+ boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
+};
+
+export function unbindAction<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>
+) {
+ // 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
+ boundActions[action] = boundActions[action]?.filter(
+ (x) => x !== handler
+ ) as any;
+
+ if (boundActions[action]?.length === 0) {
+ delete boundActions[action];
+ }
+
+ updateActiveActions();
+}
+
+/**
+ * Returns whether a given action is bound at a given time
+ *
+ * @param action The action to check
+ */
+export function isActionBound(action: Action): boolean {
+ return !!boundActions[action];
+}
+
+/**
+ * A React hook that defines a component can handle a given
+ * Action. The handler will be bound when the component is mounted
+ * and unbound when the component is unmounted.
+ * @param action The action to be bound
+ * @param handler The function to be called when the action is invoked
+ * @param isActive A ref that indicates whether the action is active
+ */
+export function useActionHandler<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>,
+ isActive: MutableRefObject<boolean> | boolean | undefined = undefined
+) {
+ const handlerRef = useRef(handler);
+ const [isBound, setIsBound] = useState(false);
+
+ // Update handler ref when handler changes
+ useEffect(() => {
+ handlerRef.current = handler;
+ }, [handler]);
+
+ // Create a stable handler wrapper
+ const stableHandler = useCallback(
+ (args: any, trigger?: InvocationTriggers) => {
+ (handlerRef.current as any)(args, trigger);
+ },
+ []
+ ) as ActionFunc<A>;
+
+ useEffect(() => {
+ const shouldBind =
+ isActive === undefined ||
+ (typeof isActive === "boolean" ? isActive : isActive.current);
+
+ if (shouldBind && !isBound) {
+ bindAction(action, stableHandler);
+ setIsBound(true);
+ } else if (!shouldBind && isBound) {
+ unbindAction(action, stableHandler);
+ setIsBound(false);
+ }
+
+ return () => {
+ if (isBound) {
+ unbindAction(action, stableHandler);
+ setIsBound(false);
+ }
+ };
+ }, [action, stableHandler, isActive, isBound]);
+
+ // Handle ref-based isActive changes
+ useEffect(() => {
+ if (isActive && typeof isActive === "object" && "current" in isActive) {
+ // Poll for ref changes
+ const interval = setInterval(() => {
+ const shouldBind = isActive.current;
+ if (shouldBind !== isBound) {
+ if (shouldBind) {
+ bindAction(action, stableHandler);
+ } else {
+ unbindAction(action, stableHandler);
+ }
+ setIsBound(shouldBind);
+ }
+ }, 100);
+ return () => clearInterval(interval);
+ }
+ }, [action, stableHandler, isActive, isBound]);
+}
+
+/**
+ * A React hook that returns the current list of active actions
+ * and re-renders when the list changes
+ */
+export function useActiveActions(): Action[] {
+ const [activeActions, setActiveActions] = useState<Action[]>([]);
+
+ useEffect(() => {
+ // Set initial value
+ setActiveActions(currentActiveActions);
+
+ // Subscribe to changes
+ const unsubscribe = actionEmitter.subscribe(setActiveActions);
+ return unsubscribe;
+ }, []);
+
+ return activeActions;
+}
+
+/**
+ * A React hook that returns whether a specific action is currently bound
+ * and re-renders when the binding state changes
+ */
+export function useIsActionBound(action: Action): boolean {
+ const [isBound, setIsBound] = useState(() => isActionBound(action));
+
+ useEffect(() => {
+ const updateBoundState = () => {
+ setIsBound(isActionBound(action));
+ };
+
+ // Set initial value
+ updateBoundState();
+
+ // Subscribe to changes
+ const unsubscribe = actionEmitter.subscribe(updateBoundState);
+ return unsubscribe;
+ }, [action]);
+
+ return isBound;
+}
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
new file mode 100644
index 00000000..e0d9b9eb
--- /dev/null
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -0,0 +1,162 @@
+"use client";
+
+import { useEffect } from "react";
+import { useActionHandler } from "@/constants/actions";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { toast } from "sonner";
+
+export function useEditorActions() {
+ const {
+ tracks,
+ selectedElements,
+ clearSelectedElements,
+ setSelectedElements,
+ removeElementFromTrack,
+ splitElement,
+ addElementToTrack,
+ snappingEnabled,
+ toggleSnapping,
+ undo,
+ redo,
+ } = useTimelineStore();
+
+ const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
+ const { activeProject } = useProjectStore();
+
+ // Playback actions
+ useActionHandler("toggle-play", () => {
+ toggle();
+ });
+
+ useActionHandler("stop-playback", () => {
+ if (isPlaying) {
+ toggle();
+ }
+ seek(0);
+ });
+
+ useActionHandler("seek-forward", (args) => {
+ const seconds = args?.seconds ?? 1;
+ seek(Math.min(duration, currentTime + seconds));
+ });
+
+ useActionHandler("seek-backward", (args) => {
+ const seconds = args?.seconds ?? 1;
+ seek(Math.max(0, currentTime - seconds));
+ });
+
+ useActionHandler("frame-step-forward", () => {
+ const projectFps = activeProject?.fps || 30;
+ seek(Math.min(duration, currentTime + 1 / projectFps));
+ });
+
+ useActionHandler("frame-step-backward", () => {
+ const projectFps = activeProject?.fps || 30;
+ seek(Math.max(0, currentTime - 1 / projectFps));
+ });
+
+ useActionHandler("jump-forward", (args) => {
+ const seconds = args?.seconds ?? 5;
+ seek(Math.min(duration, currentTime + seconds));
+ });
+
+ useActionHandler("jump-backward", (args) => {
+ const seconds = args?.seconds ?? 5;
+ seek(Math.max(0, currentTime - seconds));
+ });
+
+ useActionHandler("goto-start", () => {
+ seek(0);
+ });
+
+ useActionHandler("goto-end", () => {
+ seek(duration);
+ });
+
+ // Timeline editing actions
+ useActionHandler("split-element", () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
+ return;
+ }
+
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t: any) => t.id === trackId);
+ const element = track?.elements.find((el: any) => el.id === elementId);
+
+ if (element) {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ splitElement(trackId, elementId, currentTime);
+ } else {
+ toast.error("Playhead must be within selected element");
+ }
+ }
+ });
+
+ useActionHandler("delete-selected", () => {
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
+ return;
+ }
+ selectedElements.forEach(
+ ({ trackId, elementId }: { trackId: string; elementId: string }) => {
+ removeElementFromTrack(trackId, elementId);
+ }
+ );
+ clearSelectedElements();
+ });
+
+ useActionHandler("select-all", () => {
+ const allElements = tracks.flatMap((track: any) =>
+ track.elements.map((element: any) => ({
+ trackId: track.id,
+ elementId: element.id,
+ }))
+ );
+ setSelectedElements(allElements);
+ });
+
+ useActionHandler("duplicate-selected", () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to duplicate");
+ return;
+ }
+
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t: any) => t.id === trackId);
+ const element = track?.elements.find((el: any) => el.id === elementId);
+
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+ const { id, ...elementWithoutId } = element;
+
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+ }
+ });
+
+ useActionHandler("toggle-snapping", () => {
+ toggleSnapping();
+ });
+
+ // History actions
+ useActionHandler("undo", () => {
+ undo();
+ });
+
+ useActionHandler("redo", () => {
+ redo();
+ });
+}
diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts
new file mode 100644
index 00000000..cc5dd340
--- /dev/null
+++ b/apps/web/src/hooks/use-keybinding-conflicts.ts
@@ -0,0 +1,59 @@
+"use client";
+
+import { useMemo } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { ActionWithOptionalArgs } from "@/constants/actions";
+
+export interface KeybindingConflictInfo {
+ key: string;
+ actions: ActionWithOptionalArgs[];
+ isConflict: boolean;
+}
+
+export const useKeybindingConflicts = () => {
+ const { keybindings } = useKeybindingsStore();
+
+ const conflicts = useMemo(() => {
+ const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
+ const conflictList: KeybindingConflictInfo[] = [];
+
+ // Group actions by key
+ Object.entries(keybindings).forEach(([key, action]) => {
+ if (!keyToActions[key]) {
+ keyToActions[key] = [];
+ }
+ keyToActions[key].push(action);
+ });
+
+ // Find conflicts
+ Object.entries(keyToActions).forEach(([key, actions]) => {
+ const uniqueActions = [...new Set(actions)];
+ conflictList.push({
+ key,
+ actions: uniqueActions,
+ isConflict: uniqueActions.length > 1,
+ });
+ });
+
+ return conflictList.filter((item) => item.isConflict);
+ }, [keybindings]);
+
+ const hasConflicts = conflicts.length > 0;
+
+ const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
+ return conflicts.find((conflict) => conflict.key === key) || null;
+ };
+
+ const getConflictsForAction = (
+ action: ActionWithOptionalArgs
+ ): KeybindingConflictInfo[] => {
+ return conflicts.filter((conflict) => conflict.actions.includes(action));
+ };
+
+ return {
+ conflicts,
+ hasConflicts,
+ getConflictsForKey,
+ getConflictsForAction,
+ };
+};
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
new file mode 100644
index 00000000..7887f3f1
--- /dev/null
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -0,0 +1,64 @@
+import { useEffect } from "react";
+import { invokeAction } from "../constants/actions";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+/**
+ * A composable that hooks to the caller component's
+ * lifecycle and hooks to the keyboard events to fire
+ * the appropriate actions based on keybindings
+ */
+export function useKeybindingsListener() {
+ const { keybindings, getKeybindingString, keybindingsEnabled } =
+ useKeybindingsStore();
+
+ useEffect(() => {
+ const handleKeyDown = (ev: KeyboardEvent) => {
+ // Do not check keybinds if the mode is disabled
+ if (!keybindingsEnabled) return;
+
+ const binding = getKeybindingString(ev);
+ if (!binding) return;
+
+ const boundAction = keybindings[binding];
+ if (!boundAction) return;
+
+ ev.preventDefault();
+
+ // Handle actions with default arguments
+ let actionArgs: any = undefined;
+
+ if (boundAction === "seek-forward") {
+ actionArgs = { seconds: 1 };
+ } else if (boundAction === "seek-backward") {
+ actionArgs = { seconds: 1 };
+ } else if (boundAction === "jump-forward") {
+ actionArgs = { seconds: 5 };
+ } else if (boundAction === "jump-backward") {
+ actionArgs = { seconds: 5 };
+ }
+
+ invokeAction(boundAction, actionArgs, "keypress");
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [keybindings, getKeybindingString, keybindingsEnabled]);
+}
+
+/**
+ * This composable allows for the UI component to be disabled if the component in question is mounted
+ */
+export function useKeybindingDisabler() {
+ const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
+
+ return {
+ disableKeybindings,
+ enableKeybindings,
+ };
+}
+
+// Export the bindings for backward compatibility
+export const bindings = {};
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
new file mode 100644
index 00000000..bd8beffc
--- /dev/null
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -0,0 +1,123 @@
+"use client";
+
+import { useMemo } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { Action } from "@/constants/actions";
+
+export interface KeyboardShortcut {
+ id: string;
+ keys: string[];
+ description: string;
+ category: string;
+ action: Action;
+ icon?: React.ReactNode;
+}
+
+// Map actions to their descriptions and categories
+const actionDescriptions: Record<
+ Action,
+ { description: string; category: string }
+> = {
+ "toggle-play": { description: "Play/Pause", category: "Playback" },
+ "stop-playback": { description: "Stop playback", category: "Playback" },
+ "seek-forward": {
+ description: "Seek forward 1 second",
+ category: "Playback",
+ },
+ "seek-backward": {
+ description: "Seek backward 1 second",
+ category: "Playback",
+ },
+ "frame-step-forward": {
+ description: "Frame step forward",
+ category: "Navigation",
+ },
+ "frame-step-backward": {
+ description: "Frame step backward",
+ category: "Navigation",
+ },
+ "jump-forward": {
+ description: "Jump forward 5 seconds",
+ category: "Navigation",
+ },
+ "jump-backward": {
+ description: "Jump backward 5 seconds",
+ category: "Navigation",
+ },
+ "goto-start": { description: "Go to timeline start", category: "Navigation" },
+ "goto-end": { description: "Go to timeline end", category: "Navigation" },
+ "split-element": {
+ description: "Split element at playhead",
+ category: "Editing",
+ },
+ "delete-selected": {
+ description: "Delete selected elements",
+ category: "Editing",
+ },
+ "select-all": { description: "Select all elements", category: "Selection" },
+ "duplicate-selected": {
+ description: "Duplicate selected element",
+ category: "Selection",
+ },
+ "toggle-snapping": { description: "Toggle snapping", category: "Editing" },
+ undo: { description: "Undo", category: "History" },
+ redo: { description: "Redo", category: "History" },
+};
+
+// Convert key binding format to display format
+const formatKey = (key: string): string => {
+ return key
+ .replace("ctrl", "Cmd")
+ .replace("alt", "Alt")
+ .replace("shift", "Shift")
+ .replace("left", "ArrowLeft")
+ .replace("right", "ArrowRight")
+ .replace("up", "ArrowUp")
+ .replace("down", "ArrowDown")
+ .replace("space", "Space")
+ .replace("home", "Home")
+ .replace("end", "End")
+ .replace("delete", "Delete")
+ .replace("backspace", "Backspace")
+ .replace("-", "+");
+};
+
+export const useKeyboardShortcutsHelp = () => {
+ const { keybindings } = useKeybindingsStore();
+
+ const shortcuts = useMemo(() => {
+ const result: KeyboardShortcut[] = [];
+
+ // Group keybindings by action
+ const actionToKeys: Record<Action, string[]> = {} as any;
+
+ Object.entries(keybindings).forEach(([key, action]) => {
+ if (action) {
+ if (!actionToKeys[action]) {
+ actionToKeys[action] = [];
+ }
+ actionToKeys[action].push(formatKey(key));
+ }
+ });
+
+ // Convert to shortcuts format
+ Object.entries(actionToKeys).forEach(([action, keys]) => {
+ const actionInfo = actionDescriptions[action as Action];
+ if (actionInfo) {
+ result.push({
+ id: action,
+ keys,
+ description: actionInfo.description,
+ category: actionInfo.category,
+ action: action as Action,
+ });
+ }
+ });
+
+ return result;
+ }, [keybindings]);
+
+ return {
+ shortcuts,
+ };
+};
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts
deleted file mode 100644
index feb8f409..00000000
--- a/apps/web/src/hooks/use-keyboard-shortcuts.ts
+++ /dev/null
@@ -1,338 +0,0 @@
-"use client";
-
-import { useEffect, useCallback } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { toast } from "sonner";
-
-export interface KeyboardShortcut {
- id: string;
- keys: string[];
- description: string;
- category: string;
- action: () => void;
- enabled?: boolean;
- requiresSelection?: boolean;
- icon?: React.ReactNode;
-}
-
-interface UseKeyboardShortcutsOptions {
- enabled?: boolean;
- context?: "global" | "timeline" | "editor";
-}
-
-export const useKeyboardShortcuts = (
- options: UseKeyboardShortcutsOptions = {}
-) => {
- const { enabled = true, context = "editor" } = options;
-
- const {
- tracks,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- removeElementFromTrack,
- splitElement,
- addElementToTrack,
- snappingEnabled,
- toggleSnapping,
- undo,
- redo,
- } = useTimelineStore();
-
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
-
- const { activeProject } = useProjectStore();
-
- // Check if user is typing in an input field
- const isInputFocused = useCallback(() => {
- const activeElement = document.activeElement as HTMLElement;
- return (
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- activeElement.contentEditable === "true")
- );
- }, []);
-
- // Define all shortcuts in one place
- const shortcuts: KeyboardShortcut[] = [
- // Playback Controls
- {
- id: "play-pause",
- keys: ["Space"],
- description: "Play/Pause",
- category: "Playback",
- action: () => {
- toggle();
- },
- },
- {
- id: "rewind",
- keys: ["J"],
- description: "Rewind 1 second",
- category: "Playback",
- action: () => {
- seek(Math.max(0, currentTime - 1));
- },
- },
- {
- id: "play-pause-alt",
- keys: ["K"],
- description: "Play/Pause (alternative)",
- category: "Playback",
- action: () => {
- toggle();
- },
- },
- {
- id: "fast-forward",
- keys: ["L"],
- description: "Fast forward 1 second",
- category: "Playback",
- action: () => {
- seek(Math.min(duration, currentTime + 1));
- },
- },
-
- // Navigation
- {
- id: "frame-backward",
- keys: ["ArrowLeft"],
- description: "Frame step backward",
- category: "Navigation",
- action: () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.max(0, currentTime - 1 / projectFps));
- },
- },
- {
- id: "frame-forward",
- keys: ["ArrowRight"],
- description: "Frame step forward",
- category: "Navigation",
- action: () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.min(duration, currentTime + 1 / projectFps));
- },
- },
- {
- id: "jump-backward",
- keys: ["Shift+ArrowLeft"],
- description: "Jump back 5 seconds",
- category: "Navigation",
- action: () => {
- seek(Math.max(0, currentTime - 5));
- },
- },
- {
- id: "jump-forward",
- keys: ["Shift+ArrowRight"],
- description: "Jump forward 5 seconds",
- category: "Navigation",
- action: () => {
- seek(Math.min(duration, currentTime + 5));
- },
- },
- {
- id: "goto-start",
- keys: ["Home"],
- description: "Go to timeline start",
- category: "Navigation",
- action: () => {
- seek(0);
- },
- },
- {
- id: "goto-end",
- keys: ["End"],
- description: "Go to timeline end",
- category: "Navigation",
- action: () => {
- seek(duration);
- },
- },
-
- // Editing
- {
- id: "split-element",
- keys: ["S"],
- description: "Split element at playhead",
- category: "Editing",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to split");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t: any) => t.id === trackId);
- const element = track?.elements.find((el: any) => el.id === elementId);
-
- if (element) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitElement(trackId, elementId, currentTime);
- } else {
- toast.error("Playhead must be within selected element");
- }
- }
- },
- },
- {
- id: "delete-elements",
- keys: ["Delete", "Backspace"],
- description: "Delete selected elements",
- category: "Editing",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(
- ({ trackId, elementId }: { trackId: string; elementId: string }) => {
- removeElementFromTrack(trackId, elementId);
- }
- );
- clearSelectedElements();
- },
- },
- {
- id: "toggle-snapping",
- keys: ["N"],
- description: "Toggle snapping",
- category: "Editing",
- action: () => {
- toggleSnapping();
- },
- },
-
- // Selection & Organization
- {
- id: "select-all",
- keys: ["Cmd+A", "Ctrl+A"],
- description: "Select all elements",
- category: "Selection",
- action: () => {
- const allElements = tracks.flatMap((track: any) =>
- track.elements.map((element: any) => ({
- trackId: track.id,
- elementId: element.id,
- }))
- );
- setSelectedElements(allElements);
- },
- },
- {
- id: "duplicate-element",
- keys: ["Cmd+D", "Ctrl+D"],
- description: "Duplicate selected element",
- category: "Selection",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to duplicate");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t: any) => t.id === trackId);
- const element = track?.elements.find((el: any) => el.id === elementId);
-
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
-
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- },
- },
-
- // History
- {
- id: "undo",
- keys: ["Cmd+Z", "Ctrl+Z"],
- description: "Undo",
- category: "History",
- action: () => {
- undo();
- },
- },
- {
- id: "redo",
- keys: ["Cmd+Shift+Z", "Ctrl+Shift+Z", "Cmd+Y", "Ctrl+Y"],
- description: "Redo",
- category: "History",
- action: () => {
- redo();
- },
- },
- ];
-
- // Parse keyboard event to match against shortcuts
- const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => {
- const parts: string[] = [];
-
- if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl");
- if (e.shiftKey) parts.push("Shift");
- if (e.altKey) parts.push("Alt");
-
- parts.push(e.key);
-
- return parts.join("+").toLowerCase();
- }, []);
-
- // Handle keyboard events
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (!enabled || isInputFocused()) return;
-
- const keyCombo = parseKeyboardEvent(e);
- const shortcut = shortcuts.find((s) =>
- s.keys.some(
- (key) =>
- key.toLowerCase() === keyCombo ||
- key.toLowerCase() === e.key.toLowerCase()
- )
- );
-
- if (shortcut) {
- // Check if shortcut requires selection
- if (shortcut.requiresSelection && selectedElements.length === 0) {
- return;
- }
-
- e.preventDefault();
- shortcut.action();
- }
- },
- [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused]
- );
-
- // Set up event listener
- useEffect(() => {
- if (!enabled) return;
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [enabled, handleKeyDown]);
-
- // Return shortcuts for help component
- return {
- shortcuts: shortcuts.filter((s) => s.enabled !== false),
- enabled,
- };
-};
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index fd47cc13..8d2ee03a 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -44,6 +44,25 @@ export function generateUUID(): string {
);
}
+export function isDOMElement(el: any): el is HTMLElement {
+ return !!el && (el instanceof Element || el instanceof HTMLElement);
+}
+
+export function isTypableElement(el: HTMLElement): boolean {
+ // If content editable, then it is editable
+ if (el.isContentEditable) return true;
+
+ // If element is an input and the input is enabled, then it is typable
+ if (el.tagName === "INPUT") {
+ return !(el as HTMLInputElement).disabled;
+ }
+ // If element is a textarea and the input is enabled, then it is typable
+ if (el.tagName === "TEXTAREA") {
+ return !(el as HTMLTextAreaElement).disabled;
+ }
+
+ return false;
+}
export function isAppleDevice() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
}
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
new file mode 100644
index 00000000..60c71afd
--- /dev/null
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -0,0 +1,241 @@
+"use client";
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { ActionWithOptionalArgs } from "@/constants/actions";
+import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils";
+import { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
+
+// Default keybindings configuration
+export const defaultKeybindings: KeybindingConfig = {
+ space: "toggle-play",
+ j: "seek-backward",
+ k: "toggle-play",
+ l: "seek-forward",
+ left: "frame-step-backward",
+ right: "frame-step-forward",
+ "shift+left": "jump-backward",
+ "shift+right": "jump-forward",
+ home: "goto-start",
+ end: "goto-end",
+ s: "split-element",
+ n: "toggle-snapping",
+ "ctrl+a": "select-all",
+ "ctrl+d": "duplicate-selected",
+ "ctrl+z": "undo",
+ "ctrl+shift+z": "redo",
+ "ctrl+y": "redo",
+ delete: "delete-selected",
+ backspace: "delete-selected",
+};
+
+export interface KeybindingConflict {
+ key: ShortcutKey;
+ existingAction: ActionWithOptionalArgs;
+ newAction: ActionWithOptionalArgs;
+}
+
+interface KeybindingsState {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+ keybindingsEnabled: boolean;
+
+ // Actions
+ updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
+ removeKeybinding: (key: ShortcutKey) => void;
+ resetToDefaults: () => void;
+ importKeybindings: (config: KeybindingConfig) => void;
+ exportKeybindings: () => KeybindingConfig;
+ enableKeybindings: () => void;
+ disableKeybindings: () => void;
+
+ // Validation
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: ActionWithOptionalArgs
+ ) => KeybindingConflict | null;
+ getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[];
+
+ // Utility
+ getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
+}
+
+export const useKeybindingsStore = create<KeybindingsState>()(
+ persist(
+ (set, get) => ({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ keybindingsEnabled: true,
+
+ updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
+ set((state) => {
+ const newKeybindings = { ...state.keybindings };
+ newKeybindings[key] = action;
+
+ return {
+ keybindings: newKeybindings,
+ isCustomized: true,
+ };
+ });
+ },
+
+ removeKeybinding: (key: ShortcutKey) => {
+ set((state) => {
+ const newKeybindings = { ...state.keybindings };
+ delete newKeybindings[key];
+
+ return {
+ keybindings: newKeybindings,
+ isCustomized: true,
+ };
+ });
+ },
+
+ resetToDefaults: () => {
+ set({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ });
+ },
+
+ enableKeybindings: () => {
+ set({ keybindingsEnabled: true });
+ },
+
+ disableKeybindings: () => {
+ set({ keybindingsEnabled: false });
+ },
+
+ importKeybindings: (config: KeybindingConfig) => {
+ // Validate all keys and actions
+ for (const [key, action] of Object.entries(config)) {
+ // Validate the key format
+ if (typeof key !== "string" || key.length === 0) {
+ throw new Error(`Invalid key format: ${key}`);
+ }
+ }
+ set({
+ keybindings: { ...config },
+ isCustomized: true,
+ });
+ },
+
+ exportKeybindings: () => {
+ return get().keybindings;
+ },
+
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: ActionWithOptionalArgs
+ ) => {
+ const { keybindings } = get();
+ const existingAction = keybindings[key];
+
+ if (existingAction && existingAction !== action) {
+ return {
+ key,
+ existingAction,
+ newAction: action,
+ };
+ }
+
+ return null;
+ },
+
+ getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
+ const { keybindings } = get();
+ return Object.keys(keybindings).filter(
+ (key) => keybindings[key as ShortcutKey] === action
+ ) as ShortcutKey[];
+ },
+
+ getKeybindingString: (ev: KeyboardEvent) => {
+ return generateKeybindingString(ev) as ShortcutKey | null;
+ },
+ }),
+ {
+ name: "opencut-keybindings",
+ version: 1,
+ }
+ )
+);
+
+// Utility functions
+function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
+ const target = ev.target;
+
+ // We may or may not have a modifier key
+ const modifierKey = getActiveModifier(ev);
+
+ // We will always have a non-modifier key
+ const key = getPressedKey(ev);
+ if (!key) return null;
+
+ // All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
+ if (modifierKey) {
+ // If the modifier is shift and the target is an input, we ignore
+ if (
+ modifierKey === "shift" &&
+ isDOMElement(target) &&
+ isTypableElement(target)
+ ) {
+ return null;
+ }
+
+ return `${modifierKey}+${key}` as ShortcutKey;
+ }
+
+ // no modifier key here then we do not do anything while on input
+ if (isDOMElement(target) && isTypableElement(target)) return null;
+
+ // single key while not input
+ return `${key}` as ShortcutKey;
+}
+
+function getPressedKey(ev: KeyboardEvent): string | null {
+ // Sometimes the property code is not available on the KeyboardEvent object
+ const key = (ev.key ?? "").toLowerCase();
+
+ // Check arrow keys
+ if (key.startsWith("arrow")) {
+ return key.slice(5);
+ }
+
+ // Check for special keys
+ if (key === "tab") return "tab";
+ if (key === " " || key === "space") return "space";
+ if (key === "home") return "home";
+ if (key === "end") return "end";
+ if (key === "delete") return "delete";
+ if (key === "backspace") return "backspace";
+
+ // Check letter keys
+ const isLetter = key.length === 1 && key >= "a" && key <= "z";
+ if (isLetter) return key;
+
+ // Check if number keys
+ const isDigit = key.length === 1 && key >= "0" && key <= "9";
+ if (isDigit) return key;
+
+ // Check if slash, period or enter
+ if (key === "/" || key === "." || key === "enter") return key;
+
+ // If no other cases match, this is not a valid key
+ return null;
+}
+
+function getActiveModifier(ev: KeyboardEvent): string | null {
+ const modifierKeys = {
+ ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
+ alt: ev.altKey,
+ shift: ev.shiftKey,
+ };
+
+ // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
+ // modiferKeys object's keys are sorted to match the above order
+ const activeModifier = Object.keys(modifierKeys)
+ .filter((key) => modifierKeys[key as keyof typeof modifierKeys])
+ .join("+");
+
+ return activeModifier === "" ? null : activeModifier;
+}
diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/types/keybinding.ts
new file mode 100644
index 00000000..d97c88da
--- /dev/null
+++ b/apps/web/src/types/keybinding.ts
@@ -0,0 +1,35 @@
+import { ActionWithOptionalArgs } from "@/constants/actions";
+
+/**
+ * Alt is also regarded as macOS OPTION (⌥) key
+ * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
+ */
+export type ModifierKeys =
+ | "ctrl"
+ | "alt"
+ | "shift"
+ | "ctrl+shift"
+ | "alt+shift"
+ | "ctrl+alt"
+ | "ctrl+alt+shift";
+
+/* eslint-disable prettier/prettier */
+// prettier-ignore
+export type Key =
+ | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
+ | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
+ | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
+ | "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
+ | "right" | "/" | "?" | "." | "enter" | "tab" | "space"
+ | "escape" | "esc" | "backspace" | "delete" | "home" | "end"
+/* eslint-enable */
+
+export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
+// Singular keybindings (these will be disabled when an input-ish area has been focused)
+export type SingleCharacterShortcutKey = `${Key}`;
+
+export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
+
+export type KeybindingConfig = {
+ [key in ShortcutKey]?: ActionWithOptionalArgs;
+};