mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user