@hyperframes/studio 0.8.92 → 0.8.94

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.
Files changed (43) hide show
  1. package/dist/assets/{hyperframes-player-DKJPDHUp.js → hyperframes-player-Bkm-3G56.js} +3 -3
  2. package/dist/assets/{index-DpzpqJ_j.js → index-BUn5H_np.js} +1 -1
  3. package/dist/assets/{index-C5_j5-3u.js → index-BupvpoiM.js} +188 -188
  4. package/dist/assets/{index-CHZ4kFWm.js → index-n2ZRiO44.js} +1 -1
  5. package/dist/index.d.ts +62 -46
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +156 -102
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/TimelineToolbar.test.tsx +17 -0
  11. package/src/components/TimelineToolbar.tsx +3 -1
  12. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +2 -0
  13. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -1
  14. package/src/components/editor/DomEditCropHandles.test.tsx +87 -1
  15. package/src/components/editor/DomEditCropHandles.tsx +64 -62
  16. package/src/components/editor/DomEditOverlay.test.ts +39 -0
  17. package/src/components/editor/DomEditOverlay.tsx +6 -1
  18. package/src/components/editor/DomEditSelectionChrome.test.tsx +114 -1
  19. package/src/components/editor/DomEditSelectionChrome.tsx +17 -10
  20. package/src/components/editor/PreviewOverlayProvider.test.tsx +19 -0
  21. package/src/components/editor/PreviewOverlayProvider.tsx +1 -1
  22. package/src/components/editor/SnapToolbar.tsx +1 -1
  23. package/src/components/editor/domEditOverlayGeometry.ts +2 -0
  24. package/src/components/nle/PreviewOverlays.tsx +3 -3
  25. package/src/hooks/gsapResizeIntercept.test.ts +40 -0
  26. package/src/hooks/gsapResizeIntercept.ts +8 -4
  27. package/src/index.ts +4 -0
  28. package/src/player/components/PlayerControls.tsx +2 -15
  29. package/src/player/components/ShortcutsPanel.test.tsx +33 -1
  30. package/src/player/components/ShortcutsPanel.tsx +26 -0
  31. package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
  32. package/src/player/components/TimelineCanvas.tsx +16 -19
  33. package/src/player/components/TimelineTypes.ts +2 -0
  34. package/src/player/components/timelineLayout.test.ts +47 -0
  35. package/src/player/components/timelineLayout.ts +22 -15
  36. package/src/player/components/useTimelineProviderState.tsx +2 -0
  37. package/src/player/components/useTimelineTrackLayout.ts +11 -2
  38. package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
  39. package/src/player/components/useTimelineVirtualRows.ts +2 -2
  40. package/src/player/hooks/useTimelinePlayer.seek.test.ts +29 -1
  41. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  42. package/src/player/hooks/useTimelinePlayerLoop.ts +6 -2
  43. package/src/player/lib/playbackTypes.ts +1 -0
@@ -1,6 +1,10 @@
1
1
  import type { RefObject } from "react";
2
2
  import { type DomEditSelection } from "./domEditing";
3
- import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
3
+ import {
4
+ type GroupOverlayItem,
5
+ type OverlayRect,
6
+ RESIZE_HANDLE_HIT_PX,
7
+ } from "./domEditOverlayGeometry";
4
8
  import type { ResizeHandle } from "./domEditOverlayGestures";
5
9
  import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
10
  import { DomEditCropHandles } from "./DomEditCropHandles";
@@ -23,10 +27,6 @@ const RESIZE_HANDLE_DEFS: Array<{
23
27
  { handle: "se", cursor: "nwse-resize", x: "right", y: "bottom" },
24
28
  ];
25
29
 
26
- // Visible dot is 9px; the pointer target is a 16px invisible square centered
27
- // on the corner so click targets don't shrink with the smaller dot.
28
- const RESIZE_HANDLE_HIT_PX = 16;
29
-
30
30
  type CropInset = { top: number; right: number; bottom: number; left: number };
31
31
  const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 };
32
32
 
