mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): a focused video or audio player keeps its arrow and play keys (#4854)
* fix(studio): a focused video or audio player keeps its arrow and play keys * fix(studio): every remaining shortcut asks the shared typing check
This commit is contained in:
@@ -53,4 +53,10 @@ describe("shouldHandleCaptionNudgeKey", () => {
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), div)).toBe(true);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), null)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["video", "audio"])("leaves the arrows to a focused <%s controls>", (tag) => {
|
||||
const player = document.createElement(tag);
|
||||
player.setAttribute("controls", "");
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"), player)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,6 +211,33 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TimelineToolbar snap key", () => {
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
const snapBefore = usePlayerStore.getState().timelineSnapEnabled;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
usePlayerStore.getState().setTimelineSnapEnabled(snapBefore);
|
||||
});
|
||||
|
||||
it("N toggles snapping, but not while a picker that owns typing has focus", () => {
|
||||
root = renderToolbar().root;
|
||||
const pressN = (target: EventTarget) =>
|
||||
act(() => {
|
||||
target.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
|
||||
});
|
||||
const before = usePlayerStore.getState().timelineSnapEnabled;
|
||||
const picker = document.createElement("button");
|
||||
picker.setAttribute("role", "combobox");
|
||||
document.body.append(picker);
|
||||
|
||||
pressN(picker);
|
||||
expect(usePlayerStore.getState().timelineSnapEnabled).toBe(before);
|
||||
pressN(document.body);
|
||||
expect(usePlayerStore.getState().timelineSnapEnabled).toBe(!before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TimelineToolbar Fit", () => {
|
||||
it("shows Fit as a named icon and says whether fit is on", () => {
|
||||
const { host, root } = renderToolbar();
|
||||
|
||||
@@ -33,6 +33,7 @@ import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
||||
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
||||
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
|
||||
interface DomEditSessionSlice extends EnableKeyframesSession {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
@@ -196,14 +197,10 @@ export function TimelineToolbar({
|
||||
// "N" toggles timeline snapping (industry convention: Resolve/FCP).
|
||||
// Skip when typing in an input/contenteditable.
|
||||
useEffect(() => {
|
||||
// fallow-ignore-next-line complexity
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "n" && e.key !== "N") return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
if (target?.isContentEditable) return;
|
||||
const tag = target?.tagName?.toLowerCase() ?? "";
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
const store = usePlayerStore.getState();
|
||||
store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
|
||||
};
|
||||
|
||||
@@ -133,6 +133,37 @@ describe("SnapToolbar keyboard shortcuts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SnapToolbar keys and a focused control", () => {
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
});
|
||||
|
||||
const controls: Array<[string, string, string, string]> = [
|
||||
["a combobox", "button", "role", "combobox"],
|
||||
["a select", "select", "name", "font"],
|
||||
["a switch", "button", "role", "switch"],
|
||||
["a video player", "video", "controls", ""],
|
||||
];
|
||||
|
||||
it.each(controls)("leaves S and G alone while %s has focus", (_name, tag, attr, value) => {
|
||||
root = renderToolbar().root;
|
||||
const control = document.body.appendChild(document.createElement(tag));
|
||||
control.setAttribute(attr, value);
|
||||
act(() => {
|
||||
for (const key of ["s", "g"])
|
||||
control.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const prefs = JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}");
|
||||
expect(prefs.snapEnabled).not.toBe(false);
|
||||
expect(prefs.gridVisible).not.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SnapToolbar ruler and safe-margin toggles", () => {
|
||||
it.each([
|
||||
["Toggle ruler", "rulerVisible"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
|
||||
import { ownsPlainKeys } from "../../utils/typingTarget";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const SnapToolbar = memo(function SnapToolbar() {
|
||||
@@ -35,10 +36,8 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
|
||||
if (t instanceof HTMLElement && t.isContentEditable) return;
|
||||
if (t instanceof HTMLIFrameElement) return;
|
||||
if (ownsPlainKeys(e.target)) return;
|
||||
if (e.target instanceof HTMLIFrameElement) return;
|
||||
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
||||
|
||||
@@ -259,6 +259,51 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDomEditNudge — a focused native player owns the arrow keys", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetForTests();
|
||||
});
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each(["video", "audio"])(
|
||||
"does not nudge the selection while a <%s controls> has focus",
|
||||
(tag) => {
|
||||
root = createRoot(document.body.appendChild(document.createElement("div")));
|
||||
const element = document.body.appendChild(document.createElement("div"));
|
||||
element.id = "dot";
|
||||
const player = document.body.appendChild(document.createElement(tag));
|
||||
player.setAttribute("controls", "");
|
||||
const commit = vi.fn();
|
||||
act(() => {
|
||||
root?.render(
|
||||
React.createElement(Harness, {
|
||||
selection: makeSelection("Dot", element),
|
||||
onPathOffsetCommit: commit,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowRight",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
act(() => {
|
||||
player.dispatchEvent(event);
|
||||
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
||||
});
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("useDomEditNudge carries the route its press chose", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -127,7 +127,7 @@ function resolveSingleNudgeTarget(
|
||||
|
||||
/**
|
||||
* True when a keydown must not start/extend a nudge: canvas movement disabled,
|
||||
* a pointer gesture already owns the element, or the user is typing in a field.
|
||||
* a pointer gesture already owns the element, or focus is in a field or a native player.
|
||||
*/
|
||||
function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
|
||||
if (!p.allowCanvasMovement || event.defaultPrevented) return true;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
toggleMainLineSlide,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
removeHotspot,
|
||||
safeParseManifest,
|
||||
makeSlideshowNotesController,
|
||||
isPanelUndoKey,
|
||||
} from "./SlideshowPanel";
|
||||
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
||||
|
||||
@@ -518,3 +521,27 @@ describe("makeSlideshowNotesController", () => {
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPanelUndoKey", () => {
|
||||
function undoFrom(html: string) {
|
||||
document.body.innerHTML = `<div>${html}</div>`;
|
||||
const target = document.body.firstElementChild!.firstElementChild!;
|
||||
return { metaKey: true, ctrlKey: false, shiftKey: false, key: "z", target };
|
||||
}
|
||||
|
||||
it("undoes the panel from a button or a slider", () => {
|
||||
expect(isPanelUndoKey(undoFrom("<button>Add</button>"))).toBe(true);
|
||||
expect(isPanelUndoKey(undoFrom('<div role="slider" tabindex="0"></div>'))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves Cmd+Z to a text field, contenteditable and select included", () => {
|
||||
expect(isPanelUndoKey(undoFrom("<input />"))).toBe(false);
|
||||
expect(isPanelUndoKey(undoFrom('<p contenteditable="plaintext-only">Note</p>'))).toBe(false);
|
||||
expect(isPanelUndoKey(undoFrom("<select><option>a</option></select>"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores Cmd+Shift+Z and a bare z", () => {
|
||||
expect(isPanelUndoKey({ ...undoFrom("<button />"), shiftKey: true })).toBe(false);
|
||||
expect(isPanelUndoKey({ ...undoFrom("<button />"), metaKey: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { usePlayerStore } from "../../player";
|
||||
import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
|
||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||
import { generateId } from "../../utils/generateId";
|
||||
import { isTypingTarget } from "../../utils/typingTarget";
|
||||
import {
|
||||
SectionHeader,
|
||||
SlideList,
|
||||
@@ -153,6 +154,13 @@ export function makeSlideshowNotesController(): NotesController {
|
||||
};
|
||||
}
|
||||
|
||||
export function isPanelUndoKey(
|
||||
e: Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "shiftKey" | "key" | "target">,
|
||||
): boolean {
|
||||
if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.key.toLowerCase() !== "z") return false;
|
||||
return !isTypingTarget(e.target);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SlideshowPanelProps {
|
||||
@@ -416,13 +424,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
||||
className="flex flex-col h-full overflow-y-auto text-text-0"
|
||||
onKeyDown={(e) => {
|
||||
// In-panel undo — scoped so it never fights the app-level file undo.
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
|
||||
const target = e.target instanceof HTMLElement ? e.target.tagName : "";
|
||||
if (target === "TEXTAREA" || target === "INPUT") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUndo();
|
||||
}
|
||||
if (!isPanelUndoKey(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUndo();
|
||||
}}
|
||||
>
|
||||
{persistError && (
|
||||
|
||||
@@ -61,6 +61,9 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("keys on a focused slider", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
const slider = () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("role", "slider");
|
||||
@@ -85,6 +88,20 @@ describe("keys on a focused slider", () => {
|
||||
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["video", "audio"])("leaves Delete to a focused <%s controls>", (tag) => {
|
||||
usePlayerStore.setState({
|
||||
elements: [bgmElement],
|
||||
selectedElementId: "bgm",
|
||||
selectedElementIds: new Set(["bgm"]),
|
||||
});
|
||||
const player = document.body.appendChild(document.createElement(tag));
|
||||
player.setAttribute("controls", "");
|
||||
const cb = callbacks();
|
||||
dispatchPlainKey(from(player, { key: "Delete" }), "delete", cb);
|
||||
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
||||
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still undoes with Cmd+Z while a slider has focus", () => {
|
||||
const cb = callbacks();
|
||||
dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
|
||||
|
||||
@@ -216,6 +216,23 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
describe("with a focused picker", () => {
|
||||
const picker = document.createElement("button");
|
||||
picker.setAttribute("role", "combobox");
|
||||
afterEach(() => picker.remove());
|
||||
|
||||
it("is inert while a picker that owns typing has focus", () => {
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
|
||||
const { onCommit } = setup({});
|
||||
document.body.append(picker);
|
||||
picker.focus();
|
||||
key("Delete");
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("Cmd+C copies the active selection", () => {
|
||||
clearAutomationClipboard();
|
||||
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type HfAutomationLane,
|
||||
} from "@hyperframes/core/audio-automation";
|
||||
import { clampNumber } from "../utils/studioHelpers";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
|
||||
import type {
|
||||
AutomationLaneBinding,
|
||||
@@ -39,13 +40,6 @@ import type {
|
||||
|
||||
type PlayerState = ReturnType<typeof usePlayerStore.getState>;
|
||||
|
||||
function isTextInput(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return el instanceof HTMLElement && el.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cmd/Ctrl+<letter> chord. `e.key` is normalised because CapsLock makes it
|
||||
* "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test
|
||||
@@ -302,7 +296,7 @@ function handleCopy(
|
||||
* dead key beats today's double write.
|
||||
*/
|
||||
export function automationOwnsKey(e: KeyboardEvent): boolean {
|
||||
if (isTextInput(document.activeElement)) return false;
|
||||
if (isTypingTarget(document.activeElement)) return false;
|
||||
const state = usePlayerStore.getState();
|
||||
if (isChord(e, "c")) return state.automationSelection !== null;
|
||||
if (!isChord(e, "v")) return false;
|
||||
@@ -340,7 +334,7 @@ export function useAutomationSelectionKeyboard({
|
||||
}): void {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (isTextInput(document.activeElement)) return;
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
// Somebody upstream already claimed this key. useAppHotkeys is on
|
||||
// window/capture so it always runs first, and it deliberately lets a
|
||||
// keyframe selection outrank an automation range on Delete — without
|
||||
|
||||
@@ -86,6 +86,21 @@ describe("keyframe shortcuts", () => {
|
||||
window.removeEventListener("keydown", playback);
|
||||
});
|
||||
|
||||
describe("with a focused picker", () => {
|
||||
const picker = document.createElement("button");
|
||||
picker.setAttribute("role", "combobox");
|
||||
afterEach(() => picker.remove());
|
||||
|
||||
it("leaves K to a focused picker that owns typing, like a font combobox", () => {
|
||||
const onAddKeyframe = mountKeyframeKeyboard(true);
|
||||
document.body.append(picker);
|
||||
picker.focus();
|
||||
|
||||
expect(press("k").defaultPrevented).toBe(false);
|
||||
expect(onAddKeyframe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("K stays playback's stop key when no keyframeable element is selected", () => {
|
||||
const playback = vi.fn();
|
||||
window.addEventListener("keydown", playback);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
|
||||
interface KeyframeKeyboardOptions {
|
||||
enabled: boolean;
|
||||
onAddKeyframe?: () => void;
|
||||
}
|
||||
|
||||
function isTextInput(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return (el as HTMLElement).isContentEditable === true;
|
||||
}
|
||||
|
||||
export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
|
||||
const handler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!enabled || !onAddKeyframe) return;
|
||||
if (isTextInput(document.activeElement)) return;
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
||||
if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
|
||||
// K is also playback's stop key: claiming it here keeps playback from also pausing.
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getTimelineScrollLeftForZoomAnchor,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
shouldShowTimelineShortcutHint,
|
||||
shouldHandleTimelineDeleteKey,
|
||||
shouldAutoScrollTimeline,
|
||||
getTimelineVisibleTimeRange,
|
||||
getTimelineScrollTopForGeometryChange,
|
||||
@@ -1248,26 +1247,6 @@ describe("shouldShowTimelineShortcutHint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldHandleTimelineDeleteKey", () => {
|
||||
it("handles Delete and Backspace when focus is not in an editor", () => {
|
||||
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
||||
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores modifier shortcuts", () => {
|
||||
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
||||
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores input and editable targets", () => {
|
||||
const input = { tagName: "INPUT", isContentEditable: false };
|
||||
const editable = { tagName: "DIV", isContentEditable: true };
|
||||
|
||||
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
||||
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultDroppedTrack", () => {
|
||||
it("defaults to track 0 when there are no rows yet", () => {
|
||||
expect(getDefaultDroppedTrack([])).toBe(0);
|
||||
|
||||
@@ -26,7 +26,6 @@ export {
|
||||
getTimelineCanvasHeight,
|
||||
shouldShowTimelineShortcutHint,
|
||||
resolveTimelineAssetDrop,
|
||||
shouldHandleTimelineDeleteKey,
|
||||
getDefaultDroppedTrack,
|
||||
} from "./timelineLayout";
|
||||
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
|
||||
|
||||
@@ -506,34 +506,6 @@ export function shouldShowTimelineShortcutHint(
|
||||
return scrollHeight - clientHeight <= 1;
|
||||
}
|
||||
|
||||
export function shouldHandleTimelineDeleteKey(input: {
|
||||
key: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
target?: EventTarget | null;
|
||||
}): boolean {
|
||||
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
||||
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
||||
const target =
|
||||
input.target && typeof input.target === "object"
|
||||
? (input.target as {
|
||||
tagName?: string;
|
||||
isContentEditable?: boolean;
|
||||
closest?: (selector: string) => Element | null;
|
||||
})
|
||||
: null;
|
||||
if (target) {
|
||||
const tag = target.tagName?.toLowerCase() ?? "";
|
||||
if (target.isContentEditable) return false;
|
||||
if (["input", "textarea", "select"].includes(tag)) return false;
|
||||
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Asset drop ───────────────────────────────────────────────────── */
|
||||
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
||||
if (trackOrder.length === 0) return 0;
|
||||
|
||||
@@ -261,3 +261,46 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
||||
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePlaybackKeyboard — a focused native player owns its keys", () => {
|
||||
const KEYS = [
|
||||
{ code: "ArrowLeft", key: "ArrowLeft" },
|
||||
{ code: "ArrowRight", key: "ArrowRight" },
|
||||
{ code: "Space", key: " " },
|
||||
{ code: "KeyJ", key: "j" },
|
||||
{ code: "KeyK", key: "k" },
|
||||
{ code: "KeyL", key: "l" },
|
||||
];
|
||||
|
||||
it.each(["video", "audio"])(
|
||||
"leaves the timeline alone while a <%s controls> has focus",
|
||||
(tag) => {
|
||||
const { dispatch, spies } = setupHook();
|
||||
const player = document.createElement(tag);
|
||||
player.setAttribute("controls", "");
|
||||
document.body.append(player);
|
||||
const before = usePlayerStore.getState().currentTime;
|
||||
for (const init of KEYS) {
|
||||
const event = keydown(init);
|
||||
Object.defineProperty(event, "target", { value: player });
|
||||
dispatch(event);
|
||||
expect(event.defaultPrevented, init.code).toBe(false);
|
||||
}
|
||||
expect(spies.seek).not.toHaveBeenCalled();
|
||||
expect(spies.play).not.toHaveBeenCalled();
|
||||
expect(spies.playBackward).not.toHaveBeenCalled();
|
||||
expect(spies.pause).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().currentTime).toBe(before);
|
||||
},
|
||||
);
|
||||
|
||||
it("still steps the timeline from a player without controls", () => {
|
||||
const { dispatch } = setupHook();
|
||||
const video = document.createElement("video");
|
||||
document.body.append(video);
|
||||
const event = keydown({ code: "ArrowRight", key: "ArrowRight" });
|
||||
Object.defineProperty(event, "target", { value: video });
|
||||
dispatch(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* is active and the user is navigating caption segments).
|
||||
*/
|
||||
|
||||
import { isTypingTarget } from "../../utils/typingTarget";
|
||||
import { ownsPlainKeys } from "../../utils/typingTarget";
|
||||
|
||||
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
||||
|
||||
@@ -15,21 +15,17 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
||||
"a[href]",
|
||||
"[role='button']",
|
||||
"[role='checkbox']",
|
||||
"[role='combobox']",
|
||||
"[role='menuitem']",
|
||||
// Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
|
||||
"[role='menuitemradio']",
|
||||
"[role='radio']",
|
||||
"[role='slider']",
|
||||
"[role='spinbutton']",
|
||||
"[role='switch']",
|
||||
"[role='textbox']",
|
||||
].join(",");
|
||||
|
||||
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
||||
// Anything the user is typing into owns its keys outright, editable elements
|
||||
// included: a letter claimed here never reaches the text.
|
||||
if (isTypingTarget(target)) return true;
|
||||
// included: a letter claimed here never reaches the text. So does a native player.
|
||||
if (ownsPlainKeys(target)) return true;
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const candidate = target as { closest?: unknown };
|
||||
if (typeof candidate.closest !== "function") return false;
|
||||
|
||||
@@ -59,3 +59,16 @@ describe("isTypingTarget", () => {
|
||||
expect(isTypingTarget({} as EventTarget)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownsPlainKeys", () => {
|
||||
it("is true for a native player with controls and for anything typing claims", () => {
|
||||
expect(ownsPlainKeys(mount("<video controls></video>"))).toBe(true);
|
||||
expect(ownsPlainKeys(mount("<audio controls></audio>"))).toBe(true);
|
||||
expect(ownsPlainKeys(mount("<input />"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a player without controls and for plain elements", () => {
|
||||
expect(ownsPlainKeys(mount("<video></video>"))).toBe(false);
|
||||
expect(ownsPlainKeys(mount("<div></div>"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,14 @@ export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
return element.closest(TYPING_SELECTOR) !== null;
|
||||
}
|
||||
|
||||
/** Plain keys (arrows, Delete) also belong to a focused slider; Cmd shortcuts still do not. */
|
||||
/** Plain keys (arrows, Space, Delete) also belong to a focused slider or player; Cmd shortcuts still do not. */
|
||||
export function ownsPlainKeys(target: EventTarget | null): boolean {
|
||||
return isTypingTarget(target) || asElement(target)?.closest("[role='slider']") != null;
|
||||
return isTypingTarget(target) || asElement(target)?.closest(KEY_OWNING_CONTROLS) != null;
|
||||
}
|
||||
|
||||
// A slider steps and a native player seeks and plays with these keys themselves.
|
||||
const KEY_OWNING_CONTROLS = "[role='slider'],video[controls],audio[controls]";
|
||||
|
||||
/**
|
||||
* Things a keystroke belongs to rather than to a shortcut. `contenteditable` is
|
||||
* matched by value as well, for a host whose own property is not yet true.
|
||||
|
||||
Reference in New Issue
Block a user