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 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user