fix: scope studio playback shortcuts

This commit is contained in:
Miguel Ángel
2026-04-28 22:32:23 -04:00
parent a45f900af7
commit 9dc17ae30d
4 changed files with 142 additions and 2 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { shouldHandleCaptionNudgeKey } from "./CaptionOverlay";
function mockKeyboardEvent(
key: string,
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
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);
});
});
@@ -251,6 +251,14 @@ function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
const HANDLE = 8; const HANDLE = 8;
const ROTATION_OFFSET = 20; // px above the selection box const ROTATION_OFFSET = 20; // px above the selection box
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
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) { export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) {
const isEditMode = useCaptionStore((s) => s.isEditMode); 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(); const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
if (sel.size === 0 || !m) return; if (sel.size === 0 || !m) return;
const arrow = e.key; const arrow = e.key;
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return; if (!shouldHandleCaptionNudgeKey(e)) return;
e.preventDefault(); e.preventDefault();
const step = e.shiftKey ? 10 : 1; const step = e.shiftKey ? 10 : 1;
@@ -3,6 +3,7 @@ import {
buildStandaloneRootTimelineElement, buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades, mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc, resolveStandaloneRootCompositionSrc,
shouldIgnorePlaybackShortcutEvent,
shouldIgnorePlaybackShortcutTarget, shouldIgnorePlaybackShortcutTarget,
} from "./useTimelinePlayer"; } from "./useTimelinePlayer";
@@ -12,6 +13,20 @@ function mockTargetMatching(selectorNeedle: string): EventTarget {
} as unknown as EventTarget; } as unknown as EventTarget;
} }
function mockKeyboardEvent(
code: string,
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
code,
target: mockTargetMatching("[data-missing]"),
...overrides,
};
}
describe("buildStandaloneRootTimelineElement", () => { describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => { it("includes selector and source metadata for standalone composition fallback clips", () => {
expect( expect(
@@ -115,3 +130,46 @@ describe("shouldIgnorePlaybackShortcutTarget", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false); 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);
});
});
@@ -2,6 +2,7 @@ import { useRef, useCallback } from "react";
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect"; import { useMountEffect } from "../../hooks/useMountEffect";
import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time"; import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
import { useCaptionStore } from "../../captions/store";
interface PlaybackAdapter { interface PlaybackAdapter {
play: () => void; play: () => void;
@@ -107,6 +108,7 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
} }
const SHUTTLE_SPEEDS = [1, 2, 4] as const; const SHUTTLE_SPEEDS = [1, 2, 4] as const;
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
"input", "input",
"textarea", "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[]. * Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions. * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -698,7 +726,15 @@ export function useTimelinePlayer() {
const handlePlaybackKeyDown = useCallback( const handlePlaybackKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.defaultPrevented) return; 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); pressedCodesRef.current.add(e.code);
if (e.code === "Space") { if (e.code === "Space") {
e.preventDefault(); e.preventDefault();