@hyperframes/studio 0.8.93 → 0.8.95

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 (49) hide show
  1. package/dist/assets/{hyperframes-player-CkV9KK6A.js → hyperframes-player-DJJKab4_.js} +1 -1
  2. package/dist/assets/{index-V3fzEfOD.js → index-C1dXTZvs.js} +1 -1
  3. package/dist/assets/{index-CYk0NwSv.js → index-Cc4by8r7.js} +183 -183
  4. package/dist/assets/{index-BlLFipOk.js → index-DE1dn3QB.js} +1 -1
  5. package/dist/index.d.ts +63 -46
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +196 -111
  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/editor/manualEditsSeekReapply.ts +5 -1
  25. package/src/components/nle/PreviewOverlays.tsx +3 -3
  26. package/src/hooks/gsapResizeIntercept.test.ts +40 -0
  27. package/src/hooks/gsapResizeIntercept.ts +8 -4
  28. package/src/index.ts +4 -0
  29. package/src/player/components/PlayerControls.tsx +2 -15
  30. package/src/player/components/ShortcutsPanel.test.tsx +33 -1
  31. package/src/player/components/ShortcutsPanel.tsx +26 -0
  32. package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
  33. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
  34. package/src/player/components/Timeline.test.ts +3 -3
  35. package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
  36. package/src/player/components/TimelineCanvas.tsx +44 -19
  37. package/src/player/components/TimelineProvider.tsx +1 -0
  38. package/src/player/components/TimelineTypes.ts +2 -0
  39. package/src/player/components/timelineDragDrop.test.tsx +14 -12
  40. package/src/player/components/timelineLayout.test.ts +87 -11
  41. package/src/player/components/timelineLayout.ts +28 -21
  42. package/src/player/components/useTimelineProviderState.tsx +5 -1
  43. package/src/player/components/useTimelineTrackLayout.ts +11 -2
  44. package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
  45. package/src/player/components/useTimelineVirtualRows.ts +2 -2
  46. package/src/utils/gsapSoftReload.test.ts +51 -2
  47. package/src/utils/gsapSoftReload.ts +24 -10
  48. package/src/utils/gsapUndoRestore.test.ts +49 -0
  49. package/src/utils/gsapUndoRestore.ts +15 -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;
@@ -67,7 +67,11 @@ function reapplyRotations(doc: Document): void {
67
67
  }
68
68
 
69
69
  // Every mark a reapply below acts on, the legacy double-prefixed ones included.
70
- const STUDIO_EDIT_ATTRS = [STUDIO_PATH_OFFSET_ATTR, STUDIO_BOX_SIZE_ATTR, STUDIO_ROTATION_ATTR]
70
+ export const STUDIO_EDIT_ATTRS = [
71
+ STUDIO_PATH_OFFSET_ATTR,
72
+ STUDIO_BOX_SIZE_ATTR,
73
+ STUDIO_ROTATION_ATTR,
74
+ ]
71
75
  .flatMap((attr) => [attr, `data-${attr}`])
72
76
  .concat(STUDIO_MOTION_ATTR);
73
77
  // One selector each: Chrome answers a lone attribute selector without walking the DOM, a comma list it walks.
