summaryrefslogtreecommitdiff
path: root/apps/web/src/actions
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/actions
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/actions')
-rw-r--r--apps/web/src/actions/components/shortcuts-dialog.tsx229
-rw-r--r--apps/web/src/actions/definitions.ts209
-rw-r--r--apps/web/src/actions/index.ts3
-rw-r--r--apps/web/src/actions/keybinding.ts79
-rw-r--r--apps/web/src/actions/keybindings-store.ts251
-rw-r--r--apps/web/src/actions/keybindings/migrations/index.ts32
-rw-r--r--apps/web/src/actions/keybindings/migrations/v2-to-v3.ts26
-rw-r--r--apps/web/src/actions/keybindings/migrations/v3-to-v4.ts25
-rw-r--r--apps/web/src/actions/keybindings/migrations/v4-to-v5.ts17
-rw-r--r--apps/web/src/actions/keybindings/migrations/v5-to-v6.ts17
-rw-r--r--apps/web/src/actions/keybindings/migrations/v6-to-v7.ts19
-rw-r--r--apps/web/src/actions/registry.ts63
-rw-r--r--apps/web/src/actions/types.ts44
-rw-r--r--apps/web/src/actions/use-action-handler.ts52
-rw-r--r--apps/web/src/actions/use-editor-actions.ts488
-rw-r--r--apps/web/src/actions/use-keybindings.ts87
-rw-r--r--apps/web/src/actions/use-keyboard-shortcuts-help.ts83
17 files changed, 1724 insertions, 0 deletions
diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx
new file mode 100644
index 00000000..cd9c0242
--- /dev/null
+++ b/apps/web/src/actions/components/shortcuts-dialog.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ type KeyboardShortcut,
+ useKeyboardShortcutsHelp,
+} from "@/actions/use-keyboard-shortcuts-help";
+import { useKeybindingsStore } from "@/actions/keybindings-store";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function ShortcutsDialog({
+ isOpen,
+ onOpenChange,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [recordingShortcut, setRecordingShortcut] =
+ useState<KeyboardShortcut | null>(null);
+
+ const {
+ updateKeybinding,
+ removeKeybinding,
+ getKeybindingString,
+ validateKeybinding,
+ getKeybindingsForAction,
+ setIsRecording,
+ resetToDefaults,
+ isRecording,
+ } = useKeybindingsStore();
+
+ const { shortcuts } = useKeyboardShortcutsHelp();
+
+ const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
+
+ useEffect(() => {
+ if (!isRecording || !recordingShortcut) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const keyString = getKeybindingString(e);
+ if (keyString) {
+ const conflict = validateKeybinding(
+ keyString,
+ recordingShortcut.action,
+ );
+ if (conflict) {
+ toast.error(
+ `Key "${keyString}" is already bound to "${conflict.existingAction}"`,
+ );
+ setRecordingShortcut(null);
+ return;
+ }
+
+ const oldKeys = getKeybindingsForAction(recordingShortcut.action);
+ for (const key of oldKeys) {
+ removeKeybinding(key);
+ }
+
+ updateKeybinding(keyString, recordingShortcut.action);
+
+ setIsRecording(false);
+ setRecordingShortcut(null);
+ }
+ };
+
+ const handleClickOutside = () => {
+ setRecordingShortcut(null);
+ setIsRecording(false);
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("click", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [
+ recordingShortcut,
+ getKeybindingString,
+ updateKeybinding,
+ removeKeybinding,
+ validateKeybinding,
+ getKeybindingsForAction,
+ setIsRecording,
+ isRecording,
+ ]);
+
+ const handleStartRecording = (shortcut: KeyboardShortcut) => {
+ setRecordingShortcut(shortcut);
+ setIsRecording(true);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
+ <DialogHeader>
+ <DialogTitle>Keyboard shortcuts</DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="scrollbar-thin grow overflow-y-auto">
+ <div className="flex flex-col gap-6">
+ {categories.map((category) => (
+ <div key={category} className="flex flex-col gap-1">
+ <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
+ {category}
+ </h3>
+ <div className="flex flex-col gap-1">
+ {shortcuts
+ .filter((shortcut) => shortcut.category === category)
+ .map((shortcut) => (
+ <ShortcutItem
+ key={shortcut.action}
+ shortcut={shortcut}
+ isRecording={
+ shortcut.action === recordingShortcut?.action
+ }
+ onStartRecording={() => handleStartRecording(shortcut)}
+ />
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="destructive" onClick={resetToDefaults}>
+ Reset to default
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+function ShortcutItem({
+ shortcut,
+ isRecording,
+ onStartRecording,
+}: {
+ shortcut: KeyboardShortcut;
+ isRecording: boolean;
+ onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
+}) {
+ const displayKeys = shortcut.keys.filter((key: string) => {
+ if (
+ key.includes("Cmd") &&
+ shortcut.keys.includes(key.replace("Cmd", "Ctrl"))
+ )
+ return false;
+
+ return true;
+ });
+
+ return (
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ {shortcut.icon && (
+ <div className="text-muted-foreground">{shortcut.icon}</div>
+ )}
+ <span className="text-sm">{shortcut.description}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ {displayKeys.map((key: string, index: number) => (
+ <div key={key} className="flex items-center gap-2">
+ <div className="flex items-center gap-1">
+ {key.split("+").map((keyPart: string, partIndex: number) => {
+ const keyId = `${shortcut.id}-${index}-${partIndex}`;
+ return (
+ <EditableShortcutKey
+ key={keyId}
+ isRecording={isRecording}
+ onStartRecording={() => onStartRecording({ shortcut })}
+ >
+ {keyPart}
+ </EditableShortcutKey>
+ );
+ })}
+ </div>
+ {index < displayKeys.length - 1 && (
+ <span className="text-muted-foreground text-xs">or</span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function EditableShortcutKey({
+ children,
+ isRecording,
+ onStartRecording,
+}: {
+ children: React.ReactNode;
+ isRecording: boolean;
+ onStartRecording: () => void;
+}) {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onStartRecording();
+ };
+
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleClick}
+ title={
+ isRecording ? "Press any key combination..." : "Click to edit shortcut"
+ }
+ >
+ {children}
+ </Button>
+ );
+}
diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts
new file mode 100644
index 00000000..6f0ba700
--- /dev/null
+++ b/apps/web/src/actions/definitions.ts
@@ -0,0 +1,209 @@
+import type {
+ KeybindingConfig,
+ ShortcutKey,
+} from "@/actions/keybinding";
+import type { TActionWithOptionalArgs } from "./types";
+
+export type TActionCategory =
+ | "playback"
+ | "navigation"
+ | "editing"
+ | "selection"
+ | "history"
+ | "timeline"
+ | "controls"
+ | "assets";
+
+export interface TActionBaseDefinition {
+ description: string;
+ category: TActionCategory;
+ args?: Record<string, unknown>;
+}
+
+export interface TActionDefinition extends TActionBaseDefinition {
+ defaultShortcuts?: readonly ShortcutKey[];
+}
+
+export const ACTIONS = {
+ "toggle-play": {
+ description: "Play/Pause",
+ category: "playback",
+ },
+ "stop-playback": {
+ description: "Stop playback",
+ category: "playback",
+ },
+ "seek-forward": {
+ description: "Seek forward 1 second",
+ category: "playback",
+ args: { seconds: "number" },
+ },
+ "seek-backward": {
+ description: "Seek backward 1 second",
+ category: "playback",
+ args: { seconds: "number" },
+ },
+ "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",
+ args: { seconds: "number" },
+ },
+ "jump-backward": {
+ description: "Jump backward 5 seconds",
+ category: "navigation",
+ args: { seconds: "number" },
+ },
+ "goto-start": {
+ description: "Go to timeline start",
+ category: "navigation",
+ },
+ "goto-end": {
+ description: "Go to timeline end",
+ category: "navigation",
+ },
+ split: {
+ description: "Split elements at playhead",
+ category: "editing",
+ },
+ "split-left": {
+ description: "Split and remove left",
+ category: "editing",
+ },
+ "split-right": {
+ description: "Split and remove right",
+ category: "editing",
+ },
+ "delete-selected": {
+ description: "Delete current selection",
+ category: "editing",
+ },
+ "copy-selected": {
+ description: "Copy selected elements",
+ category: "editing",
+ },
+ "paste-copied": {
+ description: "Paste elements at playhead",
+ category: "editing",
+ },
+ "toggle-snapping": {
+ description: "Toggle snapping",
+ category: "editing",
+ },
+ "toggle-ripple-editing": {
+ description: "Toggle ripple editing",
+ category: "editing",
+ },
+ "toggle-source-audio": {
+ description: "Extract or recover source audio",
+ category: "editing",
+ },
+ "select-all": {
+ description: "Select all elements",
+ category: "selection",
+ },
+ "cancel-interaction": {
+ description: "Cancel current interaction",
+ category: "controls",
+ },
+ "deselect-all": {
+ description: "Deselect all elements",
+ category: "selection",
+ },
+ "duplicate-selected": {
+ description: "Duplicate selected element",
+ category: "selection",
+ },
+ "toggle-elements-muted-selected": {
+ description: "Mute/unmute selected elements",
+ category: "selection",
+ },
+ "toggle-elements-visibility-selected": {
+ description: "Show/hide selected elements",
+ category: "selection",
+ },
+ "toggle-bookmark": {
+ description: "Toggle bookmark at playhead",
+ category: "timeline",
+ },
+ undo: {
+ description: "Undo",
+ category: "history",
+ },
+ redo: {
+ description: "Redo",
+ category: "history",
+ },
+ "remove-media-asset": {
+ description: "Remove media asset",
+ category: "assets",
+ args: { projectId: "string", assetId: "string" },
+ },
+ "remove-media-assets": {
+ description: "Remove media assets",
+ category: "assets",
+ args: { projectId: "string", assetIds: "string[]" },
+ },
+} as const satisfies Record<string, TActionBaseDefinition>;
+
+export type TAction = keyof typeof ACTIONS;
+
+const ACTION_DEFAULT_SHORTCUTS = {
+ "toggle-play": ["space", "k"],
+ "seek-forward": ["l"],
+ "seek-backward": ["j"],
+ "frame-step-forward": ["right"],
+ "frame-step-backward": ["left"],
+ "jump-forward": ["shift+right"],
+ "jump-backward": ["shift+left"],
+ "goto-start": ["home", "enter"],
+ "goto-end": ["end"],
+ split: ["s"],
+ "split-left": ["q"],
+ "split-right": ["w"],
+ "delete-selected": ["backspace", "delete"],
+ "copy-selected": ["ctrl+c"],
+ "paste-copied": ["ctrl+v"],
+ "toggle-snapping": ["n"],
+ "select-all": ["ctrl+a"],
+ "cancel-interaction": ["escape"],
+ "duplicate-selected": ["ctrl+d"],
+ undo: ["ctrl+z"],
+ redo: ["ctrl+shift+z", "ctrl+y"],
+} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
+
+const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
+ Record<TAction, readonly ShortcutKey[]>
+> = ACTION_DEFAULT_SHORTCUTS;
+
+export function getActionDefinition({
+ action,
+}: {
+ action: TAction;
+}): TActionDefinition {
+ return {
+ ...ACTIONS[action],
+ defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
+ };
+}
+
+export function getDefaultShortcuts(): KeybindingConfig {
+ const shortcuts: KeybindingConfig = {};
+
+ for (const [action, defaultShortcuts] of Object.entries(
+ ACTION_DEFAULT_SHORTCUTS,
+ ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
+ for (const shortcut of defaultShortcuts) {
+ shortcuts[shortcut] = action;
+ }
+ }
+
+ return shortcuts;
+}
diff --git a/apps/web/src/actions/index.ts b/apps/web/src/actions/index.ts
new file mode 100644
index 00000000..801a846a
--- /dev/null
+++ b/apps/web/src/actions/index.ts
@@ -0,0 +1,3 @@
+export * from "./definitions";
+export * from "./types";
+export * from "./registry";
diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts
new file mode 100644
index 00000000..65ef4538
--- /dev/null
+++ b/apps/web/src/actions/keybinding.ts
@@ -0,0 +1,79 @@
+import type { TActionWithOptionalArgs } from "./types";
+
+/**
+ * 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";
+
+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]?: TActionWithOptionalArgs;
+};
diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts
new file mode 100644
index 00000000..c0a1a5e2
--- /dev/null
+++ b/apps/web/src/actions/keybindings-store.ts
@@ -0,0 +1,251 @@
+"use client";
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { TActionWithOptionalArgs } from "@/actions";
+import { getDefaultShortcuts } from "@/actions";
+import { isTypableDOMElement } from "@/utils/browser";
+import { isAppleDevice } from "@/utils/platform";
+import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
+import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
+
+const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
+
+export interface KeybindingConflict {
+ key: ShortcutKey;
+ existingAction: TActionWithOptionalArgs;
+ newAction: TActionWithOptionalArgs;
+}
+
+interface KeybindingsState {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+ overlayDepth: number;
+ openOverlayIds: string[];
+ isLoadingProject: boolean;
+ isRecording: boolean;
+
+ updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
+ removeKeybinding: (key: ShortcutKey) => void;
+ resetToDefaults: () => void;
+ importKeybindings: (config: KeybindingConfig) => void;
+ exportKeybindings: () => KeybindingConfig;
+ openOverlay: (overlayId: string) => void;
+ closeOverlay: (overlayId: string) => void;
+ setLoadingProject: (loading: boolean) => void;
+ setIsRecording: (isRecording: boolean) => void;
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: TActionWithOptionalArgs,
+ ) => KeybindingConflict | null;
+ getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
+ getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
+}
+
+function isDOMElement(element: EventTarget | null): element is HTMLElement {
+ return element instanceof HTMLElement;
+}
+
+export const useKeybindingsStore = create<KeybindingsState>()(
+ persist(
+ (set, get) => ({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ overlayDepth: 0,
+ openOverlayIds: [],
+ isLoadingProject: false,
+ isRecording: false,
+
+ openOverlay: (overlayId) =>
+ set((s) => {
+ const openOverlayIds = s.openOverlayIds.includes(overlayId)
+ ? s.openOverlayIds
+ : [...s.openOverlayIds, overlayId];
+ const nextOverlayDepth = openOverlayIds.length;
+ return {
+ openOverlayIds,
+ overlayDepth: nextOverlayDepth,
+ };
+ }),
+ closeOverlay: (overlayId) =>
+ set((s) => {
+ const openOverlayIds = s.openOverlayIds.filter(
+ (id) => id !== overlayId,
+ );
+ const nextOverlayDepth = openOverlayIds.length;
+ return {
+ openOverlayIds,
+ overlayDepth: nextOverlayDepth,
+ };
+ }),
+ setLoadingProject: (loading) => {
+ set({ isLoadingProject: loading });
+ },
+
+ updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
+ 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,
+ });
+ },
+
+ importKeybindings: (config: KeybindingConfig) => {
+ for (const [key] of Object.entries(config)) {
+ 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: TActionWithOptionalArgs,
+ ) => {
+ const { keybindings } = get();
+ const existingAction = keybindings[key];
+
+ if (existingAction && existingAction !== action) {
+ return {
+ key,
+ existingAction,
+ newAction: action,
+ };
+ }
+
+ return null;
+ },
+ setIsRecording: (isRecording: boolean) => {
+ set({ isRecording });
+ },
+
+ getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
+ 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: CURRENT_VERSION,
+ partialize: (state) => ({
+ keybindings: state.keybindings,
+ isCustomized: state.isCustomized,
+ }),
+ migrate: (persisted, version) =>
+ runMigrations({ state: persisted, fromVersion: version }),
+ },
+ ),
+);
+
+function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
+ const target = ev.target;
+ const modifierKey = getActiveModifier(ev);
+ const key = getPressedKey(ev);
+ if (!key) return null;
+
+ if (modifierKey) {
+ if (
+ modifierKey === "shift" &&
+ isDOMElement(target) &&
+ isTypableDOMElement({ element: target as HTMLElement })
+ ) {
+ return null;
+ }
+
+ return `${modifierKey}+${key}` as ShortcutKey;
+ }
+
+ if (
+ isDOMElement(target) &&
+ isTypableDOMElement({ element: target as HTMLElement })
+ )
+ return null;
+
+ return `${key}` as ShortcutKey;
+}
+
+function getPressedKey(ev: KeyboardEvent): string | null {
+ const key = (ev.key ?? "").toLowerCase();
+ const code = ev.code ?? "";
+
+ if (code === "Space" || key === " " || key === "spacebar" || key === "space")
+ return "space";
+
+ if (key.startsWith("arrow")) return key.slice(5);
+
+ if (key === "escape") return "escape";
+ if (key === "tab") return "tab";
+ if (key === "home") return "home";
+ if (key === "end") return "end";
+ if (key === "delete") return "delete";
+ if (key === "backspace") return "backspace";
+
+ if (code.startsWith("Key")) {
+ const letter = code.slice(3).toLowerCase();
+ if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
+ }
+
+ // Use physical key position for AZERTY and other non-QWERTY layouts
+ if (code.startsWith("Digit")) {
+ const digit = code.slice(5);
+ if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
+ }
+
+ const isDigit = key.length === 1 && key >= "0" && key <= "9";
+ if (isDigit) return key;
+
+ if (key === "/" || key === "." || key === "enter") return key;
+
+ return null;
+}
+
+function getActiveModifier(ev: KeyboardEvent): string | null {
+ const modifierKeys = {
+ ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
+ alt: ev.altKey,
+ shift: ev.shiftKey,
+ };
+
+ const activeModifier = Object.keys(modifierKeys)
+ .filter((key) => modifierKeys[key as keyof typeof modifierKeys])
+ .join("+");
+
+ return activeModifier === "" ? null : activeModifier;
+}
diff --git a/apps/web/src/actions/keybindings/migrations/index.ts b/apps/web/src/actions/keybindings/migrations/index.ts
new file mode 100644
index 00000000..71fd81a6
--- /dev/null
+++ b/apps/web/src/actions/keybindings/migrations/index.ts
@@ -0,0 +1,32 @@
+import { v2ToV3 } from "./v2-to-v3";
+import { v3ToV4 } from "./v3-to-v4";
+import { v4ToV5 } from "./v4-to-v5";
+import { v5ToV6 } from "./v5-to-v6";
+import { v6ToV7 } from "./v6-to-v7";
+
+type MigrationFn = ({ state }: { state: unknown }) => unknown;
+
+const migrations: Record<number, MigrationFn> = {
+ 2: v2ToV3,
+ 3: v3ToV4,
+ 4: v4ToV5,
+ 5: v5ToV6,
+ 6: v6ToV7,
+};
+
+export const CURRENT_VERSION = 7;
+
+export function runMigrations({
+ state,
+ fromVersion,
+}: {
+ state: unknown;
+ fromVersion: number;
+}): unknown {
+ let current = state;
+ for (let version = fromVersion; version < CURRENT_VERSION; version++) {
+ const migrate = migrations[version];
+ if (migrate) current = migrate({ state: current });
+ }
+ return current;
+}
diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
new file mode 100644
index 00000000..0171c5a5
--- /dev/null
+++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
@@ -0,0 +1,26 @@
+import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
+import type { TActionWithOptionalArgs } from "@/actions";
+
+interface V2State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v2ToV3({ state }: { state: unknown }): unknown {
+ const v2 = state as V2State;
+
+ const renames: Record<string, string> = {
+ "split-selected": "split",
+ "split-selected-left": "split-left",
+ "split-selected-right": "split-right",
+ };
+
+ const migrated = { ...v2.keybindings };
+ for (const [key, action] of Object.entries(migrated)) {
+ if (action && renames[action]) {
+ migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ }
+ }
+
+ return { ...v2, keybindings: migrated };
+}
diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
new file mode 100644
index 00000000..8541071b
--- /dev/null
+++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
@@ -0,0 +1,25 @@
+import type { TActionWithOptionalArgs } from "@/actions";
+import type { ShortcutKey } from "@/actions/keybinding";
+import type { KeybindingConfig } from "@/actions/keybinding";
+
+interface V3State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v3ToV4({ state }: { state: unknown }): unknown {
+ const v3 = state as V3State;
+
+ const renames: Record<string, string> = {
+ "paste-selected": "paste-copied",
+ };
+
+ const migrated = { ...v3.keybindings };
+ for (const [key, action] of Object.entries(migrated)) {
+ if (action && renames[action]) {
+ migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ }
+ }
+
+ return { ...v3, keybindings: migrated };
+}
diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
new file mode 100644
index 00000000..f6937c39
--- /dev/null
+++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
@@ -0,0 +1,17 @@
+import type { KeybindingConfig } from "@/actions/keybinding";
+
+interface V4State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v4ToV5({ state }: { state: unknown }): unknown {
+ const v4 = state as V4State;
+ const keybindings = { ...v4.keybindings };
+
+ if (!keybindings.escape) {
+ keybindings.escape = "deselect-all";
+ }
+
+ return { ...v4, keybindings };
+}
diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
new file mode 100644
index 00000000..09b44307
--- /dev/null
+++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
@@ -0,0 +1,17 @@
+import type { KeybindingConfig } from "@/actions/keybinding";
+
+interface V5State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v5ToV6({ state }: { state: unknown }): unknown {
+ const v5 = state as V5State;
+ const keybindings = { ...v5.keybindings };
+
+ if (keybindings.escape === "deselect-all") {
+ keybindings.escape = "cancel-interaction";
+ }
+
+ return { ...v5, keybindings };
+}
diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
new file mode 100644
index 00000000..46a99ac1
--- /dev/null
+++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
@@ -0,0 +1,19 @@
+import type { KeybindingConfig } from "@/actions/keybinding";
+
+interface V6State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v6ToV7({ state }: { state: unknown }): unknown {
+ const v6 = state as V6State;
+ const keybindings = { ...v6.keybindings };
+
+ for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
+ if (keybindings[key] === ("split-element" as never)) {
+ keybindings[key] = "split";
+ }
+ }
+
+ return { ...v6, keybindings };
+}
diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts
new file mode 100644
index 00000000..e69b3b9f
--- /dev/null
+++ b/apps/web/src/actions/registry.ts
@@ -0,0 +1,63 @@
+import type {
+ TAction,
+ TActionFunc,
+ TActionWithArgs,
+ TActionWithOptionalArgs,
+ TActionArgsMap,
+ TArgOfAction,
+ TInvocationTrigger,
+} from "./types";
+
+type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
+const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
+
+export function bindAction<A extends TAction>(
+ action: A,
+ handler: TActionFunc<A>,
+) {
+ const handlers = boundActions[action];
+ const typedHandler = handler as ActionHandler;
+ if (handlers) {
+ handlers.push(typedHandler);
+ } else {
+ boundActions[action] = [typedHandler];
+ }
+}
+
+export function unbindAction<A extends TAction>(
+ action: A,
+ handler: TActionFunc<A>,
+) {
+ const handlers = boundActions[action];
+ if (!handlers) return;
+
+ const typedHandler = handler as ActionHandler;
+ boundActions[action] = handlers.filter((h) => h !== typedHandler);
+
+ if (boundActions[action]?.length === 0) {
+ delete boundActions[action];
+ }
+}
+
+type InvokeActionFunc = {
+ (
+ action: TActionWithOptionalArgs,
+ args?: undefined,
+ trigger?: TInvocationTrigger,
+ ): void;
+ <A extends TActionWithArgs>(
+ action: A,
+ args: TActionArgsMap[A],
+ trigger?: TInvocationTrigger,
+ ): void;
+};
+
+export const invokeAction: InvokeActionFunc = <A extends TAction>(
+ action: A,
+ args?: TArgOfAction<A>,
+ trigger?: TInvocationTrigger,
+) => {
+ boundActions[action]?.forEach((handler) => {
+ handler(args, trigger);
+ });
+};
diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts
new file mode 100644
index 00000000..41258c7b
--- /dev/null
+++ b/apps/web/src/actions/types.ts
@@ -0,0 +1,44 @@
+import type { MutableRefObject } from "react";
+import type { TAction } from "./definitions";
+
+export type { TAction };
+
+export type TActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined;
+ "seek-backward": { seconds: number } | undefined;
+ "jump-forward": { seconds: number } | undefined;
+ "jump-backward": { seconds: number } | undefined;
+ "remove-media-asset": { projectId: string; assetId: string };
+ "remove-media-assets": { projectId: string; assetIds: string[] };
+};
+
+type TKeysWithValueUndefined<T> = {
+ [K in keyof T]: undefined extends T[K] ? K : never;
+}[keyof T];
+
+export type TActionWithArgs = keyof TActionArgsMap;
+
+export type TActionWithOptionalArgs =
+ | TActionWithNoArgs
+ | TKeysWithValueUndefined<TActionArgsMap>;
+
+export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
+
+export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
+ ? TActionArgsMap[A]
+ : undefined;
+
+export type TActionFunc<A extends TAction> = A extends TActionWithArgs
+ ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
+ : (_?: undefined, trigger?: TInvocationTrigger) => void;
+
+export type TInvocationTrigger = "keypress" | "mouseclick";
+
+export type TBoundActionList = {
+ [A in TAction]?: Array<TActionFunc<A>>;
+};
+
+export type TActionHandlerOptions =
+ | MutableRefObject<boolean>
+ | boolean
+ | undefined;
diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts
new file mode 100644
index 00000000..a8bb0054
--- /dev/null
+++ b/apps/web/src/actions/use-action-handler.ts
@@ -0,0 +1,52 @@
+import { useCallback, useEffect, useRef } from "react";
+import type {
+ TAction,
+ TActionFunc,
+ TActionHandlerOptions,
+ TArgOfAction,
+ TInvocationTrigger,
+} from "@/actions";
+import { bindAction, unbindAction } from "@/actions";
+
+export function useActionHandler<A extends TAction>(
+ action: A,
+ handler: TActionFunc<A>,
+ isActive: TActionHandlerOptions,
+) {
+ const handlerRef = useRef<TActionFunc<A>>(handler);
+ const isBoundRef = useRef(false);
+
+ useEffect(() => {
+ handlerRef.current = handler;
+ }, [handler]);
+
+ const stableHandler = useCallback(
+ (...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => {
+ (
+ handlerRef.current as (
+ ...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?]
+ ) => void
+ )(...parameters);
+ },
+ [],
+ ) as TActionFunc<A>;
+
+ useEffect(() => {
+ const shouldBind =
+ isActive === undefined ||
+ (typeof isActive === "boolean" ? isActive : isActive.current);
+
+ if (shouldBind && !isBoundRef.current) {
+ bindAction(action, stableHandler);
+ isBoundRef.current = true;
+ } else if (!shouldBind && isBoundRef.current) {
+ unbindAction(action, stableHandler);
+ isBoundRef.current = false;
+ }
+
+ return () => {
+ unbindAction(action, stableHandler);
+ isBoundRef.current = false;
+ };
+ }, [action, stableHandler, isActive]);
+}
diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts
new file mode 100644
index 00000000..e62a8ce0
--- /dev/null
+++ b/apps/web/src/actions/use-editor-actions.ts
@@ -0,0 +1,488 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import { useActionHandler } from "@/actions/use-action-handler";
+import { useEditor } from "@/editor/use-editor";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
+import { getElementsAtTime, hasMediaId } from "@/timeline";
+import { cancelInteraction } from "@/editor/cancel-interaction";
+import { invokeAction } from "@/actions";
+import { canToggleSourceAudio } from "@/timeline/audio-separation";
+import {
+ activateScope,
+ clearActiveScope,
+ type ScopeEntry,
+} from "@/selection/scope";
+
+export function useEditorActions() {
+ const editor = useEditor();
+ const { selectedElements, setElementSelection } = useElementSelection();
+ const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
+ const selectedMaskPointSelection = useEditor((e) =>
+ e.selection.getSelectedMaskPointSelection(),
+ );
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
+ const hasTimelineSelectionRef = useRef(false);
+ const clearTimelineSelectionRef = useRef(() => {});
+ const clearTimelineActiveSelectionRef = useRef(() => {});
+ const timelineScopeRef = useRef<ScopeEntry | null>(null);
+ const hasTimelineSelection =
+ selectedElements.length > 0 ||
+ selectedKeyframes.length > 0 ||
+ selectedMaskPointSelection !== null;
+
+ hasTimelineSelectionRef.current = hasTimelineSelection;
+ clearTimelineSelectionRef.current = () => {
+ editor.selection.clearSelection();
+ };
+ clearTimelineActiveSelectionRef.current = () => {
+ editor.selection.clearMostSpecificSelection();
+ };
+
+ if (!timelineScopeRef.current) {
+ timelineScopeRef.current = {
+ hasSelection: () => hasTimelineSelectionRef.current,
+ clear: () => {
+ clearTimelineSelectionRef.current();
+ },
+ clearActive: () => {
+ clearTimelineActiveSelectionRef.current();
+ },
+ };
+ }
+
+ useEffect(() => {
+ if (!hasTimelineSelection) {
+ return;
+ }
+
+ const timelineScope = timelineScopeRef.current;
+ if (!timelineScope) {
+ return;
+ }
+
+ return activateScope({ entry: timelineScope });
+ }, [hasTimelineSelection]);
+
+ useActionHandler(
+ "toggle-play",
+ () => {
+ editor.playback.toggle();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "stop-playback",
+ () => {
+ if (editor.playback.getIsPlaying()) {
+ editor.playback.toggle();
+ }
+ editor.playback.seek({ time: 0 });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "seek-forward",
+ (args) => {
+ const seconds = args?.seconds ?? 1;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + seconds,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "seek-backward",
+ (args) => {
+ const seconds = args?.seconds ?? 1;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "frame-step-forward",
+ () => {
+ const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + ticksPerFrame,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "frame-step-backward",
+ () => {
+ const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "jump-forward",
+ (args) => {
+ const seconds = args?.seconds ?? 5;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + seconds,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "jump-backward",
+ (args) => {
+ const seconds = args?.seconds ?? 5;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "goto-start",
+ () => {
+ editor.playback.seek({ time: 0 });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "goto-end",
+ () => {
+ editor.playback.seek({ time: editor.timeline.getTotalDuration() });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks,
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split-left",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks,
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ const rightSideElements = editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ retainSide: "right",
+ });
+
+ if (rippleEditingEnabled && rightSideElements.length > 0) {
+ const firstRightElement = editor.timeline.getElementsWithTracks({
+ elements: [rightSideElements[0]],
+ })[0];
+ if (firstRightElement) {
+ editor.playback.seek({ time: firstRightElement.element.startTime });
+ }
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split-right",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks,
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ retainSide: "left",
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "delete-selected",
+ () => {
+ switch (editor.selection.getActiveSelectionKind()) {
+ case "mask-points":
+ if (!selectedMaskPointSelection) {
+ return;
+ }
+ editor.timeline.deleteCustomMaskPoints({
+ trackId: selectedMaskPointSelection.trackId,
+ elementId: selectedMaskPointSelection.elementId,
+ maskId: selectedMaskPointSelection.maskId,
+ pointIds: selectedMaskPointSelection.pointIds,
+ });
+ return;
+ case "keyframes":
+ if (selectedKeyframes.length === 0) {
+ return;
+ }
+ editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
+ clearKeyframeSelection();
+ return;
+ case "elements":
+ if (selectedElements.length === 0) {
+ return;
+ }
+ editor.timeline.deleteElements({
+ elements: selectedElements,
+ });
+ return;
+ default:
+ return;
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-source-audio",
+ () => {
+ if (selectedElements.length !== 1) {
+ return;
+ }
+
+ const selectedElement = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ })[0];
+ if (!selectedElement) {
+ return;
+ }
+
+ const mediaAsset = (() => {
+ const { element } = selectedElement;
+ if (!hasMediaId(element)) {
+ return null;
+ }
+
+ return (
+ editor.media
+ .getAssets()
+ .find((asset) => asset.id === element.mediaId) ?? null
+ );
+ })();
+ if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
+ return;
+ }
+
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId: selectedElement.track.id,
+ elementId: selectedElement.element.id,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "select-all",
+ () => {
+ const scene = editor.scenes.getActiveScene();
+ const allElements = [
+ ...scene.tracks.overlay,
+ scene.tracks.main,
+ ...scene.tracks.audio,
+ ].flatMap((track) =>
+ track.elements.map((element) => ({
+ trackId: track.id,
+ elementId: element.id,
+ })),
+ );
+ setElementSelection({ elements: allElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "cancel-interaction",
+ () => {
+ if (!cancelInteraction()) {
+ invokeAction("deselect-all");
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "deselect-all",
+ () => {
+ if (!clearActiveScope()) {
+ editor.selection.clearMostSpecificSelection();
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "duplicate-selected",
+ () => {
+ editor.timeline.duplicateElements({
+ elements: selectedElements,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-elements-muted-selected",
+ () => {
+ editor.timeline.toggleElementsMuted({ elements: selectedElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-elements-visibility-selected",
+ () => {
+ editor.timeline.toggleElementsVisibility({ elements: selectedElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-bookmark",
+ () => {
+ editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "copy-selected",
+ () => {
+ editor.clipboard.copy();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "paste-copied",
+ () => {
+ editor.clipboard.paste();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-snapping",
+ () => {
+ toggleSnapping();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-ripple-editing",
+ () => {
+ toggleRippleEditing();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "undo",
+ () => {
+ editor.command.undo();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "redo",
+ () => {
+ editor.command.redo();
+ },
+ undefined,
+ );
+
+ // todo: potnetially unify these two actions:
+ useActionHandler(
+ "remove-media-asset",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAsset({
+ projectId: args.projectId,
+ id: args.assetId,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "remove-media-assets",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAssets({
+ projectId: args.projectId,
+ ids: args.assetIds,
+ });
+ },
+ undefined,
+ );
+}
diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts
new file mode 100644
index 00000000..c129795c
--- /dev/null
+++ b/apps/web/src/actions/use-keybindings.ts
@@ -0,0 +1,87 @@
+import { useEffect } from "react";
+import { invokeAction } from "@/actions";
+import { useEditor } from "@/editor/use-editor";
+import { useKeybindingsStore } from "@/actions/keybindings-store";
+import { isTypableDOMElement } from "@/utils/browser";
+
+/**
+ * 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 editor = useEditor();
+ const {
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ } = useKeybindingsStore();
+
+ useEffect(() => {
+ const eventOptions: AddEventListenerOptions = { capture: true };
+ const handleKeyDown = (ev: KeyboardEvent) => {
+ const normalizedKey = (ev.key ?? "").toLowerCase();
+
+ if (overlayDepth > 0 || isLoadingProject || isRecording) {
+ return;
+ }
+
+ const binding = getKeybindingString(ev);
+ const activeElement = document.activeElement;
+ const isTextInput =
+ activeElement instanceof HTMLElement &&
+ isTypableDOMElement({ element: activeElement });
+ const boundAction = binding ? keybindings[binding] : undefined;
+
+ if (normalizedKey === "escape" && isTextInput) {
+ activeElement.blur();
+ return;
+ }
+
+ if (!binding) return;
+ if (!boundAction) return;
+
+ if (isTextInput) return;
+ if (boundAction === "paste-copied") {
+ if (!editor.clipboard.hasEntry()) return;
+ ev.preventDefault();
+ invokeAction("paste-copied", undefined, "keypress");
+ return;
+ }
+
+ ev.preventDefault();
+
+ switch (boundAction) {
+ case "seek-forward":
+ invokeAction("seek-forward", { seconds: 1 }, "keypress");
+ break;
+ case "seek-backward":
+ invokeAction("seek-backward", { seconds: 1 }, "keypress");
+ break;
+ case "jump-forward":
+ invokeAction("jump-forward", { seconds: 5 }, "keypress");
+ break;
+ case "jump-backward":
+ invokeAction("jump-backward", { seconds: 5 }, "keypress");
+ break;
+ default:
+ invokeAction(boundAction, undefined, "keypress");
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown, eventOptions);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown, eventOptions);
+ };
+ }, [
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ editor,
+ ]);
+}
diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts
new file mode 100644
index 00000000..7681e664
--- /dev/null
+++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts
@@ -0,0 +1,83 @@
+"use client";
+
+import { useMemo } from "react";
+import { useKeybindingsStore } from "@/actions/keybindings-store";
+import { ACTIONS, type TActionWithOptionalArgs } from "@/actions";
+import {
+ getPlatformAlternateKey,
+ getPlatformSpecialKey,
+} from "@/utils/platform";
+
+export interface KeyboardShortcut {
+ id: string;
+ keys: string[];
+ description: string;
+ category: string;
+ action: TActionWithOptionalArgs;
+ icon?: React.ReactNode;
+}
+
+function formatKey({ key }: { key: string }): string {
+ return key
+ .replace("ctrl", getPlatformSpecialKey())
+ .replace("alt", getPlatformAlternateKey())
+ .replace("shift", "Shift")
+ .replace("left", "←")
+ .replace("right", "→")
+ .replace("up", "↑")
+ .replace("down", "↓")
+ .replace("space", "Space")
+ .replace("home", "Home")
+ .replace("enter", "Enter")
+ .replace("end", "End")
+ .replace("delete", "Delete")
+ .replace("backspace", "Backspace")
+ .replace("-", "+");
+}
+
+export function useKeyboardShortcutsHelp() {
+ const { keybindings } = useKeybindingsStore();
+
+ const shortcuts = useMemo(() => {
+ const result: KeyboardShortcut[] = [];
+ const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
+
+ for (const [key, action] of Object.entries(keybindings) as Array<
+ [string, TActionWithOptionalArgs | undefined]
+ >) {
+ if (action) {
+ if (!actionToKeys[action]) {
+ actionToKeys[action] = [];
+ }
+ actionToKeys[action].push(formatKey({ key }));
+ }
+ }
+
+ for (const [action, keys] of Object.entries(actionToKeys) as Array<
+ [TActionWithOptionalArgs, string[]]
+ >) {
+ const actionDef = ACTIONS[action];
+ if (!actionDef) {
+ continue;
+ }
+ result.push({
+ id: action,
+ keys,
+ description: actionDef.description,
+ category: actionDef.category,
+ action,
+ });
+ }
+
+ return result.sort((a, b) => {
+ if (a.category !== b.category) {
+ return a.category.localeCompare(b.category);
+ }
+ return a.description.localeCompare(b.description);
+ });
+ }, [keybindings]);
+
+ return {
+ shortcuts,
+ };
+}