@hyperframes/studio 0.8.94 → 0.8.96

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 (33) hide show
  1. package/dist/assets/{hyperframes-player-Bkm-3G56.js → hyperframes-player-ClfxdXeC.js} +1 -1
  2. package/dist/assets/{index-n2ZRiO44.js → index-BNQLP5_v.js} +1 -1
  3. package/dist/assets/{index-BupvpoiM.js → index-CSHEHeOo.js} +176 -176
  4. package/dist/assets/{index-BUn5H_np.js → index-D8ztHPd3.js} +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +109 -43
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
  11. package/src/components/editor/DomEditCropHandles.tsx +7 -10
  12. package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
  13. package/src/components/editor/domEditOverlayGestures.ts +16 -0
  14. package/src/components/editor/groupDragMove.ts +13 -6
  15. package/src/components/editor/manualEditsSeekReapply.ts +5 -1
  16. package/src/components/editor/manualOffsetDrag.test.ts +56 -0
  17. package/src/components/editor/manualOffsetDrag.ts +16 -9
  18. package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
  19. package/src/components/editor/snapEngine.ts +3 -2
  20. package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
  21. package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
  22. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
  23. package/src/player/components/Timeline.test.ts +3 -3
  24. package/src/player/components/TimelineCanvas.tsx +30 -2
  25. package/src/player/components/TimelineProvider.tsx +1 -0
  26. package/src/player/components/timelineDragDrop.test.tsx +14 -12
  27. package/src/player/components/timelineLayout.test.ts +40 -11
  28. package/src/player/components/timelineLayout.ts +6 -6
  29. package/src/player/components/useTimelineProviderState.tsx +3 -1
  30. package/src/utils/gsapSoftReload.test.ts +51 -2
  31. package/src/utils/gsapSoftReload.ts +24 -10
  32. package/src/utils/gsapUndoRestore.test.ts +49 -0
  33. package/src/utils/gsapUndoRestore.ts +15 -0
