@hyperframes/studio 0.8.138 → 0.8.139

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 (77) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-CckOoCJ8.js} +1 -1
  2. package/dist/assets/{index-DIJuIBwI.js → index-BmIPrb8I.js} +1 -1
  3. package/dist/assets/{index-BdjA0CLD.js → index-Br1Whc0f.js} +192 -192
  4. package/dist/assets/{index-DXVvWfQG.js → index-DsEkE5_G.js} +1 -1
  5. package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +846 -469
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/editor/DomEditOverlay.tsx +9 -5
  18. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  19. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  20. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  21. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  22. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  23. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  24. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  25. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  27. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  28. package/src/components/editor/manualEditsDom.ts +7 -7
  29. package/src/components/editor/manualEditsTypes.ts +5 -0
  30. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  31. package/src/components/editor/manualOffsetDrag.ts +5 -0
  32. package/src/components/editor/previewGestureStarts.ts +284 -0
  33. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  34. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  35. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  36. package/src/components/nle/NLEContext.test.ts +26 -0
  37. package/src/components/nle/NLEContext.tsx +8 -3
  38. package/src/hooks/draggedGsapPosition.ts +8 -0
  39. package/src/hooks/editMoment.ts +13 -0
  40. package/src/hooks/gsapDragCommit.ts +2 -1
  41. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  42. package/src/hooks/gsapPositionDetection.ts +5 -3
  43. package/src/hooks/gsapResizeIntercept.ts +11 -4
  44. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  47. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  48. package/src/hooks/timelineEditingHelpers.ts +18 -2
  49. package/src/hooks/useDomSelection.test.ts +21 -2
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +2 -0
  52. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  53. package/src/hooks/useGsapAwareEditing.ts +148 -136
  54. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  55. package/src/hooks/useGsapScriptCommits.ts +5 -4
  56. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  57. package/src/hooks/usePreviewPersistence.ts +1 -4
  58. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  59. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  60. package/src/index.ts +1 -0
  61. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  62. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  63. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  64. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  65. package/src/player/lib/thumbnailScheduler.ts +1 -1
  66. package/src/player/lib/timelineElementHelpers.ts +0 -11
  67. package/src/player/previewReloading.test.ts +48 -0
  68. package/src/player/previewReloading.ts +48 -0
  69. package/src/utils/rootDuration.ts +5 -4
  70. package/src/utils/studioPendingEdits.test.ts +27 -0
  71. package/src/utils/studioPendingEdits.ts +9 -0
  72. package/src/utils/timelineAssetDrop.test.ts +13 -4
  73. package/src/utils/timelineAssetDrop.ts +6 -2
  74. package/dist/chunk-TDWGXBUT.js.map +0 -1
  75. /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
  76. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  77. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -17,30 +17,55 @@ import "./domEditOverlayTestMocks";
17
17
  import { DomEditOverlay } from "./DomEditOverlay";
18
18
  import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
19
19
  import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "./manualEditsTypes";
20
+ import { PRESS_WAITING_ATTR } from "./domEditOverlayGestures";
21
+ import { PRESS_WAIT_MAX_MS } from "./previewGestureStarts";
22
+ import { readDragStamp } from "../../hooks/draggedGsapPosition";
23
+ import { usePlayerStore } from "../../player/store/playerStore";
24
+ import { whileScriptWrites } from "../../player/previewReloading";
25
+ import { cancelNewestStudioWaitingPress } from "../../utils/studioPendingEdits";
20
26
 
21
27
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
22
28
 
23
29
  const layout = vi.hoisted(() => ({
24
30
  group: [] as unknown[],
31
+ rafPaused: { current: false } as { current: boolean },
25
32
  rect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
26
33
  }));
