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:
Miguel Ángel
2026-11-02 02:38:23 +00:00
committed by GitHub
parent c06427b063
commit 9fa02badfc
37 changed files with 890 additions and 370 deletions
@@ -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;
+12 -1
View File
@@ -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);
});
});
+8 -8
View File
@@ -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();
+94 -107
View File
@@ -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);
});
});
+11 -11
View File
@@ -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");
});
});
+1 -4
View File
@@ -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";
}