mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: scope studio playback shortcuts
This commit is contained in:
@@ -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 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) {
|
||||
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;
|
||||
|
||||
@@ -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<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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user