@@ -59,6 +59,7 @@ interface DomEditGroupChromeProps {
59
59
  groupOverlayItems: GroupOverlayItem[];
60
60
  groupBounds: OverlayRect;
61
61
  allowCanvasMovement: boolean;
62
+ allowBodyDrag: boolean;
62
63
  groupCanMove: boolean;
63
64
  gestures: GestureHandlers;
64
65
  onBoxMouseDown: (e: React.MouseEvent) => void;
@@ -71,6 +72,7 @@ export function DomEditGroupChrome({
71
72
  groupOverlayItems,
72
73
  groupBounds,
73
74
  allowCanvasMovement,
75
+ allowBodyDrag,
74
76
  groupCanMove,
75
77
  gestures,
76
78
  onBoxMouseDown,
@@ -100,10 +102,10 @@ export function DomEditGroupChrome({
100
102
  top: groupBounds.top,
101
103
  width: groupBounds.width,
102
104
  height: groupBounds.height,
103
- cursor: canManipulate && groupCanMove ? "move" : "default",
105
+ cursor: !allowBodyDrag ? undefined : canManipulate && groupCanMove ? "move" : "default",
104
106
  }}
105
107
  onPointerDown={(e) => {
106
- if (!canManipulate || e.shiftKey) return;
108
+ if (!canManipulate || e.shiftKey || !allowBodyDrag) return;
107
109
  gestures.startGroupDrag(e);
108
110
  }}
109
111
  onMouseDown={onBoxMouseDown}
@@ -117,6 +119,7 @@ interface DomEditSelectionChromeProps {
117
119
  selection: DomEditSelection;
118
120
  overlayRect: OverlayRect;
119
121
  allowCanvasMovement: boolean;
122
+ allowBodyDrag: boolean;
120
123
  cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
121
124
  boxRef: RefObject<HTMLDivElement | null>;
122
125
  boxChromeClass: string;
@@ -145,6 +148,7 @@ export function DomEditSelectionChrome({
145
148
  selection,
146
149
  overlayRect,
147
150
  allowCanvasMovement,
151
+ allowBodyDrag,
148
152
  cropOutlineInsetPx,
149
153
  boxRef,
150
154
  boxChromeClass,
@@ -197,8 +201,11 @@ export function DomEditSelectionChrome({
197
201
  width: overlayRect.width,
198
202
  height: overlayRect.height,
199
203
  clipPath: boxClipPath,
200
- cursor:
201
- canManipulate && selection.capabilities.canApplyManualOffset ? "move" : "default",
204
+ cursor: !allowBodyDrag
205
+ ? undefined
206
+ : canManipulate && selection.capabilities.canApplyManualOffset
207
+ ? "move"
208
+ : "default",
202
209
  }}
203
210
  onPointerDown={(e) => {
204
211
  // A second press opens the element's text for editing, and must be
@@ -211,7 +218,7 @@ export function DomEditSelectionChrome({
211
218
  e.stopPropagation();
212
219
  return;
213
220
  }
214
- if (!canManipulate || e.shiftKey) return;
221
+ if (!allowBodyDrag || !canManipulate || e.shiftKey) return;
215
222
  if (selection.capabilities.canApplyManualOffset) {
216
223
  gestures.startGesture("drag", e);
217
224
  return;
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import { GridOverlay } from "./GridOverlay";
7
7
  import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
8
+ import { SnapToolbar } from "./SnapToolbar";
8
9
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
9
10
  import { usePlayerStore } from "../../player/store/playerStore";
10
11
 
@@ -92,4 +93,22 @@ describe("PreviewOverlayProvider", () => {
92
93
 
93
94
  expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
94
95
  });
96
+
97
+ it("lets presses reach the preview beneath it, except on children that opt in", () => {
98
+ const host = document.createElement("div");
99
+ document.body.append(host);
100
+ const root = createRoot(host);
101
+ act(() => {
102
+ root.render(
103
+ <PreviewOverlayProvider iframe={null}>
104
+ <SnapToolbar />
105
+ </PreviewOverlayProvider>,
106
+ );
107
+ });
108
+
109
+ const layer = host.firstElementChild;
110
+ expect(layer?.classList.contains("pointer-events-none")).toBe(true);
111
+ expect(layer?.firstElementChild?.classList.contains("pointer-events-auto")).toBe(true);
112
+ act(() => root.unmount());
113
+ });
95
114
  });
@@ -71,7 +71,7 @@ export function PreviewOverlayProvider({ iframe, children }: PreviewOverlayProvi
71
71
 
72
72
  return (
73
73
  <PreviewOverlayContext.Provider value={contextValue}>
74
- <div ref={overlayRef} className="absolute inset-0">
74
+ <div ref={overlayRef} className="pointer-events-none absolute inset-0">
75
75
  {children}
76
76
  </div>
77
77
  </PreviewOverlayContext.Provider>
@@ -65,7 +65,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
65
65
 
66
66
  return (
67
67
  <div
68
- className="absolute top-2 right-2 z-50 flex items-center gap-1"
68
+ className="pointer-events-auto absolute top-2 right-2 z-50 flex items-center gap-1"
69
69
  onPointerDown={(e) => e.stopPropagation()}
70
70
  >
71
71
  {motionPathCreateAvailable && (
@@ -9,6 +9,8 @@ import { hugRectForElement } from "./domEditOverlayCrop";
9
9
  import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
10
10
  import { type OverlayMeasurePass, readThroughPass } from "./domEditOverlayMeasurePass";
11
11
 
12
+ export const RESIZE_HANDLE_HIT_PX = 16;
13
+
12
14
  export interface OverlayRect {
13
15
  left: number;
14
16
  top: number;
@@ -90,7 +90,7 @@ export function PreviewOverlays({
90
90
  <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
91
91
  <CaptionOverlay iframeRef={previewIframeRef} />
92
92
  {/* Mode indicator + explicit exit */}
93
- <div className="absolute top-2 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
93
+ <div className="pointer-events-auto absolute top-2 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
94
94
  <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" aria-hidden="true" />
95
95
  <span className="text-2xs text-neutral-200">Editing captions</span>
96
96
  <button
@@ -104,7 +104,7 @@ export function PreviewOverlays({
104
104
  {captionSyncError && (
105
105
  <div
106
106
  role="alert"
107
- className="absolute top-10 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-red-500/50 bg-red-950/90 px-2.5 py-1"
107
+ className="pointer-events-auto absolute top-10 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-red-500/50 bg-red-950/90 px-2.5 py-1"
108
108
  >
109
109
  <span className="text-2xs text-red-200">{captionSyncError}</span>
110
110
  <button
@@ -152,7 +152,7 @@ export function PreviewOverlays({
152
152
  <button
153
153
  type="button"
154
154
  onClick={enterCaptionMode}
155
- className="absolute top-2 left-1/2 -translate-x-1/2 z-60 rounded-full border border-neutral-700 bg-black/60 px-2.5 py-1 text-2xs text-neutral-300 transition-colors hover:border-studio-accent/50 hover:text-studio-accent focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-studio-accent"
155
+ className="pointer-events-auto absolute top-2 left-1/2 -translate-x-1/2 z-60 rounded-full border border-neutral-700 bg-black/60 px-2.5 py-1 text-2xs text-neutral-300 transition-colors hover:border-studio-accent/50 hover:text-studio-accent focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-studio-accent"
156
156
  >
157
157
  Edit captions
158
158
  </button>
@@ -521,3 +521,43 @@ it("does not move a statically positioned element when a scale resize lands", as
521
521
  // And the live element ends on the drop point, not a drag away from it.
522
522
  expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
523
523
  });
524
+
525
+ function titleSelection(): DomEditSelection {
526
+ const el = document.createElement("h1");
527
+ el.id = "title";
528
+ document.body.append(el);
529
+ return { id: "title", selector: "#title", element: el } as DomEditSelection;
530
+ }
531
+
532
+ it("writes a plain size on an element whose only tween is a fade", async () => {
533
+ const fade = {
534
+ id: "#title-to-0-visual",
535
+ targetSelector: "#title",
536
+ propertyGroup: "visual",
537
+ method: "to",
538
+ properties: { opacity: 0.5 },
539
+ position: 0,
540
+ resolvedStart: 0,
541
+ duration: 10,
542
+ } as unknown as GsapAnimation;
543
+ const commitMutation = vi.fn();
544
+
545
+ const handled = await tryGsapResizeIntercept(
546
+ titleSelection(),
547
+ { width: 424, height: 237 },
548
+ [fade],
549
+ null,
550
+ commitMutation,
551
+ );
552
+
553
+ expect(handled).toMatchObject({ status: "persisted" });
554
+ const mutations = commitMutation.mock.calls.map((call) => call[1] as { type: string });
555
+ expect(mutations.filter((m) => /keyframe/i.test(m.type))).toEqual([]);
556
+ expect(mutations).toContainEqual({
557
+ type: "add",
558
+ targetSelector: "#title",
559
+ method: "set",
560
+ position: 0,
561
+ properties: { width: 424, height: 237 },
562
+ });
563
+ });
@@ -148,12 +148,16 @@ export async function tryGsapResizeIntercept(
148
148
  ? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
149
149
  : findSizeSetAnimation(workingAnimations, sel, selection.element);
150
150
 
151
- // If the element is animated (has a real tween, not just a static size
152
- // hold), keyframe the size at the playhead so other keyframes keep theirs —
153
- // instead of a global set that resizes every frame.
151
+ // Keyframe the size only when a real tween already animates it, as move and
152
+ // rotate do; a fade or a slide on the element gets a plain size.
154
153
  if (resizeGroup === "size") {
155
154
  const animatedTween = pickClosestToPlayhead(
156
- workingAnimations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
155
+ workingAnimations.filter(
156
+ (a) =>
157
+ !isInstantHold(a) &&
158
+ resolveTweenDuration(a) > 0 &&
159
+ animationWritesAnyProperty(a, resizeProperties),
160
+ ),
157
161
  );
158
162
  if (animatedTween) {
159
163
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export { EditorShell } from "./components/EditorShell";
6
6
  export type { EditorShellProps } from "./components/EditorShell";
7
7
  export { NLEPreview } from "./components/nle/NLEPreview";
8
8
  export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
9
+ export { ShortcutsButton } from "./player/components/ShortcutsPanel";
10
+ export type { ShortcutsButtonProps } from "./player/components/ShortcutsPanel";
9
11
  export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
10
12
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
11
13
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
@@ -49,6 +51,8 @@ export {
49
51
  } from "./player/components/TimelineParts";
50
52
  export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
51
53
  export type { TimelineTheme } from "./player/components/timelineTheme";
54
+ export { TRACK_H } from "./player/components/timelineLayout";
55
+ export type { TimelineTrackPadding } from "./player/components/timelineLayout";
52
56
 
53
57
  // Clip content thumbnails: used by a host rendering its own timeline lane.
54
58
  export { AudioWaveform } from "./player/components/AudioWaveform";
@@ -6,7 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
6
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
7
7
  import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
- import { ShortcutsPanel } from "./ShortcutsPanel";
9
+ import { ShortcutsButton } from "./ShortcutsPanel";
10
10
  import type { ShortcutSection } from "./studioShortcuts";
11
11
  import { SpeedMenu } from "./SpeedMenu";
12
12
  import { VolumeControl } from "./VolumeControl";
@@ -185,10 +185,6 @@ export const PlayerControls = memo(function PlayerControls({
185
185
  const setAudioMuted = usePlayerStore.getState().setAudioMuted;
186
186
  const setAudioVolume = usePlayerStore.getState().setAudioVolume;
187
187
  const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
188
- const inPoint = usePlayerStore((s) => s.inPoint);
189
- const outPoint = usePlayerStore((s) => s.outPoint);
190
- const setInPoint = usePlayerStore.getState().setInPoint;
191
- const setOutPoint = usePlayerStore.getState().setOutPoint;
192
188
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
193
189
  const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
194
190
 
@@ -288,16 +284,7 @@ export const PlayerControls = memo(function PlayerControls({
288
284
  {onToggleFullscreen && (
289
285
  <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
290
286
  )}
291
- <ShortcutsPanel
292
- disabled={disabled}
293
- duration={duration}
294
- inPoint={inPoint}
295
- outPoint={outPoint}
296
- setInPoint={setInPoint}
297
- setOutPoint={setOutPoint}
298
- onSeek={onSeek}
299
- sections={shortcutSections}
300
- />
287
+ <ShortcutsButton disabled={disabled} onSeek={onSeek} sections={shortcutSections} />
301
288
  </div>
302
289
  </div>
303
290
  </div>
@@ -2,10 +2,11 @@
2
2
 
3
3
  import React, { act, Profiler } from "react";
4
4
  import { describe, expect, it, vi } from "vitest";
5
- import { ShortcutsPanel } from "./ShortcutsPanel";
5
+ import { ShortcutsButton, ShortcutsPanel } from "./ShortcutsPanel";
6
6
  import { PlayerControls } from "./PlayerControls";
7
7
  import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
8
8
  import { createHappyDomRootHarness } from "./testRootHarness";
9
+ import { usePlayerStore } from "../store/playerStore";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -200,4 +201,35 @@ describe("ShortcutsPanel", () => {
200
201
  expect(errors.mock.calls.flat().join(" ")).not.toMatch(/same key/);
201
202
  errors.mockRestore();
202
203
  });
204
+
205
+ it("renders standalone for a host toolbar, reading the player store", () => {
206
+ const { duration, inPoint, requestSeek: storeSeek } = usePlayerStore.getState();
207
+ const requestSeek = vi.fn();
208
+ usePlayerStore.setState({ duration: 10, inPoint: 2, requestSeek });
209
+ try {
210
+ const host = document.createElement("div");
211
+ document.body.append(host);
212
+ act(() => {
213
+ mount(host).render(<ShortcutsButton sections={[{ title: "Host", hints: [] }]} />);
214
+ });
215
+ openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
216
+ expect(host.textContent).toContain("Host");
217
+ expect(host.textContent).toContain("00:02");
218
+
219
+ const input = host.querySelector<HTMLInputElement>('input[aria-label="Jump to frame"]')!;
220
+ act(() => {
221
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
222
+ input,
223
+ "30",
224
+ );
225
+ input.dispatchEvent(new Event("input", { bubbles: true }));
226
+ });
227
+ act(() => {
228
+ input.form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
229
+ });
230
+ expect(requestSeek).toHaveBeenCalledWith(1);
231
+ } finally {
232
+ usePlayerStore.setState({ duration, inPoint, requestSeek: storeSeek });
233
+ }
234
+ });
203
235
  });
@@ -2,6 +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
+ import { usePlayerStore } from "../store/playerStore";
5
6
  import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
6
7
 
7
8
  interface ShortcutsPanelProps {
@@ -262,3 +263,28 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
262
263
  </div>
263
264
  );
264
265
  });
266
+
267
+ export interface ShortcutsButtonProps {
268
+ sections?: readonly ShortcutSection[];
269
+ disabled?: boolean;
270
+ onSeek?: (time: number) => void;
271
+ }
272
+
273
+ export function ShortcutsButton({ sections, disabled = false, onSeek }: ShortcutsButtonProps) {
274
+ const duration = usePlayerStore((s) => s.duration);
275
+ const inPoint = usePlayerStore((s) => s.inPoint);
276
+ const outPoint = usePlayerStore((s) => s.outPoint);
277
+ const { setInPoint, setOutPoint, requestSeek } = usePlayerStore.getState();
278
+ return (
279
+ <ShortcutsPanel
280
+ disabled={disabled}
281
+ duration={duration}
282
+ inPoint={inPoint}
283
+ outPoint={outPoint}
284
+ setInPoint={setInPoint}
285
+ setOutPoint={setOutPoint}
286
+ onSeek={onSeek ?? requestSeek}
287
+ sections={sections}
288
+ />
289
+ );
290
+ }
@@ -0,0 +1,157 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
+ import { usePlayerStore } from "../store/playerStore";
9
+ import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
10
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
11
+ import type { TimelineProps } from "./TimelineTypes";
12
+
13
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ installTimelineMountEnv();
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ async function mountCanvas(trackPadding?: TimelineProps["trackPadding"]) {
22
+ usePlayerStore.setState({
23
+ duration: 10,
24
+ currentTime: 0,
25
+ timelineReady: true,
26
+ gsapAnimations: new Map(),
27
+ elements: [{ id: "card", label: "Card", tag: "div", start: 0, duration: 4, track: 0 }],
28
+ });
29
+ const host = document.createElement("div");
30
+ document.body.append(host);
31
+ const root = createRoot(host);
32
+ await act(async () => root.render(<Timeline trackPadding={trackPadding} />));
33
+ const canvas = Array.from(host.querySelectorAll<HTMLElement>("div.relative")).find(
34
+ (el) => el.style.height && el.style.width,
35
+ );
36
+ const height = canvas?.style.height;
37
+ const firstRowTop = host.querySelector<HTMLElement>('[data-timeline-row="0"]')?.style.top;
38
+ act(() => root.unmount());
39
+ return { height, firstRowTop };
40
+ }
41
+
42
+ describe("Timeline trackPadding", () => {
43
+ it("keeps Studio's pads by default", async () => {
44
+ expect(await mountCanvas()).toEqual({
45
+ height: `${RULER_H + TRACKS_TOP_PAD + TRACK_H + TRACKS_BOTTOM_PAD}px`,
46
+ firstRowTop: `${RULER_H + TRACKS_TOP_PAD}px`,
47
+ });
48
+ });
49
+
50
+ it("places the first row and sizes the canvas from the host's pads", async () => {
51
+ expect(await mountCanvas({ top: 0, bottom: TRACK_H })).toEqual({
52
+ height: `${RULER_H + 2 * TRACK_H}px`,
53
+ firstRowTop: `${RULER_H}px`,
54
+ });
55
+ });
56
+
57
+ // With no top pad, y = 96 is the middle of row 1; any reader still on the default pad sees row 0.
58
+ const ROW1_MID = RULER_H + TRACK_H + TRACK_H / 2;
59
+ const hostPads = { top: 0, bottom: TRACK_H };
60
+
61
+ async function mountThreeTracks(props: TimelineProps = {}) {
62
+ usePlayerStore.setState({
63
+ duration: 10,
64
+ currentTime: 0,
65
+ timelineReady: true,
66
+ gsapAnimations: new Map(),
67
+ selectedElementId: null,
68
+ selectedElementIds: new Set(),
69
+ elements: [0, 1, 2].map((track) => ({
70
+ id: `c${track}`,
71
+ key: `c${track}`,
72
+ domId: `c${track}`,
73
+ tag: "div",
74
+ start: 0,
75
+ duration: 2,
76
+ track,
77
+ })),
78
+ });
79
+ const host = document.createElement("div");
80
+ document.body.append(host);
81
+ const root = createRoot(host);
82
+ await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
83
+ const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
84
+ return { host, root, viewport };
85
+ }
86
+
87
+ const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
88
+
89
+ it("draws the gap strip, drop preview and insert line, and drops, on the host's rows", async () => {
90
+ const onAssetDrop = vi.fn();
91
+ const { host, root, viewport } = await mountThreeTracks({ onAssetDrop });
92
+ act(() => usePlayerStore.getState().setSelectedElementId("c1"));
93
+ expect(top(host.querySelector('[style*="--timeline-accent-faint"]'))).toBe(
94
+ `${RULER_H + TRACK_H + CLIP_Y}px`,
95
+ );
96
+
97
+ const drag = (type: string, clientY: number) => {
98
+ const event = new Event(type, { bubbles: true, cancelable: true });
99
+ Object.defineProperties(event, {
100
+ clientX: { value: 400 },
101
+ clientY: { value: clientY },
102
+ dataTransfer: {
103
+ value: {
104
+ types: [TIMELINE_ASSET_MIME],
105
+ files: [],
106
+ dropEffect: "none",
107
+ getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
108
+ },
109
+ },
110
+ });
111
+ act(() => void viewport.dispatchEvent(event));
112
+ };
113
+ drag("dragover", ROW1_MID);
114
+ expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
115
+ `${RULER_H + TRACK_H + CLIP_Y}px`,
116
+ );
117
+ drag("dragover", RULER_H / 2);
118
+ expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
119
+ `${RULER_H - 0.5}px`,
120
+ );
121
+ drag("drop", ROW1_MID);
122
+ expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 1 }));
123
+ act(() => root.unmount());
124
+ });
125
+
126
+ it("drags a clip onto the host's rows and opens a new track in the bottom pad", async () => {
127
+ const { host, root } = await mountThreeTracks({
128
+ onMoveElement: vi.fn(),
129
+ onResizeElement: vi.fn(),
130
+ });
131
+ const pointer = (target: EventTarget, type: string, clientY: number) =>
132
+ act(
133
+ () =>
134
+ void target.dispatchEvent(
135
+ new PointerEvent(type, {
136
+ bubbles: true,
137
+ button: 0,
138
+ pointerId: 1,
139
+ clientX: 400,
140
+ clientY,
141
+ }),
142
+ ),
143
+ );
144
+ const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
145
+ pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
146
+ pointer(window, "pointermove", ROW1_MID);
147
+ const ghost = Array.from(
148
+ host.querySelectorAll<HTMLElement>(".absolute.pointer-events-none"),
149
+ ).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
150
+ expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
151
+ pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
152
+ expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
153
+ `${RULER_H + 3 * TRACK_H - 0.5}px`,
154
+ );
155
+ act(() => root.unmount());
156
+ });
157
+ });
@@ -2,16 +2,7 @@ import { memo } from "react";
2
2
  import { TimelineRulerPart } from "./TimelineRulerPart";
3
3
  import { PlayheadIndicator } from "./PlayheadIndicator";
4
4
  import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
5
- import {
6
- RULER_H,
7
- CLIP_Y,
8
- TRACKS_TOP_PAD,
9
- TRACKS_BOTTOM_PAD,
10
- TRACK_H,
11
- PLAYHEAD_HEAD_W,
12
- getTimelineRowTop,
13
- getTimelineRowHeight,
14
- } from "./timelineLayout";
5
+ import { RULER_H, CLIP_Y, TRACK_H, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
15
6
  import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
16
7
  import { TimelineLanes } from "./TimelineLanes";
17
8
  import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
@@ -49,8 +40,11 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
49
40
  <TimelineRulerPart />
50
41
 
51
42
  {/* Breathing room between the sticky ruler and the first track lane — the
52
- top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
53
- <div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />
43
+ top half of the CapCut-style padding (see TimelineRowGeometry.padding). */}
44
+ <div
45
+ aria-hidden="true"
46
+ style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.top }}
47
+ />
54
48
 
55
49
  <TimelineLanes
56
50
  {...props}
@@ -67,10 +61,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
67
61
  onRazorSplitAll={props.onRazorSplitAll}
68
62
  />
69
63
 
70
- {/* Breathing room below the last track lane (~1.5 track heights) — a real
64
+ {/* Breathing room below the last track lane (1.5 tracks by default) — a real
71
65
  scrollable surface, so a clip can be dragged into the void to create a
72
- new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
73
- <div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />
66
+ new bottom track comfortably (see TimelineRowGeometry.padding / canvasHeight). */}
67
+ <div
68
+ aria-hidden="true"
69
+ style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
70
+ />
74
71
 
75
72
  {/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
76
73
  menu row would collapse; a quiet tint for every gap on the selected
@@ -91,7 +88,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
91
88
  key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
92
89
  className="pointer-events-none absolute"
93
90
  style={{
94
- top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y,
91
+ top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
95
92
  left: props.contentOrigin + gap.start * props.pps,
96
93
  width: Math.max((gap.end - gap.start) * props.pps, 2),
97
94
  height: TRACK_H - CLIP_Y * 2,
@@ -109,7 +106,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
109
106
  <div
110
107
  className="absolute pointer-events-none"
111
108
  style={{
112
- top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
109
+ top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
113
110
  left: props.contentOrigin + draggedClip.previewStart * props.pps,
114
111
  width: Math.max(draggedClip.element.duration * props.pps, 4),
115
112
  height: draggedClipHeight,
@@ -129,7 +126,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
129
126
  data-testid="timeline-drop-preview"
130
127
  className="absolute pointer-events-none"
131
128
  style={{
132
- top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
129
+ top: props.rowGeometry.getRowTop(dropRowIndex) + CLIP_Y,
133
130
  left: props.contentOrigin + props.dropPreview.start * props.pps,
134
131
  width: DROP_PREVIEW_SECONDS * props.pps,
135
132
  height: TRACK_H - CLIP_Y * 2,
@@ -148,7 +145,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
148
145
  data-testid="timeline-insert-line"
149
146
  className="absolute pointer-events-none"
150
147
  style={{
151
- top: getTimelineRowTop(insertLineRow, props.rowHeights) - 0.5,
148
+ top: props.rowGeometry.getRowTop(insertLineRow) - 0.5,
152
149
  left: props.contentOrigin,
153
150
  width: props.trackContentWidth,
154
151
  height: 1,
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
4
4
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
5
5
  import type { TimelineTheme } from "./timelineTheme";
6
+ import type { TimelineTrackPadding } from "./timelineLayout";
6
7
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
7
8
  import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
8
9
 
@@ -35,4 +36,5 @@ export interface TimelineProps
35
36
  theme?: Partial<TimelineTheme>;
36
37
  showAudioEffects?: boolean;
37
38
  showKeyframes?: boolean;
39
+ trackPadding?: TimelineTrackPadding;
38
40
  }