fix(studio): the shortcut list shows only keyframe keys that work (#4657)

* fix(studio): the shortcut list shows only keyframe keys that work

* docs(studio): animation guide drops the H and U keys and says what K does
This commit is contained in:
Miguel Ángel
2026-09-28 15:48:42 +00:00
committed by GitHub
parent 7fae35f36e
commit 16a010404c
5 changed files with 124 additions and 90 deletions
+4 -5
View File
@@ -45,12 +45,11 @@ To change existing motion:
- drag a diamond earlier or later to retime it;
- change the property value at that keyframe;
- press **Delete** to remove a selected keyframe;
- press **H** to switch selected keyframes between hold and bezier behavior;
- press **U** to expand or collapse the selected element's keyframe properties.
- press **Delete** to remove a selected keyframe.
`K` is context-sensitive: it adds a keyframe when an editable animated
selection can use it; otherwise it remains the playback stop shortcut.
`K` is context-sensitive: with a canvas element selected it adds or removes a
keyframe at the playhead, starting an animation on a static element; with
nothing selected it remains the playback stop shortcut.
## Use auto-keyframe deliberately
+4 -9
View File
@@ -40,9 +40,9 @@ implemented by the current Studio player, canvas, and timeline.
The focused area decides what a shared shortcut does. Arrow keys nudge a selected
canvas element, step through frames when nothing nudgeable is selected, or move
between timeline targets when the timeline has focus (see [Timeline
navigation](#timeline-navigation) below). `J` and `K` work the same way: with
keyframes selected they move between keyframes and add one, and otherwise they
drive playback.
navigation](#timeline-navigation) below). `K` adds or removes a keyframe at the
playhead when a canvas element is selected, starting an animation on a static
element, and otherwise stops playback. `J` always drives playback.
## Timeline navigation
@@ -66,12 +66,7 @@ If the destination is outside the virtualized viewport, Studio scrolls it into v
| Shortcut | Action |
| --- | --- |
| K | Add a keyframe when the current selection supports it |
| J / Shift + J | Jump to the previous or next keyframe |
| H | Toggle hold or bezier for selected keyframes |
| U | Expand or collapse the selected element’s keyframe properties |
| Left / Right arrow | Nudge selected keyframes by one frame |
| Shift + Left / Right arrow | Nudge selected keyframes by ten frames |
| K | Add or remove a keyframe at the playhead when the current selection supports it |
| R | Start or stop gesture recording |
During gesture recording, drag records position, scroll records depth,
@@ -0,0 +1,99 @@
// @vitest-environment happy-dom
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyframeKeyboard } from "./useKeyframeKeyboard";
import { dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
import { DEFAULT_SHORTCUT_SECTIONS } from "../player/components/studioShortcuts";
import { usePlayerStore } from "../player/store/playerStore";
import { createHappyDomRootHarness } from "../player/components/testRootHarness";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const { mount } = createHappyDomRootHarness();
function KeyframeKeyboard(props: { enabled: boolean; onAddKeyframe: () => void }) {
useKeyframeKeyboard(props);
return null;
}
/** Wires the keyframe keys the way TimelineToolbar does; returns the add-keyframe spy. */
function mountKeyframeKeyboard(enabled: boolean) {
const onAddKeyframe = vi.fn();
const host = document.createElement("div");
document.body.append(host);
act(() =>
mount(host).render(<KeyframeKeyboard enabled={enabled} onAddKeyframe={onAddKeyframe} />),
);
return onAddKeyframe;
}
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
window.dispatchEvent(event);
return event;
}
function selectKeyframe() {
usePlayerStore.setState({ selectedElementId: "box", selectedKeyframes: new Set(["box:50"]) });
}
beforeEach(selectKeyframe);
afterEach(() => usePlayerStore.getState().reset());
describe("keyframe shortcuts", () => {
it("every key the Keyframes section lists does something with a keyframe selected", () => {
mountKeyframeKeyboard(true);
const cb = {
onDeleteSelectedKeyframes: vi.fn(),
onToggleRecording: vi.fn(),
domEditSelectionRef: { current: null },
readOnlyPreview: false,
} as unknown as HotkeyCallbacks;
const appHotkeys = (event: KeyboardEvent) =>
dispatchPlainKey(event, event.key.toLowerCase(), cb);
window.addEventListener("keydown", appHotkeys, true);
const section = DEFAULT_SHORTCUT_SECTIONS.find((s) => s.title.startsWith("Keyframes"));
const dead = (section?.hints ?? []).filter((hint) => {
selectKeyframe();
return !press(hint.key === "Del" ? "Delete" : hint.key.toLowerCase()).defaultPrevented;
});
window.removeEventListener("keydown", appHotkeys, true);
expect(section?.hints.length).toBeGreaterThan(0);
expect(dead.map((hint) => hint.key)).toEqual([]);
});
it("K adds a keyframe only while enabled and leaves J and the arrows to playback", () => {
const playback = vi.fn();
window.addEventListener("keydown", playback);
const onAddKeyframe = mountKeyframeKeyboard(true);
for (const [key, shiftKey] of [
["j", false],
["J", true],
["ArrowLeft", false],
["ArrowRight", true],
] as const) {
expect(press(key, { shiftKey }).defaultPrevented).toBe(false);
}
expect(playback).toHaveBeenCalledTimes(4);
expect(onAddKeyframe).not.toHaveBeenCalled();
expect(press("k").defaultPrevented).toBe(true);
expect(onAddKeyframe).toHaveBeenCalledTimes(1);
expect(playback).toHaveBeenCalledTimes(4);
window.removeEventListener("keydown", playback);
});
it("K stays playback's stop key when no keyframeable element is selected", () => {
const playback = vi.fn();
window.addEventListener("keydown", playback);
const onAddKeyframe = mountKeyframeKeyboard(false);
expect(press("k").defaultPrevented).toBe(false);
expect(onAddKeyframe).not.toHaveBeenCalled();
expect(playback).toHaveBeenCalledTimes(1);
window.removeEventListener("keydown", playback);
});
});
@@ -1,15 +1,9 @@
import { useEffect, useCallback } from "react";
import { usePlayerStore } from "../player/store/playerStore";
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
interface KeyframeKeyboardOptions {
enabled: boolean;
onAddKeyframe?: () => void;
onDeleteKeyframe?: () => void;
onPrevKeyframe?: () => void;
onNextKeyframe?: () => void;
onToggleHold?: () => void;
onToggleExpand?: () => void;
onNudgeKeyframe?: (direction: -1 | 1, large: boolean) => void;
}
function isTextInput(el: Element | null): boolean {
@@ -19,75 +13,19 @@ function isTextInput(el: Element | null): boolean {
return (el as HTMLElement).isContentEditable === true;
}
export function useKeyframeKeyboard({
enabled,
onAddKeyframe,
onDeleteKeyframe,
onPrevKeyframe,
onNextKeyframe,
onToggleHold,
onToggleExpand,
onNudgeKeyframe,
}: KeyframeKeyboardOptions): void {
export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
const handler = useCallback(
(e: KeyboardEvent) => {
if (!enabled) return;
if (!enabled || !onAddKeyframe) return;
if (isTextInput(document.activeElement)) return;
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
const hasSelectedKeyframes = usePlayerStore.getState().selectedKeyframes.size > 0;
// Only consume a key we can actually act on. The fall-through matters:
// these keys (k/j/arrows) double as JKL playback shortcuts in
// usePlaybackKeyboard, so when a handler is absent we must let the event
// continue. When we DO act, stopImmediatePropagation prevents the playback
// handler from also firing (e.g. K pausing instead of adding a keyframe).
// The listener is registered in the capture phase so it runs first.
const consume = (run: () => void) => {
e.preventDefault();
e.stopImmediatePropagation();
run();
};
switch (e.key.toLowerCase()) {
case "k":
if (onAddKeyframe) consume(onAddKeyframe);
break;
case "delete":
case "backspace":
if (onDeleteKeyframe && hasSelectedKeyframes) consume(onDeleteKeyframe);
break;
case "j": {
const nav = e.shiftKey ? onNextKeyframe : onPrevKeyframe;
if (nav) consume(nav);
break;
}
case "h":
if (onToggleHold && hasSelectedKeyframes) consume(onToggleHold);
break;
case "u":
if (onToggleExpand) consume(onToggleExpand);
break;
case "arrowleft":
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
consume(() => onNudgeKeyframe(-1, e.shiftKey));
break;
case "arrowright":
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
consume(() => onNudgeKeyframe(1, e.shiftKey));
break;
}
if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
// K is also playback's stop key: claiming it here keeps playback from also pausing.
e.preventDefault();
e.stopImmediatePropagation();
onAddKeyframe();
},
[
enabled,
onAddKeyframe,
onDeleteKeyframe,
onPrevKeyframe,
onNextKeyframe,
onToggleHold,
onToggleExpand,
onNudgeKeyframe,
],
[enabled, onAddKeyframe],
);
useEffect(() => {
@@ -1,5 +1,10 @@
/** Bare keys Studio's app hotkeys bind; the shortcuts list names them from here. */
export const STUDIO_PLAIN_KEYS = { fullscreen: "f", split: "s", record: "r" } as const;
/** Bare keys Studio's hotkeys bind; the shortcuts list names them from here. */
export const STUDIO_PLAIN_KEYS = {
fullscreen: "f",
split: "s",
record: "r",
addKeyframe: "k",
} as const;
export interface ShortcutHint {
key: string;
@@ -32,10 +37,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
{
title: "Keyframes (when an element is selected)",
hints: [
{ key: "K", label: "Add keyframe at playhead" },
{ key: hintKey(STUDIO_PLAIN_KEYS.addKeyframe), label: "Add / remove keyframe at playhead" },
{ key: "Del", label: "Delete selected keyframe" },
{ key: "H", label: "Toggle hold / bezier" },
{ key: "U", label: "Expand / collapse properties" },
{ key: hintKey(STUDIO_PLAIN_KEYS.record), label: "Record gesture" },
],
},