From 9dc17ae30d8a91615b1019dc74517c78607aac67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 28 Apr 2026 22:32:23 -0400 Subject: [PATCH] fix: scope studio playback shortcuts --- .../components/CaptionOverlay.test.ts | 38 ++++++++++++ .../captions/components/CaptionOverlay.tsx | 10 +++- .../player/hooks/useTimelinePlayer.test.ts | 58 +++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 38 +++++++++++- 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/captions/components/CaptionOverlay.test.ts diff --git a/packages/studio/src/captions/components/CaptionOverlay.test.ts b/packages/studio/src/captions/components/CaptionOverlay.test.ts new file mode 100644 index 000000000..886c4b80e --- /dev/null +++ b/packages/studio/src/captions/components/CaptionOverlay.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { shouldHandleCaptionNudgeKey } from "./CaptionOverlay"; + +function mockKeyboardEvent( + key: string, + overrides: Partial> = {}, +): Pick { + return { + altKey: false, + ctrlKey: false, + metaKey: false, + key, + ...overrides, + }; +} + +describe("shouldHandleCaptionNudgeKey", () => { + it("handles plain and Shift-modified arrow keys for caption nudging", () => { + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true); + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true); + }); + + it("ignores browser and app shortcut chords", () => { + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe( + false, + ); + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe( + false, + ); + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe( + false, + ); + }); + + it("ignores non-arrow keys", () => { + expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false); + }); +}); diff --git a/packages/studio/src/captions/components/CaptionOverlay.tsx b/packages/studio/src/captions/components/CaptionOverlay.tsx index a30f788f7..78e72bc96 100644 --- a/packages/studio/src/captions/components/CaptionOverlay.tsx +++ b/packages/studio/src/captions/components/CaptionOverlay.tsx @@ -251,6 +251,14 @@ function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) { const HANDLE = 8; const ROTATION_OFFSET = 20; // px above the selection box +const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]); + +type CaptionNudgeKeyEvent = Pick; + +export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean { + if (event.metaKey || event.ctrlKey || event.altKey) return false; + return CAPTION_NUDGE_KEYS.has(event.key); +} export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) { const isEditMode = useCaptionStore((s) => s.isEditMode); @@ -329,7 +337,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState(); if (sel.size === 0 || !m) return; const arrow = e.key; - if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return; + if (!shouldHandleCaptionNudgeKey(e)) return; e.preventDefault(); const step = e.shiftKey ? 10 : 1; diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts index 096afe28c..c0f5db6a1 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts @@ -3,6 +3,7 @@ import { buildStandaloneRootTimelineElement, mergeTimelineElementsPreservingDowngrades, resolveStandaloneRootCompositionSrc, + shouldIgnorePlaybackShortcutEvent, shouldIgnorePlaybackShortcutTarget, } from "./useTimelinePlayer"; @@ -12,6 +13,20 @@ function mockTargetMatching(selectorNeedle: string): EventTarget { } as unknown as EventTarget; } +function mockKeyboardEvent( + code: string, + overrides: Partial> = {}, +): Pick { + return { + altKey: false, + ctrlKey: false, + metaKey: false, + code, + target: mockTargetMatching("[data-missing]"), + ...overrides, + }; +} + describe("buildStandaloneRootTimelineElement", () => { it("includes selector and source metadata for standalone composition fallback clips", () => { expect( @@ -115,3 +130,46 @@ describe("shouldIgnorePlaybackShortcutTarget", () => { expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false); }); }); + +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 }; + + expect( + shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection), + ).toBe(true); + expect( + shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection), + ).toBe(true); + expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe( + false, + ); + }); + + it("allows Arrow frame shortcuts when captions are not selected", () => { + expect( + shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), { + isCaptionEditMode: true, + selectedCaptionSegmentCount: 0, + }), + ).toBe(false); + expect( + shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), { + isCaptionEditMode: false, + selectedCaptionSegmentCount: 1, + }), + ).toBe(false); + }); +}); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index a20e368f7..9947b1d63 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -2,6 +2,7 @@ import { useRef, useCallback } from "react"; import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; import { useMountEffect } from "../../hooks/useMountEffect"; import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time"; +import { useCaptionStore } from "../../captions/store"; interface PlaybackAdapter { play: () => void; @@ -107,6 +108,7 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi } const SHUTTLE_SPEEDS = [1, 2, 4] as const; +const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]); const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [ "input", "textarea", @@ -137,6 +139,32 @@ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): ); } +interface PlaybackShortcutCaptionState { + isCaptionEditMode: boolean; + selectedCaptionSegmentCount: number; +} + +type PlaybackShortcutEvent = Pick< + KeyboardEvent, + "altKey" | "ctrlKey" | "metaKey" | "code" | "target" +>; + +export function shouldIgnorePlaybackShortcutEvent( + event: PlaybackShortcutEvent, + captionState: PlaybackShortcutCaptionState = { + isCaptionEditMode: false, + selectedCaptionSegmentCount: 0, + }, +): boolean { + if (event.metaKey || event.ctrlKey || event.altKey) return true; + if (shouldIgnorePlaybackShortcutTarget(event.target)) return true; + return ( + PLAYBACK_FRAME_STEP_CODES.has(event.code) && + captionState.isCaptionEditMode && + captionState.selectedCaptionSegmentCount > 0 + ); +} + /** * Parse [data-start] elements from a Document into TimelineElement[]. * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions. @@ -698,7 +726,15 @@ export function useTimelinePlayer() { const handlePlaybackKeyDown = useCallback( (e: KeyboardEvent) => { if (e.defaultPrevented) return; - if (shouldIgnorePlaybackShortcutTarget(e.target)) return; + const captionState = useCaptionStore.getState(); + if ( + shouldIgnorePlaybackShortcutEvent(e, { + isCaptionEditMode: captionState.isEditMode, + selectedCaptionSegmentCount: captionState.selectedSegmentIds.size, + }) + ) { + return; + } pressedCodesRef.current.add(e.code); if (e.code === "Space") { e.preventDefault();