@@ -0,0 +1,358 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, createRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "./domEditing";
6
+ import type { GestureState, GroupGestureState } from "./domEditOverlayGestures";
7
+ import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
8
+ import { createManualOffsetDragMember } from "./manualOffsetDrag";
9
+ import { resolveSnapAdjustment } from "./snapEngine";
10
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestureState | null) {
15
+ const opts = {
16
+ gestureRef: { current: gesture },
17
+ groupGestureRef: { current: group },
18
+ selectionRef: { current: null },
19
+ hoverSelectionRef: { current: null },
20
+ boxRef: { current: null },
21
+ blockedMoveRef: { current: null },
22
+ rafPausedRef: { current: false },
23
+ suppressNextBoxClickRef: { current: false },
24
+ snapGuidesRef: { current: null },
25
+ setOverlayRect: vi.fn(),
26
+ setGroupOverlayItems: vi.fn(),
27
+ onCanvasMouseDown: vi.fn(),
28
+ onCanvasPointerMoveRef: { current: vi.fn() },
29
+ onPathOffsetCommitRef: { current: vi.fn() },
30
+ onGroupPathOffsetCommitRef: { current: vi.fn() },
31
+ };
32
+ const handlers = createDomEditOverlayGestureHandlers(opts as never);
33
+ const pointer = (clientX: number, clientY: number, shiftKey: boolean) =>
34
+ ({ clientX, clientY, shiftKey, altKey: false }) as never;
35
+ return { opts, handlers, pointer };
36
+ }
37
+
38
+ function singleDrag() {
39
+ const element = document.createElement("div");
40
+ return gestureHarness(
41
+ {
42
+ kind: "drag",
43
+ mode: "path-offset",
44
+ selection: { element } as unknown as DomEditSelection,
45
+ startX: 100,
46
+ startY: 100,
47
+ originLeft: 10,
48
+ originTop: 20,
49
+ originWidth: 50,
50
+ originHeight: 40,
51
+ editScaleX: 1,
52
+ editScaleY: 1,
53
+ actualRotation: 0,
54
+ },
55
+ null,
56
+ );
57
+ }
58
+
59
+ const groupGesture = () =>
60
+ ({ startX: 100, startY: 100, originItems: [], members: [] }) as unknown as GroupGestureState;
61
+
62
+ describe("shift+drag locks a move to the axis the pointer travels further on", () => {
63
+ it("keeps a mostly horizontal single drag on its row", () => {
64
+ const { opts, handlers, pointer } = singleDrag();
65
+ handlers.onPointerMove(pointer(130, 108, true));
66
+ expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
67
+ expect.objectContaining({ left: 40, top: 20 }),
68
+ );
69
+ });
70
+
71
+ it("keeps a mostly vertical single drag on its column", () => {
72
+ const { opts, handlers, pointer } = singleDrag();
73
+ handlers.onPointerMove(pointer(95, 160, true));
74
+ expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
75
+ expect.objectContaining({ left: 10, top: 80 }),
76
+ );
77
+ });
78
+
79
+ it("frees the drag on the next move once shift is released, and locks again when pressed", () => {
80
+ const { opts, handlers, pointer } = singleDrag();
81
+ handlers.onPointerMove(pointer(130, 108, false));
82
+ expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
83
+ expect.objectContaining({ left: 40, top: 28 }),
84
+ );
85
+ handlers.onPointerMove(pointer(140, 110, true));
86
+ expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
87
+ expect.objectContaining({ left: 50, top: 20 }),
88
+ );
89
+ });
90
+
91
+ it("locks a group drag the same way", () => {
92
+ const group = groupGesture();
93
+ const { handlers, pointer } = gestureHarness(null, group);
94
+ handlers.onPointerMove(pointer(130, 108, true));
95
+ expect([group.lastSnappedDx, group.lastSnappedDy]).toEqual([30, 0]);
96
+ });
97
+
98
+ it("snaps a locked drag only along its free axis", () => {
99
+ const target = {
100
+ id: "t",
101
+ left: 0,
102
+ top: 203,
103
+ right: 400,
104
+ bottom: 303,
105
+ centerX: 200,
106
+ centerY: 253,
107
+ };
108
+ const snap = (lockedAxis?: "x" | "y") =>
109
+ resolveSnapAdjustment({
110
+ movingRect: { left: 0, top: 0, width: 50, height: 200 },
111
+ proposedDx: 97,
112
+ proposedDy: 0,
113
+ targets: [target],
114
+ threshold: 6,
115
+ disabled: false,
116
+ lockedAxis,
117
+ } as never);
118
+ expect(snap().dy).toBe(3);
119
+ const locked = snap("y");
120
+ expect(locked.dy).toBe(0);
121
+ expect(locked.guides.every((guide) => guide.axis === "x")).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe("shift+click on a selected box still toggles the element under the pointer", () => {
126
+ it.each([
127
+ ["group", () => gestureHarness(null, groupGesture())],
128
+ ["single", () => singleMemberDrag()],
129
+ ])(
130
+ "hands a %s shift press that never travelled to the canvas as an additive click",
131
+ (_, make) => {
132
+ const { opts, handlers, pointer } = make();
133
+ handlers.onPointerUp(pointer(101, 100, true));
134
+ expect(opts.onCanvasMouseDown).toHaveBeenCalledWith(
135
+ expect.objectContaining({ shiftKey: true }),
136
+ expect.objectContaining({ preferClipAncestor: false }),
137
+ );
138
+ expect(opts.suppressNextBoxClickRef.current).toBe(true);
139
+ },
140
+ );
141
+
142
+ it("leaves a plain press on the group box alone", () => {
143
+ const { opts, handlers, pointer } = gestureHarness(null, groupGesture());
144
+ handlers.onPointerUp(pointer(101, 100, false));
145
+ expect(opts.onCanvasMouseDown).not.toHaveBeenCalled();
146
+ });
147
+ });
148
+
149
+ describe("a shift press on a selected box starts the drag", () => {
150
+ const rect = { left: 10, top: 10, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
151
+ const gestures = () => ({
152
+ startGesture: vi.fn(),
153
+ startGroupDrag: vi.fn(),
154
+ startBlockedMove: vi.fn(),
155
+ });
156
+ const shiftPress = (el: Element) =>
157
+ act(() => {
158
+ el.dispatchEvent(
159
+ new PointerEvent("pointerdown", {
160
+ bubbles: true,
161
+ cancelable: true,
162
+ shiftKey: true,
163
+ pointerId: 1,
164
+ }),
165
+ );
166
+ });
167
+
168
+ function mount(node: (spies: ReturnType<typeof gestures>) => React.ReactNode) {
169
+ const host = document.createElement("div");
170
+ document.body.append(host);
171
+ const root = createRoot(host);
172
+ const spies = gestures();
173
+ act(() => root.render(node(spies)));
174
+ const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
175
+ return { spies, box, unmount: () => act(() => root.unmount()) };
176
+ }
177
+
178
+ const single = (canMove: boolean) =>
179
+ mount((spies) => (
180
+ <DomEditSelectionChrome
181
+ selection={
182
+ {
183
+ element: document.createElement("div"),
184
+ capabilities: { canApplyManualOffset: canMove },
185
+ } as unknown as DomEditSelection
186
+ }
187
+ overlayRect={rect}
188
+ allowCanvasMovement
189
+ allowBodyDrag
190
+ boxRef={createRef()}
191
+ boxChromeClass=""
192
+ boxClipPath={undefined}
193
+ selectionKey="box"
194
+ groupSelectionCount={0}
195
+ gestures={spies as never}
196
+ onBoxMouseDown={vi.fn()}
197
+ onBoxClick={vi.fn()}
198
+ />
199
+ ));
200
+
201
+ const group = (groupCanMove: boolean) =>
202
+ mount((spies) => (
203
+ <DomEditGroupChrome
204
+ groupOverlayItems={[]}
205
+ groupBounds={rect}
206
+ allowCanvasMovement
207
+ allowBodyDrag
208
+ groupCanMove={groupCanMove}
209
+ gestures={spies as never}
210
+ onBoxMouseDown={vi.fn()}
211
+ onBoxClick={vi.fn()}
212
+ />
213
+ ));
214
+
215
+ it("drags a single selection", () => {
216
+ const { spies, box, unmount } = single(true);
217
+ shiftPress(box);
218
+ expect(spies.startGesture).toHaveBeenCalledWith("drag", expect.anything());
219
+ unmount();
220
+ });
221
+
222
+ it("drags a group selection", () => {
223
+ const { spies, box, unmount } = group(true);
224
+ shiftPress(box);
225
+ expect(spies.startGroupDrag).toHaveBeenCalledTimes(1);
226
+ unmount();
227
+ });
228
+
229
+ it("leaves a shift press on a box that cannot move to the multi-select toggle", () => {
230
+ const one = single(false);
231
+ shiftPress(one.box);
232
+ expect(one.spies.startBlockedMove).not.toHaveBeenCalled();
233
+ one.unmount();
234
+ const many = group(false);
235
+ shiftPress(many.box);
236
+ expect(many.spies.startGroupDrag).not.toHaveBeenCalled();
237
+ many.unmount();
238
+ });
239
+ });
240
+
241
+ type GsapStub = {
242
+ set: (el: Element, vars: object) => void;
243
+ getProperty: (el: Element, p: string) => number;
244
+ };
245
+
246
+ function previewGsap(): GsapStub {
247
+ const win = window as unknown as { gsap?: GsapStub };
248
+ if (win.gsap) return win.gsap;
249
+ const positions = new WeakMap<Element, Record<string, number>>();
250
+ win.gsap = {
251
+ set: (el, vars) => positions.set(el, { ...positions.get(el), ...vars }),
252
+ getProperty: (el, prop) => positions.get(el)?.[prop] ?? 0,
253
+ };
254
+ return win.gsap;
255
+ }
256
+
257
+ function draggableMember(key: string) {
258
+ const gsap = previewGsap();
259
+ const element = document.createElement("div");
260
+ document.body.append(element);
261
+ gsap.set(element, { x: 10, y: -10 });
262
+ const selection = {
263
+ element,
264
+ capabilities: { canApplyManualOffset: true },
265
+ } as unknown as DomEditSelection;
266
+ const result = createManualOffsetDragMember({
267
+ key,
268
+ selection,
269
+ element,
270
+ rect: { left: 0, top: 0, width: 50, height: 40, editScaleX: 1, editScaleY: 1 },
271
+ } as never);
272
+ if (!result.ok) throw new Error(result.reason);
273
+ const at = () => [gsap.getProperty(element, "x"), gsap.getProperty(element, "y")];
274
+ return { member: result.member, selection, at };
275
+ }
276
+
277
+ function singleMemberDrag() {
278
+ const { member, selection, at } = draggableMember("single");
279
+ const harness = gestureHarness(
280
+ {
281
+ kind: "drag",
282
+ mode: "path-offset",
283
+ selection,
284
+ startX: 100,
285
+ startY: 100,
286
+ originLeft: 0,
287
+ originTop: 0,
288
+ originWidth: 50,
289
+ originHeight: 40,
290
+ editScaleX: 1,
291
+ editScaleY: 1,
292
+ actualRotation: 0,
293
+ pathOffsetMember: member,
294
+ initialPathOffset: member.initialPathOffset,
295
+ manualEditDragToken: member.gestureToken,
296
+ },
297
+ null,
298
+ );
299
+ return { ...harness, at };
300
+ }
301
+
302
+ function groupMemberDrag() {
303
+ const a = draggableMember("a");
304
+ const b = draggableMember("b");
305
+ const group = { startX: 100, startY: 100, originItems: [], members: [a.member, b.member] };
306
+ const harness = gestureHarness(null, group as unknown as GroupGestureState);
307
+ return { ...harness, at: () => [a.at(), b.at()] };
308
+ }
309
+
310
+ afterEach(() => {
311
+ delete (window as unknown as { gsap?: GsapStub }).gsap;
312
+ });
313
+
314
+ describe("a press that stays under the drag threshold puts the preview back where it started", () => {
315
+ it.each([true, false])("single, shift %s", (shift) => {
316
+ const { handlers, pointer, at } = singleMemberDrag();
317
+ handlers.onPointerMove(pointer(102, 100, shift));
318
+ expect(at()).toEqual([12, -10]);
319
+ handlers.onPointerUp(pointer(102, 100, shift));
320
+ expect(at()).toEqual([10, -10]);
321
+ });
322
+
323
+ it.each([true, false])("group, shift %s", (shift) => {
324
+ const { handlers, pointer, at } = groupMemberDrag();
325
+ handlers.onPointerMove(pointer(102, 100, shift));
326
+ handlers.onPointerUp(pointer(102, 100, shift));
327
+ expect(at()).toEqual([
328
+ [10, -10],
329
+ [10, -10],
330
+ ]);
331
+ });
332
+ });
333
+
334
+ describe("a drag that travelled is not a click, even when it ends near its start", () => {
335
+ it.each([
336
+ ["single", singleMemberDrag, "onPathOffsetCommitRef"],
337
+ ["group", groupMemberDrag, "onGroupPathOffsetCommitRef"],
338
+ ] as const)("commits a %s shift drag instead of toggling", (_, make, commit) => {
339
+ const { opts, handlers, pointer } = make();
340
+ handlers.onPointerMove(pointer(180, 100, true));
341
+ handlers.onPointerMove(pointer(101, 100, true));
342
+ handlers.onPointerUp(pointer(101, 100, true));
343
+ expect(opts.onCanvasMouseDown).not.toHaveBeenCalled();
344
+ expect(opts[commit].current).toHaveBeenCalledTimes(1);
345
+ });
346
+ });
347
+
348
+ describe("a cancelled drag puts the preview back where it started", () => {
349
+ it("returns a single drag that travelled to its start and saves nothing", () => {
350
+ const { opts, handlers, pointer, at } = singleMemberDrag();
351
+ handlers.onPointerMove(pointer(140, 100, false));
352
+ expect(at()).toEqual([50, -10]);
353
+ handlers.clearPointerState({ current: null });
354
+ expect(at()).toEqual([10, -10]);
355
+ expect(opts.gestureRef.current).toBeNull();
356
+ expect(opts.onPathOffsetCommitRef.current).not.toHaveBeenCalled();
357
+ });
358
+ });
@@ -379,6 +379,7 @@ export function resolveSnapAdjustment(input: {
379
379
  disabled: boolean;
380
380
  /** Set when the gesture has not travelled far enough for snapping yet. */
381
381
  disabledForTravel?: boolean;
382
+ lockedAxis?: "x" | "y";
382
383
  }): SnapResult {
383
384
  if (input.disabled || input.disabledForTravel || input.threshold <= 0) {
384
385
  return DISABLED_RESULT(input.proposedDx, input.proposedDy);
@@ -407,8 +408,8 @@ export function resolveSnapAdjustment(input: {
407
408
  input.threshold,
408
409
  );
409
410
 
410
- const bestX = pickBest(xCandidates);
411
- const bestY = pickBest(yCandidates);
411
+ const bestX = input.lockedAxis === "x" ? null : pickBest(xCandidates);
412
+ const bestY = input.lockedAxis === "y" ? null : pickBest(yCandidates);
412
413
  const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
413
414
  const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
414
415
 
@@ -12,7 +12,6 @@ import {
12
12
  applyRotationDraftViaGsap,
13
13
  endManualOffsetDragMembers,
14
14
  restoreManualOffsetDragMembers,
15
- resumeGsapTimelines,
16
15
  } from "./manualOffsetDrag";
17
16
  import {
18
17
  applyStudioBoxSize,
@@ -40,6 +39,7 @@ import {
40
39
  type UseDomEditOverlayGesturesOptions,
41
40
  ROTATED_SNAP_BYPASS_DEGREES,
42
41
  hasDomEditRotationChanged,
42
+ lockDragToDominantAxis,
43
43
  resolveDomEditRotationGesture,
44
44
  } from "./domEditOverlayGestures";
45
45
  import { resolveCenterResizeSize } from "./domEditResizeLocal";
@@ -61,6 +61,13 @@ import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug
61
61
  import { createGroupDragMover } from "./groupDragMove";
62
62
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
63
63
 
64
+ function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
65
+ return (
66
+ !g.travelled &&
67
+ Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < BLOCKED_MOVE_THRESHOLD_PX
68
+ );
69
+ }
70
+
64
71
  function logGestureCommitFailure(message: string, error: unknown): void {
65
72
  if (error instanceof DomEditSaveQueueOpenError) return;
66
73
  console.error(message, error);
@@ -136,11 +143,13 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
136
143
  }
137
144
 
138
145
  if (groupG) {
146
+ if (!isTap(groupG, e)) groupG.travelled = true;
139
147
  moveGroupDrag(groupG, e);
140
148
  return;
141
149
  }
142
150
 
143
151
  if (!g || !sel) return;
152
+ if (!isTap(g, e)) g.travelled = true;
144
153
  let dx = e.clientX - g.startX;
145
154
  let dy = e.clientY - g.startY;
146
155
 
@@ -164,6 +173,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
164
173
  }
165
174
 
166
175
  if (g.kind === "drag") {
176
+ const lock = lockDragToDominantAxis(dx, dy, e.shiftKey);
177
+ dx = lock.dx;
178
+ dy = lock.dy;
167
179
  const sc = g.snapContext;
168
180
  // Bypass edge-snapping for rotated elements — the snap targets and the
169
181
  // snapped rect are axis-aligned, so snapping a rotated box's AABB shifts it
@@ -196,6 +208,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
196
208
  gridEdges: sc.gridEdges ?? undefined,
197
209
  threshold: SNAP_THRESHOLD_PX,
198
210
  disabled: e.altKey,
211
+ lockedAxis: lock.lockedAxis,
199
212
  });
200
213
  dx = snap.dx;
201
214
  dy = snap.dy;
@@ -302,8 +315,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
302
315
  // ordinary click — which lands between the members, resolves to nothing,
303
316
  // and deselects the group the drag just moved.
304
317
  opts.suppressNextBoxClickRef.current = true;
305
- if (Math.hypot(rawDx, rawDy) < BLOCKED_MOVE_THRESHOLD_PX) {
318
+ if (isTap(groupG, e)) {
306
319
  restoreGroupPathOffsets(groupG);
320
+ if (e.shiftKey) {
321
+ opts.onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
322
+ preferClipAncestor: false,
323
+ hoverSelection: opts.hoverSelectionRef.current,
324
+ });
325
+ }
307
326
  return;
308
327
  }
309
328
  const dx = groupG.lastSnappedDx ?? rawDx;
@@ -360,10 +379,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
360
379
  opts.rafPausedRef.current = false;
361
380
  const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
362
381
 
363
- if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
364
- restoreStudioPathOffset(sel.element, g.initialPathOffset);
365
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
366
- resumeGsapTimelines(sel.element);
382
+ if (g.kind === "drag" && isTap(g, e)) {
383
+ if (g.pathOffsetMember) restoreManualOffsetDragMembers([g.pathOffsetMember]);
367
384
  if (box) {
368
385
  box.style.left = `${g.originLeft}px`;
369
386
  box.style.top = `${g.originTop}px`;
@@ -525,9 +542,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
525
542
  const g = opts.gestureRef.current;
526
543
  const sel = g?.selection ?? selectionRef.current;
527
544
  if (g?.mode === "path-offset" && sel) {
528
- restoreStudioPathOffset(sel.element, g.initialPathOffset);
529
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
530
- resumeGsapTimelines(sel.element);
545
+ if (g.pathOffsetMember) restoreManualOffsetDragMembers([g.pathOffsetMember]);
531
546
  restoreGestureOverlayRect(g);
532
547
  }
533
548
  if (g?.mode === "box-size" && sel) {
@@ -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
  });
@@ -24,6 +24,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
24
24
  const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
25
25
  const insertLineRow =
26
26
  (draggedClip?.started ? draggedClip.insertRow : null) ?? props.dropPreview?.insertRow ?? null;
27
+ const ghostLaneActive =
28
+ (insertLineRow ?? -1) >= props.trackOrder.length ||
29
+ (props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
30
+ // A host bottom pad shorter than a track leaves no lane to draw.
31
+ const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
32
+ // Past the last committed track: a drag's new bottom-track preview row is the lane itself.
33
+ const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
27
34
  const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
28
35
  // A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
29
36
  // occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
@@ -61,14 +68,35 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
61
68
  onRazorSplitAll={props.onRazorSplitAll}
62
69
  />
63
70
 
64
- {/* Breathing room below the last track lane (1.5 tracks by default) — a real
71
+ {/* Breathing room below the last track lane (one track by default) — a real
65
72
  scrollable surface, so a clip can be dragged into the void to create a
66
- new bottom track comfortably (see TimelineRowGeometry.padding / canvasHeight). */}
73
+ new bottom track (see TimelineRowGeometry.padding / canvasHeight). */}
67
74
  <div
68
75
  aria-hidden="true"
69
76
  style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
70
77
  />
71
78
 
79
+ {showGhostLane && (
80
+ <div
81
+ aria-hidden="true"
82
+ data-testid="timeline-ghost-lane"
83
+ data-active={ghostLaneActive || undefined}
84
+ className={`pointer-events-none absolute flex items-center justify-center rounded-lg border border-dashed text-[11px] transition-colors duration-150 ${
85
+ ghostLaneActive
86
+ ? "border-studio-accent/60 bg-studio-accent/6 text-studio-accent"
87
+ : "border-neutral-700/50 text-neutral-500"
88
+ }`}
89
+ style={{
90
+ top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
91
+ left: props.contentOrigin,
92
+ width: props.trackContentWidth,
93
+ height: TRACK_H - CLIP_Y * 2,
94
+ }}
95
+ >
96
+ {props.acceptsMediaDrop && "Drop media here"}
97
+ </div>
98
+ )}
99
+
72
100
  {/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
73
101
  menu row would collapse; a quiet tint for every gap on the selected
74
102
  clip's lane. Geometry mirrors the drop placeholder (row top + clip