diff options
Diffstat (limited to 'apps/web/src/actions')
| -rw-r--r-- | apps/web/src/actions/components/shortcuts-dialog.tsx | 229 | ||||
| -rw-r--r-- | apps/web/src/actions/definitions.ts | 209 | ||||
| -rw-r--r-- | apps/web/src/actions/index.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/actions/keybinding.ts | 79 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings-store.ts | 251 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings/migrations/index.ts | 32 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings/migrations/v2-to-v3.ts | 26 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings/migrations/v3-to-v4.ts | 25 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings/migrations/v4-to-v5.ts | 17 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings/migrations/v5-to-v6.ts | 17 | ||||
| -rw-r--r-- | apps/web/src/actions/keybindings/migrations/v6-to-v7.ts | 19 | ||||
| -rw-r--r-- | apps/web/src/actions/registry.ts | 63 | ||||
| -rw-r--r-- | apps/web/src/actions/types.ts | 44 | ||||
| -rw-r--r-- | apps/web/src/actions/use-action-handler.ts | 52 | ||||
| -rw-r--r-- | apps/web/src/actions/use-editor-actions.ts | 488 | ||||
| -rw-r--r-- | apps/web/src/actions/use-keybindings.ts | 87 | ||||
| -rw-r--r-- | apps/web/src/actions/use-keyboard-shortcuts-help.ts | 83 |
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, + }; +} |