@@ -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,126 @@
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, TRACK_H, getTimelineRowTop } 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
+ const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
22
+ const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
23
+
24
+ async function mountThreeTracks(props: TimelineProps = {}) {
25
+ usePlayerStore.setState({
26
+ duration: 10,
27
+ currentTime: 0,
28
+ timelineReady: true,
29
+ gsapAnimations: new Map(),
30
+ selectedElementId: null,
31
+ selectedElementIds: new Set(),
32
+ elements: [0, 1, 2].map((track) => ({
33
+ id: `c${track}`,
34
+ key: `c${track}`,
35
+ domId: `c${track}`,
36
+ tag: "div",
37
+ start: 0,
38
+ duration: 2,
39
+ track,
40
+ })),
41
+ });
42
+ const host = document.createElement("div");
43
+ document.body.append(host);
44
+ const root = createRoot(host);
45
+ await act(async () => root.render(<Timeline {...props} />));
46
+ const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
47
+ return { host, root, ghost };
48
+ }
49
+
50
+ describe("Timeline ghost lane", () => {
51
+ it("draws one empty track below the last with a drop hint", async () => {
52
+ const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
53
+ expect(ghost()?.textContent).toBe("Drop media here");
54
+ expect(ghost()?.getAttribute("aria-hidden")).toBe("true");
55
+ expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
56
+ expect(ghost()?.dataset.active).toBeUndefined();
57
+ act(() => root.unmount());
58
+ });
59
+
60
+ it("drops the hint without a drop handler and the lane without a bottom pad", async () => {
61
+ const readOnly = await mountThreeTracks();
62
+ expect(readOnly.ghost()?.textContent).toBe("");
63
+ act(() => readOnly.root.unmount());
64
+ const unpadded = await mountThreeTracks({ trackPadding: { bottom: 0 } });
65
+ expect(unpadded.ghost()).toBeNull();
66
+ act(() => unpadded.root.unmount());
67
+ });
68
+
69
+ it("lights up under a file dragged into it and opens a new track on drop", async () => {
70
+ const onAssetDrop = vi.fn();
71
+ const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
72
+ const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
73
+ const drag = (type: string, clientY: number) => {
74
+ const event = new Event(type, { bubbles: true, cancelable: true });
75
+ Object.defineProperties(event, {
76
+ clientX: { value: 400 },
77
+ clientY: { value: clientY },
78
+ dataTransfer: {
79
+ value: {
80
+ types: [TIMELINE_ASSET_MIME],
81
+ files: [],
82
+ dropEffect: "none",
83
+ getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
84
+ },
85
+ },
86
+ });
87
+ act(() => void viewport.dispatchEvent(event));
88
+ };
89
+ drag("dragover", ROW1_MID);
90
+ expect(ghost()?.dataset.active).toBeUndefined();
91
+ drag("dragover", GHOST_MID);
92
+ expect(ghost()?.dataset.active).toBe("true");
93
+ drag("drop", GHOST_MID);
94
+ expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 3 }));
95
+ act(() => root.unmount());
96
+ });
97
+
98
+ it.each([0, 2])("lights up under clip c%i dragged into it", async (track) => {
99
+ const { host, root, ghost } = await mountThreeTracks({
100
+ onMoveElement: vi.fn(),
101
+ onResizeElement: vi.fn(),
102
+ });
103
+ const pointer = (target: EventTarget, type: string, clientY: number) =>
104
+ act(
105
+ () =>
106
+ void target.dispatchEvent(
107
+ new PointerEvent(type, {
108
+ bubbles: true,
109
+ button: 0,
110
+ pointerId: 1,
111
+ clientX: 400,
112
+ clientY,
113
+ }),
114
+ ),
115
+ );
116
+ const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
117
+ pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
118
+ pointer(window, "pointermove", ROW1_MID);
119
+ expect(ghost()?.dataset.active).toBeUndefined();
120
+ pointer(window, "pointermove", GHOST_MID);
121
+ expect(ghost()?.dataset.active).toBe("true");
122
+ // Dragging the last track's clip adds a preview row; the lane must stay under the pointer.
123
+ expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
124
+ act(() => root.unmount());
125
+ });
126
+ });
@@ -108,4 +108,11 @@ describe("Timeline ruler origin", () => {
108
108
  expect(origin).toBe("80px");
109
109
  expect(published).toBe("80");
110
110
  });
111
+
112
+ it("seeks on a press when the host omits sessionEpoch after a session began", async () => {
113
+ usePlayerStore.getState().beginTimelineSession("host-project");
114
+ expect(usePlayerStore.getState().timelineSessionEpoch).toBeGreaterThan(0);
115
+ const { time, tickTime } = await seekOnFirstTick({}, false);
116
+ expect(time).toBeCloseTo(tickTime, 1);
117
+ });
111
118
  });
@@ -35,6 +35,7 @@ import {
35
35
  getTimelineDisplayContentWidth,
36
36
  getTimelineFitPps,
37
37
  getTimelineLaneTop,
38
+ getTimelineRowTop,
38
39
  createTimelineRowGeometry,
39
40
  } from "./timelineLayout";
40
41
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
@@ -1292,9 +1293,8 @@ describe("resolveTimelineAssetDrop", () => {
1292
1293
  trackOrder: [0, 3, 7],
1293
1294
  },
1294
1295
  432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
1295
- // clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48)
1296
- // + TRACK_H/2(24) = 368 → row 1 → track 3.
1297
- 368,
1296
+ // clientY: rectTop(200) + the middle of row 1 → track 3.
1297
+ 200 + getTimelineRowTop(1) + TRACK_H / 2,
1298
1298
  ),
1299
1299
  ).toEqual({ start: 3, track: 3 });
1300
1300
  });