27
34
  vi.mock("./useDomEditOverlayRects", () => ({
28
- useDomEditOverlayRects: () => {
35
+ useDomEditOverlayRects: (options: { rafPausedRef: { current: boolean } }) => {
36
+ layout.rafPaused = options.rafPausedRef;
29
37
  const { rect, group } = layout;
38
+ const [overlayRect, setOverlayRect] = React.useState(rect);
39
+ const overlayRectRef = React.useRef(overlayRect);
40
+ overlayRectRef.current = overlayRect;
30
41
  const groupOverlayItemsRef = { current: group };
31
42
  const noop = () => undefined;
32
43
  return {
33
- overlayRect: rect,
34
- overlayRectRef: { current: rect },
44
+ overlayRect,
45
+ overlayRectRef,
35
46
  groupOverlayItems: group,
36
47
  groupOverlayItemsRef,
37
48
  hoverRect: null,
38
49
  childRects: [],
39
- setOverlayRect: noop,
50
+ setOverlayRect,
40
51
  setGroupOverlayItems: noop,
41
52
  };
42
53
  },
43
54
  }));
55
+ vi.mock("./domEditOverlayGeometry", async (importOriginal) => ({
56
+ ...(await importOriginal<typeof import("./domEditOverlayGeometry")>()),
57
+ orientedGroupAwareOverlayRect: (_overlay: unknown, _iframe: unknown, element: HTMLElement) => {
58
+ const [x = 0, y = 0] = element.style
59
+ .getPropertyValue("translate")
60
+ .split(" ")
61
+ .map(Number.parseFloat);
62
+ return {
63
+ ...layout.rect,
64
+ left: 100 + (Number.isFinite(x) ? x : 0),
65
+ top: 100 + (Number.isFinite(y) ? y : 0),
66
+ };
67
+ },
68
+ }));
44
69
  vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
45
70
  ...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
46
71
  ensureMotionPathPluginLoaded: vi.fn(),
@@ -65,6 +90,14 @@ const marked = (doc: Document) => doc.querySelectorAll(`[${STUDIO_MANUAL_EDIT_GE
65
90
  const byId = (iframe: HTMLIFrameElement | null, id: string) =>
66
91
  iframe!.contentDocument!.getElementById(id) as HTMLElement;
67
92
  const api = (): TimelinePlayerApi => player.getApi();
93
+ /** The south-east resize handle. */
94
+ const corner = (overlay: HTMLElement) =>
95
+ [...overlay.querySelectorAll<HTMLElement>("div.h-4.w-4")].reduce((a, b) =>
96
+ parseFloat(b.style.left) + parseFloat(b.style.top) >
97
+ parseFloat(a.style.left) + parseFloat(a.style.top)
98
+ ? b
99
+ : a,
100
+ );
68
101
 
69
102
  function pointer(
70
103
  target: Element,
@@ -105,6 +138,7 @@ function mountEditor(
105
138
  const write = (sel: DomEditSelection, next: { x: number; y: number }) =>
106
139
  saveStyle(sel, `translate: ${next.x}px ${next.y}px`);
107
140
  const onPathOffsetCommit = vi.fn(write);
141
+ const onCanvasMouseDown = vi.fn();
108
142
  const onGroupPathOffsetCommit = vi.fn(
109
143
  async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
110
144
  for (const { selection, next } of updates) await write(selection, next);
@@ -112,17 +146,17 @@ function mountEditor(
112
146
  );
113
147
  const host = document.body.appendChild(document.createElement("div"));
114
148
  overlayRoot = createRoot(host);
115
- const render = (readOnly: boolean) =>
149
+ const render = (readOnly: boolean, selection = selections[0]!) =>
116
150
  act(() =>
117
151
  overlayRoot.render(
118
152
  <PreviewReadOnlyProvider readOnly={readOnly}>
119
153
  <DomEditOverlay
120
154
  iframeRef={api().iframeRef}
121
155
  activeCompositionPath={null}
122
- selection={group ? null : selections[0]!}
156
+ selection={group ? null : selection}
123
157
  groupSelections={group ? selections : []}
124
158
  hoverSelection={null}
125
- onCanvasMouseDown={() => undefined}
159
+ onCanvasMouseDown={onCanvasMouseDown}
126
160
  onCanvasPointerMove={() => Promise.resolve(null)}
127
161
  onCanvasPointerLeave={() => undefined}
128
162
  onSelectionChange={() => undefined}
@@ -138,7 +172,14 @@ function mountEditor(
138
172
  );
139
173
  render(false);
140
174
  const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLElement;
141
- return { live, overlay, box: overlay.querySelector(BOX)!, onPathOffsetCommit, render };
175
+ return {
176
+ live,
177
+ overlay,
178
+ box: overlay.querySelector(BOX)!,
179
+ onPathOffsetCommit,
180
+ onCanvasMouseDown,
181
+ render,
182
+ };
142
183
  }
143
184
 
144
185
  /** A reload whose shadow paints while the gesture is still live. */
@@ -149,6 +190,9 @@ async function reloadMidGesture() {
149
190
  return { shadow, gen };
150
191
  }
151
192
 
193
+ /** Polls inside act, as testing-library's waitFor does, so a replayed press's renders are flushed. */
194
+ const waitFor = (check: () => void) => act(() => vi.waitFor(check));
195
+
152
196
  async function settle() {
153
197
  await act(async () => {
154
198
  for (let tick = 0; tick < 10; tick++) await Promise.resolve();
@@ -160,7 +204,11 @@ beforeEach(() => {
160
204
  HTMLElement.prototype.releasePointerCapture = () => undefined;
161
205
  });
162
206
 
207
+ // Writes a test leaves out would hold every later press in the file.
208
+ const stuckWrites: Array<() => void> = [];
209
+
163
210
  afterEach(() => {
211
+ for (const land of stuckWrites.splice(0)) land();
164
212
  act(() => overlayRoot?.unmount());
165
213
  act(() => player?.root.unmount());
166
214
  layout.group = [];
@@ -199,13 +247,6 @@ describe("a reload during a drag", () => {
199
247
  );
200
248
 
201
249
  const handle = (overlay: HTMLElement, selector: string) => overlay.querySelector(selector)!;
202
- const corner = (overlay: HTMLElement) =>
203
- [...overlay.querySelectorAll<HTMLElement>("div.h-4.w-4")].reduce((a, b) =>
204
- parseFloat(b.style.left) + parseFloat(b.style.top) >
205
- parseFloat(a.style.left) + parseFloat(a.style.top)
206
- ? b
207
- : a,
208
- );
209
250
  // Each edit presses at `from`, drags to `to`, and releases on `on`.
210
251
  const edits = {
211
252
  move: (o: HTMLElement) => ({ press: handle(o, BOX), on: o, from: [150, 150], to: [190, 170] }),
@@ -268,6 +309,354 @@ describe("a reload during a drag", () => {
268
309
  });
269
310
  });
270
311
 
312
+ describe("a press while the preview reloads", () => {
313
+ it("drags its element on the reloaded preview, at the time it was pressed", async () => {
314
+ const editor = mountEditor(false);
315
+ act(() => usePlayerStore.setState({ currentTime: 2 }));
316
+ act(() => api().refreshPlayer());
317
+ pointer(editor.box, "pointerdown", 150, 150);
318
+ pointer(editor.overlay, "pointermove", 190, 170);
319
+ expect(marked(editor.live.contentDocument!), "the page being replaced").toHaveLength(0);
320
+ expect(editor.box.hasAttribute(PRESS_WAITING_ATTR), "drawn at the pointer").toBe(true);
321
+ pointer(editor.overlay, "pointerup", 190, 170);
322
+ act(() => usePlayerStore.setState({ currentTime: 3 }));
323
+ let pressedAt: number | undefined;
324
+ const save = editor.onPathOffsetCommit.getMockImplementation()!;
325
+ editor.onPathOffsetCommit.mockImplementationOnce((sel, ...rest) => {
326
+ pressedAt = readDragStamp(sel.element).at?.time;
327
+ return save(sel, ...rest);
328
+ });
329
+
330
+ const shown = served("?_t=1");
331
+ await paintShadow(api, shown);
332
+ editor.render(false, makeSelection("title", byId(shown, "title")));
333
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
334
+ expect(byId(shown, "title").style.getPropertyValue("translate")).toBe("40px 20px");
335
+ expect(pressedAt).toBe(2);
336
+ });
337
+ });
338
+
339
+ describe("a press while a script write is out", () => {
340
+ it("waits for the write to land, then drags", async () => {
341
+ const editor = mountEditor(false);
342
+ let land: () => void = () => {};
343
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
344
+ pointer(editor.box, "pointerdown", 150, 150);
345
+ pointer(editor.overlay, "pointermove", 190, 170);
346
+ pointer(editor.overlay, "pointerup", 190, 170);
347
+ expect(marked(editor.live.contentDocument!), "no drag before the write lands").toHaveLength(0);
348
+ expect(editor.onPathOffsetCommit).not.toHaveBeenCalled();
349
+
350
+ await act(async () => {
351
+ land();
352
+ await write;
353
+ });
354
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
355
+ });
356
+
357
+ it("keeps a released press when the browser releases pointer capture", async () => {
358
+ const editor = mountEditor(false);
359
+ let land: () => void = () => {};
360
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
361
+ pointer(editor.box, "pointerdown", 150, 150);
362
+ pointer(editor.overlay, "pointermove", 190, 170);
363
+ pointer(editor.overlay, "pointerup", 190, 170);
364
+ pointer(editor.box, "lostpointercapture", 190, 170);
365
+ act(() => void editor.box.dispatchEvent(new MouseEvent("click", { bubbles: true })));
366
+ expect(editor.onCanvasMouseDown).not.toHaveBeenCalled();
367
+
368
+ await act(async () => {
369
+ land();
370
+ await write;
371
+ });
372
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
373
+ expect(editor.onPathOffsetCommit).toHaveBeenCalledTimes(1);
374
+ });
375
+
376
+ it("keeps the released waiting box still while the pointer moves to the next handle", async () => {
377
+ const editor = mountEditor(false);
378
+ let land: () => void = () => {};
379
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
380
+ pointer(editor.box, "pointerdown", 150, 150);
381
+ pointer(editor.overlay, "pointermove", 190, 170);
382
+ pointer(editor.overlay, "pointerup", 190, 170);
383
+ expect((editor.box as HTMLElement).style.left).toBe("140px");
384
+ expect((editor.box as HTMLElement).style.top).toBe("120px");
385
+ pointer(editor.overlay, "pointermove", 340, 220, { buttons: 0 });
386
+ expect((editor.box as HTMLElement).style.left).toBe("140px");
387
+ expect((editor.box as HTMLElement).style.top).toBe("120px");
388
+
389
+ await act(async () => {
390
+ land();
391
+ await write;
392
+ });
393
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
394
+ });
395
+
396
+ it("keeps the newest waiting box in place when an earlier press replays", async () => {
397
+ const editor = mountEditor(false);
398
+ let land: () => void = () => {};
399
+ let landFirst: () => void = () => {};
400
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
401
+ const firstSaved = new Promise<void>((resolve) => (landFirst = resolve));
402
+ const commit = editor.onPathOffsetCommit.getMockImplementation()!;
403
+ editor.onPathOffsetCommit.mockImplementationOnce((...args) =>
404
+ whileScriptWrites(async () => {
405
+ await firstSaved;
406
+ return commit(...args);
407
+ }),
408
+ );
409
+ pointer(editor.box, "pointerdown", 150, 150);
410
+ pointer(editor.overlay, "pointermove", 190, 170);
411
+ pointer(editor.overlay, "pointerup", 190, 170);
412
+ pointer(editor.box, "pointerdown", 190, 170);
413
+ pointer(editor.overlay, "pointermove", 210, 180);
414
+ pointer(editor.overlay, "pointerup", 210, 180);
415
+ expect((editor.box as HTMLElement).style.left).toBe("160px");
416
+ await act(async () => {
417
+ land();
418
+ await write;
419
+ });
420
+ await waitFor(() => expect(editor.onPathOffsetCommit).toHaveBeenCalledTimes(1));
421
+ expect((editor.box as HTMLElement).style.left).toBe("160px");
422
+ expect((editor.box as HTMLElement).style.top).toBe("130px");
423
+ await act(async () => {
424
+ landFirst();
425
+ await firstSaved;
426
+ });
427
+ await waitFor(() => expect(file.title).toBe("translate: 60px 30px"));
428
+ });
429
+
430
+ it("runs a second press after the first one, never instead of it", async () => {
431
+ const editor = mountEditor(false);
432
+ let land: () => void = () => {};
433
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
434
+ pointer(editor.box, "pointerdown", 150, 150);
435
+ pointer(editor.overlay, "pointermove", 190, 170);
436
+ pointer(editor.overlay, "pointerup", 190, 170);
437
+ pointer(editor.box, "pointerdown", 150, 150);
438
+ pointer(editor.overlay, "pointermove", 160, 150);
439
+ pointer(editor.overlay, "pointerup", 160, 150);
440
+
441
+ await act(async () => {
442
+ land();
443
+ await write;
444
+ });
445
+ await waitFor(() => expect(editor.onPathOffsetCommit).toHaveBeenCalledTimes(2));
446
+ expect(editor.onPathOffsetCommit.mock.calls.map((call) => call[1])).toEqual([
447
+ { x: 40, y: 20 },
448
+ { x: 50, y: 20 },
449
+ ]);
450
+ });
451
+
452
+ it("moves the element it pressed, even when another is selected before the write lands", async () => {
453
+ const editor = mountEditor(false);
454
+ let land: () => void = () => {};
455
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
456
+ pointer(editor.box, "pointerdown", 150, 150);
457
+ pointer(editor.overlay, "pointermove", 190, 170);
458
+ pointer(editor.overlay, "pointerup", 190, 170);
459
+ editor.render(false, makeSelection("sub", byId(editor.live, "sub")));
460
+
461
+ await act(async () => {
462
+ land();
463
+ await write;
464
+ });
465
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
466
+ expect(file.sub).toBe("");
467
+ });
468
+
469
+ it("draws a waiting resize at the size it saves", async () => {
470
+ const editor = mountEditor(false);
471
+ let land: () => void = () => {};
472
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
473
+ pointer(corner(editor.overlay), "pointerdown", 300, 200);
474
+ pointer(editor.overlay, "pointermove", 340, 200);
475
+ pointer(editor.overlay, "pointerup", 340, 200);
476
+ const box = editor.box as HTMLElement;
477
+ const drawn = [box.style.width, box.style.height].map(Number.parseFloat);
478
+
479
+ await act(async () => {
480
+ land();
481
+ await write;
482
+ });
483
+ await waitFor(() => expect(file.title).toMatch(/^width: /));
484
+ const saved = Number.parseFloat(file.title.slice("width: ".length));
485
+ // The save rounds to whole px.
486
+ expect(drawn[0]).toBeCloseTo(saved, 0);
487
+ expect(drawn[1]).toBeCloseTo((saved * RECT.height) / RECT.width, 0);
488
+ });
489
+
490
+ it("stops waiting on a write that never lands, and runs on the page shown", async () => {
491
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame", "performance"] });
492
+ let land: () => void = () => {};
493
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
494
+ try {
495
+ const editor = mountEditor(false);
496
+ pointer(editor.box, "pointerdown", 150, 150);
497
+ pointer(editor.overlay, "pointermove", 190, 170);
498
+ pointer(editor.overlay, "pointerup", 190, 170);
499
+ await act(async () => void vi.advanceTimersByTime(PRESS_WAIT_MAX_MS - 100));
500
+ expect(editor.onPathOffsetCommit, "still waiting").not.toHaveBeenCalled();
501
+ await act(async () => void vi.advanceTimersByTime(200));
502
+ expect(editor.onPathOffsetCommit.mock.calls.map((call) => call[1])).toEqual([
503
+ { x: 40, y: 20 },
504
+ ]);
505
+
506
+ pointer(editor.box, "pointerdown", 190, 170);
507
+ expect(editor.box.hasAttribute(PRESS_WAITING_ATTR), "no second wait on the same write").toBe(
508
+ false,
509
+ );
510
+ pointer(editor.overlay, "pointerup", 190, 170);
511
+ } finally {
512
+ vi.useRealTimers();
513
+ await act(async () => {
514
+ land();
515
+ await write;
516
+ });
517
+ }
518
+ });
519
+
520
+ it("is what Cmd+Z undoes while it waits, so it never runs", async () => {
521
+ const editor = mountEditor(false);
522
+ let land: () => void = () => {};
523
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
524
+ pointer(editor.box, "pointerdown", 150, 150);
525
+ pointer(editor.overlay, "pointermove", 190, 170);
526
+ pointer(editor.overlay, "pointerup", 190, 170);
527
+
528
+ let cancelled = false;
529
+ act(() => void (cancelled = cancelNewestStudioWaitingPress()));
530
+ expect(cancelled, "the waiting press is the newest edit").toBe(true);
531
+ expect((editor.box as HTMLElement).style.left, "the outline goes back").toBe("100px");
532
+ expect(editor.box.hasAttribute(PRESS_WAITING_ATTR)).toBe(false);
533
+
534
+ await act(async () => {
535
+ land();
536
+ await write;
537
+ });
538
+ await settle();
539
+ expect(editor.onPathOffsetCommit).not.toHaveBeenCalled();
540
+ expect(file.title).toBe("");
541
+ });
542
+
543
+ /** Two presses wait on a write; the first runs and starts its own write, so the second still waits. */
544
+ function queuedBehindOneThatRan() {
545
+ const editor = mountEditor(false);
546
+ let land: () => void = () => {};
547
+ let landFirst: () => void = () => {};
548
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
549
+ const firstSaved = new Promise<void>((resolve) => (landFirst = resolve));
550
+ stuckWrites.push(
551
+ () => land(),
552
+ () => landFirst(),
553
+ );
554
+ const commit = editor.onPathOffsetCommit.getMockImplementation()!;
555
+ editor.onPathOffsetCommit.mockImplementationOnce((...args) =>
556
+ whileScriptWrites(async () => {
557
+ await firstSaved;
558
+ return commit(...args);
559
+ }),
560
+ );
561
+ pointer(editor.box, "pointerdown", 150, 150);
562
+ pointer(editor.overlay, "pointermove", 190, 170);
563
+ pointer(editor.overlay, "pointerup", 190, 170);
564
+ pointer(editor.box, "pointerdown", 190, 170);
565
+ pointer(editor.overlay, "pointermove", 210, 180);
566
+ const ran = async () => {
567
+ await act(async () => {
568
+ land();
569
+ await write;
570
+ });
571
+ await waitFor(() => expect(editor.onPathOffsetCommit).toHaveBeenCalledTimes(1));
572
+ };
573
+ const landFirstSave = () =>
574
+ act(async () => {
575
+ landFirst();
576
+ await firstSaved;
577
+ });
578
+ return { editor, ran, landFirstSave };
579
+ }
580
+
581
+ it("leaves Cmd+Z to history once the press it could take back has run", async () => {
582
+ const editor = mountEditor(false);
583
+ let land: () => void = () => {};
584
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
585
+ stuckWrites.push(() => land());
586
+ pointer(editor.box, "pointerdown", 150, 150);
587
+ pointer(editor.overlay, "pointermove", 190, 170);
588
+ pointer(editor.overlay, "pointerup", 190, 170);
589
+ await act(async () => {
590
+ land();
591
+ await write;
592
+ });
593
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
594
+ expect(cancelNewestStudioWaitingPress()).toBe(false);
595
+ });
596
+
597
+ it("takes back a press queued behind one that ran, and leaves nothing waiting", async () => {
598
+ const { editor, ran, landFirstSave } = queuedBehindOneThatRan();
599
+ pointer(editor.overlay, "pointerup", 210, 180);
600
+ await ran();
601
+ expect(editor.box.hasAttribute(PRESS_WAITING_ATTR), "the second press waits").toBe(true);
602
+
603
+ act(() => void cancelNewestStudioWaitingPress());
604
+ expect(editor.box.hasAttribute(PRESS_WAITING_ATTR)).toBe(false);
605
+ expect(cancelNewestStudioWaitingPress(), "the following Cmd+Z steps history").toBe(false);
606
+ await landFirstSave();
607
+ await settle();
608
+ expect(editor.onPathOffsetCommit).toHaveBeenCalledTimes(1);
609
+ });
610
+
611
+ it("drops a held press queued behind one that ran on blur, and leaves nothing waiting", async () => {
612
+ const { editor, ran, landFirstSave } = queuedBehindOneThatRan();
613
+ await ran();
614
+ act(() => void window.dispatchEvent(new Event("blur")));
615
+ expect(editor.box.hasAttribute(PRESS_WAITING_ATTR)).toBe(false);
616
+ expect(cancelNewestStudioWaitingPress()).toBe(false);
617
+ await landFirstSave();
618
+ });
619
+
620
+ it("lands a released drag when the window loses focus before the write does", async () => {
621
+ const editor = mountEditor(false);
622
+ let land: () => void = () => {};
623
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
624
+ pointer(editor.box, "pointerdown", 150, 150);
625
+ pointer(editor.overlay, "pointermove", 190, 170);
626
+ pointer(editor.overlay, "pointerup", 190, 170);
627
+ act(() => void window.dispatchEvent(new Event("blur")));
628
+ expect(layout.rafPaused.current, "the box does not re-measure under a waiting press").toBe(
629
+ true,
630
+ );
631
+
632
+ await act(async () => {
633
+ land();
634
+ await write;
635
+ });
636
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
637
+ });
638
+
639
+ it("drops only the press still held when the window loses focus", async () => {
640
+ const editor = mountEditor(false);
641
+ let land: () => void = () => {};
642
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
643
+ pointer(editor.box, "pointerdown", 150, 150);
644
+ pointer(editor.overlay, "pointermove", 190, 170);
645
+ pointer(editor.overlay, "pointerup", 190, 170);
646
+ pointer(editor.box, "pointerdown", 190, 170);
647
+ pointer(editor.overlay, "pointermove", 220, 170);
648
+ act(() => void window.dispatchEvent(new Event("blur")));
649
+ expect((editor.box as HTMLElement).style.left, "back to the released drop").toBe("140px");
650
+
651
+ await act(async () => {
652
+ land();
653
+ await write;
654
+ });
655
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
656
+ expect(editor.onPathOffsetCommit).toHaveBeenCalledTimes(1);
657
+ });
658
+ });
659
+
271
660
  describe("a drag moves only with its own pointer pressed", () => {
272
661
  it.each([
273
662
  ["no button held, as Chromium sends after a layout change", { buttons: 0 }],
@@ -289,6 +678,34 @@ describe("a drag moves only with its own pointer pressed", () => {
289
678
  );
290
679
  });
291
680
 
681
+ describe("a press while the preview swaps scenes in place", () => {
682
+ it("waits for the swap and moves the swapped-in node", async () => {
683
+ const { live, overlay, box } = mountEditor(false);
684
+ Object.assign(live.contentWindow!, {
685
+ __hfSwapScenes: (html: string) => {
686
+ live.contentDocument!.body.innerHTML = html.replace(/^[\s\S]*<body>|<\/body>[\s\S]*$/g, "");
687
+ },
688
+ });
689
+ let reply: (r: Response) => void = () => {};
690
+ vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => (reply = resolve)));
691
+ try {
692
+ const replaced = byId(live, "title");
693
+ act(() => api().refreshPlayer());
694
+ pointer(box, "pointerdown", 150, 150);
695
+ pointer(overlay, "pointermove", 190, 170);
696
+ pointer(overlay, "pointerup", 190, 170);
697
+ expect(box.hasAttribute(PRESS_WAITING_ATTR), "held until the new scenes show").toBe(true);
698
+
699
+ reply(new Response(served("").contentDocument!.documentElement.outerHTML));
700
+ await waitFor(() => expect(file.title).toBe("translate: 40px 20px"));
701
+ expect(byId(live, "title")).not.toBe(replaced);
702
+ expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
703
+ } finally {
704
+ vi.unstubAllGlobals();
705
+ }
706
+ });
707
+ });
708
+
292
709
  describe("an outside change during a drag, on a preview that can swap scenes in place", () => {
293
710
  it("leaves the dragged node in place and shows the change after the drop, loaded fresh", async () => {
294
711
  const { live, overlay, box } = mountEditor(false);
@@ -37,6 +37,7 @@ describe("dropping a dragged group eats the click that follows", () => {
37
37
  gestureRef: { current: null },
38
38
  groupGestureRef,
39
39
  blockedMoveRef: { current: null },
40
+ waitingPressRef: { current: null },
40
41
  rafPausedRef: { current: false },
41
42
  suppressNextBoxClickRef,
42
43
  setOverlayRect: vi.fn(),
@@ -6,6 +6,8 @@ import {
6
6
  STUDIO_ROTATION_PROP,
7
7
  STUDIO_PATH_OFFSET_ATTR,
8
8
  STUDIO_MANUAL_EDIT_GESTURE_ATTR,
9
+ STUDIO_EDIT_MOMENT_ATTR,
10
+ type EditMoment,
9
11
  STUDIO_BOX_SIZE_ATTR,
10
12
  STUDIO_ROTATION_ATTR,
11
13
  STUDIO_ORIGINAL_TRANSLATE_ATTR,
@@ -52,10 +54,13 @@ const GESTURE_DRAWS: Record<StudioGestureDraws, readonly string[]> = {
52
54
  export function beginStudioManualEditGesture(
53
55
  element: HTMLElement,
54
56
  draws: StudioGestureDraws,
57
+ at?: EditMoment,
55
58
  ): string {
56
59
  studioManualEditGestureId += 1;
57
60
  const token = `gesture-${studioManualEditGestureId}:${draws}`;
58
61
  element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
62
+ if (at) element.setAttribute(STUDIO_EDIT_MOMENT_ATTR, JSON.stringify(at));
63
+ else element.removeAttribute(STUDIO_EDIT_MOMENT_ATTR);
59
64
  return token;
60
65
  }
61
66
 
@@ -65,6 +70,7 @@ export function endStudioManualEditGesture(element: HTMLElement, token?: string)
65
70
  if (token && element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) !== token) return;
66
71
  if (!element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
67
72
  element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
73
+ element.removeAttribute(STUDIO_EDIT_MOMENT_ATTR);
68
74
  const doc = element.ownerDocument;
69
75
  doc.dispatchEvent(new (doc.defaultView?.Event ?? Event)(GESTURE_ENDED));
70
76
  }
@@ -132,13 +138,7 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb
132
138
  };
133
139
  }
134
140
 
135
- /**
136
- * The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can
137
- * linger after GSAP re-bakes the element's transform (`translate:"none"`), so the
138
- * raw var isn't a safe drag base — using it re-commits a phantom offset and flings
139
- * the element off-screen. The offset only counts when the inline `translate` is the
140
- * studio var-translate; otherwise it's dormant and the applied offset is zero.
141
- */
141
+ // GSAP can leave dormant offset vars behind; only the live var-translate counts.
142
142
  export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } {
143
143
  return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP)
144
144
  ? readStudioPathOffset(element)
@@ -10,6 +10,11 @@ export const STUDIO_ROTATION_PROP = "--hf-studio-rotation";
10
10
 
11
11
  /* ── Internal DOM attribute names ─────────────────────────────────── */
12
12
  export const STUDIO_PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
13
+ export const STUDIO_EDIT_MOMENT_ATTR = "data-hf-studio-edit-at";
14
+ export interface EditMoment {
15
+ time: number;
16
+ keyframePct: number | null;
17
+ }
13
18
  export const STUDIO_BOX_SIZE_ATTR = "data-hf-studio-box-size";
14
19
  export const STUDIO_ROTATION_ATTR = "data-hf-studio-rotation";
15
20
  export const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
@@ -452,6 +452,29 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
452
452
  });
453
453
  });
454
454
 
455
+ describe("a drag that starts while an earlier gesture is still saving", () => {
456
+ it("keeps the timelines paused until the later drag ends", () => {
457
+ const { element, member } = makeGsapDot();
458
+ let paused = false;
459
+ const timeline = {
460
+ pause: () => (paused = true),
461
+ paused: (v?: boolean) => (paused = v ?? paused),
462
+ };
463
+ (element.ownerDocument.defaultView as unknown as { __timelines: unknown }).__timelines = {
464
+ main: timeline,
465
+ };
466
+ const earlier = member();
467
+ applyManualOffsetDragCommit(earlier, -50, 0);
468
+ const later = member();
469
+
470
+ endManualOffsetDragMembers([earlier]);
471
+
472
+ expect(paused).toBe(true);
473
+ endManualOffsetDragMembers([later]);
474
+ expect(paused).toBe(false);
475
+ });
476
+ });
477
+
455
478
  describe("sub-pixel drags", () => {
456
479
  it("draft and commit both land the exact fractional position", () => {
457
480
  const { element, sets, member } = makeGsapDot();
@@ -7,10 +7,12 @@ import {
7
7
  captureStudioPathOffset,
8
8
  clearStudioPathOffset,
9
9
  endStudioManualEditGesture,
10
+ isStudioManualEditGestureCurrent,
10
11
  readAppliedStudioPathOffset,
11
12
  restoreStudioPathOffset,
12
13
  type StudioPathOffsetSnapshot,
13
14
  } from "./manualEdits";
15
+ import type { EditMoment } from "./manualEditsTypes";
14
16
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
15
17
  import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
16
18
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
@@ -315,6 +317,7 @@ export function createManualOffsetDragMember(input: {
315
317
  element: HTMLElement;
316
318
  rect: ManualOffsetDragRect;
317
319
  gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
320
+ at?: EditMoment;
318
321
  }): ManualOffsetDragMemberResult {
319
322
  const plainTranslate =
320
323
  !!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
@@ -357,6 +360,7 @@ export function createManualOffsetDragMember(input: {
357
360
  const gestureToken = beginStudioManualEditGesture(
358
361
  input.element,
359
362
  input.gesture === "resize" ? "resize" : "move",
363
+ input.at,
360
364
  );
361
365
  const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
362
366
  scaleX: input.rect.editScaleX,
@@ -531,6 +535,7 @@ function settleDraftTranslate(element: HTMLElement, gsapOwnsPosition: boolean):
531
535
  /** Teardown after a COMMITTED drag. */
532
536
  export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
533
537
  for (const member of members) {
538
+ if (!isStudioManualEditGestureCurrent(member.element, member.gestureToken)) continue;
534
539
  endStudioManualEditGesture(member.element, member.gestureToken);
535
540
  member.element.removeAttribute("data-hf-drag-initial-offset-x");
536
541
  member.element.removeAttribute("data-hf-drag-initial-offset-y");