@hyperframes/studio 0.8.72 → 0.8.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.72",
3
+ "version": "0.8.74",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,11 +52,11 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.72",
56
- "@hyperframes/parsers": "0.8.72",
57
- "@hyperframes/player": "0.8.72",
58
- "@hyperframes/studio-server": "0.8.72",
59
- "@hyperframes/sdk": "0.8.72"
55
+ "@hyperframes/core": "0.8.74",
56
+ "@hyperframes/parsers": "0.8.74",
57
+ "@hyperframes/sdk": "0.8.74",
58
+ "@hyperframes/studio-server": "0.8.74",
59
+ "@hyperframes/player": "0.8.74"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@tailwindcss/vite": "^4.3.3",
@@ -72,7 +72,7 @@
72
72
  "vite": "^6.4.2",
73
73
  "vitest": "^4.1.11",
74
74
  "zustand": "^5.0.0",
75
- "@hyperframes/producer": "0.8.72"
75
+ "@hyperframes/producer": "0.8.74"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "react": "19",
@@ -8,6 +8,7 @@ import { isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { trackStudioEvent } from "../utils/studioTelemetry";
11
+ import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
11
12
 
12
13
  // Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
13
14
  // following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
@@ -158,7 +159,7 @@ export function dispatchModifierKey(
158
159
  * Delete arbitration between keyframes, an automation range and the clip can
159
160
  * be asserted without standing up the whole hook. */
160
161
  export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
161
- if (key === "f" && !event.shiftKey && !event.altKey) {
162
+ if (key === STUDIO_PLAIN_KEYS.fullscreen && !event.shiftKey && !event.altKey) {
162
163
  event.preventDefault();
163
164
  if (document.fullscreenElement) void document.exitFullscreen();
164
165
  else
@@ -166,7 +167,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
166
167
  return;
167
168
  }
168
169
 
169
- if (event.key === "s" && !event.altKey) {
170
+ if (event.key === STUDIO_PLAIN_KEYS.split && !event.altKey) {
170
171
  // Reserve bare `s` for Split even when the current selection cannot split,
171
172
  // so secondary listeners do not reinterpret the same key as Snap toggle.
172
173
  event.preventDefault();
@@ -274,7 +275,12 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
274
275
  return;
275
276
  }
276
277
 
277
- if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
278
+ if (
279
+ event.key === STUDIO_PLAIN_KEYS.record &&
280
+ !event.shiftKey &&
281
+ !event.altKey &&
282
+ cb.onToggleRecording
283
+ ) {
278
284
  event.preventDefault();
279
285
  cb.onToggleRecording();
280
286
  }
package/src/index.ts CHANGED
@@ -35,6 +35,8 @@ export type { ToggleProps } from "./components/ui/Toggle";
35
35
  export { EditorShell } from "./components/EditorShell";
36
36
  export type { EditorShellProps } from "./components/EditorShell";
37
37
  export { NLEPreview } from "./components/nle/NLEPreview";
38
+ export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
39
+ export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
38
40
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
39
41
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
40
42
 
@@ -7,6 +7,7 @@ import { trackStudioEvent } from "../../utils/studioTelemetry";
7
7
  import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
+ import type { ShortcutSection } from "./studioShortcuts";
10
11
  import { SpeedMenu } from "./SpeedMenu";
11
12
  import { VolumeControl } from "./VolumeControl";
12
13
 
@@ -161,6 +162,8 @@ interface PlayerControlsProps {
161
162
  disabled?: boolean;
162
163
  isFullscreen?: boolean;
163
164
  onToggleFullscreen?: () => void;
165
+ /** Replaces the shortcuts panel's list, e.g. DEFAULT_SHORTCUT_SECTIONS with entries dropped or relabelled. */
166
+ shortcutSections?: readonly ShortcutSection[];
164
167
  }
165
168
 
166
169
  export const PlayerControls = memo(function PlayerControls({
@@ -169,6 +172,7 @@ export const PlayerControls = memo(function PlayerControls({
169
172
  disabled = false,
170
173
  isFullscreen = false,
171
174
  onToggleFullscreen,
175
+ shortcutSections,
172
176
  }: PlayerControlsProps) {
173
177
  const isPlaying = usePlayerStore((s) => s.isPlaying);
174
178
  const duration = usePlayerStore((s) => s.duration);
@@ -291,6 +295,7 @@ export const PlayerControls = memo(function PlayerControls({
291
295
  setInPoint={setInPoint}
292
296
  setOutPoint={setOutPoint}
293
297
  onSeek={onSeek}
298
+ sections={shortcutSections}
294
299
  />
295
300
  </div>
296
301
  </div>
@@ -3,6 +3,8 @@
3
3
  import React, { act, Profiler } from "react";
4
4
  import { describe, expect, it, vi } from "vitest";
5
5
  import { ShortcutsPanel } from "./ShortcutsPanel";
6
+ import { PlayerControls } from "./PlayerControls";
7
+ import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
6
8
  import { createHappyDomRootHarness } from "./testRootHarness";
7
9
 
8
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -149,4 +151,53 @@ describe("ShortcutsPanel", () => {
149
151
  expect(panel?.getAttribute("aria-modal")).toBeNull();
150
152
  expect(panel?.id).toBe(panelId);
151
153
  });
154
+
155
+ it("lists what an embedder passes to PlayerControls instead of Studio's defaults", () => {
156
+ const listed = (sections?: readonly ShortcutSection[]) => {
157
+ const host = document.createElement("div");
158
+ document.body.append(host);
159
+ act(() => {
160
+ mount(host).render(
161
+ <PlayerControls onTogglePlay={vi.fn()} onSeek={vi.fn()} shortcutSections={sections} />,
162
+ );
163
+ });
164
+ openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
165
+ return host.textContent ?? "";
166
+ };
167
+ const withoutSplit = DEFAULT_SHORTCUT_SECTIONS.map((section) => ({
168
+ ...section,
169
+ hints: section.hints.filter((hint) => hint.label !== "Split clip at playhead"),
170
+ }));
171
+
172
+ expect(listed()).toContain("Split clip at playhead");
173
+ expect(listed(withoutSplit)).not.toContain("Split clip at playhead");
174
+ expect(listed(withoutSplit)).toContain("Toggle fullscreen");
175
+ });
176
+
177
+ it("renders an embedder list that repeats a key or a section title without key clashes", () => {
178
+ const errors = vi.spyOn(console, "error").mockImplementation(() => {});
179
+ const host = document.createElement("div");
180
+ document.body.append(host);
181
+ const sections = [
182
+ {
183
+ title: "Keys",
184
+ hints: [
185
+ { key: "S", label: "Select" },
186
+ { key: "S", label: "Snap" },
187
+ ],
188
+ },
189
+ { title: "Keys", hints: [{ key: "V", label: "Move" }] },
190
+ ];
191
+ act(() => {
192
+ mount(host).render(
193
+ <PlayerControls onTogglePlay={vi.fn()} onSeek={vi.fn()} shortcutSections={sections} />,
194
+ );
195
+ });
196
+ openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
197
+
198
+ expect(host.textContent).toContain("Select");
199
+ expect(host.textContent).toContain("Snap");
200
+ expect(errors.mock.calls.flat().join(" ")).not.toMatch(/same key/);
201
+ errors.mockRestore();
202
+ });
152
203
  });
@@ -2,92 +2,7 @@ import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
2
2
  import { formatTime, frameToSeconds } from "../lib/time";
3
3
  import { Tooltip } from "../../components/ui";
4
4
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
5
-
6
- const SHORTCUT_SECTIONS = [
7
- {
8
- title: "Playback",
9
- hints: [
10
- { key: "Space", label: "Play / Pause" },
11
- { key: "J", label: "Play backward" },
12
- { key: "K", label: "Stop" },
13
- { key: "L", label: "Play forward" },
14
- { key: "M", label: "Toggle mute" },
15
- { key: "⇧L", label: "Toggle loop" },
16
- { key: "←/→", label: "Step 1 frame" },
17
- { key: "⇧←/⇧→", label: "Step 10 frames" },
18
- { key: "F", label: "Toggle fullscreen" },
19
- ],
20
- },
21
- {
22
- title: "Keyframes (when an element is selected)",
23
- hints: [
24
- { key: "K", label: "Add keyframe at playhead" },
25
- { key: "Del", label: "Delete selected keyframe" },
26
- { key: "H", label: "Toggle hold / bezier" },
27
- { key: "U", label: "Expand / collapse properties" },
28
- { key: "R", label: "Record gesture" },
29
- ],
30
- },
31
- {
32
- title: "Editing",
33
- hints: [
34
- { key: "⌘Z", label: "Undo" },
35
- { key: "⌘⇧Z", label: "Redo" },
36
- { key: "⌘C", label: "Copy element" },
37
- { key: "⌘V", label: "Paste element" },
38
- { key: "⌘X", label: "Cut element" },
39
- { key: "S", label: "Split clip at playhead" },
40
- { key: "⇧Click", label: "Razor tool: split all tracks" },
41
- { key: "⌘G", label: "Group elements" },
42
- { key: "⌘⇧G", label: "Ungroup" },
43
- { key: "Del", label: "Delete selected element (no keyframe selected)" },
44
- ],
45
- },
46
- {
47
- title: "Gesture recording modifiers",
48
- hints: [
49
- { key: "Drag", label: "Record x / y position" },
50
- { key: "Scroll", label: "Record z depth" },
51
- { key: "⇧ Drag", label: "Record rotationX / rotationY" },
52
- { key: "⌥ Drag", label: "Record rotation" },
53
- { key: "⌘ Drag↕", label: "Record opacity" },
54
- { key: "⌘ Scroll", label: "Record scale" },
55
- ],
56
- },
57
- {
58
- title: "Canvas",
59
- hints: [
60
- { key: "Drag", label: "Move element / add keyframe" },
61
- { key: "⌥ Drag", label: "Move entire animation path" },
62
- { key: "⇧ Drag", label: "Uniform resize" },
63
- ],
64
- },
65
- {
66
- title: "Crop",
67
- hints: [
68
- { key: "Drag edge", label: "Crop a side" },
69
- { key: "Drag center", label: "Reposition the crop" },
70
- ],
71
- },
72
- {
73
- title: "Panels",
74
- hints: [
75
- { key: "⌘1", label: "Compositions tab" },
76
- { key: "⌘2", label: "Assets tab" },
77
- ],
78
- },
79
- {
80
- title: "Work area",
81
- hints: [
82
- { key: "I", label: "Set in-point" },
83
- { key: "⇧I", label: "Clear in-point" },
84
- { key: "O", label: "Set out-point" },
85
- { key: "⇧O", label: "Clear out-point" },
86
- { key: "A", label: "Jump to in-point" },
87
- { key: "E", label: "Jump to out-point" },
88
- ],
89
- },
90
- ] as const;
5
+ import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
91
6
 
92
7
  interface ShortcutsPanelProps {
93
8
  disabled: boolean;
@@ -97,6 +12,7 @@ interface ShortcutsPanelProps {
97
12
  setInPoint: (v: number | null) => void;
98
13
  setOutPoint: (v: number | null) => void;
99
14
  onSeek: (time: number) => void;
15
+ sections?: readonly ShortcutSection[];
100
16
  }
101
17
 
102
18
  export const ShortcutsPanel = memo(function ShortcutsPanel({
@@ -107,6 +23,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
107
23
  setInPoint,
108
24
  setOutPoint,
109
25
  onSeek,
26
+ sections = DEFAULT_SHORTCUT_SECTIONS,
110
27
  }: ShortcutsPanelProps) {
111
28
  const [showShortcuts, setShowShortcuts] = useState(false);
112
29
  const [jumpFrame, setJumpFrame] = useState("");
@@ -319,14 +236,14 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
319
236
  </div>
320
237
  <div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
321
238
  <div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
322
- {SHORTCUT_SECTIONS.map((section) => (
323
- <div key={section.title}>
239
+ {sections.map((section, sectionIndex) => (
240
+ <div key={sectionIndex}>
324
241
  <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
325
242
  {section.title}
326
243
  </p>
327
244
  <div className="flex flex-col gap-1">
328
- {section.hints.map((hint) => (
329
- <div key={hint.key} className="flex items-center gap-3">
245
+ {section.hints.map((hint, hintIndex) => (
246
+ <div key={hintIndex} className="flex items-center gap-3">
330
247
  <span
331
248
  className="font-mono text-[10px] rounded-sm border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
332
249
  style={{ background: "rgba(255,255,255,0.05)" }}
@@ -0,0 +1,98 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TimelineRuler } from "./TimelineRuler";
7
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
8
+ import type { TimelineTheme } from "./timelineTheme";
9
+
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ describe("TimelineRuler", () => {
17
+ it("masks the one tick label the track header would slice, once the scroll settles", () => {
18
+ vi.useFakeTimers();
19
+ const scroller = document.createElement("div");
20
+ let scrollLeft = 0;
21
+ Object.defineProperty(scroller, "scrollLeft", { get: () => scrollLeft });
22
+ const host = document.createElement("div");
23
+ scroller.append(host);
24
+ const root = createRoot(host);
25
+ act(() =>
26
+ root.render(
27
+ <TimelineRuler
28
+ major={[0, 10, 20]}
29
+ minor={[]}
30
+ pps={20}
31
+ trackContentWidth={600}
32
+ totalH={100}
33
+ effectiveDuration={30}
34
+ majorTickInterval={10}
35
+ theme={{} as TimelineTheme}
36
+ contentOrigin={80}
37
+ scrollRef={{ current: scroller }}
38
+ />,
39
+ ),
40
+ );
41
+ const mask = () =>
42
+ host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left ?? null;
43
+ const scrollTo = (left: number) =>
44
+ act(() => {
45
+ scrollLeft = left;
46
+ scroller.dispatchEvent(new Event("scroll"));
47
+ });
48
+ const settle = () => act(() => vi.advanceTimersByTime(TIMELINE_SCROLL_SETTLE_MS));
49
+
50
+ expect(mask()).toBeNull();
51
+ // The 10s label starts at 200 - 0.5 + 5 = 204.5 in ruler space.
52
+ scrollTo(204);
53
+ expect(mask()).toBeNull();
54
+ settle();
55
+ expect(mask()).toBe("4.5px");
56
+ scrollTo(205);
57
+ expect(mask()).toBeNull();
58
+ settle();
59
+ expect(mask()).toBe("204.5px");
60
+ act(() => root.unmount());
61
+ });
62
+
63
+ it("masks right away when the scroll element is swapped mid-scroll", () => {
64
+ vi.useFakeTimers();
65
+ const make = (left: number) => {
66
+ const el = document.createElement("div");
67
+ Object.defineProperty(el, "scrollLeft", { get: () => left });
68
+ return el;
69
+ };
70
+ const first = make(205);
71
+ const host = document.createElement("div");
72
+ const root = createRoot(host);
73
+ const render = (el: HTMLDivElement) =>
74
+ act(() =>
75
+ root.render(
76
+ <TimelineRuler
77
+ major={[0, 10, 20]}
78
+ minor={[]}
79
+ pps={20}
80
+ trackContentWidth={600}
81
+ totalH={100}
82
+ effectiveDuration={30}
83
+ majorTickInterval={10}
84
+ theme={{} as TimelineTheme}
85
+ contentOrigin={80}
86
+ scrollRef={{ current: el }}
87
+ />,
88
+ ),
89
+ );
90
+ render(first);
91
+ act(() => first.dispatchEvent(new Event("scroll")));
92
+ render(make(205));
93
+ expect(host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left).toBe(
94
+ "204.5px",
95
+ );
96
+ act(() => root.unmount());
97
+ });
98
+ });
@@ -1,4 +1,5 @@
1
- import { memo } from "react";
1
+ import { memo, useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
2
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
2
3
  import type { TimelineTheme } from "./timelineTheme";
3
4
  import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
4
5
  import { formatTimelineTickLabel } from "./timelineRulerGeometry";
@@ -19,8 +20,95 @@ interface TimelineRulerProps {
19
20
  beatAnalysis?: MusicBeatAnalysis | null;
20
21
  contentOrigin: number;
21
22
  renderTimeRange?: TimelineTimeRange;
23
+ scrollRef?: RefObject<HTMLDivElement | null>;
22
24
  }
23
25
 
26
+ const TICK_LABEL_INSET_PX = 5;
27
+
28
+ // Index of the major tick whose label starts under the track-header corner, which
29
+ // covers ruler x < scrollLeft; -1 when none does.
30
+ function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number): number {
31
+ let lo = 0;
32
+ let hi = major.length - 1;
33
+ let found = -1;
34
+ while (lo <= hi) {
35
+ const mid = (lo + hi) >> 1;
36
+ if (major[mid]! * pps - 0.5 + TICK_LABEL_INSET_PX < scrollLeft) {
37
+ found = mid;
38
+ lo = mid + 1;
39
+ } else hi = mid - 1;
40
+ }
41
+ return found;
42
+ }
43
+
44
+ /**
45
+ * Paints over the one label the header corner would slice, once a scroll settles. Moving it
46
+ * on every tick crossing cost 2-3x the scroll budget on a large timeline, so mid-scroll a
47
+ * fragment can still show.
48
+ */
49
+ const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
50
+ scrollRef,
51
+ major,
52
+ pps,
53
+ background,
54
+ }: {
55
+ scrollRef: RefObject<HTMLDivElement | null>;
56
+ major: number[];
57
+ pps: number;
58
+ background: string;
59
+ }) {
60
+ const scrollingRef = useRef(false);
61
+ const subscribe = useCallback(
62
+ (onChange: () => void) => {
63
+ const el = scrollRef.current;
64
+ let settle: ReturnType<typeof setTimeout> | undefined;
65
+ const onScroll = () => {
66
+ if (!scrollingRef.current) {
67
+ scrollingRef.current = true;
68
+ onChange();
69
+ }
70
+ clearTimeout(settle);
71
+ settle = setTimeout(() => {
72
+ scrollingRef.current = false;
73
+ onChange();
74
+ }, TIMELINE_SCROLL_SETTLE_MS);
75
+ };
76
+ el?.addEventListener("scroll", onScroll, { passive: true });
77
+ return () => {
78
+ clearTimeout(settle);
79
+ scrollingRef.current = false;
80
+ el?.removeEventListener("scroll", onScroll);
81
+ };
82
+ },
83
+ [scrollRef],
84
+ );
85
+ const index = useSyncExternalStore(
86
+ subscribe,
87
+ () =>
88
+ scrollingRef.current
89
+ ? -1
90
+ : tickIndexUnderHeader(major, pps, scrollRef.current?.scrollLeft ?? 0),
91
+ () => -1,
92
+ );
93
+ if (index < 0) return null;
94
+ const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
95
+ const next = major[index + 1];
96
+ return (
97
+ <div
98
+ data-timeline-ruler-label-mask=""
99
+ className="absolute pointer-events-none"
100
+ style={{
101
+ left: labelLeft,
102
+ top: 4,
103
+ height: 12,
104
+ // Up to the next label, so only this label's text band is covered.
105
+ width: next === undefined ? 80 : (next - major[index]!) * pps - TICK_LABEL_INSET_PX,
106
+ background,
107
+ }}
108
+ />
109
+ );
110
+ });
111
+
24
112
  export const TimelineRuler = memo(function TimelineRuler({
25
113
  major,
26
114
  minor,
@@ -33,6 +121,7 @@ export const TimelineRuler = memo(function TimelineRuler({
33
121
  beatAnalysis,
34
122
  contentOrigin,
35
123
  renderTimeRange,
124
+ scrollRef,
36
125
  }: TimelineRulerProps) {
37
126
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
38
127
  const beatTimes = beatAnalysis?.beatTimes ?? [];
@@ -131,7 +220,7 @@ export const TimelineRuler = memo(function TimelineRuler({
131
220
  className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
132
221
  style={{
133
222
  color: theme.tickText,
134
- left: 5,
223
+ left: TICK_LABEL_INSET_PX,
135
224
  top: 5,
136
225
  fontSize: 10,
137
226
  }}
@@ -143,6 +232,14 @@ export const TimelineRuler = memo(function TimelineRuler({
143
232
  <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
144
233
  </div>
145
234
  ))}
235
+ {scrollRef && (
236
+ <HeaderSlicedLabelMask
237
+ scrollRef={scrollRef}
238
+ major={major}
239
+ pps={pps}
240
+ background={theme.shellBackground}
241
+ />
242
+ )}
146
243
  </div>
147
244
  </div>
148
245
  </>
@@ -17,6 +17,7 @@ export function TimelineRulerPart() {
17
17
  beatAnalysis={props.beatAnalysis}
18
18
  contentOrigin={props.contentOrigin}
19
19
  renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
20
+ scrollRef={props.scrollRef}
20
21
  />
21
22
  );
22
23
  }
@@ -0,0 +1,101 @@
1
+ /** Bare keys Studio's app hotkeys bind; the shortcuts list names them from here. */
2
+ export const STUDIO_PLAIN_KEYS = { fullscreen: "f", split: "s", record: "r" } as const;
3
+
4
+ export interface ShortcutHint {
5
+ key: string;
6
+ label: string;
7
+ }
8
+
9
+ export interface ShortcutSection {
10
+ title: string;
11
+ hints: readonly ShortcutHint[];
12
+ }
13
+
14
+ const hintKey = (key: string) => key.toUpperCase();
15
+
16
+ /** What PlayerControls' shortcuts panel lists unless an embedder passes its own sections. */
17
+ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
18
+ {
19
+ title: "Playback",
20
+ hints: [
21
+ { key: "Space", label: "Play / Pause" },
22
+ { key: "J", label: "Play backward" },
23
+ { key: "K", label: "Stop" },
24
+ { key: "L", label: "Play forward" },
25
+ { key: "M", label: "Toggle mute" },
26
+ { key: "⇧L", label: "Toggle loop" },
27
+ { key: "←/→", label: "Step 1 frame" },
28
+ { key: "⇧←/⇧→", label: "Step 10 frames" },
29
+ { key: hintKey(STUDIO_PLAIN_KEYS.fullscreen), label: "Toggle fullscreen" },
30
+ ],
31
+ },
32
+ {
33
+ title: "Keyframes (when an element is selected)",
34
+ hints: [
35
+ { key: "K", label: "Add keyframe at playhead" },
36
+ { key: "Del", label: "Delete selected keyframe" },
37
+ { key: "H", label: "Toggle hold / bezier" },
38
+ { key: "U", label: "Expand / collapse properties" },
39
+ { key: hintKey(STUDIO_PLAIN_KEYS.record), label: "Record gesture" },
40
+ ],
41
+ },
42
+ {
43
+ title: "Editing",
44
+ hints: [
45
+ { key: "⌘Z", label: "Undo" },
46
+ { key: "⌘⇧Z", label: "Redo" },
47
+ { key: "⌘C", label: "Copy element" },
48
+ { key: "⌘V", label: "Paste element" },
49
+ { key: "⌘X", label: "Cut element" },
50
+ { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
51
+ { key: "⇧Click", label: "Razor tool: split all tracks" },
52
+ { key: "⌘G", label: "Group elements" },
53
+ { key: "⌘⇧G", label: "Ungroup" },
54
+ { key: "Del", label: "Delete selected element (no keyframe selected)" },
55
+ ],
56
+ },
57
+ {
58
+ title: "Gesture recording modifiers",
59
+ hints: [
60
+ { key: "Drag", label: "Record x / y position" },
61
+ { key: "Scroll", label: "Record z depth" },
62
+ { key: "⇧ Drag", label: "Record rotationX / rotationY" },
63
+ { key: "⌥ Drag", label: "Record rotation" },
64
+ { key: "⌘ Drag↕", label: "Record opacity" },
65
+ { key: "⌘ Scroll", label: "Record scale" },
66
+ ],
67
+ },
68
+ {
69
+ title: "Canvas",
70
+ hints: [
71
+ { key: "Drag", label: "Move element / add keyframe" },
72
+ { key: "⌥ Drag", label: "Move entire animation path" },
73
+ { key: "⇧ Drag", label: "Uniform resize" },
74
+ ],
75
+ },
76
+ {
77
+ title: "Crop",
78
+ hints: [
79
+ { key: "Drag edge", label: "Crop a side" },
80
+ { key: "Drag center", label: "Reposition the crop" },
81
+ ],
82
+ },
83
+ {
84
+ title: "Panels",
85
+ hints: [
86
+ { key: "⌘1", label: "Compositions tab" },
87
+ { key: "⌘2", label: "Assets tab" },
88
+ ],
89
+ },
90
+ {
91
+ title: "Work area",
92
+ hints: [
93
+ { key: "I", label: "Set in-point" },
94
+ { key: "⇧I", label: "Clear in-point" },
95
+ { key: "O", label: "Set out-point" },
96
+ { key: "⇧O", label: "Clear out-point" },
97
+ { key: "A", label: "Jump to in-point" },
98
+ { key: "E", label: "Jump to out-point" },
99
+ ],
100
+ },
101
+ ];