diff --git a/packages/studio/src/captions/components/CaptionOverlay.tsx b/packages/studio/src/captions/components/CaptionOverlay.tsx index 1b30bff8c8..a1e6983f24 100644 --- a/packages/studio/src/captions/components/CaptionOverlay.tsx +++ b/packages/studio/src/captions/components/CaptionOverlay.tsx @@ -2,7 +2,8 @@ import { memo, useEffect, useState, useCallback, useRef } from "react"; import { useLivePreviewIframe } from "../../player/store/previewIframeStore"; import { useCaptionStore } from "../store"; import { usePlayerStore } from "../../player"; -import { shouldHandleCaptionNudgeKey, isEditableEventTarget } from "../keyboard"; +import { resolveCaptionNudge, isEditableEventTarget } from "../keyboard"; +import { matchesKey } from "../../player/components/studioShortcuts"; import { readWordBoxes, getWordEl, @@ -171,7 +172,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio const { selectedSegmentIds: sel, model: m } = store; // Escape: cancel an in-flight drag first, else clear the selection. - if (e.key === "Escape") { + if (matchesKey(e, "cancel")) { if (cancelActiveDrag()) { e.preventDefault(); scheduleTick(); @@ -185,28 +186,20 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio } // ⌘A / Ctrl+A selects every caption word (unless typing in a field). - if ( - (e.metaKey || e.ctrlKey) && - e.key.toLowerCase() === "a" && - !e.shiftKey && - !e.altKey && - !isEditableEventTarget(e.target) - ) { + if (matchesKey(e, "captionSelectAll") && !isEditableEventTarget(e.target)) { e.preventDefault(); store.selectAll(); return; } if (sel.size === 0 || !m) return; - const arrow = e.key; // Pass the event target so arrows inside inputs keep their native // behavior instead of nudging the canvas word. - if (!shouldHandleCaptionNudgeKey(e, e.target)) return; + const nudge = resolveCaptionNudge(e, e.target); + if (!nudge) return; e.preventDefault(); - const step = e.shiftKey ? 10 : 1; - const dx = arrow === "ArrowLeft" ? -step : arrow === "ArrowRight" ? step : 0; - const dy = arrow === "ArrowUp" ? -step : arrow === "ArrowDown" ? step : 0; + const { dx, dy } = nudge; const iframe = iframeRef.current; const win = iframe?.contentWindow; diff --git a/packages/studio/src/captions/keyboard.test.ts b/packages/studio/src/captions/keyboard.test.ts index e65faf6553..cfab8b057b 100644 --- a/packages/studio/src/captions/keyboard.test.ts +++ b/packages/studio/src/captions/keyboard.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { setStudioKeys } from "../player/components/studioShortcuts"; import { shouldHandleCaptionNudgeKey } from "./keyboard"; function mockKeyboardEvent( @@ -60,3 +61,13 @@ describe("shouldHandleCaptionNudgeKey", () => { expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"), player)).toBe(false); }); }); + +describe("shouldHandleCaptionNudgeKey: remapped", () => { + afterEach(() => setStudioKeys(null)); + + it("follows the canvas nudge keys", () => { + setStudioKeys({ nudgeUp: "u" }); + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"))).toBe(false); + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("u"))).toBe(true); + }); +}); diff --git a/packages/studio/src/captions/keyboard.ts b/packages/studio/src/captions/keyboard.ts index 0ea2f05450..4ec78af122 100644 --- a/packages/studio/src/captions/keyboard.ts +++ b/packages/studio/src/captions/keyboard.ts @@ -1,18 +1,18 @@ import { isEditableTarget } from "../utils/timelineDiscovery"; import { ownsPlainKeys } from "../utils/typingTarget"; +import { resolveCanvasNudgeDelta } from "../components/editor/domEditNudge"; -const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]); +type CaptionNudgeKeyEvent = Parameters[0]; -type CaptionNudgeKeyEvent = Pick; +export function resolveCaptionNudge(event: CaptionNudgeKeyEvent, target?: EventTarget | null) { + if (target != null && ownsPlainKeys(target)) return null; + return resolveCanvasNudgeDelta(event); +} -export function shouldHandleCaptionNudgeKey( +export const shouldHandleCaptionNudgeKey = ( event: CaptionNudgeKeyEvent, target?: EventTarget | null, -): boolean { - if (event.metaKey || event.ctrlKey || event.altKey) return false; - if (target != null && ownsPlainKeys(target)) return false; - return CAPTION_NUDGE_KEYS.has(event.key); -} +): boolean => resolveCaptionNudge(event, target) !== null; // Single editable-target check for the caption surface — re-exported from the // shared timeline helper so the two never diverge (it also covers ARIA diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index c312569550..e4cac7f4b6 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, type ReactNode } from "react"; +import { matchesKey } from "../player/components/studioShortcuts"; import { PreviewPane } from "./nle/PreviewPane"; import { TimelinePane } from "./nle/TimelinePane"; import { PreviewOverlays } from "./nle/PreviewOverlays"; @@ -293,7 +294,7 @@ function EditorShellBody({ // Keyboard: Escape to pop composition level const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === "Escape" && compositionStack.length > 1) { + if (matchesKey(e, "cancel") && compositionStack.length > 1) { updateCompositionStack((prev) => prev.slice(0, -1)); } }, diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 5bce49e7ba..a5a92c607b 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -40,7 +40,7 @@ import { LinkedSelectionToggle } from "./LinkedSelectionToggle"; import { useProjectHasAudio } from "../utils/audioMeterMath"; import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions"; import { isTypingTarget } from "../utils/typingTarget"; -import { getStudioKey, keyHint, useStudioKeys } from "../player/components/studioShortcuts"; +import { keyHint, matchesKey, useStudioKeys } from "../player/components/studioShortcuts"; import type { TimelineProps } from "../player/components/TimelineTypes"; interface DomEditSessionSlice extends EnableKeyframesSession { @@ -213,8 +213,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({ // Skip when typing in an input/contenteditable. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.key.toLowerCase() !== getStudioKey("timelineSnap")) return; - if (e.metaKey || e.ctrlKey || e.altKey) return; + if (!matchesKey(e, "timelineSnap")) return; if (isTypingTarget(e.target)) return; const store = usePlayerStore.getState(); store.setTimelineSnapEnabled(!store.timelineSnapEnabled); diff --git a/packages/studio/src/components/editor/InspectorHeaderActions.tsx b/packages/studio/src/components/editor/InspectorHeaderActions.tsx index ceacadeaf0..988405691d 100644 --- a/packages/studio/src/components/editor/InspectorHeaderActions.tsx +++ b/packages/studio/src/components/editor/InspectorHeaderActions.tsx @@ -4,6 +4,8 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import type { DomEditSelection } from "./domEditingTypes"; /** The action buttons in the inspector header: visibility, Ungroup (groups only), copy, clear. */ +import { keyHint, useStudioKeys } from "../../player/components/studioShortcuts"; + export function InspectorHeaderActions({ element, copied, @@ -53,7 +55,7 @@ export function InspectorHeaderActions({ track("button", "Ungroup"); onUngroup(); }} - title="Ungroup (⌘⇧G)" + title={`Ungroup (${keyHint("ungroup", useStudioKeys())})`} className="flex h-6 items-center rounded-sm px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200" > Ungroup diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx index 52c4bfe016..e0e20a390b 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx @@ -9,6 +9,8 @@ const ICON_COLOR_BY_KIND = { other: "text-panel-container", } as const; +import { keyHint, useStudioKeys } from "../../player/components/studioShortcuts"; + export function PropertyPanelFlatHeader({ name, meta, @@ -54,7 +56,7 @@ export function PropertyPanelFlatHeader({ )} {onPaste && ( @@ -231,7 +231,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({ }} > Paste - ⌘V + {keyHint("paste", keys)} )} {onDuplicate && ( @@ -245,7 +245,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({ }} > {selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"} - ⌘D + {keyHint("duplicate", keys)} )} diff --git a/packages/studio/src/player/components/TimelineClipFades.tsx b/packages/studio/src/player/components/TimelineClipFades.tsx index 6dc23dd9c1..35c3791671 100644 --- a/packages/studio/src/player/components/TimelineClipFades.tsx +++ b/packages/studio/src/player/components/TimelineClipFades.tsx @@ -9,6 +9,7 @@ import { type KeyboardEvent, type PointerEvent, } from "react"; +import { matchesKey } from "./studioShortcuts"; import { HF_AUDIO_FADE_IN_ATTR, HF_AUDIO_FADE_OUT_ATTR, @@ -329,7 +330,7 @@ export function TimelineClipFades({ if (dragging === null) return; const cancelOnWindowEscape = (e: globalThis.KeyboardEvent) => { const g = gesture.current; - if (e.key !== "Escape" || !g) return; + if (!matchesKey(e, "cancel") || !g) return; e.preventDefault(); e.stopPropagation(); gesture.current = null; diff --git a/packages/studio/src/player/components/clipMenuLinkItems.tsx b/packages/studio/src/player/components/clipMenuLinkItems.tsx index 3c46a314d8..74b53b3cd6 100644 --- a/packages/studio/src/player/components/clipMenuLinkItems.tsx +++ b/packages/studio/src/player/components/clipMenuLinkItems.tsx @@ -1,4 +1,5 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { keyHint } from "./studioShortcuts"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import type { TimelineLinkEdit } from "./timelineCallbacks"; import { linkedMembersOf } from "./audioClipLink"; @@ -61,7 +62,7 @@ function linkItem( const suffix = partnerSuffix(element, linkPair); return { label: `Link${suffix && ` to${suffix}`}`, - ...(selectedPair ? { shortcut: "⌘L" } : {}), + ...(selectedPair ? { shortcut: keyHint("linkToggle") } : {}), run: () => onLinkEdit({ kind: "link", elements: linkPair }), }; } @@ -82,7 +83,7 @@ export function resolveLinkMenuItems(input: { if (canDetachAudio(element)) { items.push({ label: "Detach audio", - shortcut: "⌥⇧D", + shortcut: keyHint("detachAudio"), run: () => onLinkEdit({ kind: "detach", element }), }); } @@ -90,7 +91,7 @@ export function resolveLinkMenuItems(input: { if (linked) { items.push({ label: `Unlink${partnerSuffix(element, members) && ` from${partnerSuffix(element, members)}`}`, - shortcut: "⌘L", + shortcut: keyHint("linkToggle"), run: () => onLinkEdit({ kind: "unlink", elements: members }), }); } else { @@ -108,7 +109,7 @@ export function resolveLinkMenuItems(input: { if (linked && onDeleteElementOnly) { items.push({ label: "Delete this clip only", - shortcut: "⌥⌫", + shortcut: keyHint("deleteLinked"), destructive: true, run: () => onDeleteElementOnly(element), }); diff --git a/packages/studio/src/player/components/studioCombo.test.ts b/packages/studio/src/player/components/studioCombo.test.ts new file mode 100644 index 0000000000..2fdcf277ca --- /dev/null +++ b/packages/studio/src/player/components/studioCombo.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatCombo, matchCombo, parseCombo } from "./studioCombo"; +import { getStudioKey, keyHint, matchesKey, setStudioKeys } from "./studioShortcuts"; + +afterEach(() => { + setStudioKeys(null); + vi.restoreAllMocks(); +}); + +const press = (init: KeyboardEventInit) => new KeyboardEvent("keydown", init); +const matches = (text: string, init: KeyboardEventInit) => + matchCombo(press(init), parseCombo(text)!); + +describe("combo model", () => { + it("parses characters, named keys and modifiers", () => { + expect(parseCombo("B")).toMatchObject({ key: "b", named: false }); + expect(parseCombo("Space")).toMatchObject({ key: "Space", named: true }); + expect(parseCombo("mod+shift+z")).toMatchObject({ mod: true, shift: true, key: "z" }); + expect(parseCombo("cmd++")).toMatchObject({ meta: true, key: "+" }); + }); + + it("refuses what cannot be matched: modifier-only, unknown, empty, and Ctrl+Alt (AltGr on Windows)", () => { + for (const bad of [ + "ctrl", + "shift+", + "", + "mod+ab", + "hyper+x", + "ctrl+alt+x", + "mod+alt+x", + "Spacebar", + ]) { + expect(parseCombo(bad), bad).toBeNull(); + } + expect(parseCombo("cmd+alt+x")).not.toBeNull(); + }); + + it("mod is Cmd or Ctrl; cmd and ctrl are exact; extra modifiers never match", () => { + expect(matches("mod+c", { key: "c", metaKey: true })).toBe(true); + expect(matches("mod+c", { key: "c", ctrlKey: true })).toBe(true); + expect(matches("mod+c", { key: "c" })).toBe(false); + expect(matches("mod+c", { key: "c", metaKey: true, shiftKey: true })).toBe(false); + expect(matches("cmd+c", { key: "c", ctrlKey: true })).toBe(false); + expect(matches("c", { key: "c", metaKey: true })).toBe(false); + }); + + it("characters match e.key (so layouts follow the typed letter), named keys match e.code", () => { + expect(matches("a", { key: "a", code: "KeyQ" })).toBe(true); + expect(matches("a", { key: "q", code: "KeyA" })).toBe(false); + expect(matches("Space", { key: " ", code: "Space" })).toBe(true); + expect( + matches("shift+ArrowLeft", { key: "ArrowLeft", code: "ArrowLeft", shiftKey: true }), + ).toBe(true); + expect(matches("alt+shift+d", { key: "∂", code: "KeyD", altKey: true, shiftKey: true })).toBe( + true, + ); + }); + + it("formats like the shortcut lists do", () => { + expect(formatCombo(parseCombo("mod+shift+z")!)).toBe("⌘⇧Z"); + expect(formatCombo(parseCombo("alt+shift+d")!)).toBe("⌥⇧D"); + expect(formatCombo(parseCombo("ArrowLeft")!)).toBe("←"); + expect(formatCombo(parseCombo("[")!)).toBe("["); + }); +}); + +describe("resolver with combos", () => { + it("accepts a combo for an action that today takes a single key, and the list follows", () => { + setStudioKeys({ fullscreen: "mod+shift+f" }); + expect(getStudioKey("fullscreen")).toBe("mod+shift+f"); + expect(keyHint("fullscreen")).toBe("⌘⇧F"); + expect(matchesKey(press({ key: "F", metaKey: true, shiftKey: true }), "fullscreen")).toBe(true); + expect(matchesKey(press({ key: "f" }), "fullscreen")).toBe(false); + }); + + it("keeps a single character working and canonical", () => { + setStudioKeys({ mute: "X" }); + expect(getStudioKey("mute")).toBe("x"); + expect(matchesKey(press({ key: "x" }), "mute")).toBe(true); + }); + + it("refuses a map with an AltGr combo or two actions on one combo, keeping every default", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + setStudioKeys({ mute: "ctrl+alt+m" }); + expect(getStudioKey("mute")).toBe("m"); + setStudioKeys({ mute: "mod+k", record: "cmd+k" }); + expect(getStudioKey("mute")).toBe("m"); + expect(getStudioKey("record")).toBe("r"); + }); +}); + +describe("combos with symbol keys", () => { + it("matches a symbol whatever Shift it needs, and refuses shift+symbol", () => { + expect(matches("?", { key: "?", shiftKey: true })).toBe(true); + expect(matches("mod++", { key: "+", metaKey: true, shiftKey: true })).toBe(true); + expect(parseCombo("shift+1")).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/studioCombo.ts b/packages/studio/src/player/components/studioCombo.ts new file mode 100644 index 0000000000..dc983691d8 --- /dev/null +++ b/packages/studio/src/player/components/studioCombo.ts @@ -0,0 +1,108 @@ +export interface Combo { + mod: boolean; + meta: boolean; + ctrl: boolean; + shift: boolean; + alt: boolean; + key: string; + named: boolean; +} + +const NAMED = new Set([ + "Space", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", + "Delete", + "Backspace", + "Escape", + "Enter", + "Tab", +]); +const SYMBOL: Record = { + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Delete: "Del", + Backspace: "⌫", + Escape: "Esc", +}; +const MODIFIERS = new Set(["mod", "cmd", "ctrl", "shift", "alt"]); + +/** "mod+shift+z", "Space", "b": mod is Cmd or Ctrl; Ctrl+Alt is refused because Windows sends AltGr as Ctrl+Alt. */ +export function parseCombo(text: string): Combo | null { + const split = text.length > 1 ? text.slice(0, -1).lastIndexOf("+") : -1; + const tokens = + split < 0 + ? [] + : text + .slice(0, split) + .split("+") + .map((t) => t.toLowerCase()); + const key = text.slice(split + 1); + const named = NAMED.has(key); + const symbol = !named && !/^[A-Za-z]$/.test(key); + if (tokens.some((t) => !MODIFIERS.has(t)) || (!named && [...key].length !== 1)) return null; + if (symbol && tokens.includes("shift")) return null; + const combo = { + mod: tokens.includes("mod"), + meta: tokens.includes("cmd"), + ctrl: tokens.includes("ctrl"), + shift: tokens.includes("shift"), + alt: tokens.includes("alt"), + key: named ? key : key.toLowerCase(), + named, + }; + return combo.alt && (combo.ctrl || combo.mod) ? null : combo; +} + +/** The one string a combo is stored and compared as. */ +export function comboText(c: Combo): string { + const mods = [ + c.mod && "mod", + c.meta && "cmd", + c.ctrl && "ctrl", + c.alt && "alt", + c.shift && "shift", + ]; + return [...mods.filter(Boolean), c.key].join("+"); +} + +const namedMatches = (e: ComboEvent, name: string) => + e.code === name || e.key === (name === "Space" ? " " : name); + +function charMatches(e: ComboEvent, c: Combo): boolean { + if (e.key.toLowerCase() === c.key) return true; + // Option rewrites e.key on macOS (Option+D is "∂"), so fall back to the physical key. + const code = /^[0-9]$/.test(c.key) ? `Digit${c.key}` : `Key${c.key.toUpperCase()}`; + return e.altKey && /^[a-z0-9]$/.test(c.key) && e.code === code; +} + +export type ComboEvent = Pick< + KeyboardEvent, + "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey" +> & { + code?: string; +}; + +export function matchCombo(e: ComboEvent, c: Combo): boolean { + const symbol = !c.named && !/^[a-z]$/.test(c.key); + if ((!symbol && !!e.shiftKey !== c.shift) || !!e.altKey !== c.alt) return false; + const modsOk = c.mod ? e.metaKey || e.ctrlKey : !!e.metaKey === c.meta && !!e.ctrlKey === c.ctrl; + return modsOk && (c.named ? namedMatches(e, c.key) : charMatches(e, c)); +} + +export function formatCombo(c: Combo): string { + const mods = `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.mod || c.meta ? "⌘" : ""}${c.shift ? "⇧" : ""}`; + return mods + (SYMBOL[c.key] ?? (c.named ? c.key : c.key.toUpperCase())); +} + +/** Two combos clash when one press could satisfy both; Cmd and Ctrl both count as mod. */ +export const comboClashKey = (c: Combo) => + [c.mod || c.meta || c.ctrl, c.alt, c.shift, c.key].join("|"); diff --git a/packages/studio/src/player/components/studioShortcuts.test.tsx b/packages/studio/src/player/components/studioShortcuts.test.tsx index 80d8cf26fb..19f8a58b0e 100644 --- a/packages/studio/src/player/components/studioShortcuts.test.tsx +++ b/packages/studio/src/player/components/studioShortcuts.test.tsx @@ -66,7 +66,7 @@ describe("studio key resolver", () => { expect(hintFor("Toggle loop")).toBe("⇧L"); setStudioKeys({ shuttleForward: "y" }); expect(hintFor("Play forward")).toBe("Y"); - expect(hintFor("Toggle loop")).toBe("⇧Y"); + expect(hintFor("Toggle loop")).toBe("⇧L"); }); it("refuses a map that binds two actions to one key and keeps every default", () => { @@ -107,3 +107,18 @@ describe("studio key resolver", () => { act(() => root.unmount()); }); }); + +describe("lists follow combos", () => { + it("shows the remapped combos and keeps today's default text", () => { + expect(hintFor("Undo")).toBe("⌘Z"); + expect(hintFor("Redo")).toBe("⌘⇧Z"); + expect(hintFor("Play / Pause")).toBe("Space"); + expect(hintFor("Step 1 frame")).toBe("←/→"); + expect(hintFor("Step 10 frames")).toBe("⇧←/⇧→"); + expect(hintFor("Toggle loop")).toBe("⇧L"); + setStudioKeys({ undo: "mod+u", playPause: "p", frameBack: "y", frameForward: "u" }); + expect(hintFor("Undo")).toBe("⌘U"); + expect(hintFor("Play / Pause")).toBe("P"); + expect(hintFor("Step 1 frame")).toBe("Y/U"); + }); +}); diff --git a/packages/studio/src/player/components/studioShortcuts.ts b/packages/studio/src/player/components/studioShortcuts.ts index 4825304239..97e7ef787d 100644 --- a/packages/studio/src/player/components/studioShortcuts.ts +++ b/packages/studio/src/player/components/studioShortcuts.ts @@ -1,4 +1,12 @@ import { useSyncExternalStore } from "react"; +import { + type ComboEvent, + comboClashKey, + comboText, + formatCombo, + matchCombo, + parseCombo, +} from "./studioCombo"; /** Every single-character bare key Studio answers, by action. */ export const DEFAULT_STUDIO_KEYS = { @@ -24,6 +32,46 @@ export const DEFAULT_STUDIO_KEYS = { snap: "s", grid: "g", timelineSnap: "n", + playPause: "Space", + frameBack: "ArrowLeft", + frameForward: "ArrowRight", + frameBackTen: "shift+ArrowLeft", + frameForwardTen: "shift+ArrowRight", + loop: "shift+l", + clearInPoint: "shift+i", + clearOutPoint: "shift+o", + undo: "mod+z", + redo: "mod+shift+z", + redoAlt: "ctrl+y", + tabCompositions: "mod+1", + tabAssets: "mod+2", + group: "mod+g", + ungroup: "mod+shift+g", + copy: "mod+c", + paste: "mod+v", + cut: "mod+x", + duplicate: "mod+d", + linkToggle: "mod+l", + detachAudio: "alt+shift+d", + deleteLinked: "alt+Backspace", + deleteLinkedAlt: "alt+Delete", + deleteSelected: "Delete", + deleteSelectedAlt: "Backspace", + resetKeyframes: "Backspace", + nudgeLeft: "ArrowLeft", + nudgeRight: "ArrowRight", + nudgeUp: "ArrowUp", + nudgeDown: "ArrowDown", + nudgeLeftTen: "shift+ArrowLeft", + nudgeRightTen: "shift+ArrowRight", + nudgeUpTen: "shift+ArrowUp", + nudgeDownTen: "shift+ArrowDown", + captionSelectAll: "mod+a", + pickupClip: "Space", + pickupUp: "ArrowUp", + pickupDown: "ArrowDown", + pickupDrop: "Enter", + cancel: "Escape", }; export type StudioKeyAction = keyof typeof DEFAULT_STUDIO_KEYS; @@ -34,6 +82,14 @@ const SHARED_BY_DESIGN: ReadonlyArray function findClash(keys: StudioKeys): string | null { const actions = Object.keys(keys) as StudioKeyAction[]; + const clashKey = (action: StudioKeyAction) => comboClashKey(parseCombo(keys[action])!); for (const [i, a] of actions.entries()) { for (const b of actions.slice(i + 1)) { - if (keys[a] === keys[b] && !sharedByDesign(a, b)) return `${a} and ${b} both on "${keys[a]}"`; + if (clashKey(a) === clashKey(b) && !sharedByDesign(a, b)) + return `${a} and ${b} both on "${keys[a]}"`; } } return null; @@ -54,18 +112,15 @@ function findClash(keys: StudioKeys): string | null { function resolve(overrides: Partial>): StudioKeys { const next: Record = { ...DEFAULT_STUDIO_KEYS }; - for (const [action, key] of Object.entries(overrides)) { - if ( - !Object.hasOwn(DEFAULT_STUDIO_KEYS, action) || - typeof key !== "string" || - [...key].length !== 1 - ) { + for (const [action, text] of Object.entries(overrides)) { + const combo = typeof text === "string" ? parseCombo(text) : null; + if (!Object.hasOwn(DEFAULT_STUDIO_KEYS, action) || !combo) { console.warn( - `Studio keys: using defaults, "${action}" is not a known action with one character`, + `Studio keys: using defaults, "${action}" is not a known action with a valid combo`, ); return DEFAULT_STUDIO_KEYS; } - next[action] = key.toLowerCase(); + next[action] = comboText(combo); } const clash = findClash(next as StudioKeys); if (!clash) return next as StudioKeys; @@ -104,10 +159,11 @@ export interface ShortcutSection { hints: readonly ShortcutHint[]; } -const hintKey = (key: string) => key.toUpperCase(); - export const keyHint = (action: StudioKeyAction, keys: StudioKeys = current) => - hintKey(keys[action]); + formatCombo(parseCombo(keys[action])!); + +export const matchesKey = (e: ComboEvent, action: StudioKeyAction) => + matchCombo(e, parseCombo(current[action])!); export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSection[] { const k = (action: StudioKeyAction) => keyHint(action, keys); @@ -115,14 +171,14 @@ export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSectio { title: "Playback", hints: [ - { key: "Space", label: "Play / Pause" }, + { key: k("playPause"), label: "Play / Pause" }, { key: k("shuttleBack"), label: "Play backward" }, { key: k("pause"), label: "Stop" }, { key: k("shuttleForward"), label: "Play forward" }, { key: k("mute"), label: "Toggle mute" }, - { key: `⇧${k("shuttleForward")}`, label: "Toggle loop" }, - { key: "←/→", label: "Step 1 frame" }, - { key: "⇧←/⇧→", label: "Step 10 frames" }, + { key: k("loop"), label: "Toggle loop" }, + { key: `${k("frameBack")}/${k("frameForward")}`, label: "Step 1 frame" }, + { key: `${k("frameBackTen")}/${k("frameForwardTen")}`, label: "Step 10 frames" }, { key: k("fullscreen"), label: "Toggle fullscreen" }, ], }, @@ -130,27 +186,27 @@ export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSectio title: "Keyframes (when an element is selected)", hints: [ { key: k("addKeyframe"), label: "Add / remove keyframe at playhead" }, - { key: "Del", label: "Delete selected keyframe" }, + { key: k("deleteSelected"), label: "Delete selected keyframe" }, { key: k("record"), label: "Record gesture" }, ], }, { title: "Editing", hints: [ - { key: "⌘Z", label: "Undo" }, - { key: "⌘⇧Z", label: "Redo" }, - { key: "⌘C", label: "Copy element" }, - { key: "⌘V", label: "Paste element" }, - { key: "⌘X", label: "Cut element" }, + { key: k("undo"), label: "Undo" }, + { key: k("redo"), label: "Redo" }, + { key: k("copy"), label: "Copy element" }, + { key: k("paste"), label: "Paste element" }, + { key: k("cut"), label: "Cut element" }, { key: k("split"), label: "Split clip at playhead" }, { key: k("zoomSelection"), label: "Zoom timeline to selected clips" }, { key: k("audioGain"), label: "Audio Gain for clips with sound" }, { key: "⇧Click", label: "Razor tool: split all tracks" }, { key: k("selectLeftward"), label: "Select clips starting before the playhead" }, { key: k("selectRightward"), label: "Select clips running at or after the playhead" }, - { key: "⌘G", label: "Group elements" }, - { key: "⌘⇧G", label: "Ungroup" }, - { key: "Del", label: "Delete selected element (no keyframe selected)" }, + { key: k("group"), label: "Group elements" }, + { key: k("ungroup"), label: "Ungroup" }, + { key: k("deleteSelected"), label: "Delete selected element (no keyframe selected)" }, ], }, { @@ -182,17 +238,17 @@ export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSectio { title: "Panels", hints: [ - { key: "⌘1", label: "Compositions tab" }, - { key: "⌘2", label: "Assets tab" }, + { key: k("tabCompositions"), label: "Compositions tab" }, + { key: k("tabAssets"), label: "Assets tab" }, ], }, { title: "Work area", hints: [ { key: k("inPoint"), label: "Set in-point" }, - { key: `⇧${k("inPoint")}`, label: "Clear in-point" }, + { key: k("clearInPoint"), label: "Clear in-point" }, { key: k("outPoint"), label: "Set out-point" }, - { key: `⇧${k("outPoint")}`, label: "Clear out-point" }, + { key: k("clearOutPoint"), label: "Clear out-point" }, { key: k("seekToIn"), label: "Jump to in-point" }, { key: k("seekToOut"), label: "Jump to out-point" }, ], diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts index eec421f1a0..a6854d3aa6 100644 --- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts @@ -1,4 +1,5 @@ import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout"; +import { matchesKey } from "./studioShortcuts"; import type { Dispatch, RefObject, SetStateAction } from "react"; import { resolveTimelineDragEscape } from "./timelineEditing"; import { commitDraggedClipMove } from "./timelineClipDragCommit"; @@ -407,7 +408,7 @@ export function mountTimelineClipDragGestureLifecycle({ const handleWindowKeyDown = (event: KeyboardEvent) => { const decision = resolveTimelineDragEscape({ - key: event.key, + cancelPressed: matchesKey(event, "cancel"), drag: draggedClipRef.current, resize: resizingClipRef.current, blocked: blockedClipRef.current, diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index e6cea2885a..ce62f491c3 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -555,7 +555,7 @@ export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): { cancel: boolean; suppressClick: boolean; } { - if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) { + if (!input.cancelPressed || (!input.drag && !input.resize && !input.blocked)) { return { cancel: false, suppressClick: false }; } return { @@ -565,7 +565,7 @@ export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): { } export interface TimelineDragEscapeInput { - key: string; + cancelPressed: boolean; drag: { started: boolean } | null; resize: { started: boolean } | null; blocked: { started: boolean } | null; diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 67ce14cf71..9e6eafad14 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -1,4 +1,5 @@ import { useRef, useState, useCallback, useMemo, useEffect } from "react"; +import { matchesKey } from "./studioShortcuts"; import { useMountEffect } from "../../hooks/useMountEffect"; import { markTimelineMotion } from "./timelineMotion"; import { @@ -546,7 +547,7 @@ export function useTimelineClipDrag({ return; } if (resizingClipRef.current) return; - if (event.key !== " " || event.repeat) return; + if (!matchesKey(event, "pickupClip") || event.repeat) return; if (viewport) pickup(event, viewport); }; window.addEventListener("keydown", handleKeyDown, true); diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.ts b/packages/studio/src/player/components/useTimelineKeyboardActor.ts index 43d0524a5d..a26f372e0b 100644 --- a/packages/studio/src/player/components/useTimelineKeyboardActor.ts +++ b/packages/studio/src/player/components/useTimelineKeyboardActor.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react"; +import { matchesKey } from "./studioShortcuts"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout"; import { @@ -24,24 +25,12 @@ type ClipPickupAction = | { kind: "focus" } | { kind: "consume" }; -function clipPickupKeyboardAction(event: { - key: string; - repeat: boolean; -}): ClipPickupAction | null { - switch (event.key) { - case "ArrowUp": - return { kind: "move", step: -1 }; - case "ArrowDown": - return { kind: "move", step: 1 }; - case "Enter": - return { kind: event.repeat ? "focus" : "commit" }; - case "Escape": - return { kind: "cancel" }; - case " ": - return { kind: "consume" }; - default: - return null; - } +function clipPickupKeyboardAction(event: globalThis.KeyboardEvent): ClipPickupAction | null { + if (matchesKey(event, "pickupUp")) return { kind: "move", step: -1 }; + if (matchesKey(event, "pickupDown")) return { kind: "move", step: 1 }; + if (matchesKey(event, "pickupDrop")) return { kind: event.repeat ? "focus" : "commit" }; + if (matchesKey(event, "cancel")) return { kind: "cancel" }; + return matchesKey(event, "pickupClip") ? { kind: "consume" } : null; } export function handleClipPickupKeyboardEvent( diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts index 33dc64d994..3ef90fb755 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts @@ -3,6 +3,7 @@ import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; +import { matchesKey } from "./studioShortcuts"; import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation"; import { progressAtTime, runEaseOf, timeAtProgress } from "../../utils/gsapKeyframeEases"; import { @@ -451,7 +452,7 @@ export function beginTimelineKeyframeRetime( if (actor.phase === "active" && matchesPointer(event)) cancel(actor); }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") cancel(actor); + if (matchesKey(event, "cancel")) cancel(actor); }; window.addEventListener("pointermove", onPointerMove, true); diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.ts b/packages/studio/src/player/components/useTimelineRangeSelection.ts index efa232698f..6ac7ef96fc 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelection.ts +++ b/packages/studio/src/player/components/useTimelineRangeSelection.ts @@ -1,4 +1,5 @@ import { useRef, useState, useCallback, useEffect } from "react"; +import { matchesKey } from "./studioShortcuts"; import { applyTimelineAutoScrollStep, resolveTimelineAutoScrollLoopAction, @@ -537,13 +538,13 @@ export function useTimelineRangeSelection({ // otherwise it clears any lingering multi-selection. useEffect(() => { const onBandEscape = (e: KeyboardEvent) => { - if (e.key !== "Escape" || !(marqueeRef.current || isRangeSelecting.current)) return; + if (!matchesKey(e, "cancel") || !(marqueeRef.current || isRangeSelecting.current)) return; e.preventDefault(); e.stopPropagation(); cancelActiveGesture(true, true); }; const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; + if (!matchesKey(e, "cancel")) return; const store = usePlayerStore.getState(); if (isDragging.current) { cancelActiveGesture(true, true); diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts index 1d4ae49b67..8f1ce4b30b 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts @@ -76,11 +76,19 @@ function setupHook(): HookHandle { }; } -function keydown(init: { code: string; key: string; shiftKey?: boolean }): KeyboardEvent { +function keydown(init: { + code: string; + key: string; + shiftKey?: boolean; + metaKey?: boolean; + altKey?: boolean; +}): KeyboardEvent { return new KeyboardEvent("keydown", { code: init.code, key: init.key, shiftKey: init.shiftKey ?? false, + metaKey: init.metaKey ?? false, + altKey: init.altKey ?? false, cancelable: true, }); } @@ -199,7 +207,7 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => { expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true }); }); - it("Shift+A still seeks to the in-point while the razor is armed, since only plain A exits the razor", () => { + it("Shift+A is a different combo from A: it neither jumps to the in-point nor exits the razor", () => { const { dispatch, spies } = setupHook(); usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" }); @@ -207,7 +215,8 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => { dispatch(keydown({ code: "KeyA", key: "a", shiftKey: true })); }); - expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true }); + expect(spies.seek).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().activeTool).toBe("razor"); }); }); @@ -369,7 +378,7 @@ describe("usePlaybackKeyboard — keys the host can remap", () => { expect(spies.play).not.toHaveBeenCalled(); press(dispatch, "y"); expect(spies.play).toHaveBeenCalledTimes(1); - press(dispatch, "y", true); + press(dispatch, "l", true); expect(usePlayerStore.getState().loopEnabled).toBe(true); }); @@ -400,3 +409,77 @@ describe("usePlaybackKeyboard — keys the host can remap", () => { expect(spies.seek).toHaveBeenCalledWith(3, { keepPlaying: true }); }); }); + +describe("usePlaybackKeyboard: space, arrows, loop and clear keys are combos", () => { + const send = (dispatch: HookHandle["dispatch"], init: Partial[0]>) => + act(() => { + dispatch(keydown({ code: "", key: "", ...init })); + }); + + it("Space, arrows (Shift = 10), Shift+L and Shift+I answer by default", () => { + const { dispatch, spies } = setupHook(); + usePlayerStore.setState({ isPlaying: false, inPoint: 1, loopEnabled: false }); + send(dispatch, { code: "Space", key: " " }); + expect(spies.play).toHaveBeenCalledTimes(1); + send(dispatch, { code: "ArrowRight", key: "ArrowRight", shiftKey: true }); + send(dispatch, { code: "ArrowLeft", key: "ArrowLeft" }); + expect(spies.seek).toHaveBeenCalledTimes(2); + send(dispatch, { code: "KeyL", key: "L", shiftKey: true }); + expect(usePlayerStore.getState().loopEnabled).toBe(true); + send(dispatch, { code: "KeyI", key: "I", shiftKey: true }); + expect(usePlayerStore.getState().inPoint).toBeNull(); + }); + + it("a remapped key answers and the old one does not", () => { + setStudioKeys({ + playPause: "p", + frameForward: "x", + loop: "mod+shift+u", + clearInPoint: "shift+y", + }); + const { dispatch, spies } = setupHook(); + usePlayerStore.setState({ isPlaying: false, inPoint: 1, loopEnabled: false }); + send(dispatch, { code: "Space", key: " " }); + send(dispatch, { code: "ArrowRight", key: "ArrowRight" }); + send(dispatch, { code: "KeyL", key: "L", shiftKey: true }); + send(dispatch, { code: "KeyI", key: "I", shiftKey: true }); + expect(spies.play).not.toHaveBeenCalled(); + expect(spies.seek).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().loopEnabled).toBe(false); + expect(usePlayerStore.getState().inPoint).toBe(1); + send(dispatch, { code: "KeyP", key: "p" }); + expect(spies.play).toHaveBeenCalledTimes(1); + send(dispatch, { code: "KeyX", key: "x" }); + expect(spies.seek).toHaveBeenCalledTimes(1); + send(dispatch, { code: "KeyY", key: "Y", shiftKey: true }); + expect(usePlayerStore.getState().inPoint).toBeNull(); + }); +}); + +describe("usePlaybackKeyboard: modifier combos and named keys", () => { + const send = (dispatch: HookHandle["dispatch"], init: Parameters[0]) => + act(() => { + dispatch(keydown(init)); + }); + + it("a playback action remapped to a Cmd or Alt combo answers, and the plain key does not", () => { + setStudioKeys({ playPause: "mod+p", mute: "alt+m" }); + const { dispatch, spies } = setupHook(); + usePlayerStore.setState({ isPlaying: false, audioMuted: false }); + send(dispatch, { code: "KeyP", key: "p", metaKey: true }); + expect(spies.play).toHaveBeenCalledTimes(1); + send(dispatch, { code: "KeyM", key: "µ", altKey: true }); + expect(usePlayerStore.getState().audioMuted).toBe(true); + send(dispatch, { code: "KeyP", key: "p" }); + expect(spies.play).toHaveBeenCalledTimes(1); + }); + + it("holding a stop key that is a named key still frame-steps instead of shuttling", () => { + setStudioKeys({ pause: "Home" }); + const { dispatch, spies } = setupHook(); + send(dispatch, { code: "Home", key: "Home" }); + send(dispatch, { code: "KeyJ", key: "j" }); + expect(spies.seek).toHaveBeenCalledTimes(1); + expect(spies.playBackward).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts index 611e68ada3..99a59a2dac 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts @@ -15,7 +15,8 @@ import { } from "../lib/playbackShortcuts"; import { canvasNudgeKeysClaimed } from "../../utils/canvasNudgeGate"; import { usePlayerStore } from "../store/playerStore"; -import { getStudioKeys } from "../components/studioShortcuts"; +import { matchesKey, type StudioKeyAction } from "../components/studioShortcuts"; +import { resolveCanvasNudgeDelta } from "../../components/editor/domEditNudge"; import { stepFrameTime } from "../lib/time"; import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences"; import type { PlaybackAdapter } from "../lib/playbackTypes"; @@ -32,26 +33,61 @@ interface UsePlaybackKeyboardParams { seek: (time: number, options?: { keepPlaying?: boolean }) => void; } +const FRAME_STEPS = { + frameBack: -1, + frameForward: 1, + frameBackTen: -10, + frameForwardTen: 10, +} as const; +const PLAYBACK_ACTIONS = [ + "playPause", + ...(Object.keys(FRAME_STEPS) as Array), + "mute", + "loop", + "pause", + "shuttleBack", + "shuttleForward", + "inPoint", + "clearInPoint", + "outPoint", + "clearOutPoint", + "seekToIn", + "seekToOut", +] as const satisfies readonly StudioKeyAction[]; +type PlaybackAction = (typeof PLAYBACK_ACTIONS)[number]; + +const playbackActionOf = (e: KeyboardEvent): PlaybackAction | undefined => + PLAYBACK_ACTIONS.find((action) => matchesKey(e, action)); + +function ignoresPlaybackKey(e: KeyboardEvent): boolean { + const captionState = useCaptionStore.getState(); + return shouldIgnorePlaybackShortcutEvent(e, { + isCaptionEditMode: captionState.isEditMode, + selectedCaptionSegmentCount: captionState.selectedSegmentIds.size, + }); +} + function handleWorkAreaKey( e: KeyboardEvent, - key: string, + action: PlaybackAction, getAdapter: () => PlaybackAdapter | null, seek: UsePlaybackKeyboardParams["seek"], ): boolean { - if (razorClaimsKey(e, key)) return false; - const keys = getStudioKeys(); const store = usePlayerStore.getState(); const time = () => getAdapter()?.getTime() ?? store.currentTime; const end = () => store.outPoint ?? getAdapter()?.getDuration() ?? store.duration; - const actions: Record void> = { - [keys.inPoint]: () => store.setInPoint(e.shiftKey ? null : time()), - [keys.outPoint]: () => store.setOutPoint(e.shiftKey ? null : time()), - [keys.seekToIn]: () => seek(store.inPoint ?? 0, { keepPlaying: true }), - [keys.seekToOut]: () => seek(end(), { keepPlaying: true }), + const actions: Partial void>> = { + inPoint: () => store.setInPoint(time()), + clearInPoint: () => store.setInPoint(null), + outPoint: () => store.setOutPoint(time()), + clearOutPoint: () => store.setOutPoint(null), + seekToIn: () => seek(store.inPoint ?? 0, { keepPlaying: true }), + seekToOut: () => seek(end(), { keepPlaying: true }), }; - if (!Object.hasOwn(actions, key)) return false; + const run = actions[action]; + if (!run || razorClaimsKey(e)) return false; e.preventDefault(); - actions[key](); + run(); return true; } @@ -66,7 +102,7 @@ export function usePlaybackKeyboard({ pause, seek, }: UsePlaybackKeyboardParams) { - const pressedKeysRef = useRef(new Set()); + const heldPauseRef = useRef(new Set()); const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {}); const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {}); @@ -111,87 +147,47 @@ export function usePlaybackKeyboard({ } }, [play, pause]); - // Pre-existing dispatcher, already over the complexity gate before this - // branch's one-line "a" fix touched it. A real fix is a dispatch-table - // rewrite, out of scope here; the branch this PR changed is its own function. - // fallow-ignore-next-line complexity const handlePlaybackKeyDown = useCallback( (e: KeyboardEvent) => { - if (e.defaultPrevented) return; - const captionState = useCaptionStore.getState(); - if ( - shouldIgnorePlaybackShortcutEvent(e, { - isCaptionEditMode: captionState.isEditMode, - selectedCaptionSegmentCount: captionState.selectedSegmentIds.size, - }) - ) { - return; - } - const key = e.key.toLowerCase(); - const keys = getStudioKeys(); - pressedKeysRef.current.add(key); - if (e.code === "Space") { - e.preventDefault(); - togglePlay(); - return; - } + if (e.defaultPrevented || ignoresPlaybackKey(e)) return; + const action = playbackActionOf(e); + if (action === "pause") heldPauseRef.current.add(e.code || e.key); + if (!action) return; + const isFrameStep = action in FRAME_STEPS; // A nudgeable canvas selection owns the arrow keys (DomEditOverlay moves // the element); frame-stepping would double-handle the same keystroke. - const arrowStep = e.code === "ArrowLeft" || e.code === "ArrowRight"; - if (arrowStep && canvasNudgeKeysClaimed()) return; - if (e.code === "ArrowLeft") { - e.preventDefault(); - stepFrames(e.shiftKey ? -10 : -1); + if (isFrameStep && canvasNudgeKeysClaimed() && resolveCanvasNudgeDelta(e)) return; + if (e.repeat && action !== "playPause" && !isFrameStep) return; + const shuttleOrStep = (direction: -1 | 1) => + heldPauseRef.current.size > 0 + ? stepFrames(direction) + : shuttle(direction < 0 ? "backward" : "forward"); + const player = usePlayerStore.getState(); + const run: Partial void>> = { + playPause: togglePlay, + frameBack: () => stepFrames(FRAME_STEPS.frameBack), + frameForward: () => stepFrames(FRAME_STEPS.frameForward), + frameBackTen: () => stepFrames(FRAME_STEPS.frameBackTen), + frameForwardTen: () => stepFrames(FRAME_STEPS.frameForwardTen), + mute: () => player.setAudioMuted(!player.audioMuted), + loop: () => player.setLoopEnabled(!player.loopEnabled), + pause, + shuttleBack: () => shuttleOrStep(-1), + shuttleForward: () => shuttleOrStep(1), + }; + const act = run[action]; + if (!act) { + handleWorkAreaKey(e, action, getAdapter, seek); return; } - if (e.code === "ArrowRight") { - e.preventDefault(); - stepFrames(e.shiftKey ? 10 : 1); - return; - } - if (e.repeat) return; - if (key === keys.mute) { - e.preventDefault(); - const state = usePlayerStore.getState(); - state.setAudioMuted(!state.audioMuted); - return; - } - if (key === keys.shuttleForward && e.shiftKey) { - e.preventDefault(); - const state = usePlayerStore.getState(); - state.setLoopEnabled(!state.loopEnabled); - return; - } - if (key === keys.pause) { - e.preventDefault(); - pause(); - return; - } - if (key === keys.shuttleBack) { - e.preventDefault(); - if (pressedKeysRef.current.has(keys.pause)) { - stepFrames(-1); - return; - } - shuttle("backward"); - return; - } - if (key === keys.shuttleForward) { - e.preventDefault(); - if (pressedKeysRef.current.has(keys.pause)) { - stepFrames(1); - return; - } - shuttle("forward"); - return; - } - handleWorkAreaKey(e, key, getAdapter, seek); + e.preventDefault(); + act(); }, [pause, shuttle, stepFrames, togglePlay, getAdapter, seek], ); const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => { - pressedKeysRef.current.delete(e.key.toLowerCase()); + heldPauseRef.current.delete(e.code || e.key); }, []); playbackKeyDownRef.current = handlePlaybackKeyDown; diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts index cc8e996f20..dc96eabb3e 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts @@ -1,3 +1,4 @@ +import { setStudioKeys } from "../components/studioShortcuts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Window } from "happy-dom"; import { @@ -48,8 +49,10 @@ function mockTargetMatching(selectorNeedle: string): EventTarget { function mockKeyboardEvent( code: string, overrides: Partial> = {}, -): Pick { +): Pick { return { + key: code, + shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, @@ -556,18 +559,6 @@ describe("shouldIgnorePlaybackShortcutTarget", () => { }); describe("shouldIgnorePlaybackShortcutEvent", () => { - it("ignores modified playback shortcuts so browser and app chords can handle them", () => { - expect( - shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })), - ).toBe(true); - expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe( - true, - ); - expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe( - true, - ); - }); - it("defers Arrow frame shortcuts while caption edit mode has selected words", () => { const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 }; @@ -582,6 +573,21 @@ describe("shouldIgnorePlaybackShortcutEvent", () => { ); }); + it("defers a remapped frame key, not the arrows, while caption words are selected", () => { + setStudioKeys({ frameBack: "," }); + const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 }; + expect( + shouldIgnorePlaybackShortcutEvent( + { ...mockKeyboardEvent("Comma"), key: "," }, + captionSelection, + ), + ).toBe(true); + expect( + shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection), + ).toBe(false); + setStudioKeys(null); + }); + it("allows Arrow frame shortcuts when captions are not selected", () => { expect( shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), { diff --git a/packages/studio/src/player/lib/playbackShortcuts.ts b/packages/studio/src/player/lib/playbackShortcuts.ts index 21f668560a..abbefbd48f 100644 --- a/packages/studio/src/player/lib/playbackShortcuts.ts +++ b/packages/studio/src/player/lib/playbackShortcuts.ts @@ -7,10 +7,16 @@ */ import { ownsPlainKeys } from "../../utils/typingTarget"; -import { getStudioKey } from "../components/studioShortcuts"; +import type { ComboEvent } from "../components/studioCombo"; +import { matchesKey } from "../components/studioShortcuts"; import { usePlayerStore } from "../store/playerStore"; -const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]); +const FRAME_STEP_ACTIONS = [ + "frameBack", + "frameForward", + "frameBackTen", + "frameForwardTen", +] as const; const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [ "button", @@ -61,10 +67,7 @@ interface PlaybackShortcutCaptionState { selectedCaptionSegmentCount: number; } -type PlaybackShortcutEvent = Pick< - KeyboardEvent, - "altKey" | "ctrlKey" | "metaKey" | "code" | "target" ->; +type PlaybackShortcutEvent = ComboEvent & Pick; export function shouldIgnorePlaybackShortcutEvent( event: PlaybackShortcutEvent, @@ -73,11 +76,10 @@ export function shouldIgnorePlaybackShortcutEvent( selectedCaptionSegmentCount: 0, }, ): boolean { - if (event.metaKey || event.ctrlKey || event.altKey) return true; if (shouldIgnorePlaybackShortcutTarget(event.target)) return true; if (isModalDialogOpen()) return true; return ( - PLAYBACK_FRAME_STEP_CODES.has(event.code) && + FRAME_STEP_ACTIONS.some((action) => matchesKey(event, action)) && captionState.isCaptionEditMode && captionState.selectedCaptionSegmentCount > 0 ); @@ -86,12 +88,7 @@ export function shouldIgnorePlaybackShortcutEvent( /** JKL shuttle speeds (×1, ×2, ×4). */ export const SHUTTLE_SPEEDS = [1, 2, 4] as const; -/** Plain exit-razor key while the razor is armed: the razor owns it, playback must leave it alone. */ -export function razorClaimsKey(e: KeyboardEvent, key: string): boolean { - return ( - key === getStudioKey("exitRazor") && - !e.shiftKey && - !e.altKey && - usePlayerStore.getState().activeTool === "razor" - ); +/** The exit-razor combo while the razor is armed: the razor owns it, playback must leave it alone. */ +export function razorClaimsKey(e: KeyboardEvent): boolean { + return matchesKey(e, "exitRazor") && usePlayerStore.getState().activeTool === "razor"; }