mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 04:07:03 +00:00
feat(studio): every editor and player shortcut is remappable (#6966)
* feat(studio): shortcut combos in the key resolver * feat(studio): every editor shortcut is a remappable combo * refactor(studio): playback keys dispatch through one table * fix(studio): combo parsing without backtracking, symbol keys match, fewer comments * style(studio): format the shortcut files * chore(studio): drop a doc comment the ratchet counts * chore(studio): drop a doc comment the ratchet counts * fix(studio): modifier combos fire for playback keys, caption guard and held stop key follow the map
This commit is contained in:
@@ -2,7 +2,8 @@ import { memo, useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
||||
import { useCaptionStore } from "../store";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { shouldHandleCaptionNudgeKey, isEditableEventTarget } from "../keyboard";
|
||||
import { resolveCaptionNudge, isEditableEventTarget } from "../keyboard";
|
||||
import { matchesKey } from "../../player/components/studioShortcuts";
|
||||
import {
|
||||
readWordBoxes,
|
||||
getWordEl,
|
||||
@@ -171,7 +172,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
const { selectedSegmentIds: sel, model: m } = store;
|
||||
|
||||
// Escape: cancel an in-flight drag first, else clear the selection.
|
||||
if (e.key === "Escape") {
|
||||
if (matchesKey(e, "cancel")) {
|
||||
if (cancelActiveDrag()) {
|
||||
e.preventDefault();
|
||||
scheduleTick();
|
||||
@@ -185,28 +186,20 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
}
|
||||
|
||||
// ⌘A / Ctrl+A selects every caption word (unless typing in a field).
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key.toLowerCase() === "a" &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!isEditableEventTarget(e.target)
|
||||
) {
|
||||
if (matchesKey(e, "captionSelectAll") && !isEditableEventTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
store.selectAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sel.size === 0 || !m) return;
|
||||
const arrow = e.key;
|
||||
// Pass the event target so arrows inside inputs keep their native
|
||||
// behavior instead of nudging the canvas word.
|
||||
if (!shouldHandleCaptionNudgeKey(e, e.target)) return;
|
||||
const nudge = resolveCaptionNudge(e, e.target);
|
||||
if (!nudge) return;
|
||||
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const dx = arrow === "ArrowLeft" ? -step : arrow === "ArrowRight" ? step : 0;
|
||||
const dy = arrow === "ArrowUp" ? -step : arrow === "ArrowDown" ? step : 0;
|
||||
const { dx, dy } = nudge;
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
const win = iframe?.contentWindow;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setStudioKeys } from "../player/components/studioShortcuts";
|
||||
import { shouldHandleCaptionNudgeKey } from "./keyboard";
|
||||
|
||||
function mockKeyboardEvent(
|
||||
@@ -60,3 +61,13 @@ describe("shouldHandleCaptionNudgeKey", () => {
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"), player)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldHandleCaptionNudgeKey: remapped", () => {
|
||||
afterEach(() => setStudioKeys(null));
|
||||
|
||||
it("follows the canvas nudge keys", () => {
|
||||
setStudioKeys({ nudgeUp: "u" });
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"))).toBe(false);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("u"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { isEditableTarget } from "../utils/timelineDiscovery";
|
||||
import { ownsPlainKeys } from "../utils/typingTarget";
|
||||
import { resolveCanvasNudgeDelta } from "../components/editor/domEditNudge";
|
||||
|
||||
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
||||
type CaptionNudgeKeyEvent = Parameters<typeof resolveCanvasNudgeDelta>[0];
|
||||
|
||||
type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
|
||||
export function resolveCaptionNudge(event: CaptionNudgeKeyEvent, target?: EventTarget | null) {
|
||||
if (target != null && ownsPlainKeys(target)) return null;
|
||||
return resolveCanvasNudgeDelta(event);
|
||||
}
|
||||
|
||||
export function shouldHandleCaptionNudgeKey(
|
||||
export const shouldHandleCaptionNudgeKey = (
|
||||
event: CaptionNudgeKeyEvent,
|
||||
target?: EventTarget | null,
|
||||
): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (target != null && ownsPlainKeys(target)) return false;
|
||||
return CAPTION_NUDGE_KEYS.has(event.key);
|
||||
}
|
||||
): boolean => resolveCaptionNudge(event, target) !== null;
|
||||
|
||||
// Single editable-target check for the caption surface — re-exported from the
|
||||
// shared timeline helper so the two never diverge (it also covers ARIA
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { matchesKey } from "../player/components/studioShortcuts";
|
||||
import { PreviewPane } from "./nle/PreviewPane";
|
||||
import { TimelinePane } from "./nle/TimelinePane";
|
||||
import { PreviewOverlays } from "./nle/PreviewOverlays";
|
||||
@@ -293,7 +294,7 @@ function EditorShellBody({
|
||||
// Keyboard: Escape to pop composition level
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && compositionStack.length > 1) {
|
||||
if (matchesKey(e, "cancel") && compositionStack.length > 1) {
|
||||
updateCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ import { LinkedSelectionToggle } from "./LinkedSelectionToggle";
|
||||
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
||||
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
import { getStudioKey, keyHint, useStudioKeys } from "../player/components/studioShortcuts";
|
||||
import { keyHint, matchesKey, useStudioKeys } from "../player/components/studioShortcuts";
|
||||
import type { TimelineProps } from "../player/components/TimelineTypes";
|
||||
|
||||
interface DomEditSessionSlice extends EnableKeyframesSession {
|
||||
@@ -213,8 +213,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
||||
// Skip when typing in an input/contenteditable.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() !== getStudioKey("timelineSnap")) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (!matchesKey(e, "timelineSnap")) return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
const store = usePlayerStore.getState();
|
||||
store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
/** The action buttons in the inspector header: visibility, Ungroup (groups only), copy, clear. */
|
||||
import { keyHint, useStudioKeys } from "../../player/components/studioShortcuts";
|
||||
|
||||
export function InspectorHeaderActions({
|
||||
element,
|
||||
copied,
|
||||
@@ -53,7 +55,7 @@ export function InspectorHeaderActions({
|
||||
track("button", "Ungroup");
|
||||
onUngroup();
|
||||
}}
|
||||
title="Ungroup (⌘⇧G)"
|
||||
title={`Ungroup (${keyHint("ungroup", useStudioKeys())})`}
|
||||
className="flex h-6 items-center rounded-sm px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
|
||||
>
|
||||
Ungroup
|
||||
|
||||
@@ -9,6 +9,8 @@ const ICON_COLOR_BY_KIND = {
|
||||
other: "text-panel-container",
|
||||
} as const;
|
||||
|
||||
import { keyHint, useStudioKeys } from "../../player/components/studioShortcuts";
|
||||
|
||||
export function PropertyPanelFlatHeader({
|
||||
name,
|
||||
meta,
|
||||
@@ -54,7 +56,7 @@ export function PropertyPanelFlatHeader({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Ungroup"
|
||||
title="Ungroup (⌘⇧G)"
|
||||
title={`Ungroup (${keyHint("ungroup", useStudioKeys())})`}
|
||||
onClick={() => {
|
||||
track("button", "Ungroup");
|
||||
onUngroup?.();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-i
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
|
||||
import { ownsPlainKeys } from "../../utils/typingTarget";
|
||||
import { getStudioKey, keyHint, useStudioKeys } from "../../player/components/studioShortcuts";
|
||||
import { keyHint, matchesKey, useStudioKeys } from "../../player/components/studioShortcuts";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const SnapToolbar = memo(function SnapToolbar() {
|
||||
@@ -53,12 +53,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
||||
if (e.defaultPrevented) return;
|
||||
if (ownsPlainKeys(e.target)) return;
|
||||
if (e.target instanceof HTMLIFrameElement) return;
|
||||
if (e.key === getStudioKey("snap") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (matchesKey(e, "snap")) {
|
||||
e.preventDefault();
|
||||
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
||||
if (!e.repeat) trackPreviewFeatureUsed("snapping", "keyboard");
|
||||
}
|
||||
if (e.key === getStudioKey("grid") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (matchesKey(e, "grid")) {
|
||||
e.preventDefault();
|
||||
updatePrefs({ gridVisible: !prefs.gridVisible });
|
||||
if (!e.repeat) trackPreviewFeatureUsed("grid", "keyboard");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setStudioKeys } from "../../player/components/studioShortcuts";
|
||||
import {
|
||||
CANVAS_NUDGE_SHIFT_STEP_PX,
|
||||
CANVAS_NUDGE_STEP_PX,
|
||||
@@ -78,3 +79,18 @@ describe("canCanvasNudgeTargets", () => {
|
||||
expect(canCanvasNudgeTargets([movable, locked])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanvasNudgeDelta: remapped keys", () => {
|
||||
afterEach(() => setStudioKeys(null));
|
||||
|
||||
it("a remapped direction and step key answer and the arrow goes dead", () => {
|
||||
setStudioKeys({ nudgeLeft: "h", nudgeRightTen: "alt+l" });
|
||||
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft"))).toBeNull();
|
||||
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("h"))).toEqual({ dx: -1, dy: 0 });
|
||||
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("l", { altKey: true }))).toEqual({
|
||||
dx: 10,
|
||||
dy: 0,
|
||||
});
|
||||
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight", { shiftKey: true }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* mutually exclusively (caption edit mode returns before the DOM overlay).
|
||||
*/
|
||||
|
||||
const CANVAS_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
||||
import { matchesKey, type StudioKeyAction } from "../../player/components/studioShortcuts";
|
||||
|
||||
export const CANVAS_NUDGE_STEP_PX = 1;
|
||||
export const CANVAS_NUDGE_SHIFT_STEP_PX = 10;
|
||||
@@ -16,22 +16,25 @@ export const CANVAS_NUDGE_COMMIT_DEBOUNCE_MS = 400;
|
||||
type CanvasNudgeKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"
|
||||
>;
|
||||
> & { code?: string };
|
||||
|
||||
/**
|
||||
* Arrow key → composition-px delta (Shift = 10). Null when the key is not a
|
||||
* plain/Shift arrow, so browser and app shortcut chords pass through.
|
||||
*/
|
||||
const NUDGES: ReadonlyArray<[StudioKeyAction, number, number, number]> = [
|
||||
["nudgeLeft", -1, 0, CANVAS_NUDGE_STEP_PX],
|
||||
["nudgeRight", 1, 0, CANVAS_NUDGE_STEP_PX],
|
||||
["nudgeUp", 0, -1, CANVAS_NUDGE_STEP_PX],
|
||||
["nudgeDown", 0, 1, CANVAS_NUDGE_STEP_PX],
|
||||
["nudgeLeftTen", -1, 0, CANVAS_NUDGE_SHIFT_STEP_PX],
|
||||
["nudgeRightTen", 1, 0, CANVAS_NUDGE_SHIFT_STEP_PX],
|
||||
["nudgeUpTen", 0, -1, CANVAS_NUDGE_SHIFT_STEP_PX],
|
||||
["nudgeDownTen", 0, 1, CANVAS_NUDGE_SHIFT_STEP_PX],
|
||||
];
|
||||
|
||||
/** Nudge key (default: arrows, Shift = 10) to a composition-px delta; null for any other key. */
|
||||
export function resolveCanvasNudgeDelta(
|
||||
event: CanvasNudgeKeyEvent,
|
||||
): { dx: number; dy: number } | null {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return null;
|
||||
if (!CANVAS_NUDGE_KEYS.has(event.key)) return null;
|
||||
const step = event.shiftKey ? CANVAS_NUDGE_SHIFT_STEP_PX : CANVAS_NUDGE_STEP_PX;
|
||||
return {
|
||||
dx: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
|
||||
dy: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
|
||||
};
|
||||
const hit = NUDGES.find(([action]) => matchesKey(event, action));
|
||||
return hit ? { dx: hit[1] * hit[3], dy: hit[2] * hit[3] } : null;
|
||||
}
|
||||
|
||||
export interface CanvasNudgeTarget {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
|
||||
import { matchesKey } from "../../player/components/studioShortcuts";
|
||||
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
|
||||
// fallow-ignore-file code-duplication
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -233,7 +234,7 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
||||
|
||||
useEffect(() => {
|
||||
const cancelBandBeforeHostEscape = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" || !marqueeRef.current) return;
|
||||
if (!matchesKey(e, "cancel") || !marqueeRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancel();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { razorClaimsKey } from "../player/lib/playbackShortcuts";
|
||||
import { getStudioKeys, keyHint } from "../player/components/studioShortcuts";
|
||||
import { getStudioKeys, keyHint, matchesKey } from "../player/components/studioShortcuts";
|
||||
import { zoomTimelineToSelectedClips } from "../player/components/timelineZoomInput";
|
||||
import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
|
||||
import type { LinkShortcutCallbacks } from "./linkShortcuts";
|
||||
@@ -19,18 +19,11 @@ import type { LinkShortcutCallbacks } from "./linkShortcuts";
|
||||
// hook still owns the actual keydown listeners and calls into these.
|
||||
|
||||
function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onUndo();
|
||||
return true;
|
||||
}
|
||||
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
||||
event.preventDefault();
|
||||
onRedo();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
const isUndo = matchesKey(event, "undo");
|
||||
if (!isUndo && !matchesKey(event, "redo") && !matchesKey(event, "redoAlt")) return false;
|
||||
event.preventDefault();
|
||||
(isUndo ? onUndo : onRedo)();
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface HotkeyCallbacks extends LinkShortcutCallbacks {
|
||||
@@ -60,17 +53,80 @@ function timelineOwnsKey(event: KeyboardEvent): boolean {
|
||||
return event.target instanceof Element && event.target.closest("[data-studio-timeline]") !== null;
|
||||
}
|
||||
|
||||
/** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
|
||||
* arbitration between an automation range and the clip clipboard be asserted
|
||||
* without standing up the whole hook. */
|
||||
function dispatchTabKey(event: KeyboardEvent): boolean {
|
||||
const tab = matchesKey(event, "tabCompositions")
|
||||
? ["tab_compositions", "compositions"]
|
||||
: matchesKey(event, "tabAssets")
|
||||
? ["tab_assets", "assets"]
|
||||
: null;
|
||||
if (!tab) return false;
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: tab[0] });
|
||||
useDockLayoutStore.getState().activatePanel(tab[1] as "compositions" | "assets");
|
||||
return true;
|
||||
}
|
||||
|
||||
function dispatchGroupKey(event: KeyboardEvent, cb: HotkeyCallbacks): boolean {
|
||||
const group = matchesKey(event, "group");
|
||||
if (!group && !matchesKey(event, "ungroup")) return false;
|
||||
if (isTypingTarget(event.target)) return false;
|
||||
event.preventDefault();
|
||||
if (cb.readOnlyPreview) return true;
|
||||
if (group) cb.onGroupSelection?.();
|
||||
else cb.onUngroupSelection?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function dispatchClipboardKey(event: KeyboardEvent, cb: HotkeyCallbacks): boolean {
|
||||
if (isEditableTarget(event.target)) return false;
|
||||
const copy = matchesKey(event, "copy");
|
||||
const paste = matchesKey(event, "paste");
|
||||
const cut = matchesKey(event, "cut");
|
||||
const duplicate = matchesKey(event, "duplicate");
|
||||
if (!copy && !paste && !cut && !duplicate) return false;
|
||||
// An active automation range owns copy/paste: this capture listener runs before its own, so without
|
||||
// this both clipboards raced the same key. No preventDefault, so the downstream handler still sees it.
|
||||
if (automationOwnsKey(event)) return true;
|
||||
if (copy) {
|
||||
if (cb.handleCopy()) {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "copy" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (cb.readOnlyPreview && cb.domEditSelectionRef.current !== null && !timelineOwnsKey(event)) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
const hasSelection =
|
||||
usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current;
|
||||
if (paste) {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "paste" });
|
||||
void cb.handlePaste();
|
||||
} else if (cut && hasSelection) {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "cut" });
|
||||
void cb.handleCut();
|
||||
} else if (duplicate) {
|
||||
// Own this key even with nothing selected, or the browser's bookmark shortcut fires over the editor.
|
||||
event.preventDefault();
|
||||
if (usePlayerStore.getState().selectedElementId) {
|
||||
trackStudioEvent("keyboard_shortcut", { action: "duplicate" });
|
||||
void cb.handleDuplicate();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Exported for tests: the shortcuts that act on the editor, each read as a combo at event time. */
|
||||
export function dispatchModifierKey(
|
||||
event: KeyboardEvent,
|
||||
key: string,
|
||||
_key: string,
|
||||
cb: HotkeyCallbacks,
|
||||
): boolean {
|
||||
if (
|
||||
!shouldIgnoreHistoryShortcut(event.target) &&
|
||||
handleUndoRedoKey(
|
||||
if (!shouldIgnoreHistoryShortcut(event.target)) {
|
||||
const handled = handleUndoRedoKey(
|
||||
event,
|
||||
() => {
|
||||
trackStudioEvent("keyboard_shortcut", { action: "undo" });
|
||||
@@ -80,92 +136,26 @@ export function dispatchModifierKey(
|
||||
trackStudioEvent("keyboard_shortcut", { action: "redo" });
|
||||
void cb.handleRedo();
|
||||
},
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
if (event.key === "1") {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
|
||||
useDockLayoutStore.getState().activatePanel("compositions");
|
||||
return true;
|
||||
);
|
||||
if (handled) return true;
|
||||
}
|
||||
if (event.key === "2") {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
|
||||
useDockLayoutStore.getState().activatePanel("assets");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
if (cb.readOnlyPreview) return true;
|
||||
if (event.shiftKey) cb.onUngroupSelection?.();
|
||||
else cb.onGroupSelection?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
|
||||
// An active automation range owns Cmd+C/Cmd+V, same as Delete below: this
|
||||
// capture listener runs before useAutomationSelectionKeyboard's, so without
|
||||
// this both clipboards raced the same key. No preventDefault, so the
|
||||
// downstream handler still sees it.
|
||||
if (automationOwnsKey(event)) return true;
|
||||
if (key === "c") {
|
||||
if (cb.handleCopy()) {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "copy" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const previewOwnsMutation =
|
||||
cb.readOnlyPreview && cb.domEditSelectionRef.current !== null && !timelineOwnsKey(event);
|
||||
if (previewOwnsMutation && ["v", "x", "d"].includes(key)) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "paste" });
|
||||
void cb.handlePaste();
|
||||
return true;
|
||||
}
|
||||
if (key === "x") {
|
||||
if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
|
||||
event.preventDefault();
|
||||
trackStudioEvent("keyboard_shortcut", { action: "cut" });
|
||||
void cb.handleCut();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key === "d") {
|
||||
// Always own this key here, even with nothing selected — otherwise the
|
||||
// browser's own Cmd+D (bookmark this page) fires over the editor.
|
||||
event.preventDefault();
|
||||
if (usePlayerStore.getState().selectedElementId) {
|
||||
trackStudioEvent("keyboard_shortcut", { action: "duplicate" });
|
||||
void cb.handleDuplicate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return dispatchTabKey(event) || dispatchGroupKey(event, cb) || dispatchClipboardKey(event, cb);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
/** Exported for tests: the unmodified-key half of the dispatcher, so the
|
||||
* Delete arbitration between keyframes, an automation range and the clip can
|
||||
* be asserted without standing up the whole hook. */
|
||||
export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
|
||||
export function dispatchPlainKey(event: KeyboardEvent, _key: string, cb: HotkeyCallbacks): void {
|
||||
if (ownsPlainKeys(event.target)) return;
|
||||
const keys = getStudioKeys();
|
||||
if (key === keys.zoomSelection && !event.shiftKey && !event.altKey) {
|
||||
if (matchesKey(event, "zoomSelection")) {
|
||||
if (!zoomTimelineToSelectedClips()) return;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === keys.fullscreen && !event.shiftKey && !event.altKey) {
|
||||
if (matchesKey(event, "fullscreen")) {
|
||||
event.preventDefault();
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else
|
||||
@@ -173,7 +163,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === keys.split && !event.altKey) {
|
||||
if (matchesKey(event, "split")) {
|
||||
// Reserve bare `s` for Split even when the current selection cannot split,
|
||||
// so secondary listeners do not reinterpret the same key as Snap toggle.
|
||||
event.preventDefault();
|
||||
@@ -204,46 +194,43 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
||||
}
|
||||
|
||||
// Only with a sound clip selected, so bare G still reaches the canvas grid toggle otherwise.
|
||||
if (key === keys.audioGain && !event.shiftKey && !event.altKey) {
|
||||
if (matchesKey(event, "audioGain")) {
|
||||
if (!cb.readOnlyPreview && openAudioGainDialog()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === keys.razorTool && !event.shiftKey && !event.altKey) {
|
||||
if (matchesKey(event, "razorTool")) {
|
||||
event.preventDefault();
|
||||
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
||||
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === keys.selectTool && !event.shiftKey && !event.altKey) {
|
||||
if (matchesKey(event, "selectTool")) {
|
||||
event.preventDefault();
|
||||
usePlayerStore.getState().setActiveTool("select");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(key === keys.selectLeftward || key === keys.selectRightward) &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
const leftward = matchesKey(event, "selectLeftward");
|
||||
if (leftward || matchesKey(event, "selectRightward")) {
|
||||
event.preventDefault();
|
||||
const store = usePlayerStore.getState();
|
||||
if (key === keys.selectLeftward) store.selectLeftward();
|
||||
if (leftward) store.selectLeftward();
|
||||
else store.selectRightward();
|
||||
return;
|
||||
}
|
||||
|
||||
// CapCut returns to the select tool with "A"; playback's seek-to-in asks the same razorClaimsKey.
|
||||
if (razorClaimsKey(event, key)) {
|
||||
if (razorClaimsKey(event)) {
|
||||
event.preventDefault();
|
||||
usePlayerStore.getState().setActiveTool("select");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (matchesKey(event, "cancel")) {
|
||||
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
|
||||
usePlayerStore.getState();
|
||||
if (activeTool === "razor") {
|
||||
@@ -254,7 +241,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && !event.altKey) {
|
||||
if (matchesKey(event, "deleteSelected") || matchesKey(event, "deleteSelectedAlt")) {
|
||||
if (usePlayerStore.getState().selectedKeyframes.size > 0) {
|
||||
cb.onDeleteSelectedKeyframes();
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
@@ -266,7 +253,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
||||
// is what lets the document-level handler still see the key instead of
|
||||
// the press reaching the clip delete below.
|
||||
if (usePlayerStore.getState().automationSelection) return;
|
||||
if (event.key === "Backspace") {
|
||||
if (matchesKey(event, "resetKeyframes")) {
|
||||
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
|
||||
if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
|
||||
event.preventDefault();
|
||||
@@ -299,7 +286,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === keys.record && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
|
||||
if (matchesKey(event, "record") && cb.onToggleRecording) {
|
||||
event.preventDefault();
|
||||
cb.onToggleRecording();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { dispatchLinkShortcut } from "./linkShortcuts";
|
||||
import { setStudioKeys } from "../player/components/studioShortcuts";
|
||||
|
||||
const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
||||
id,
|
||||
@@ -84,3 +85,22 @@ describe("dispatchLinkShortcut", () => {
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchLinkShortcut: remapped combos", () => {
|
||||
afterEach(() => setStudioKeys(null));
|
||||
|
||||
it("link, detach and delete-one follow the map and the old combos go dead", () => {
|
||||
setStudioKeys({ linkToggle: "mod+u", detachAudio: "alt+shift+u", deleteLinked: "alt+x" });
|
||||
const video = clip("talk", "video", { muted: true, link: "lk-1", hasAudio: true });
|
||||
select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
|
||||
const handleLinkEdit = vi.fn();
|
||||
const handleTimelineElementDeleteOnly = vi.fn();
|
||||
const cb = { handleLinkEdit, handleTimelineElementDeleteOnly };
|
||||
expect(dispatchLinkShortcut(key({ metaKey: true, key: "l" }), cb)).toBe(false);
|
||||
expect(dispatchLinkShortcut(key({ metaKey: true, key: "u" }), cb)).toBe(true);
|
||||
expect(handleLinkEdit).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchLinkShortcut(key({ altKey: true, key: "Backspace" }), cb)).toBe(false);
|
||||
expect(dispatchLinkShortcut(key({ altKey: true, key: "≈", code: "KeyX" }), cb)).toBe(true);
|
||||
expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,24 +2,24 @@ import { usePlayerStore } from "../player";
|
||||
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
|
||||
import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
import { keyHint, matchesKey } from "../player/components/studioShortcuts";
|
||||
|
||||
export interface LinkShortcutCallbacks {
|
||||
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
||||
handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
|
||||
}
|
||||
|
||||
const isDeleteKey = (event: KeyboardEvent) => event.key === "Backspace" || event.key === "Delete";
|
||||
const hasMod = (event: KeyboardEvent) => event.metaKey || event.ctrlKey;
|
||||
const ACTIONS = [
|
||||
["linkToggle", "linkToggle"],
|
||||
["detachAudio", "detachAudio"],
|
||||
["deleteLinked", "deleteLinked"],
|
||||
["deleteLinkedAlt", "deleteLinked"],
|
||||
] as const;
|
||||
|
||||
const SHORTCUTS: ReadonlyArray<[string, (event: KeyboardEvent) => boolean]> = [
|
||||
["⌘L", (e) => hasMod(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "l"],
|
||||
["⌥⇧D", (e) => !hasMod(e) && e.altKey && e.shiftKey && e.code === "KeyD"],
|
||||
["⌥⌫", (e) => !hasMod(e) && e.altKey && !e.shiftKey && isDeleteKey(e)],
|
||||
];
|
||||
|
||||
function shortcutOf(event: KeyboardEvent): string | null {
|
||||
return SHORTCUTS.find(([, matches]) => matches(event))?.[0] ?? null;
|
||||
}
|
||||
const shortcutOf = (event: KeyboardEvent): string | null => {
|
||||
const hit = ACTIONS.find(([action]) => matchesKey(event, action));
|
||||
return hit ? keyHint(hit[1]) : null;
|
||||
};
|
||||
|
||||
/** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
|
||||
export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
||||
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
||||
import type { TimelineElement } from "../player/store/timelineElement";
|
||||
import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
|
||||
import { setStudioKeys } from "../player/components/studioShortcuts";
|
||||
import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
|
||||
import {
|
||||
@@ -641,3 +642,145 @@ describe("editor keys the host can remap", () => {
|
||||
expect(split.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every app shortcut is a remappable combo", () => {
|
||||
const dock = vi.fn();
|
||||
const ev = (init: KeyboardEventInit) =>
|
||||
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||
const select = () => {
|
||||
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
||||
};
|
||||
const rows: Array<{
|
||||
action: string;
|
||||
combo: string;
|
||||
press: KeyboardEventInit;
|
||||
moved: KeyboardEventInit;
|
||||
hit: (cb: ReturnType<typeof callbacks>) => unknown;
|
||||
}> = [
|
||||
{
|
||||
action: "undo",
|
||||
combo: "mod+u",
|
||||
press: { key: "z", metaKey: true },
|
||||
moved: { key: "u", metaKey: true },
|
||||
hit: (c) => c.handleUndo,
|
||||
},
|
||||
{
|
||||
action: "redo",
|
||||
combo: "mod+shift+u",
|
||||
press: { key: "z", metaKey: true, shiftKey: true },
|
||||
moved: { key: "u", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.handleRedo,
|
||||
},
|
||||
{
|
||||
action: "redoAlt",
|
||||
combo: "ctrl+shift+y",
|
||||
press: { key: "y", ctrlKey: true },
|
||||
moved: { key: "y", ctrlKey: true, shiftKey: true },
|
||||
hit: (c) => c.handleRedo,
|
||||
},
|
||||
{
|
||||
action: "copy",
|
||||
combo: "mod+shift+c",
|
||||
press: { key: "c", metaKey: true },
|
||||
moved: { key: "c", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.handleCopy,
|
||||
},
|
||||
{
|
||||
action: "paste",
|
||||
combo: "mod+shift+v",
|
||||
press: { key: "v", metaKey: true },
|
||||
moved: { key: "v", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.handlePaste,
|
||||
},
|
||||
{
|
||||
action: "cut",
|
||||
combo: "mod+shift+x",
|
||||
press: { key: "x", metaKey: true },
|
||||
moved: { key: "x", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.handleCut,
|
||||
},
|
||||
{
|
||||
action: "duplicate",
|
||||
combo: "mod+shift+d",
|
||||
press: { key: "d", metaKey: true },
|
||||
moved: { key: "d", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.handleDuplicate,
|
||||
},
|
||||
{
|
||||
action: "group",
|
||||
combo: "mod+shift+k",
|
||||
press: { key: "g", metaKey: true },
|
||||
moved: { key: "k", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.onGroupSelection,
|
||||
},
|
||||
{
|
||||
action: "ungroup",
|
||||
combo: "mod+shift+j",
|
||||
press: { key: "g", metaKey: true, shiftKey: true },
|
||||
moved: { key: "j", metaKey: true, shiftKey: true },
|
||||
hit: (c) => c.onUngroupSelection,
|
||||
},
|
||||
{
|
||||
action: "tabCompositions",
|
||||
combo: "mod+3",
|
||||
press: { key: "1", metaKey: true },
|
||||
moved: { key: "3", metaKey: true },
|
||||
hit: () => dock,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
dock.mockClear();
|
||||
useDockLayoutStore.setState({ activatePanel: dock });
|
||||
});
|
||||
|
||||
it.each(rows)("$action answers its default and only its new combo once moved", (row) => {
|
||||
for (const [init, expected] of [
|
||||
[row.press, true],
|
||||
[row.moved, false],
|
||||
] as const) {
|
||||
select();
|
||||
const cb = callbacks({ handleCopy: vi.fn(() => true) });
|
||||
dispatchModifierKey(ev(init), (init.key ?? "").toLowerCase(), cb);
|
||||
expect(Boolean(vi.mocked(row.hit(cb) as never).mock.calls.length), row.action).toBe(expected);
|
||||
dock.mockClear();
|
||||
}
|
||||
setStudioKeys({ [row.action]: row.combo } as never);
|
||||
for (const [init, expected] of [
|
||||
[row.press, false],
|
||||
[row.moved, true],
|
||||
] as const) {
|
||||
select();
|
||||
const cb = callbacks({ handleCopy: vi.fn(() => true) });
|
||||
dispatchModifierKey(ev(init), (init.key ?? "").toLowerCase(), cb);
|
||||
expect(Boolean(vi.mocked(row.hit(cb) as never).mock.calls.length), row.action).toBe(expected);
|
||||
dock.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it("Delete, Backspace and the cancel key are combos too", () => {
|
||||
select();
|
||||
let cb = callbacks();
|
||||
dispatchPlainKey(ev({ key: "Delete", code: "Delete" }), "delete", cb);
|
||||
expect(cb.handleTimelineElementsDelete).toHaveBeenCalledTimes(1);
|
||||
setStudioKeys({ deleteSelected: "x", deleteSelectedAlt: "shift+x" });
|
||||
cb = callbacks();
|
||||
dispatchPlainKey(ev({ key: "Delete", code: "Delete" }), "delete", cb);
|
||||
dispatchPlainKey(ev({ key: "Backspace", code: "Backspace" }), "backspace", cb);
|
||||
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
||||
dispatchPlainKey(ev({ key: "x", code: "KeyX" }), "x", cb);
|
||||
expect(cb.handleTimelineElementsDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancel (Escape by default) returns the armed razor to select, and only the new key does once moved", () => {
|
||||
usePlayerStore.getState().setActiveTool("razor");
|
||||
dispatchPlainKey(ev({ key: "Escape", code: "Escape" }), "escape", callbacks());
|
||||
expect(usePlayerStore.getState().activeTool).toBe("select");
|
||||
setStudioKeys({ cancel: "q" });
|
||||
usePlayerStore.getState().setActiveTool("razor");
|
||||
dispatchPlainKey(ev({ key: "Escape", code: "Escape" }), "escape", callbacks());
|
||||
expect(usePlayerStore.getState().activeTool).toBe("razor");
|
||||
dispatchPlainKey(ev({ key: "q", code: "KeyQ" }), "q", callbacks());
|
||||
expect(usePlayerStore.getState().activeTool).toBe("select");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,10 +226,7 @@ export function useAppHotkeys({
|
||||
const cb = cbRef.current;
|
||||
const key = event.key.toLowerCase();
|
||||
if (dispatchLinkShortcut(event, cb)) return;
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
dispatchModifierKey(event, key, cb);
|
||||
return;
|
||||
}
|
||||
if (dispatchModifierKey(event, key, cb)) return;
|
||||
dispatchPlainKey(event, key, cb);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@hyperframes/core/audio-automation";
|
||||
import { clampNumber } from "../utils/studioHelpers";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
import { matchesKey } from "../player/components/studioShortcuts";
|
||||
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
|
||||
import type {
|
||||
AutomationLaneBinding,
|
||||
@@ -40,17 +41,6 @@ import type {
|
||||
|
||||
type PlayerState = ReturnType<typeof usePlayerStore.getState>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* would silently drop the keystroke here while that dispatcher still acted on
|
||||
* it. Shift and Alt are excluded for the same parity reason (useAppHotkeys
|
||||
* gates its own copy/paste on `!shiftKey && !altKey`).
|
||||
*/
|
||||
function isChord(e: KeyboardEvent, letter: string): boolean {
|
||||
return (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === letter;
|
||||
}
|
||||
|
||||
/** A `TimelineElement`'s identity as the selection and lane bindings key by. */
|
||||
function elementKeyOf(element: TimelineElement): string {
|
||||
return element.key ?? element.id;
|
||||
@@ -221,7 +211,7 @@ function handlePaste(
|
||||
state: PlayerState,
|
||||
lanes: UseAutomationLanesResult,
|
||||
): boolean {
|
||||
if (!isChord(e, "v")) return false;
|
||||
if (!matchesKey(e, "paste")) return false;
|
||||
const clip = readClipboard(state.timelineProjectId);
|
||||
if (!clip) return false;
|
||||
const sel = state.automationSelection;
|
||||
@@ -271,7 +261,7 @@ function handleCopy(
|
||||
lanes: UseAutomationLanesResult,
|
||||
sel: AutomationSelection,
|
||||
): boolean {
|
||||
if (!isChord(e, "c")) return false;
|
||||
if (!matchesKey(e, "copy")) return false;
|
||||
const ctx = resolveSelectionContext(state, lanes, sel);
|
||||
if (!ctx) return false;
|
||||
if (!copyRange(state.timelineProjectId, ctx.lane, ctx.range, sel.t0, sel.t1)) return false;
|
||||
@@ -298,8 +288,8 @@ function handleCopy(
|
||||
export function automationOwnsKey(e: KeyboardEvent): boolean {
|
||||
if (isTypingTarget(document.activeElement)) return false;
|
||||
const state = usePlayerStore.getState();
|
||||
if (isChord(e, "c")) return state.automationSelection !== null;
|
||||
if (!isChord(e, "v")) return false;
|
||||
if (matchesKey(e, "copy")) return state.automationSelection !== null;
|
||||
if (!matchesKey(e, "paste")) return false;
|
||||
const clip = readClipboard(state.timelineProjectId);
|
||||
if (!clip) return false;
|
||||
const element = findElement(state.elements, state.selectedElementId);
|
||||
@@ -317,8 +307,7 @@ function handleDelete(
|
||||
lanes: UseAutomationLanesResult,
|
||||
sel: AutomationSelection,
|
||||
): boolean {
|
||||
const isDeleteKey = e.key === "Delete" || e.key === "Backspace";
|
||||
if (!isDeleteKey || e.metaKey || e.ctrlKey) return false;
|
||||
if (!matchesKey(e, "deleteSelected") && !matchesKey(e, "deleteSelectedAlt")) return false;
|
||||
const write = resolveDeleteWrite(state, lanes, sel);
|
||||
if (!write) return false;
|
||||
e.preventDefault();
|
||||
@@ -348,7 +337,7 @@ export function useAutomationSelectionKeyboard({
|
||||
const sel = state.automationSelection;
|
||||
if (!sel) return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (matchesKey(e, "cancel")) {
|
||||
state.clearAutomationSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { getStudioKey } from "../player/components/studioShortcuts";
|
||||
import { matchesKey } from "../player/components/studioShortcuts";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
|
||||
interface KeyframeKeyboardOptions {
|
||||
@@ -12,8 +12,7 @@ export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboard
|
||||
(e: KeyboardEvent) => {
|
||||
if (!enabled || !onAddKeyframe) return;
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
||||
if (e.key.toLowerCase() !== getStudioKey("addKeyframe")) return;
|
||||
if (!matchesKey(e, "addKeyframe")) return;
|
||||
// K is also playback's stop key: claiming it here keeps playback from also pausing.
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
|
||||
// Timeline clip deletion: the marquee/multi path and the single-clip wrapper
|
||||
// the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
|
||||
// it under the studio 600-line cap, following useTimelineAssetDropOps.
|
||||
import { keyHint } from "../player/components/studioShortcuts";
|
||||
import { useCallback, useRef, type MutableRefObject, type RefObject } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
@@ -216,7 +217,7 @@ export function useTimelineDeleteOps({
|
||||
if (rippleChanges && !rippleFailed && !rippleNoticeShownRef.current) {
|
||||
rippleNoticeShownRef.current = true;
|
||||
showToast(
|
||||
"Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
|
||||
`Ripple closed the gap on the main track. Undo (${keyHint("undo")}) restores it, or turn off ` +
|
||||
"Ripple in the timeline toolbar.",
|
||||
"info",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useSyncExternalStore } from "react";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import {
|
||||
deleteBeatAtCompositionTime,
|
||||
moveBeatCompositionTime,
|
||||
@@ -184,7 +185,7 @@ function handleBeatDragPointerCancel(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function handleBeatDragKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== "Escape" || !beatDragActor) return;
|
||||
if (!matchesKey(event, "cancel") || !beatDragActor) return;
|
||||
event.preventDefault();
|
||||
cancelBeatDrag();
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">{keyHint("copy", keys)}</span>
|
||||
</button>
|
||||
)}
|
||||
{onPaste && (
|
||||
@@ -231,7 +231,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Paste</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">{keyHint("paste", keys)}</span>
|
||||
</button>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
@@ -245,7 +245,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">{keyHint("duplicate", keys)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import {
|
||||
HF_AUDIO_FADE_IN_ATTR,
|
||||
HF_AUDIO_FADE_OUT_ATTR,
|
||||
@@ -329,7 +330,7 @@ export function TimelineClipFades({
|
||||
if (dragging === null) return;
|
||||
const cancelOnWindowEscape = (e: globalThis.KeyboardEvent) => {
|
||||
const g = gesture.current;
|
||||
if (e.key !== "Escape" || !g) return;
|
||||
if (!matchesKey(e, "cancel") || !g) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
gesture.current = null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { keyHint } from "./studioShortcuts";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import type { TimelineLinkEdit } from "./timelineCallbacks";
|
||||
import { linkedMembersOf } from "./audioClipLink";
|
||||
@@ -61,7 +62,7 @@ function linkItem(
|
||||
const suffix = partnerSuffix(element, linkPair);
|
||||
return {
|
||||
label: `Link${suffix && ` to${suffix}`}`,
|
||||
...(selectedPair ? { shortcut: "⌘L" } : {}),
|
||||
...(selectedPair ? { shortcut: keyHint("linkToggle") } : {}),
|
||||
run: () => onLinkEdit({ kind: "link", elements: linkPair }),
|
||||
};
|
||||
}
|
||||
@@ -82,7 +83,7 @@ export function resolveLinkMenuItems(input: {
|
||||
if (canDetachAudio(element)) {
|
||||
items.push({
|
||||
label: "Detach audio",
|
||||
shortcut: "⌥⇧D",
|
||||
shortcut: keyHint("detachAudio"),
|
||||
run: () => onLinkEdit({ kind: "detach", element }),
|
||||
});
|
||||
}
|
||||
@@ -90,7 +91,7 @@ export function resolveLinkMenuItems(input: {
|
||||
if (linked) {
|
||||
items.push({
|
||||
label: `Unlink${partnerSuffix(element, members) && ` from${partnerSuffix(element, members)}`}`,
|
||||
shortcut: "⌘L",
|
||||
shortcut: keyHint("linkToggle"),
|
||||
run: () => onLinkEdit({ kind: "unlink", elements: members }),
|
||||
});
|
||||
} else {
|
||||
@@ -108,7 +109,7 @@ export function resolveLinkMenuItems(input: {
|
||||
if (linked && onDeleteElementOnly) {
|
||||
items.push({
|
||||
label: "Delete this clip only",
|
||||
shortcut: "⌥⌫",
|
||||
shortcut: keyHint("deleteLinked"),
|
||||
destructive: true,
|
||||
run: () => onDeleteElementOnly(element),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatCombo, matchCombo, parseCombo } from "./studioCombo";
|
||||
import { getStudioKey, keyHint, matchesKey, setStudioKeys } from "./studioShortcuts";
|
||||
|
||||
afterEach(() => {
|
||||
setStudioKeys(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const press = (init: KeyboardEventInit) => new KeyboardEvent("keydown", init);
|
||||
const matches = (text: string, init: KeyboardEventInit) =>
|
||||
matchCombo(press(init), parseCombo(text)!);
|
||||
|
||||
describe("combo model", () => {
|
||||
it("parses characters, named keys and modifiers", () => {
|
||||
expect(parseCombo("B")).toMatchObject({ key: "b", named: false });
|
||||
expect(parseCombo("Space")).toMatchObject({ key: "Space", named: true });
|
||||
expect(parseCombo("mod+shift+z")).toMatchObject({ mod: true, shift: true, key: "z" });
|
||||
expect(parseCombo("cmd++")).toMatchObject({ meta: true, key: "+" });
|
||||
});
|
||||
|
||||
it("refuses what cannot be matched: modifier-only, unknown, empty, and Ctrl+Alt (AltGr on Windows)", () => {
|
||||
for (const bad of [
|
||||
"ctrl",
|
||||
"shift+",
|
||||
"",
|
||||
"mod+ab",
|
||||
"hyper+x",
|
||||
"ctrl+alt+x",
|
||||
"mod+alt+x",
|
||||
"Spacebar",
|
||||
]) {
|
||||
expect(parseCombo(bad), bad).toBeNull();
|
||||
}
|
||||
expect(parseCombo("cmd+alt+x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mod is Cmd or Ctrl; cmd and ctrl are exact; extra modifiers never match", () => {
|
||||
expect(matches("mod+c", { key: "c", metaKey: true })).toBe(true);
|
||||
expect(matches("mod+c", { key: "c", ctrlKey: true })).toBe(true);
|
||||
expect(matches("mod+c", { key: "c" })).toBe(false);
|
||||
expect(matches("mod+c", { key: "c", metaKey: true, shiftKey: true })).toBe(false);
|
||||
expect(matches("cmd+c", { key: "c", ctrlKey: true })).toBe(false);
|
||||
expect(matches("c", { key: "c", metaKey: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("characters match e.key (so layouts follow the typed letter), named keys match e.code", () => {
|
||||
expect(matches("a", { key: "a", code: "KeyQ" })).toBe(true);
|
||||
expect(matches("a", { key: "q", code: "KeyA" })).toBe(false);
|
||||
expect(matches("Space", { key: " ", code: "Space" })).toBe(true);
|
||||
expect(
|
||||
matches("shift+ArrowLeft", { key: "ArrowLeft", code: "ArrowLeft", shiftKey: true }),
|
||||
).toBe(true);
|
||||
expect(matches("alt+shift+d", { key: "∂", code: "KeyD", altKey: true, shiftKey: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("formats like the shortcut lists do", () => {
|
||||
expect(formatCombo(parseCombo("mod+shift+z")!)).toBe("⌘⇧Z");
|
||||
expect(formatCombo(parseCombo("alt+shift+d")!)).toBe("⌥⇧D");
|
||||
expect(formatCombo(parseCombo("ArrowLeft")!)).toBe("←");
|
||||
expect(formatCombo(parseCombo("[")!)).toBe("[");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolver with combos", () => {
|
||||
it("accepts a combo for an action that today takes a single key, and the list follows", () => {
|
||||
setStudioKeys({ fullscreen: "mod+shift+f" });
|
||||
expect(getStudioKey("fullscreen")).toBe("mod+shift+f");
|
||||
expect(keyHint("fullscreen")).toBe("⌘⇧F");
|
||||
expect(matchesKey(press({ key: "F", metaKey: true, shiftKey: true }), "fullscreen")).toBe(true);
|
||||
expect(matchesKey(press({ key: "f" }), "fullscreen")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a single character working and canonical", () => {
|
||||
setStudioKeys({ mute: "X" });
|
||||
expect(getStudioKey("mute")).toBe("x");
|
||||
expect(matchesKey(press({ key: "x" }), "mute")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a map with an AltGr combo or two actions on one combo, keeping every default", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
setStudioKeys({ mute: "ctrl+alt+m" });
|
||||
expect(getStudioKey("mute")).toBe("m");
|
||||
setStudioKeys({ mute: "mod+k", record: "cmd+k" });
|
||||
expect(getStudioKey("mute")).toBe("m");
|
||||
expect(getStudioKey("record")).toBe("r");
|
||||
});
|
||||
});
|
||||
|
||||
describe("combos with symbol keys", () => {
|
||||
it("matches a symbol whatever Shift it needs, and refuses shift+symbol", () => {
|
||||
expect(matches("?", { key: "?", shiftKey: true })).toBe(true);
|
||||
expect(matches("mod++", { key: "+", metaKey: true, shiftKey: true })).toBe(true);
|
||||
expect(parseCombo("shift+1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface Combo {
|
||||
mod: boolean;
|
||||
meta: boolean;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
key: string;
|
||||
named: boolean;
|
||||
}
|
||||
|
||||
const NAMED = new Set([
|
||||
"Space",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Delete",
|
||||
"Backspace",
|
||||
"Escape",
|
||||
"Enter",
|
||||
"Tab",
|
||||
]);
|
||||
const SYMBOL: Record<string, string> = {
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
Delete: "Del",
|
||||
Backspace: "⌫",
|
||||
Escape: "Esc",
|
||||
};
|
||||
const MODIFIERS = new Set(["mod", "cmd", "ctrl", "shift", "alt"]);
|
||||
|
||||
/** "mod+shift+z", "Space", "b": mod is Cmd or Ctrl; Ctrl+Alt is refused because Windows sends AltGr as Ctrl+Alt. */
|
||||
export function parseCombo(text: string): Combo | null {
|
||||
const split = text.length > 1 ? text.slice(0, -1).lastIndexOf("+") : -1;
|
||||
const tokens =
|
||||
split < 0
|
||||
? []
|
||||
: text
|
||||
.slice(0, split)
|
||||
.split("+")
|
||||
.map((t) => t.toLowerCase());
|
||||
const key = text.slice(split + 1);
|
||||
const named = NAMED.has(key);
|
||||
const symbol = !named && !/^[A-Za-z]$/.test(key);
|
||||
if (tokens.some((t) => !MODIFIERS.has(t)) || (!named && [...key].length !== 1)) return null;
|
||||
if (symbol && tokens.includes("shift")) return null;
|
||||
const combo = {
|
||||
mod: tokens.includes("mod"),
|
||||
meta: tokens.includes("cmd"),
|
||||
ctrl: tokens.includes("ctrl"),
|
||||
shift: tokens.includes("shift"),
|
||||
alt: tokens.includes("alt"),
|
||||
key: named ? key : key.toLowerCase(),
|
||||
named,
|
||||
};
|
||||
return combo.alt && (combo.ctrl || combo.mod) ? null : combo;
|
||||
}
|
||||
|
||||
/** The one string a combo is stored and compared as. */
|
||||
export function comboText(c: Combo): string {
|
||||
const mods = [
|
||||
c.mod && "mod",
|
||||
c.meta && "cmd",
|
||||
c.ctrl && "ctrl",
|
||||
c.alt && "alt",
|
||||
c.shift && "shift",
|
||||
];
|
||||
return [...mods.filter(Boolean), c.key].join("+");
|
||||
}
|
||||
|
||||
const namedMatches = (e: ComboEvent, name: string) =>
|
||||
e.code === name || e.key === (name === "Space" ? " " : name);
|
||||
|
||||
function charMatches(e: ComboEvent, c: Combo): boolean {
|
||||
if (e.key.toLowerCase() === c.key) return true;
|
||||
// Option rewrites e.key on macOS (Option+D is "∂"), so fall back to the physical key.
|
||||
const code = /^[0-9]$/.test(c.key) ? `Digit${c.key}` : `Key${c.key.toUpperCase()}`;
|
||||
return e.altKey && /^[a-z0-9]$/.test(c.key) && e.code === code;
|
||||
}
|
||||
|
||||
export type ComboEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey"
|
||||
> & {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export function matchCombo(e: ComboEvent, c: Combo): boolean {
|
||||
const symbol = !c.named && !/^[a-z]$/.test(c.key);
|
||||
if ((!symbol && !!e.shiftKey !== c.shift) || !!e.altKey !== c.alt) return false;
|
||||
const modsOk = c.mod ? e.metaKey || e.ctrlKey : !!e.metaKey === c.meta && !!e.ctrlKey === c.ctrl;
|
||||
return modsOk && (c.named ? namedMatches(e, c.key) : charMatches(e, c));
|
||||
}
|
||||
|
||||
export function formatCombo(c: Combo): string {
|
||||
const mods = `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.mod || c.meta ? "⌘" : ""}${c.shift ? "⇧" : ""}`;
|
||||
return mods + (SYMBOL[c.key] ?? (c.named ? c.key : c.key.toUpperCase()));
|
||||
}
|
||||
|
||||
/** Two combos clash when one press could satisfy both; Cmd and Ctrl both count as mod. */
|
||||
export const comboClashKey = (c: Combo) =>
|
||||
[c.mod || c.meta || c.ctrl, c.alt, c.shift, c.key].join("|");
|
||||
@@ -66,7 +66,7 @@ describe("studio key resolver", () => {
|
||||
expect(hintFor("Toggle loop")).toBe("⇧L");
|
||||
setStudioKeys({ shuttleForward: "y" });
|
||||
expect(hintFor("Play forward")).toBe("Y");
|
||||
expect(hintFor("Toggle loop")).toBe("⇧Y");
|
||||
expect(hintFor("Toggle loop")).toBe("⇧L");
|
||||
});
|
||||
|
||||
it("refuses a map that binds two actions to one key and keeps every default", () => {
|
||||
@@ -107,3 +107,18 @@ describe("studio key resolver", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists follow combos", () => {
|
||||
it("shows the remapped combos and keeps today's default text", () => {
|
||||
expect(hintFor("Undo")).toBe("⌘Z");
|
||||
expect(hintFor("Redo")).toBe("⌘⇧Z");
|
||||
expect(hintFor("Play / Pause")).toBe("Space");
|
||||
expect(hintFor("Step 1 frame")).toBe("←/→");
|
||||
expect(hintFor("Step 10 frames")).toBe("⇧←/⇧→");
|
||||
expect(hintFor("Toggle loop")).toBe("⇧L");
|
||||
setStudioKeys({ undo: "mod+u", playPause: "p", frameBack: "y", frameForward: "u" });
|
||||
expect(hintFor("Undo")).toBe("⌘U");
|
||||
expect(hintFor("Play / Pause")).toBe("P");
|
||||
expect(hintFor("Step 1 frame")).toBe("Y/U");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
type ComboEvent,
|
||||
comboClashKey,
|
||||
comboText,
|
||||
formatCombo,
|
||||
matchCombo,
|
||||
parseCombo,
|
||||
} from "./studioCombo";
|
||||
|
||||
/** Every single-character bare key Studio answers, by action. */
|
||||
export const DEFAULT_STUDIO_KEYS = {
|
||||
@@ -24,6 +32,46 @@ export const DEFAULT_STUDIO_KEYS = {
|
||||
snap: "s",
|
||||
grid: "g",
|
||||
timelineSnap: "n",
|
||||
playPause: "Space",
|
||||
frameBack: "ArrowLeft",
|
||||
frameForward: "ArrowRight",
|
||||
frameBackTen: "shift+ArrowLeft",
|
||||
frameForwardTen: "shift+ArrowRight",
|
||||
loop: "shift+l",
|
||||
clearInPoint: "shift+i",
|
||||
clearOutPoint: "shift+o",
|
||||
undo: "mod+z",
|
||||
redo: "mod+shift+z",
|
||||
redoAlt: "ctrl+y",
|
||||
tabCompositions: "mod+1",
|
||||
tabAssets: "mod+2",
|
||||
group: "mod+g",
|
||||
ungroup: "mod+shift+g",
|
||||
copy: "mod+c",
|
||||
paste: "mod+v",
|
||||
cut: "mod+x",
|
||||
duplicate: "mod+d",
|
||||
linkToggle: "mod+l",
|
||||
detachAudio: "alt+shift+d",
|
||||
deleteLinked: "alt+Backspace",
|
||||
deleteLinkedAlt: "alt+Delete",
|
||||
deleteSelected: "Delete",
|
||||
deleteSelectedAlt: "Backspace",
|
||||
resetKeyframes: "Backspace",
|
||||
nudgeLeft: "ArrowLeft",
|
||||
nudgeRight: "ArrowRight",
|
||||
nudgeUp: "ArrowUp",
|
||||
nudgeDown: "ArrowDown",
|
||||
nudgeLeftTen: "shift+ArrowLeft",
|
||||
nudgeRightTen: "shift+ArrowRight",
|
||||
nudgeUpTen: "shift+ArrowUp",
|
||||
nudgeDownTen: "shift+ArrowDown",
|
||||
captionSelectAll: "mod+a",
|
||||
pickupClip: "Space",
|
||||
pickupUp: "ArrowUp",
|
||||
pickupDown: "ArrowDown",
|
||||
pickupDrop: "Enter",
|
||||
cancel: "Escape",
|
||||
};
|
||||
|
||||
export type StudioKeyAction = keyof typeof DEFAULT_STUDIO_KEYS;
|
||||
@@ -34,6 +82,14 @@ const SHARED_BY_DESIGN: ReadonlyArray<readonly [StudioKeyAction, StudioKeyAction
|
||||
["seekToIn", "exitRazor"],
|
||||
["split", "snap"],
|
||||
["audioGain", "grid"],
|
||||
["playPause", "pickupClip"],
|
||||
["frameBack", "nudgeLeft"],
|
||||
["frameForward", "nudgeRight"],
|
||||
["frameBackTen", "nudgeLeftTen"],
|
||||
["frameForwardTen", "nudgeRightTen"],
|
||||
["nudgeUp", "pickupUp"],
|
||||
["nudgeDown", "pickupDown"],
|
||||
["deleteSelectedAlt", "resetKeyframes"],
|
||||
];
|
||||
|
||||
let current: StudioKeys = DEFAULT_STUDIO_KEYS;
|
||||
@@ -44,9 +100,11 @@ const sharedByDesign = (a: StudioKeyAction, b: StudioKeyAction) =>
|
||||
|
||||
function findClash(keys: StudioKeys): string | null {
|
||||
const actions = Object.keys(keys) as StudioKeyAction[];
|
||||
const clashKey = (action: StudioKeyAction) => comboClashKey(parseCombo(keys[action])!);
|
||||
for (const [i, a] of actions.entries()) {
|
||||
for (const b of actions.slice(i + 1)) {
|
||||
if (keys[a] === keys[b] && !sharedByDesign(a, b)) return `${a} and ${b} both on "${keys[a]}"`;
|
||||
if (clashKey(a) === clashKey(b) && !sharedByDesign(a, b))
|
||||
return `${a} and ${b} both on "${keys[a]}"`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -54,18 +112,15 @@ function findClash(keys: StudioKeys): string | null {
|
||||
|
||||
function resolve(overrides: Partial<Record<StudioKeyAction, string>>): StudioKeys {
|
||||
const next: Record<string, string> = { ...DEFAULT_STUDIO_KEYS };
|
||||
for (const [action, key] of Object.entries(overrides)) {
|
||||
if (
|
||||
!Object.hasOwn(DEFAULT_STUDIO_KEYS, action) ||
|
||||
typeof key !== "string" ||
|
||||
[...key].length !== 1
|
||||
) {
|
||||
for (const [action, text] of Object.entries(overrides)) {
|
||||
const combo = typeof text === "string" ? parseCombo(text) : null;
|
||||
if (!Object.hasOwn(DEFAULT_STUDIO_KEYS, action) || !combo) {
|
||||
console.warn(
|
||||
`Studio keys: using defaults, "${action}" is not a known action with one character`,
|
||||
`Studio keys: using defaults, "${action}" is not a known action with a valid combo`,
|
||||
);
|
||||
return DEFAULT_STUDIO_KEYS;
|
||||
}
|
||||
next[action] = key.toLowerCase();
|
||||
next[action] = comboText(combo);
|
||||
}
|
||||
const clash = findClash(next as StudioKeys);
|
||||
if (!clash) return next as StudioKeys;
|
||||
@@ -104,10 +159,11 @@ export interface ShortcutSection {
|
||||
hints: readonly ShortcutHint[];
|
||||
}
|
||||
|
||||
const hintKey = (key: string) => key.toUpperCase();
|
||||
|
||||
export const keyHint = (action: StudioKeyAction, keys: StudioKeys = current) =>
|
||||
hintKey(keys[action]);
|
||||
formatCombo(parseCombo(keys[action])!);
|
||||
|
||||
export const matchesKey = (e: ComboEvent, action: StudioKeyAction) =>
|
||||
matchCombo(e, parseCombo(current[action])!);
|
||||
|
||||
export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSection[] {
|
||||
const k = (action: StudioKeyAction) => keyHint(action, keys);
|
||||
@@ -115,14 +171,14 @@ export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSectio
|
||||
{
|
||||
title: "Playback",
|
||||
hints: [
|
||||
{ key: "Space", label: "Play / Pause" },
|
||||
{ key: k("playPause"), label: "Play / Pause" },
|
||||
{ key: k("shuttleBack"), label: "Play backward" },
|
||||
{ key: k("pause"), label: "Stop" },
|
||||
{ key: k("shuttleForward"), label: "Play forward" },
|
||||
{ key: k("mute"), label: "Toggle mute" },
|
||||
{ key: `⇧${k("shuttleForward")}`, label: "Toggle loop" },
|
||||
{ key: "←/→", label: "Step 1 frame" },
|
||||
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
||||
{ key: k("loop"), label: "Toggle loop" },
|
||||
{ key: `${k("frameBack")}/${k("frameForward")}`, label: "Step 1 frame" },
|
||||
{ key: `${k("frameBackTen")}/${k("frameForwardTen")}`, label: "Step 10 frames" },
|
||||
{ key: k("fullscreen"), label: "Toggle fullscreen" },
|
||||
],
|
||||
},
|
||||
@@ -130,27 +186,27 @@ export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSectio
|
||||
title: "Keyframes (when an element is selected)",
|
||||
hints: [
|
||||
{ key: k("addKeyframe"), label: "Add / remove keyframe at playhead" },
|
||||
{ key: "Del", label: "Delete selected keyframe" },
|
||||
{ key: k("deleteSelected"), label: "Delete selected keyframe" },
|
||||
{ key: k("record"), label: "Record gesture" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Editing",
|
||||
hints: [
|
||||
{ key: "⌘Z", label: "Undo" },
|
||||
{ key: "⌘⇧Z", label: "Redo" },
|
||||
{ key: "⌘C", label: "Copy element" },
|
||||
{ key: "⌘V", label: "Paste element" },
|
||||
{ key: "⌘X", label: "Cut element" },
|
||||
{ key: k("undo"), label: "Undo" },
|
||||
{ key: k("redo"), label: "Redo" },
|
||||
{ key: k("copy"), label: "Copy element" },
|
||||
{ key: k("paste"), label: "Paste element" },
|
||||
{ key: k("cut"), label: "Cut element" },
|
||||
{ key: k("split"), label: "Split clip at playhead" },
|
||||
{ key: k("zoomSelection"), label: "Zoom timeline to selected clips" },
|
||||
{ key: k("audioGain"), label: "Audio Gain for clips with sound" },
|
||||
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
||||
{ key: k("selectLeftward"), label: "Select clips starting before the playhead" },
|
||||
{ key: k("selectRightward"), label: "Select clips running at or after the playhead" },
|
||||
{ key: "⌘G", label: "Group elements" },
|
||||
{ key: "⌘⇧G", label: "Ungroup" },
|
||||
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
||||
{ key: k("group"), label: "Group elements" },
|
||||
{ key: k("ungroup"), label: "Ungroup" },
|
||||
{ key: k("deleteSelected"), label: "Delete selected element (no keyframe selected)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -182,17 +238,17 @@ export function buildShortcutSections(keys: StudioKeys): readonly ShortcutSectio
|
||||
{
|
||||
title: "Panels",
|
||||
hints: [
|
||||
{ key: "⌘1", label: "Compositions tab" },
|
||||
{ key: "⌘2", label: "Assets tab" },
|
||||
{ key: k("tabCompositions"), label: "Compositions tab" },
|
||||
{ key: k("tabAssets"), label: "Assets tab" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Work area",
|
||||
hints: [
|
||||
{ key: k("inPoint"), label: "Set in-point" },
|
||||
{ key: `⇧${k("inPoint")}`, label: "Clear in-point" },
|
||||
{ key: k("clearInPoint"), label: "Clear in-point" },
|
||||
{ key: k("outPoint"), label: "Set out-point" },
|
||||
{ key: `⇧${k("outPoint")}`, label: "Clear out-point" },
|
||||
{ key: k("clearOutPoint"), label: "Clear out-point" },
|
||||
{ key: k("seekToIn"), label: "Jump to in-point" },
|
||||
{ key: k("seekToOut"), label: "Jump to out-point" },
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { resolveTimelineDragEscape } from "./timelineEditing";
|
||||
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
||||
@@ -407,7 +408,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
const decision = resolveTimelineDragEscape({
|
||||
key: event.key,
|
||||
cancelPressed: matchesKey(event, "cancel"),
|
||||
drag: draggedClipRef.current,
|
||||
resize: resizingClipRef.current,
|
||||
blocked: blockedClipRef.current,
|
||||
|
||||
@@ -555,7 +555,7 @@ export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
|
||||
cancel: boolean;
|
||||
suppressClick: boolean;
|
||||
} {
|
||||
if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
|
||||
if (!input.cancelPressed || (!input.drag && !input.resize && !input.blocked)) {
|
||||
return { cancel: false, suppressClick: false };
|
||||
}
|
||||
return {
|
||||
@@ -565,7 +565,7 @@ export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
|
||||
}
|
||||
|
||||
export interface TimelineDragEscapeInput {
|
||||
key: string;
|
||||
cancelPressed: boolean;
|
||||
drag: { started: boolean } | null;
|
||||
resize: { started: boolean } | null;
|
||||
blocked: { started: boolean } | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { markTimelineMotion } from "./timelineMotion";
|
||||
import {
|
||||
@@ -546,7 +547,7 @@ export function useTimelineClipDrag({
|
||||
return;
|
||||
}
|
||||
if (resizingClipRef.current) return;
|
||||
if (event.key !== " " || event.repeat) return;
|
||||
if (!matchesKey(event, "pickupClip") || event.repeat) return;
|
||||
if (viewport) pickup(event, viewport);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import {
|
||||
@@ -24,24 +25,12 @@ type ClipPickupAction =
|
||||
| { kind: "focus" }
|
||||
| { kind: "consume" };
|
||||
|
||||
function clipPickupKeyboardAction(event: {
|
||||
key: string;
|
||||
repeat: boolean;
|
||||
}): ClipPickupAction | null {
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
return { kind: "move", step: -1 };
|
||||
case "ArrowDown":
|
||||
return { kind: "move", step: 1 };
|
||||
case "Enter":
|
||||
return { kind: event.repeat ? "focus" : "commit" };
|
||||
case "Escape":
|
||||
return { kind: "cancel" };
|
||||
case " ":
|
||||
return { kind: "consume" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
function clipPickupKeyboardAction(event: globalThis.KeyboardEvent): ClipPickupAction | null {
|
||||
if (matchesKey(event, "pickupUp")) return { kind: "move", step: -1 };
|
||||
if (matchesKey(event, "pickupDown")) return { kind: "move", step: 1 };
|
||||
if (matchesKey(event, "pickupDrop")) return { kind: event.repeat ? "focus" : "commit" };
|
||||
if (matchesKey(event, "cancel")) return { kind: "cancel" };
|
||||
return matchesKey(event, "pickupClip") ? { kind: "consume" } : null;
|
||||
}
|
||||
|
||||
export function handleClipPickupKeyboardEvent(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
||||
import { progressAtTime, runEaseOf, timeAtProgress } from "../../utils/gsapKeyframeEases";
|
||||
import {
|
||||
@@ -451,7 +452,7 @@ export function beginTimelineKeyframeRetime(
|
||||
if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") cancel(actor);
|
||||
if (matchesKey(event, "cancel")) cancel(actor);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove, true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { matchesKey } from "./studioShortcuts";
|
||||
import {
|
||||
applyTimelineAutoScrollStep,
|
||||
resolveTimelineAutoScrollLoopAction,
|
||||
@@ -537,13 +538,13 @@ export function useTimelineRangeSelection({
|
||||
// otherwise it clears any lingering multi-selection.
|
||||
useEffect(() => {
|
||||
const onBandEscape = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" || !(marqueeRef.current || isRangeSelecting.current)) return;
|
||||
if (!matchesKey(e, "cancel") || !(marqueeRef.current || isRangeSelecting.current)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelActiveGesture(true, true);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (!matchesKey(e, "cancel")) return;
|
||||
const store = usePlayerStore.getState();
|
||||
if (isDragging.current) {
|
||||
cancelActiveGesture(true, true);
|
||||
|
||||
@@ -76,11 +76,19 @@ function setupHook(): HookHandle {
|
||||
};
|
||||
}
|
||||
|
||||
function keydown(init: { code: string; key: string; shiftKey?: boolean }): KeyboardEvent {
|
||||
function keydown(init: {
|
||||
code: string;
|
||||
key: string;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
}): KeyboardEvent {
|
||||
return new KeyboardEvent("keydown", {
|
||||
code: init.code,
|
||||
key: init.key,
|
||||
shiftKey: init.shiftKey ?? false,
|
||||
metaKey: init.metaKey ?? false,
|
||||
altKey: init.altKey ?? false,
|
||||
cancelable: true,
|
||||
});
|
||||
}
|
||||
@@ -199,7 +207,7 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
|
||||
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
||||
});
|
||||
|
||||
it("Shift+A still seeks to the in-point while the razor is armed, since only plain A exits the razor", () => {
|
||||
it("Shift+A is a different combo from A: it neither jumps to the in-point nor exits the razor", () => {
|
||||
const { dispatch, spies } = setupHook();
|
||||
usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
|
||||
|
||||
@@ -207,7 +215,8 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
|
||||
dispatch(keydown({ code: "KeyA", key: "a", shiftKey: true }));
|
||||
});
|
||||
|
||||
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
||||
expect(spies.seek).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().activeTool).toBe("razor");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,7 +378,7 @@ describe("usePlaybackKeyboard — keys the host can remap", () => {
|
||||
expect(spies.play).not.toHaveBeenCalled();
|
||||
press(dispatch, "y");
|
||||
expect(spies.play).toHaveBeenCalledTimes(1);
|
||||
press(dispatch, "y", true);
|
||||
press(dispatch, "l", true);
|
||||
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
||||
});
|
||||
|
||||
@@ -400,3 +409,77 @@ describe("usePlaybackKeyboard — keys the host can remap", () => {
|
||||
expect(spies.seek).toHaveBeenCalledWith(3, { keepPlaying: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePlaybackKeyboard: space, arrows, loop and clear keys are combos", () => {
|
||||
const send = (dispatch: HookHandle["dispatch"], init: Partial<Parameters<typeof keydown>[0]>) =>
|
||||
act(() => {
|
||||
dispatch(keydown({ code: "", key: "", ...init }));
|
||||
});
|
||||
|
||||
it("Space, arrows (Shift = 10), Shift+L and Shift+I answer by default", () => {
|
||||
const { dispatch, spies } = setupHook();
|
||||
usePlayerStore.setState({ isPlaying: false, inPoint: 1, loopEnabled: false });
|
||||
send(dispatch, { code: "Space", key: " " });
|
||||
expect(spies.play).toHaveBeenCalledTimes(1);
|
||||
send(dispatch, { code: "ArrowRight", key: "ArrowRight", shiftKey: true });
|
||||
send(dispatch, { code: "ArrowLeft", key: "ArrowLeft" });
|
||||
expect(spies.seek).toHaveBeenCalledTimes(2);
|
||||
send(dispatch, { code: "KeyL", key: "L", shiftKey: true });
|
||||
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
||||
send(dispatch, { code: "KeyI", key: "I", shiftKey: true });
|
||||
expect(usePlayerStore.getState().inPoint).toBeNull();
|
||||
});
|
||||
|
||||
it("a remapped key answers and the old one does not", () => {
|
||||
setStudioKeys({
|
||||
playPause: "p",
|
||||
frameForward: "x",
|
||||
loop: "mod+shift+u",
|
||||
clearInPoint: "shift+y",
|
||||
});
|
||||
const { dispatch, spies } = setupHook();
|
||||
usePlayerStore.setState({ isPlaying: false, inPoint: 1, loopEnabled: false });
|
||||
send(dispatch, { code: "Space", key: " " });
|
||||
send(dispatch, { code: "ArrowRight", key: "ArrowRight" });
|
||||
send(dispatch, { code: "KeyL", key: "L", shiftKey: true });
|
||||
send(dispatch, { code: "KeyI", key: "I", shiftKey: true });
|
||||
expect(spies.play).not.toHaveBeenCalled();
|
||||
expect(spies.seek).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
||||
expect(usePlayerStore.getState().inPoint).toBe(1);
|
||||
send(dispatch, { code: "KeyP", key: "p" });
|
||||
expect(spies.play).toHaveBeenCalledTimes(1);
|
||||
send(dispatch, { code: "KeyX", key: "x" });
|
||||
expect(spies.seek).toHaveBeenCalledTimes(1);
|
||||
send(dispatch, { code: "KeyY", key: "Y", shiftKey: true });
|
||||
expect(usePlayerStore.getState().inPoint).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePlaybackKeyboard: modifier combos and named keys", () => {
|
||||
const send = (dispatch: HookHandle["dispatch"], init: Parameters<typeof keydown>[0]) =>
|
||||
act(() => {
|
||||
dispatch(keydown(init));
|
||||
});
|
||||
|
||||
it("a playback action remapped to a Cmd or Alt combo answers, and the plain key does not", () => {
|
||||
setStudioKeys({ playPause: "mod+p", mute: "alt+m" });
|
||||
const { dispatch, spies } = setupHook();
|
||||
usePlayerStore.setState({ isPlaying: false, audioMuted: false });
|
||||
send(dispatch, { code: "KeyP", key: "p", metaKey: true });
|
||||
expect(spies.play).toHaveBeenCalledTimes(1);
|
||||
send(dispatch, { code: "KeyM", key: "µ", altKey: true });
|
||||
expect(usePlayerStore.getState().audioMuted).toBe(true);
|
||||
send(dispatch, { code: "KeyP", key: "p" });
|
||||
expect(spies.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("holding a stop key that is a named key still frame-steps instead of shuttling", () => {
|
||||
setStudioKeys({ pause: "Home" });
|
||||
const { dispatch, spies } = setupHook();
|
||||
send(dispatch, { code: "Home", key: "Home" });
|
||||
send(dispatch, { code: "KeyJ", key: "j" });
|
||||
expect(spies.seek).toHaveBeenCalledTimes(1);
|
||||
expect(spies.playBackward).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
} from "../lib/playbackShortcuts";
|
||||
import { canvasNudgeKeysClaimed } from "../../utils/canvasNudgeGate";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { getStudioKeys } from "../components/studioShortcuts";
|
||||
import { matchesKey, type StudioKeyAction } from "../components/studioShortcuts";
|
||||
import { resolveCanvasNudgeDelta } from "../../components/editor/domEditNudge";
|
||||
import { stepFrameTime } from "../lib/time";
|
||||
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
||||
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
||||
@@ -32,26 +33,61 @@ interface UsePlaybackKeyboardParams {
|
||||
seek: (time: number, options?: { keepPlaying?: boolean }) => void;
|
||||
}
|
||||
|
||||
const FRAME_STEPS = {
|
||||
frameBack: -1,
|
||||
frameForward: 1,
|
||||
frameBackTen: -10,
|
||||
frameForwardTen: 10,
|
||||
} as const;
|
||||
const PLAYBACK_ACTIONS = [
|
||||
"playPause",
|
||||
...(Object.keys(FRAME_STEPS) as Array<keyof typeof FRAME_STEPS>),
|
||||
"mute",
|
||||
"loop",
|
||||
"pause",
|
||||
"shuttleBack",
|
||||
"shuttleForward",
|
||||
"inPoint",
|
||||
"clearInPoint",
|
||||
"outPoint",
|
||||
"clearOutPoint",
|
||||
"seekToIn",
|
||||
"seekToOut",
|
||||
] as const satisfies readonly StudioKeyAction[];
|
||||
type PlaybackAction = (typeof PLAYBACK_ACTIONS)[number];
|
||||
|
||||
const playbackActionOf = (e: KeyboardEvent): PlaybackAction | undefined =>
|
||||
PLAYBACK_ACTIONS.find((action) => matchesKey(e, action));
|
||||
|
||||
function ignoresPlaybackKey(e: KeyboardEvent): boolean {
|
||||
const captionState = useCaptionStore.getState();
|
||||
return shouldIgnorePlaybackShortcutEvent(e, {
|
||||
isCaptionEditMode: captionState.isEditMode,
|
||||
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
||||
});
|
||||
}
|
||||
|
||||
function handleWorkAreaKey(
|
||||
e: KeyboardEvent,
|
||||
key: string,
|
||||
action: PlaybackAction,
|
||||
getAdapter: () => PlaybackAdapter | null,
|
||||
seek: UsePlaybackKeyboardParams["seek"],
|
||||
): boolean {
|
||||
if (razorClaimsKey(e, key)) return false;
|
||||
const keys = getStudioKeys();
|
||||
const store = usePlayerStore.getState();
|
||||
const time = () => getAdapter()?.getTime() ?? store.currentTime;
|
||||
const end = () => store.outPoint ?? getAdapter()?.getDuration() ?? store.duration;
|
||||
const actions: Record<string, () => void> = {
|
||||
[keys.inPoint]: () => store.setInPoint(e.shiftKey ? null : time()),
|
||||
[keys.outPoint]: () => store.setOutPoint(e.shiftKey ? null : time()),
|
||||
[keys.seekToIn]: () => seek(store.inPoint ?? 0, { keepPlaying: true }),
|
||||
[keys.seekToOut]: () => seek(end(), { keepPlaying: true }),
|
||||
const actions: Partial<Record<PlaybackAction, () => void>> = {
|
||||
inPoint: () => store.setInPoint(time()),
|
||||
clearInPoint: () => store.setInPoint(null),
|
||||
outPoint: () => store.setOutPoint(time()),
|
||||
clearOutPoint: () => store.setOutPoint(null),
|
||||
seekToIn: () => seek(store.inPoint ?? 0, { keepPlaying: true }),
|
||||
seekToOut: () => seek(end(), { keepPlaying: true }),
|
||||
};
|
||||
if (!Object.hasOwn(actions, key)) return false;
|
||||
const run = actions[action];
|
||||
if (!run || razorClaimsKey(e)) return false;
|
||||
e.preventDefault();
|
||||
actions[key]();
|
||||
run();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -66,7 +102,7 @@ export function usePlaybackKeyboard({
|
||||
pause,
|
||||
seek,
|
||||
}: UsePlaybackKeyboardParams) {
|
||||
const pressedKeysRef = useRef(new Set<string>());
|
||||
const heldPauseRef = useRef(new Set<string>());
|
||||
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
||||
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
||||
|
||||
@@ -111,87 +147,47 @@ export function usePlaybackKeyboard({
|
||||
}
|
||||
}, [play, pause]);
|
||||
|
||||
// Pre-existing dispatcher, already over the complexity gate before this
|
||||
// branch's one-line "a" fix touched it. A real fix is a dispatch-table
|
||||
// rewrite, out of scope here; the branch this PR changed is its own function.
|
||||
// fallow-ignore-next-line complexity
|
||||
const handlePlaybackKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const captionState = useCaptionStore.getState();
|
||||
if (
|
||||
shouldIgnorePlaybackShortcutEvent(e, {
|
||||
isCaptionEditMode: captionState.isEditMode,
|
||||
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const key = e.key.toLowerCase();
|
||||
const keys = getStudioKeys();
|
||||
pressedKeysRef.current.add(key);
|
||||
if (e.code === "Space") {
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
return;
|
||||
}
|
||||
if (e.defaultPrevented || ignoresPlaybackKey(e)) return;
|
||||
const action = playbackActionOf(e);
|
||||
if (action === "pause") heldPauseRef.current.add(e.code || e.key);
|
||||
if (!action) return;
|
||||
const isFrameStep = action in FRAME_STEPS;
|
||||
// A nudgeable canvas selection owns the arrow keys (DomEditOverlay moves
|
||||
// the element); frame-stepping would double-handle the same keystroke.
|
||||
const arrowStep = e.code === "ArrowLeft" || e.code === "ArrowRight";
|
||||
if (arrowStep && canvasNudgeKeysClaimed()) return;
|
||||
if (e.code === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
stepFrames(e.shiftKey ? -10 : -1);
|
||||
if (isFrameStep && canvasNudgeKeysClaimed() && resolveCanvasNudgeDelta(e)) return;
|
||||
if (e.repeat && action !== "playPause" && !isFrameStep) return;
|
||||
const shuttleOrStep = (direction: -1 | 1) =>
|
||||
heldPauseRef.current.size > 0
|
||||
? stepFrames(direction)
|
||||
: shuttle(direction < 0 ? "backward" : "forward");
|
||||
const player = usePlayerStore.getState();
|
||||
const run: Partial<Record<PlaybackAction, () => void>> = {
|
||||
playPause: togglePlay,
|
||||
frameBack: () => stepFrames(FRAME_STEPS.frameBack),
|
||||
frameForward: () => stepFrames(FRAME_STEPS.frameForward),
|
||||
frameBackTen: () => stepFrames(FRAME_STEPS.frameBackTen),
|
||||
frameForwardTen: () => stepFrames(FRAME_STEPS.frameForwardTen),
|
||||
mute: () => player.setAudioMuted(!player.audioMuted),
|
||||
loop: () => player.setLoopEnabled(!player.loopEnabled),
|
||||
pause,
|
||||
shuttleBack: () => shuttleOrStep(-1),
|
||||
shuttleForward: () => shuttleOrStep(1),
|
||||
};
|
||||
const act = run[action];
|
||||
if (!act) {
|
||||
handleWorkAreaKey(e, action, getAdapter, seek);
|
||||
return;
|
||||
}
|
||||
if (e.code === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
stepFrames(e.shiftKey ? 10 : 1);
|
||||
return;
|
||||
}
|
||||
if (e.repeat) return;
|
||||
if (key === keys.mute) {
|
||||
e.preventDefault();
|
||||
const state = usePlayerStore.getState();
|
||||
state.setAudioMuted(!state.audioMuted);
|
||||
return;
|
||||
}
|
||||
if (key === keys.shuttleForward && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const state = usePlayerStore.getState();
|
||||
state.setLoopEnabled(!state.loopEnabled);
|
||||
return;
|
||||
}
|
||||
if (key === keys.pause) {
|
||||
e.preventDefault();
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
if (key === keys.shuttleBack) {
|
||||
e.preventDefault();
|
||||
if (pressedKeysRef.current.has(keys.pause)) {
|
||||
stepFrames(-1);
|
||||
return;
|
||||
}
|
||||
shuttle("backward");
|
||||
return;
|
||||
}
|
||||
if (key === keys.shuttleForward) {
|
||||
e.preventDefault();
|
||||
if (pressedKeysRef.current.has(keys.pause)) {
|
||||
stepFrames(1);
|
||||
return;
|
||||
}
|
||||
shuttle("forward");
|
||||
return;
|
||||
}
|
||||
handleWorkAreaKey(e, key, getAdapter, seek);
|
||||
e.preventDefault();
|
||||
act();
|
||||
},
|
||||
[pause, shuttle, stepFrames, togglePlay, getAdapter, seek],
|
||||
);
|
||||
|
||||
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
pressedKeysRef.current.delete(e.key.toLowerCase());
|
||||
heldPauseRef.current.delete(e.code || e.key);
|
||||
}, []);
|
||||
|
||||
playbackKeyDownRef.current = handlePlaybackKeyDown;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setStudioKeys } from "../components/studioShortcuts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
@@ -48,8 +49,10 @@ function mockTargetMatching(selectorNeedle: string): EventTarget {
|
||||
function mockKeyboardEvent(
|
||||
code: string,
|
||||
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
|
||||
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
|
||||
): Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "code" | "target"> {
|
||||
return {
|
||||
key: code,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
@@ -556,18 +559,6 @@ describe("shouldIgnorePlaybackShortcutTarget", () => {
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@@ -582,6 +573,21 @@ describe("shouldIgnorePlaybackShortcutEvent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defers a remapped frame key, not the arrows, while caption words are selected", () => {
|
||||
setStudioKeys({ frameBack: "," });
|
||||
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(
|
||||
{ ...mockKeyboardEvent("Comma"), key: "," },
|
||||
captionSelection,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
|
||||
).toBe(false);
|
||||
setStudioKeys(null);
|
||||
});
|
||||
|
||||
it("allows Arrow frame shortcuts when captions are not selected", () => {
|
||||
expect(
|
||||
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
*/
|
||||
|
||||
import { ownsPlainKeys } from "../../utils/typingTarget";
|
||||
import { getStudioKey } from "../components/studioShortcuts";
|
||||
import type { ComboEvent } from "../components/studioCombo";
|
||||
import { matchesKey } from "../components/studioShortcuts";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
|
||||
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
||||
const FRAME_STEP_ACTIONS = [
|
||||
"frameBack",
|
||||
"frameForward",
|
||||
"frameBackTen",
|
||||
"frameForwardTen",
|
||||
] as const;
|
||||
|
||||
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
||||
"button",
|
||||
@@ -61,10 +67,7 @@ interface PlaybackShortcutCaptionState {
|
||||
selectedCaptionSegmentCount: number;
|
||||
}
|
||||
|
||||
type PlaybackShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
|
||||
>;
|
||||
type PlaybackShortcutEvent = ComboEvent & Pick<KeyboardEvent, "target">;
|
||||
|
||||
export function shouldIgnorePlaybackShortcutEvent(
|
||||
event: PlaybackShortcutEvent,
|
||||
@@ -73,11 +76,10 @@ export function shouldIgnorePlaybackShortcutEvent(
|
||||
selectedCaptionSegmentCount: 0,
|
||||
},
|
||||
): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
||||
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
||||
if (isModalDialogOpen()) return true;
|
||||
return (
|
||||
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
||||
FRAME_STEP_ACTIONS.some((action) => matchesKey(event, action)) &&
|
||||
captionState.isCaptionEditMode &&
|
||||
captionState.selectedCaptionSegmentCount > 0
|
||||
);
|
||||
@@ -86,12 +88,7 @@ export function shouldIgnorePlaybackShortcutEvent(
|
||||
/** JKL shuttle speeds (×1, ×2, ×4). */
|
||||
export const SHUTTLE_SPEEDS = [1, 2, 4] as const;
|
||||
|
||||
/** Plain exit-razor key while the razor is armed: the razor owns it, playback must leave it alone. */
|
||||
export function razorClaimsKey(e: KeyboardEvent, key: string): boolean {
|
||||
return (
|
||||
key === getStudioKey("exitRazor") &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
usePlayerStore.getState().activeTool === "razor"
|
||||
);
|
||||
/** The exit-razor combo while the razor is armed: the razor owns it, playback must leave it alone. */
|
||||
export function razorClaimsKey(e: KeyboardEvent): boolean {
|
||||
return matchesKey(e, "exitRazor") && usePlayerStore.getState().activeTool === "razor";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user