From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../src/actions/components/shortcuts-dialog.tsx | 229 ++++++++++ apps/web/src/actions/definitions.ts | 209 +++++++++ apps/web/src/actions/index.ts | 3 + apps/web/src/actions/keybinding.ts | 79 ++++ apps/web/src/actions/keybindings-store.ts | 251 +++++++++++ .../src/actions/keybindings/migrations/index.ts | 32 ++ .../src/actions/keybindings/migrations/v2-to-v3.ts | 26 ++ .../src/actions/keybindings/migrations/v3-to-v4.ts | 25 ++ .../src/actions/keybindings/migrations/v4-to-v5.ts | 17 + .../src/actions/keybindings/migrations/v5-to-v6.ts | 17 + .../src/actions/keybindings/migrations/v6-to-v7.ts | 19 + apps/web/src/actions/registry.ts | 63 +++ apps/web/src/actions/types.ts | 44 ++ apps/web/src/actions/use-action-handler.ts | 52 +++ apps/web/src/actions/use-editor-actions.ts | 488 +++++++++++++++++++++ apps/web/src/actions/use-keybindings.ts | 87 ++++ .../web/src/actions/use-keyboard-shortcuts-help.ts | 83 ++++ 17 files changed, 1724 insertions(+) create mode 100644 apps/web/src/actions/components/shortcuts-dialog.tsx create mode 100644 apps/web/src/actions/definitions.ts create mode 100644 apps/web/src/actions/index.ts create mode 100644 apps/web/src/actions/keybinding.ts create mode 100644 apps/web/src/actions/keybindings-store.ts create mode 100644 apps/web/src/actions/keybindings/migrations/index.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v2-to-v3.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v3-to-v4.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v4-to-v5.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v5-to-v6.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v6-to-v7.ts create mode 100644 apps/web/src/actions/registry.ts create mode 100644 apps/web/src/actions/types.ts create mode 100644 apps/web/src/actions/use-action-handler.ts create mode 100644 apps/web/src/actions/use-editor-actions.ts create mode 100644 apps/web/src/actions/use-keybindings.ts create mode 100644 apps/web/src/actions/use-keyboard-shortcuts-help.ts (limited to 'apps/web/src/actions') 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(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 ( + + + + Keyboard shortcuts + + + +
+ {categories.map((category) => ( +
+

+ {category} +

+
+ {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut) => ( + handleStartRecording(shortcut)} + /> + ))} +
+
+ ))} +
+
+ + + +
+
+ ); +} + +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 ( +
+
+ {shortcut.icon && ( +
{shortcut.icon}
+ )} + {shortcut.description} +
+
+ {displayKeys.map((key: string, index: number) => ( +
+
+ {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + onStartRecording({ shortcut })} + > + {keyPart} + + ); + })} +
+ {index < displayKeys.length - 1 && ( + or + )} +
+ ))} +
+
+ ); +} + +function EditableShortcutKey({ + children, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + isRecording: boolean; + onStartRecording: () => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; + + return ( + + ); +} 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; +} + +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; + +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>; + +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< + Record +> = 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()( + 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 = { + 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 = { + "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 = { + "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) { + 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> = {}; + +export function bindAction( + action: A, + handler: TActionFunc, +) { + const handlers = boundActions[action]; + const typedHandler = handler as ActionHandler; + if (handlers) { + handlers.push(typedHandler); + } else { + boundActions[action] = [typedHandler]; + } +} + +export function unbindAction( + action: A, + handler: TActionFunc, +) { + 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; + ( + action: A, + args: TActionArgsMap[A], + trigger?: TInvocationTrigger, + ): void; +}; + +export const invokeAction: InvokeActionFunc = ( + action: A, + args?: TArgOfAction, + 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 = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined; + +export type TActionWithNoArgs = Exclude; + +export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array>; +}; + +export type TActionHandlerOptions = + | MutableRefObject + | 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( + action: A, + handler: TActionFunc, + isActive: TActionHandlerOptions, +) { + const handlerRef = useRef>(handler); + const isBoundRef = useRef(false); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + const stableHandler = useCallback( + (...parameters: [TArgOfAction, TInvocationTrigger?]) => { + ( + handlerRef.current as ( + ...handlerParameters: [TArgOfAction, TInvocationTrigger?] + ) => void + )(...parameters); + }, + [], + ) as TActionFunc; + + 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(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> = {}; + + 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, + }; +} -- cgit v1.3.1