summaryrefslogtreecommitdiff
path: root/apps/web/src/actions
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 00:37:56 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commita9b9cf6bf50f08723896659634f2e6afc17bcd42 (patch)
tree9175b49ff21f4326872ac9e541d2765cc7994ebc /apps/web/src/actions
parentd6622dc6b3523a5658cd512b44f15f7a66fd540c (diff)
downloadopencut-a9b9cf6bf50f08723896659634f2e6afc17bcd42.tar.gz
chore: switch from biome to eslint + prettier; fix ton of lint issues
Diffstat (limited to 'apps/web/src/actions')
-rw-r--r--apps/web/src/actions/components/shortcuts-dialog.tsx13
-rw-r--r--apps/web/src/actions/definitions.ts75
-rw-r--r--apps/web/src/actions/keybinding.ts122
-rw-r--r--apps/web/src/actions/keybindings-store.ts195
-rw-r--r--apps/web/src/actions/keybindings/__tests__/persistence.test.ts144
-rw-r--r--apps/web/src/actions/keybindings/migrations/v2-to-v3.ts16
-rw-r--r--apps/web/src/actions/keybindings/migrations/v3-to-v4.ts17
-rw-r--r--apps/web/src/actions/keybindings/migrations/v4-to-v5.ts10
-rw-r--r--apps/web/src/actions/keybindings/migrations/v5-to-v6.ts10
-rw-r--r--apps/web/src/actions/keybindings/migrations/v6-to-v7.ts14
-rw-r--r--apps/web/src/actions/keybindings/persisted-state.ts37
-rw-r--r--apps/web/src/actions/keybindings/persistence.ts119
-rw-r--r--apps/web/src/actions/registry.ts3
-rw-r--r--apps/web/src/actions/types.ts88
-rw-r--r--apps/web/src/actions/use-action-handler.ts1
-rw-r--r--apps/web/src/actions/use-editor-actions.ts46
-rw-r--r--apps/web/src/actions/use-keybindings.ts2
-rw-r--r--apps/web/src/actions/use-keyboard-shortcuts-help.ts26
18 files changed, 597 insertions, 341 deletions
diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx
index cd9c0242..7f7d8a87 100644
--- a/apps/web/src/actions/components/shortcuts-dialog.tsx
+++ b/apps/web/src/actions/components/shortcuts-dialog.tsx
@@ -51,10 +51,10 @@ export function ShortcutsDialog({
const keyString = getKeybindingString(e);
if (keyString) {
- const conflict = validateKeybinding(
- keyString,
- recordingShortcut.action,
- );
+ const conflict = validateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({
removeKeybinding(key);
}
- updateKeybinding(keyString, recordingShortcut.action);
+ updateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
setIsRecording(false);
setRecordingShortcut(null);
diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts
index 6f0ba700..986510ec 100644
--- a/apps/web/src/actions/definitions.ts
+++ b/apps/web/src/actions/definitions.ts
@@ -1,7 +1,4 @@
-import type {
- KeybindingConfig,
- ShortcutKey,
-} from "@/actions/keybinding";
+import type { ShortcutKey } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory =
@@ -155,33 +152,36 @@ export const ACTIONS = {
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 = [
+ ["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 ReadonlyArray<
+ readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
+>;
-const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
- Record<TAction, readonly ShortcutKey[]>
-> = ACTION_DEFAULT_SHORTCUTS;
+const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
+ TAction,
+ readonly ShortcutKey[]
+>(ACTION_DEFAULT_SHORTCUTS);
export function getActionDefinition({
action,
@@ -190,18 +190,19 @@ export function getActionDefinition({
}): TActionDefinition {
return {
...ACTIONS[action],
- defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
+ defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
};
}
-export function getDefaultShortcuts(): KeybindingConfig {
- const shortcuts: KeybindingConfig = {};
+export function getDefaultShortcuts(): Map<
+ ShortcutKey,
+ TActionWithOptionalArgs
+> {
+ const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
- for (const [action, defaultShortcuts] of Object.entries(
- ACTION_DEFAULT_SHORTCUTS,
- ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
+ for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
for (const shortcut of defaultShortcuts) {
- shortcuts[shortcut] = action;
+ shortcuts.set(shortcut, action);
}
}
diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts
index 65ef4538..aeec2fd6 100644
--- a/apps/web/src/actions/keybinding.ts
+++ b/apps/web/src/actions/keybinding.ts
@@ -1,79 +1,43 @@
-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;
-};
+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";
+
+const KEYS = [
+ "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",
+] as const;
+
+export type Key = (typeof KEYS)[number];
+
+const KEY_SET: ReadonlySet<string> = new Set(KEYS);
+
+export function isKey(value: string): value is Key {
+ return KEY_SET.has(value);
+}
+
+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
index c0a1a5e2..8f344487 100644
--- a/apps/web/src/actions/keybindings-store.ts
+++ b/apps/web/src/actions/keybindings-store.ts
@@ -6,11 +6,15 @@ 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 type {
+ Key,
+ KeybindingConfig,
+ ModifierKeys,
+ ShortcutKey,
+} from "@/actions/keybinding";
+import { isKey } from "@/actions/keybinding";
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
-const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
-
export interface KeybindingConflict {
key: ShortcutKey;
existingAction: TActionWithOptionalArgs;
@@ -18,38 +22,57 @@ export interface KeybindingConflict {
}
interface KeybindingsState {
- keybindings: KeybindingConfig;
+ keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
isCustomized: boolean;
overlayDepth: number;
openOverlayIds: string[];
isLoadingProject: boolean;
isRecording: boolean;
- updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
+ updateKeybinding: (params: {
+ key: ShortcutKey;
+ action: TActionWithOptionalArgs;
+ }) => void;
removeKeybinding: (key: ShortcutKey) => void;
resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void;
- exportKeybindings: () => KeybindingConfig;
+ exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
openOverlay: (overlayId: string) => void;
closeOverlay: (overlayId: string) => void;
setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void;
- validateKeybinding: (
- key: ShortcutKey,
- action: TActionWithOptionalArgs,
- ) => KeybindingConflict | null;
+ validateKeybinding: (params: {
+ key: ShortcutKey;
+ action: TActionWithOptionalArgs;
+ }) => KeybindingConflict | null;
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
}
+type PersistedState = {
+ keybindings: Record<string, TActionWithOptionalArgs>;
+ isCustomized: boolean;
+};
+
function isDOMElement(element: EventTarget | null): element is HTMLElement {
return element instanceof HTMLElement;
}
+function isPersistedState(value: unknown): value is PersistedState {
+ if (!value || typeof value !== "object") return false;
+ if (!("keybindings" in value) || !("isCustomized" in value)) return false;
+ const { keybindings, isCustomized } = value;
+ return (
+ typeof keybindings === "object" &&
+ keybindings !== null &&
+ typeof isCustomized === "boolean"
+ );
+}
+
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set, get) => ({
- keybindings: { ...defaultKeybindings },
+ keybindings: getDefaultShortcuts(),
isCustomized: false,
overlayDepth: 0,
openOverlayIds: [],
@@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.includes(overlayId)
? s.openOverlayIds
: [...s.openOverlayIds, overlayId];
- const nextOverlayDepth = openOverlayIds.length;
return {
openOverlayIds,
- overlayDepth: nextOverlayDepth,
+ overlayDepth: openOverlayIds.length,
};
}),
closeOverlay: (overlayId) =>
@@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.filter(
(id) => id !== overlayId,
);
- const nextOverlayDepth = openOverlayIds.length;
return {
openOverlayIds,
- overlayDepth: nextOverlayDepth,
+ overlayDepth: openOverlayIds.length,
};
}),
setLoadingProject: (loading) => {
set({ isLoadingProject: loading });
},
- updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
+ updateKeybinding: ({ key, action }) => {
set((state) => {
- const newKeybindings = { ...state.keybindings };
- newKeybindings[key] = action;
-
+ const next = new Map(state.keybindings);
+ next.set(key, action);
return {
- keybindings: newKeybindings,
+ keybindings: next,
isCustomized: true,
};
});
},
- removeKeybinding: (key: ShortcutKey) => {
+ removeKeybinding: (key) => {
set((state) => {
- const newKeybindings = { ...state.keybindings };
- delete newKeybindings[key];
-
+ const next = new Map(state.keybindings);
+ next.delete(key);
return {
- keybindings: newKeybindings,
+ keybindings: next,
isCustomized: true,
};
});
@@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()(
resetToDefaults: () => {
set({
- keybindings: { ...defaultKeybindings },
+ keybindings: getDefaultShortcuts(),
isCustomized: false,
});
},
- importKeybindings: (config: KeybindingConfig) => {
- for (const [key] of Object.entries(config)) {
+ importKeybindings: (config) => {
+ const next = new Map<ShortcutKey, TActionWithOptionalArgs>();
+ for (const [key, action] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`);
}
+ if (action !== undefined) {
+ // Public type's keys are `ShortcutKey`; trust the caller's typing.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ next.set(key as ShortcutKey, action);
+ }
}
set({
- keybindings: { ...config },
+ keybindings: next,
isCustomized: true,
});
},
exportKeybindings: () => {
- return get().keybindings;
+ return Object.fromEntries(get().keybindings);
},
- validateKeybinding: (
- key: ShortcutKey,
- action: TActionWithOptionalArgs,
- ) => {
- const { keybindings } = get();
- const existingAction = keybindings[key];
-
+ validateKeybinding: ({ key, action }) => {
+ const existingAction = get().keybindings.get(key);
if (existingAction && existingAction !== action) {
return {
key,
@@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()(
newAction: action,
};
}
-
return null;
},
- setIsRecording: (isRecording: boolean) => {
+ setIsRecording: (isRecording) => {
set({ isRecording });
},
- getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
- const { keybindings } = get();
- return Object.keys(keybindings).filter(
- (key) => keybindings[key as ShortcutKey] === action,
- ) as ShortcutKey[];
+ getKeybindingsForAction: (action) => {
+ const result: ShortcutKey[] = [];
+ for (const [key, mapped] of get().keybindings) {
+ if (mapped === action) result.push(key);
+ }
+ return result;
},
- getKeybindingString: (ev: KeyboardEvent) => {
- return generateKeybindingString(ev) as ShortcutKey | null;
- },
+ getKeybindingString: (ev) => generateKeybindingString(ev),
}),
{
name: "opencut-keybindings",
version: CURRENT_VERSION,
- partialize: (state) => ({
- keybindings: state.keybindings,
+ partialize: (state): PersistedState => ({
+ keybindings: Object.fromEntries(state.keybindings),
isCustomized: state.isCustomized,
}),
migrate: (persisted, version) =>
runMigrations({ state: persisted, fromVersion: version }),
+ merge: (persisted, current) => {
+ if (!isPersistedState(persisted)) return current;
+ const entries = Object.entries(persisted.keybindings);
+ // Persistence boundary: keys are normalized by the migration chain.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const typedEntries = entries as Array<
+ [ShortcutKey, TActionWithOptionalArgs]
+ >;
+ return {
+ ...current,
+ keybindings: new Map(typedEntries),
+ isCustomized: persisted.isCustomized,
+ };
+ },
},
),
);
@@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
if (
modifierKey === "shift" &&
isDOMElement(target) &&
- isTypableDOMElement({ element: target as HTMLElement })
+ isTypableDOMElement({ element: target })
) {
return null;
}
- return `${modifierKey}+${key}` as ShortcutKey;
+ return `${modifierKey}+${key}`;
}
- if (
- isDOMElement(target) &&
- isTypableDOMElement({ element: target as HTMLElement })
- )
+ if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
return null;
+ }
- return `${key}` as ShortcutKey;
+ return key;
}
-function getPressedKey(ev: KeyboardEvent): string | null {
- const key = (ev.key ?? "").toLowerCase();
+function getPressedKey(ev: KeyboardEvent): Key | null {
+ const raw = (ev.key ?? "").toLowerCase();
const code = ev.code ?? "";
- if (code === "Space" || key === " " || key === "spacebar" || key === "space")
+ if (code === "Space" || raw === " " || raw === "spacebar" || raw === "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 (raw === "arrowup") return "up";
+ if (raw === "arrowdown") return "down";
+ if (raw === "arrowleft") return "left";
+ if (raw === "arrowright") return "right";
if (code.startsWith("Key")) {
const letter = code.slice(3).toLowerCase();
- if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
+ if (isKey(letter)) return letter;
}
- // Use physical key position for AZERTY and other non-QWERTY layouts
+ // 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;
+ if (isKey(digit)) return digit;
}
- const isDigit = key.length === 1 && key >= "0" && key <= "9";
- if (isDigit) return key;
-
- if (key === "/" || key === "." || key === "enter") return key;
-
+ if (isKey(raw)) return raw;
return null;
}
-function getActiveModifier(ev: KeyboardEvent): string | null {
- const modifierKeys = {
- ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
- alt: ev.altKey,
- shift: ev.shiftKey,
- };
+function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
+ const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
+ const alt = ev.altKey;
+ const shift = ev.shiftKey;
- const activeModifier = Object.keys(modifierKeys)
- .filter((key) => modifierKeys[key as keyof typeof modifierKeys])
- .join("+");
-
- return activeModifier === "" ? null : activeModifier;
+ if (ctrl && alt && shift) return "ctrl+alt+shift";
+ if (ctrl && alt) return "ctrl+alt";
+ if (ctrl && shift) return "ctrl+shift";
+ if (alt && shift) return "alt+shift";
+ if (ctrl) return "ctrl";
+ if (alt) return "alt";
+ if (shift) return "shift";
+ return null;
}
diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts
new file mode 100644
index 00000000..bcccc6a4
--- /dev/null
+++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts
@@ -0,0 +1,144 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ mock,
+ test,
+} from "bun:test";
+import {
+ decodePersistedKeybindingsState,
+ migratePersistedKeybindingsState,
+ parseImportedKeybindings,
+ serializeKeybindingsState,
+} from "../persistence";
+
+describe("keybinding persistence", () => {
+ let warnSpy: ReturnType<typeof mock>;
+ let originalWarn: typeof console.warn;
+
+ beforeEach(() => {
+ originalWarn = console.warn;
+ warnSpy = mock(() => {});
+ console.warn = warnSpy;
+ });
+
+ afterEach(() => {
+ console.warn = originalWarn;
+ });
+
+ test("migrates legacy persisted keybindings before decoding them", () => {
+ const migrated = migratePersistedKeybindingsState({
+ state: {
+ keybindings: {
+ s: "split-selected",
+ "ctrl+v": "paste-selected",
+ },
+ isCustomized: true,
+ },
+ fromVersion: 2,
+ });
+
+ const decoded = decodePersistedKeybindingsState({ state: migrated });
+ expect(decoded).not.toBeNull();
+ if (!decoded) throw new Error("Expected migrated keybindings to decode");
+
+ expect(decoded.isCustomized).toBe(true);
+ expect(decoded.keybindings.get("s")).toBe("split");
+ expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied");
+ expect(decoded.keybindings.get("escape")).toBe("cancel-interaction");
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ test("filters invalid persisted entries at the boundary and warns", () => {
+ const decoded = decodePersistedKeybindingsState({
+ state: {
+ keybindings: {
+ space: "toggle-play",
+ "shift+bogus": "toggle-play",
+ "ctrl+v": "not-an-action",
+ },
+ isCustomized: false,
+ },
+ });
+
+ expect(decoded).not.toBeNull();
+ if (!decoded) throw new Error("Expected persisted keybindings to decode");
+
+ expect(Array.from(decoded.keybindings.entries())).toEqual([
+ ["space", "toggle-play"],
+ ]);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("returns null and warns when persisted shape is unrecognizable", () => {
+ const decoded = decodePersistedKeybindingsState({ state: "garbage" });
+ expect(decoded).toBeNull();
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("round-trips actions that have no default shortcut", () => {
+ // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the
+ // defaults table; the validator must still accept it.
+ const serialized = serializeKeybindingsState({
+ keybindings: new Map([
+ ["space", "toggle-play"],
+ ["x", "stop-playback"],
+ ["b", "toggle-bookmark"],
+ ]),
+ isCustomized: true,
+ });
+
+ const decoded = decodePersistedKeybindingsState({ state: serialized });
+ expect(decoded).not.toBeNull();
+ if (!decoded) throw new Error("Expected round-tripped keybindings");
+
+ expect(decoded.keybindings.get("space")).toBe("toggle-play");
+ expect(decoded.keybindings.get("x")).toBe("stop-playback");
+ expect(decoded.keybindings.get("b")).toBe("toggle-bookmark");
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe("parseImportedKeybindings", () => {
+ test("accepts a valid configuration", () => {
+ const result = parseImportedKeybindings({
+ config: {
+ space: "toggle-play",
+ x: "stop-playback",
+ },
+ });
+
+ expect(result.get("space")).toBe("toggle-play");
+ expect(result.get("x")).toBe("stop-playback");
+ });
+
+ test("throws on non-object input", () => {
+ expect(() => parseImportedKeybindings({ config: null })).toThrow(
+ /JSON object/,
+ );
+ expect(() => parseImportedKeybindings({ config: [] })).toThrow(
+ /JSON object/,
+ );
+ });
+
+ test("throws on non-string action values", () => {
+ expect(() =>
+ parseImportedKeybindings({ config: { space: 42 } }),
+ ).toThrow(/expected string/);
+ });
+
+ test("throws on invalid shortcut keys", () => {
+ expect(() =>
+ parseImportedKeybindings({
+ config: { "shift+bogus": "toggle-play" },
+ }),
+ ).toThrow(/shift\+bogus/);
+ });
+
+ test("throws on invalid actions", () => {
+ expect(() =>
+ parseImportedKeybindings({ config: { space: "not-an-action" } }),
+ ).toThrow(/not-an-action/);
+ });
+});
diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
index 0171c5a5..81fd86a7 100644
--- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
+++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
@@ -1,13 +1,8 @@
-import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
-import type { TActionWithOptionalArgs } from "@/actions";
-
-interface V2State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v2ToV3({ state }: { state: unknown }): unknown {
- const v2 = state as V2State;
+ const v2 = getPersistedKeybindingsState({ state });
+ if (!v2) return state;
const renames: Record<string, string> = {
"split-selected": "split",
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown {
const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
index 8541071b..3724f61c 100644
--- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
+++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
@@ -1,14 +1,8 @@
-import type { TActionWithOptionalArgs } from "@/actions";
-import type { ShortcutKey } from "@/actions/keybinding";
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V3State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v3ToV4({ state }: { state: unknown }): unknown {
- const v3 = state as V3State;
+ const v3 = getPersistedKeybindingsState({ state });
+ if (!v3) return state;
const renames: Record<string, string> = {
"paste-selected": "paste-copied",
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown {
const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
index f6937c39..bb60b044 100644
--- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
+++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
@@ -1,12 +1,8 @@
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V4State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v4ToV5({ state }: { state: unknown }): unknown {
- const v4 = state as V4State;
+ const v4 = getPersistedKeybindingsState({ state });
+ if (!v4) return state;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
index 09b44307..cb572aac 100644
--- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
+++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
@@ -1,12 +1,8 @@
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V5State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v5ToV6({ state }: { state: unknown }): unknown {
- const v5 = state as V5State;
+ const v5 = getPersistedKeybindingsState({ state });
+ if (!v5) return state;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
index 46a99ac1..9c05db23 100644
--- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
+++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
@@ -1,16 +1,12 @@
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V6State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v6ToV7({ state }: { state: unknown }): unknown {
- const v6 = state as V6State;
+ const v6 = getPersistedKeybindingsState({ state });
+ if (!v6) return state;
const keybindings = { ...v6.keybindings };
- for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
- if (keybindings[key] === ("split-element" as never)) {
+ for (const [key, action] of Object.entries(keybindings)) {
+ if (action === "split-element") {
keybindings[key] = "split";
}
}
diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts
new file mode 100644
index 00000000..57d83f6d
--- /dev/null
+++ b/apps/web/src/actions/keybindings/persisted-state.ts
@@ -0,0 +1,37 @@
+export type PersistedKeybindingConfig = Record<string, string | undefined>;
+
+export interface PersistedKeybindingsState {
+ keybindings: PersistedKeybindingConfig;
+ isCustomized: boolean;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function getPersistedKeybindingsState({
+ state,
+}: {
+ state: unknown;
+}): PersistedKeybindingsState | null {
+ if (!isRecord(state)) return null;
+
+ const { keybindings, isCustomized } = state;
+ if (!isRecord(keybindings) || typeof isCustomized !== "boolean") {
+ return null;
+ }
+
+ const normalizedKeybindings: PersistedKeybindingConfig = {};
+ for (const [key, action] of Object.entries(keybindings)) {
+ if (action !== undefined && typeof action !== "string") {
+ return null;
+ }
+
+ normalizedKeybindings[key] = action;
+ }
+
+ return {
+ keybindings: normalizedKeybindings,
+ isCustomized,
+ };
+}
diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts
new file mode 100644
index 00000000..ced44bd0
--- /dev/null
+++ b/apps/web/src/actions/keybindings/persistence.ts
@@ -0,0 +1,119 @@
+import type { ShortcutKey } from "@/actions/keybinding";
+import { isShortcutKey } from "@/actions/keybinding";
+import type { TActionWithOptionalArgs } from "@/actions";
+import { isActionWithOptionalArgs } from "@/actions";
+import { runMigrations } from "./migrations";
+import {
+ getPersistedKeybindingsState,
+ type PersistedKeybindingsState,
+} from "./persisted-state";
+
+export interface DecodedKeybindingsState {
+ keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
+ isCustomized: boolean;
+}
+
+export function serializeKeybindingsState({
+ keybindings,
+ isCustomized,
+}: DecodedKeybindingsState): PersistedKeybindingsState {
+ return {
+ keybindings: Object.fromEntries(keybindings),
+ isCustomized,
+ };
+}
+
+export function migratePersistedKeybindingsState({
+ state,
+ fromVersion,
+}: {
+ state: unknown;
+ fromVersion: number;
+}): unknown {
+ return runMigrations({ state, fromVersion });
+}
+
+/**
+ * Decode a persisted/migrated keybindings blob into the in-memory shape.
+ *
+ * Lossy by design: invalid entries are dropped and a warning is emitted, so the
+ * user falls back to (mostly) sensible defaults instead of a broken store.
+ * Returns `null` if the top-level shape is unrecognizable, in which case the
+ * caller should keep its current state.
+ */
+export function decodePersistedKeybindingsState({
+ state,
+}: {
+ state: unknown;
+}): DecodedKeybindingsState | null {
+ const persisted = getPersistedKeybindingsState({ state });
+ if (!persisted) {
+ console.warn(
+ "[keybindings] Persisted state has unexpected shape; keeping current keybindings.",
+ state,
+ );
+ return null;
+ }
+
+ const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>();
+ const dropped: Array<{ key: string; action: string | undefined }> = [];
+ for (const [key, action] of Object.entries(persisted.keybindings)) {
+ if (action === undefined) continue;
+ if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) {
+ dropped.push({ key, action });
+ continue;
+ }
+
+ keybindings.set(key, action);
+ }
+
+ if (dropped.length > 0) {
+ console.warn(
+ "[keybindings] Dropped invalid persisted entries:",
+ dropped,
+ );
+ }
+
+ return {
+ keybindings,
+ isCustomized: persisted.isCustomized,
+ };
+}
+
+/**
+ * Parse a user-supplied keybindings configuration (typically the output of
+ * `JSON.parse` on an imported file).
+ *
+ * Strict by design: throws on the first invalid entry so the caller can surface
+ * the failure to the user instead of silently producing a half-applied import.
+ * Accepts `unknown` because the input has already crossed a trust boundary.
+ */
+export function parseImportedKeybindings({
+ config,
+}: {
+ config: unknown;
+}): Map<ShortcutKey, TActionWithOptionalArgs> {
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
+ throw new Error("Imported keybindings must be a JSON object");
+ }
+
+ const result = new Map<ShortcutKey, TActionWithOptionalArgs>();
+ for (const [key, action] of Object.entries(config)) {
+ if (action === undefined) continue;
+ if (typeof action !== "string") {
+ throw new Error(
+ `Invalid action for "${key}": expected string, got ${typeof action}`,
+ );
+ }
+ if (!isShortcutKey(key)) {
+ throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`);
+ }
+ if (!isActionWithOptionalArgs(action)) {
+ throw new Error(
+ `Invalid action for "${key}": ${JSON.stringify(action)}`,
+ );
+ }
+ result.set(key, action);
+ }
+ return result;
+}
diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts
index e69b3b9f..ece14eaf 100644
--- a/apps/web/src/actions/registry.ts
+++ b/apps/web/src/actions/registry.ts
@@ -11,6 +11,7 @@ import type {
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
+// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function bindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -24,6 +25,7 @@ export function bindAction<A extends TAction>(
}
}
+// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function unbindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -52,6 +54,7 @@ type InvokeActionFunc = {
): void;
};
+// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
export const invokeAction: InvokeActionFunc = <A extends TAction>(
action: A,
args?: TArgOfAction<A>,
diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts
index 41258c7b..38627b95 100644
--- a/apps/web/src/actions/types.ts
+++ b/apps/web/src/actions/types.ts
@@ -1,44 +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;
+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
index a8bb0054..d4a37b50 100644
--- a/apps/web/src/actions/use-action-handler.ts
+++ b/apps/web/src/actions/use-action-handler.ts
@@ -8,6 +8,7 @@ import type {
} from "@/actions";
import { bindAction, unbindAction } from "@/actions";
+// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive).
export function useActionHandler<A extends TAction>(
action: A,
handler: TActionFunc<A>,
diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts
index 5e90a6a1..73300c6e 100644
--- a/apps/web/src/actions/use-editor-actions.ts
+++ b/apps/web/src/actions/use-editor-actions.ts
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor";
@@ -25,6 +25,7 @@ import {
clearActiveScope,
type ScopeEntry,
} from "@/selection/scope";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
export function useEditorActions() {
const editor = useEditor();
@@ -36,47 +37,34 @@ export function useEditorActions() {
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 = () => {
+ const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
+ const clearTimelineSelectionRef = useCommittedRef(() => {
editor.selection.clearSelection();
- };
- clearTimelineActiveSelectionRef.current = () => {
+ });
+ const clearTimelineActiveSelectionRef = useCommittedRef(() => {
editor.selection.clearMostSpecificSelection();
- };
-
- if (!timelineScopeRef.current) {
- timelineScopeRef.current = {
- hasSelection: () => hasTimelineSelectionRef.current,
- clear: () => {
- clearTimelineSelectionRef.current();
- },
- clearActive: () => {
- clearTimelineActiveSelectionRef.current();
- },
- };
- }
+ });
+ const [timelineScope] = useState<ScopeEntry>(() => ({
+ 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]);
+ }, [hasTimelineSelection, timelineScope]);
useActionHandler(
"toggle-play",
diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts
index c129795c..e9d78982 100644
--- a/apps/web/src/actions/use-keybindings.ts
+++ b/apps/web/src/actions/use-keybindings.ts
@@ -33,7 +33,7 @@ export function useKeybindingsListener() {
const isTextInput =
activeElement instanceof HTMLElement &&
isTypableDOMElement({ element: activeElement });
- const boundAction = binding ? keybindings[binding] : undefined;
+ const boundAction = binding ? keybindings.get(binding) : undefined;
if (normalizedKey === "escape" && isTextInput) {
activeElement.blur();
diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts
index 7681e664..89a17887 100644
--- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts
@@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() {
const { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => {
- const result: KeyboardShortcut[] = [];
- const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
+ const actionToKeys = new Map<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 [key, action] of keybindings) {
+ const existing = actionToKeys.get(action);
+ if (existing) {
+ existing.push(formatKey({ key }));
+ } else {
+ actionToKeys.set(action, [formatKey({ key })]);
}
}
- for (const [action, keys] of Object.entries(actionToKeys) as Array<
- [TActionWithOptionalArgs, string[]]
- >) {
+ const result: KeyboardShortcut[] = [];
+ for (const [action, keys] of actionToKeys) {
const actionDef = ACTIONS[action];
- if (!actionDef) {
- continue;
- }
+ if (!actionDef) continue;
result.push({
id: action,
keys,