@hyperframes/studio 0.8.98 → 0.8.99

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 (51) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-itc1XziP.js → index-CVDArg3m.js} +178 -178
  3. package/dist/assets/{index-B6vU39NM.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-BxnoLgxG.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +542 -373
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -9
  9. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  10. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  11. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  12. package/src/components/editor/cropResize.test.ts +129 -0
  13. package/src/components/editor/cropResize.ts +143 -0
  14. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  15. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  17. package/src/components/editor/elementPositionOffset.ts +5 -4
  18. package/src/components/editor/manualEdits.test.ts +22 -8
  19. package/src/components/editor/manualEditsDom.ts +27 -11
  20. package/src/components/editor/manualEditsParsing.ts +0 -4
  21. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  22. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  23. package/src/hooks/domSelectionTestHarness.ts +8 -0
  24. package/src/hooks/draggedGsapPosition.ts +4 -2
  25. package/src/hooks/gsapDragCommit.ts +6 -6
  26. package/src/hooks/gsapEditOutcome.ts +6 -0
  27. package/src/hooks/gsapResizeIntercept.ts +5 -5
  28. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  29. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  30. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  32. package/src/hooks/timelineTimingSync.ts +40 -4
  33. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  34. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  35. package/src/hooks/useCropOverlay.ts +8 -2
  36. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  37. package/src/hooks/useDomEditCommits.ts +1 -0
  38. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  39. package/src/hooks/useDomEditSession.ts +2 -0
  40. package/src/hooks/useDomGeometryCommit.ts +1 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  42. package/src/hooks/useDomGeometryCommits.ts +5 -0
  43. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  44. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  45. package/src/hooks/useGsapAwareEditing.ts +37 -23
  46. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  47. package/src/hooks/useTimelineEditing.ts +13 -13
  48. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  49. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  50. package/src/utils/rounding.ts +5 -0
  51. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -1,11 +1,13 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
8
- import { mountReactHarness } from "./domSelectionTestHarness";
8
+ import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
9
+ import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
10
+ import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
9
11
 
10
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
13
 
@@ -56,6 +58,7 @@ function mountResizeHandler(
56
58
  const anchorRollback = vi.fn();
57
59
  const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback }));
58
60
  const commitMutation = vi.fn().mockResolvedValue(undefined);
61
+ const commitPatch = vi.fn().mockResolvedValue(undefined);
59
62
  let resize:
60
63
  | ((
61
64
  selection: DomEditSelection,
@@ -64,8 +67,9 @@ function mountResizeHandler(
64
67
  restore?: () => void,
65
68
  ) => Promise<void>)
66
69
  | null = null;
70
+ let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
67
71
  function Harness() {
68
- resize = useGsapAwareEditing({
72
+ const editing = useGsapAwareEditing({
69
73
  domEditSelection: selection,
70
74
  selectedGsapAnimations: animations,
71
75
  gsapCommitMutation: commitMutation,
@@ -76,11 +80,14 @@ function mountResizeHandler(
76
80
  trackGsapInteractionFailure: vi.fn(),
77
81
  stageElementPositionOffset: elementOffset,
78
82
  handleDomBoxSizeCommit: fallback,
83
+ commitPositionPatchToHtml: commitPatch,
79
84
  addGsapAnimation: vi.fn(),
80
85
  convertToKeyframes: vi.fn(),
81
86
  setArcPath: vi.fn(),
82
87
  updateArcSegment: vi.fn(),
83
- }).handleGsapAwareBoxSizeCommit;
88
+ });
89
+ resize = editing.handleGsapAwareBoxSizeCommit;
90
+ property = editing.commitAnimatedProperty;
84
91
  return null;
85
92
  }
86
93
  const root = mountReactHarness(<Harness />);
@@ -91,7 +98,9 @@ function mountResizeHandler(
91
98
  anchorSave,
92
99
  anchorRollback,
93
100
  commitMutation,
101
+ commitPatch,
94
102
  resize: resize!,
103
+ property: property!,
95
104
  root,
96
105
  };
97
106
  }
@@ -122,6 +131,7 @@ function mountGroupHandler({
122
131
  trackGsapInteractionFailure,
123
132
  stageElementPositionOffset,
124
133
  handleDomBoxSizeCommit: vi.fn(),
134
+ commitPositionPatchToHtml: vi.fn(),
125
135
  addGsapAnimation: vi.fn(),
126
136
  convertToKeyframes: vi.fn(),
127
137
  setArcPath: vi.fn(),
@@ -257,6 +267,92 @@ describe("useGsapAwareEditing anchored resize", () => {
257
267
  act(() => h.root.unmount());
258
268
  });
259
269
 
270
+ function mountCropped() {
271
+ const h = mountResizeHandler([]);
272
+ const el = withInlineLayoutBox(h.selection.element);
273
+ el.id = "clip";
274
+ el.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
275
+ document.body.append(el);
276
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
277
+ const crop = mountReactHarness(
278
+ <DomEditCropHandles selection={h.selection} overlayRect={rect} onStyleCommit={vi.fn()} />,
279
+ );
280
+ return { ...h, el, crop };
281
+ }
282
+
283
+ function resizeLandsWhen(where: "intercept" | "dispatch", h: { commitMutation: Mock }) {
284
+ const land = (el: HTMLElement) => {
285
+ el.style.width = "450px";
286
+ el.style.height = "300px";
287
+ };
288
+ mocks.resize.mockImplementation(async (selection, _next, _a, _i, commit) => {
289
+ if (where === "intercept") land(selection.element);
290
+ await commit(selection, { type: "size" }, { label: "Resize", softReload: true });
291
+ return { status: "persisted" };
292
+ });
293
+ if (where === "dispatch") h.commitMutation.mockImplementation(async (s) => land(s.element));
294
+ }
295
+
296
+ const scaled = "inset(0px 90px 0px 0px)";
297
+
298
+ it("saves the crop scaled with a size that lands only when saved (the W/H fields)", async () => {
299
+ const h = mountCropped();
300
+ resizeLandsWhen("dispatch", h);
301
+ await act(() => h.resize(h.selection, { width: 450, height: 300 }));
302
+
303
+ expect(h.commitPatch).toHaveBeenCalledWith(
304
+ h.selection,
305
+ [{ type: "inline-style", property: "clip-path", value: scaled }],
306
+ expect.objectContaining({ coalesceKey: h.commitMutation.mock.calls[0]![2].coalesceKey }),
307
+ );
308
+ act(() => h.crop.unmount());
309
+ expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled);
310
+ act(() => h.root.unmount());
311
+ });
312
+
313
+ it("scales from the box before a draft its caller applied (the agent tool)", async () => {
314
+ const h = mountCropped();
315
+ resizeLandsWhen("intercept", h);
316
+ applyStudioBoxSizeDraft(h.el, { width: 450, height: 300 });
317
+ await act(() => h.resize(h.selection, { width: 450, height: 300 }));
318
+
319
+ expect(h.commitPatch.mock.calls[0]![1]).toEqual([
320
+ { type: "inline-style", property: "clip-path", value: scaled },
321
+ ]);
322
+ act(() => h.crop.unmount());
323
+ act(() => h.root.unmount());
324
+ });
325
+
326
+ it("a W field on an animated element saves the scaled crop in the same undo step", async () => {
327
+ const h = mountCropped();
328
+ mocks.commitAnimatedProperties.mockImplementation(async (selection, _props, keyed) => {
329
+ await (keyed ?? h.commitMutation)(selection, { type: "set" }, { label: "Edit width" });
330
+ selection.element.style.width = "450px";
331
+ });
332
+ await act(() => h.property(h.selection, "width", 450));
333
+
334
+ const key = h.commitMutation.mock.calls[0]![2].coalesceKey;
335
+ expect(key).toBeTruthy();
336
+ expect(h.commitPatch).toHaveBeenCalledWith(
337
+ h.selection,
338
+ [{ type: "inline-style", property: "clip-path", value: "inset(0px 90px 0px 0px)" }],
339
+ expect.objectContaining({ coalesceKey: key }),
340
+ );
341
+ act(() => h.crop.unmount());
342
+ act(() => h.root.unmount());
343
+ });
344
+
345
+ it("shows the scaled crop when deselected before the save lands", async () => {
346
+ const h = mountCropped();
347
+ resizeLandsWhen("intercept", h);
348
+ const saved = h.resize(h.selection, { width: 450, height: 300 });
349
+ act(() => h.crop.unmount());
350
+ await act(() => saved);
351
+
352
+ expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled);
353
+ act(() => h.root.unmount());
354
+ });
355
+
260
356
  it("does not keep the anchor move when the size save fails", async () => {
261
357
  resizeWritesSize();
262
358
  const h = mountResizeHandler([]);
@@ -288,7 +384,7 @@ describe("useGsapAwareEditing anchored resize", () => {
288
384
  commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
289
385
  });
290
386
 
291
- expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70, 55);
387
+ expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70.2, 54.6);
292
388
  expect(mocks.setPosition.mock.invocationCallOrder[0]).toBeLessThan(
293
389
  mocks.resize.mock.invocationCallOrder[0]!,
294
390
  );
@@ -34,14 +34,14 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
34
34
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
35
35
  import { runGestureTransaction } from "./gestureTransaction";
36
36
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
37
- import { assertGsapEditPersisted, type GsapEditOutcome } from "./gsapEditOutcome";
37
+ import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
38
38
  import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
39
-
40
- /** A move only a shared tween positions is saved on the element itself; a blocked one throws. */
41
- async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise<void>) {
42
- if (outcome.status === "element-offset") return saveOnElement();
43
- assertGsapEditPersisted(outcome);
44
- }
39
+ import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
40
+ import {
41
+ prepareCropResize,
42
+ saveCropResize,
43
+ writeSizeWithCrop,
44
+ } from "../components/editor/cropResize";
45
45
 
46
46
  // Distinct coalesceKey per group drag so consecutive group drags don't fold
47
47
  // into one another's undo entry (module-local counter, not Date.now()).
@@ -87,6 +87,7 @@ export interface UseGsapAwareEditingParams {
87
87
  next: { width: number; height: number },
88
88
  offset?: { x: number; y: number },
89
89
  ) => Promise<void>;
90
+ commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
90
91
  // GSAP script commit ops (from useGsapScriptCommits)
91
92
  addGsapAnimation: (
92
93
  sel: DomEditSelection,
@@ -130,6 +131,7 @@ export function useGsapAwareEditing({
130
131
  trackGsapInteractionFailure,
131
132
  stageElementPositionOffset,
132
133
  handleDomBoxSizeCommit,
134
+ commitPositionPatchToHtml,
133
135
  addGsapAnimation,
134
136
  convertToKeyframes,
135
137
  setArcPath,
@@ -353,6 +355,8 @@ export function useGsapAwareEditing({
353
355
  animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
354
356
  });
355
357
  let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
358
+ const stageCrop = prepareCropResize(selection.element);
359
+ let cropUndoKey: string | null = null;
356
360
  return runGestureTransaction({
357
361
  element: selection.element,
358
362
  label: "Resize layer",
@@ -381,6 +385,7 @@ export function useGsapAwareEditing({
381
385
  makeFetchFallback(selection),
382
386
  );
383
387
  assertGsapEditPersisted(outcome);
388
+ cropUndoKey = coalesceKey;
384
389
  // What the resize actually did, not what its animations suggest
385
390
  // it would do. An element whose scale is an instant hold has a
386
391
  // scale-group tween and still commits width/height, so guessing
@@ -424,7 +429,13 @@ export function useGsapAwareEditing({
424
429
  logResizeSettle(selection.element, "dom-route");
425
430
  await handleDomBoxSizeCommit(selection, next, offset);
426
431
  },
427
- afterBufferedCommitsSaved: () => anchorMove?.save() ?? Promise.resolve(),
432
+ afterBufferedCommitsSaved: async () => {
433
+ await anchorMove?.save();
434
+ // Only now is the size live for every caller, drag or not.
435
+ if (cropUndoKey) {
436
+ await saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey);
437
+ }
438
+ },
428
439
  restore: () => {
429
440
  anchorMove?.rollback();
430
441
  restore();
@@ -434,6 +445,7 @@ export function useGsapAwareEditing({
434
445
  },
435
446
  [
436
447
  handleDomBoxSizeCommit,
448
+ commitPositionPatchToHtml,
437
449
  stageElementPositionOffset,
438
450
  gsapCommitMutation,
439
451
  previewIframeRef,
@@ -481,10 +493,7 @@ export function useGsapAwareEditing({
481
493
 
482
494
  // ── Animated property commit ──
483
495
 
484
- const {
485
- commitAnimatedProperty: commitAnimatedPropertyRaw,
486
- commitAnimatedProperties: commitAnimatedPropertiesRaw,
487
- } = useAnimatedPropertyCommit({
496
+ const { commitAnimatedProperties: commitAnimatedPropertiesRaw } = useAnimatedPropertyCommit({
488
497
  selectedGsapAnimations,
489
498
  gsapCommitMutation,
490
499
  addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
@@ -496,25 +505,30 @@ export function useGsapAwareEditing({
496
505
  const commitAnimatedProperties = useCallback(
497
506
  async (selection: DomEditSelection, properties: Record<string, number | string>) => {
498
507
  try {
499
- await commitAnimatedPropertiesRaw(selection, properties);
508
+ await writeSizeWithCrop(
509
+ selection,
510
+ properties,
511
+ gsapCommitMutation,
512
+ commitPositionPatchToHtml,
513
+ (keyed) => commitAnimatedPropertiesRaw(selection, properties, keyed),
514
+ );
500
515
  } catch (error) {
501
516
  trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
502
517
  throw error;
503
518
  }
504
519
  },
505
- [commitAnimatedPropertiesRaw, trackGsapInteractionFailure],
520
+ [
521
+ commitAnimatedPropertiesRaw,
522
+ commitPositionPatchToHtml,
523
+ gsapCommitMutation,
524
+ trackGsapInteractionFailure,
525
+ ],
506
526
  );
507
527
 
508
528
  const commitAnimatedProperty = useCallback(
509
- async (selection: DomEditSelection, property: string, value: number | string) => {
510
- try {
511
- await commitAnimatedPropertyRaw(selection, property, value);
512
- } catch (error) {
513
- trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
514
- throw error;
515
- }
516
- },
517
- [commitAnimatedPropertyRaw, trackGsapInteractionFailure],
529
+ (selection: DomEditSelection, property: string, value: number | string) =>
530
+ commitAnimatedProperties(selection, { [property]: value }),
531
+ [commitAnimatedProperties],
518
532
  );
519
533
 
520
534
  // ── Arc path wrappers ──
@@ -21,6 +21,10 @@ import {
21
21
  resolveTimelineGroupResizeChanges,
22
22
  } from "../player/components/timelineGroupEditing";
23
23
  import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
24
+ import {
25
+ scalePositionsInScript,
26
+ shiftPositionsInScript,
27
+ } from "@hyperframes/core/gsap-writer-acorn";
24
28
 
25
29
  vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
26
30
  const actual =
@@ -134,6 +138,7 @@ function renderTimelineEditingHook(input: {
134
138
  invalidateGsapCache?: () => void;
135
139
  showToast?: (message: string, kind?: string) => void;
136
140
  canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
141
+ activeCompPath?: string;
137
142
  }): {
138
143
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
139
144
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
@@ -166,7 +171,7 @@ function renderTimelineEditingHook(input: {
166
171
  commitRef.current = input.onZIndexCommit;
167
172
  const hook = useTimelineEditing({
168
173
  projectId: input.projectId ?? null,
169
- activeCompPath: "index.html",
174
+ activeCompPath: input.activeCompPath ?? "index.html",
170
175
  timelineElements: input.timelineElements,
171
176
  showToast: input.showToast ?? vi.fn(),
172
177
  writeProjectFile: input.writeProjectFile ?? vi.fn(),
@@ -372,39 +377,6 @@ function setupSingleClipHarness(options?: {
372
377
  };
373
378
  }
374
379
 
375
- const SDK_KEYFRAMED_SOURCE = [
376
- `<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
377
- ` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
378
- `</div>`,
379
- `<script>`,
380
- `const tl = gsap.timeline({ paused: true });`,
381
- `tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
382
- `window.__timelines = [tl];`,
383
- `</script>`,
384
- ].join("\n");
385
-
386
- async function setupSdkKeyframedClipHarness() {
387
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
388
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
389
- const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
390
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
391
- const invalidateGsapCache = vi.fn();
392
- const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
393
- usePlayerStore.getState().setDuration(10);
394
- const hook = renderTimelineEditingHook({
395
- timelineElements: [clip],
396
- iframe,
397
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
398
- projectId: "p1",
399
- writeProjectFile,
400
- recordEdit: vi.fn(async () => {}),
401
- sdkSession,
402
- publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
403
- invalidateGsapCache,
404
- });
405
- return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
406
- }
407
-
408
380
  /** Assert a lane write landed in both the live iframe DOM and the persisted file. */
409
381
  function expectLanePersisted(
410
382
  iframe: HTMLIFrameElement,
@@ -830,58 +802,6 @@ describe("useTimelineEditing timeline z-index reorder", () => {
830
802
  h.unmount();
831
803
  });
832
804
 
833
- it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
834
- const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
835
- await setupSdkKeyframedClipHarness();
836
-
837
- await act(async () => {
838
- await hook.move(clip, { start: 2.25, track: clip.track });
839
- });
840
-
841
- expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
842
- const mutationCall = fetchMock.mock.calls.find((call) =>
843
- requestUrl(call[0]).includes("/gsap-mutations/"),
844
- );
845
- expect(mutationCall).toBeDefined();
846
- const init = mutationCall?.[1] as RequestInit | undefined;
847
- expect(JSON.parse(String(init?.body))).toEqual({
848
- type: "shift-positions",
849
- targetSelector: "#clip",
850
- delta: 1.25,
851
- });
852
- expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
853
-
854
- hook.unmount();
855
- });
856
-
857
- it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
858
- const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
859
- await setupSdkKeyframedClipHarness();
860
-
861
- await act(async () => {
862
- await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
863
- });
864
-
865
- expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
866
- expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
867
- const mutationCall = fetchMock.mock.calls.find((call) =>
868
- requestUrl(call[0]).includes("/gsap-mutations/"),
869
- );
870
- expect(mutationCall).toBeDefined();
871
- const init = mutationCall?.[1] as RequestInit | undefined;
872
- expect(JSON.parse(String(init?.body))).toEqual({
873
- type: "scale-positions",
874
- targetSelector: "#clip",
875
- oldStart: 1,
876
- oldDuration: 2,
877
- newStart: 2,
878
- newDuration: 4,
879
- });
880
- expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
881
-
882
- hook.unmount();
883
- });
884
-
885
805
  it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
886
806
  // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
887
807
  // pure lane change routed through onMoveElement (no onMoveElements wired)
@@ -993,55 +913,6 @@ describe("useTimelineEditing timeline z-index reorder", () => {
993
913
  unmount();
994
914
  });
995
915
 
996
- it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
997
- const source = [
998
- `<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
999
- ` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
1000
- ` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
1001
- `</div>`,
1002
- `<script>`,
1003
- `const tl = gsap.timeline({ paused: true });`,
1004
- `tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
1005
- `tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
1006
- `window.__timelines = [tl];`,
1007
- `</script>`,
1008
- ].join("\n");
1009
- const { iframe, a, b } = makeTwoClipPair();
1010
- const sdkSession = await openComposition(source);
1011
- const fetchMock = stubProjectFetch(source);
1012
- const invalidateGsapCache = vi.fn();
1013
- usePlayerStore.getState().setDuration(10);
1014
- const hook = renderTimelineEditingHook({
1015
- timelineElements: [a, b],
1016
- iframe,
1017
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1018
- projectId: "p1",
1019
- writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
1020
- recordEdit: vi.fn(async () => {}),
1021
- sdkSession,
1022
- publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
1023
- invalidateGsapCache,
1024
- });
1025
-
1026
- await act(async () => {
1027
- await hook.groupMove([
1028
- { element: a, start: 1 },
1029
- { element: b, start: 2 },
1030
- ]);
1031
- });
1032
-
1033
- const mutations = fetchMock.mock.calls
1034
- .filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
1035
- .map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
1036
- expect(mutations).toEqual([
1037
- { type: "shift-positions", targetSelector: "#a", delta: 1 },
1038
- { type: "shift-positions", targetSelector: "#b", delta: 1 },
1039
- ]);
1040
- expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
1041
-
1042
- hook.unmount();
1043
- });
1044
-
1045
916
  it("partitions a group move by source file while keeping one undo entry", async () => {
1046
917
  const files: Record<string, string> = {
1047
918
  "index.html": '<div id="a" data-start="0" data-duration="1"></div>',
@@ -2353,3 +2224,187 @@ describe("useTimelineEditing: nested rows write composition-local starts", () =>
2353
2224
  hook.unmount();
2354
2225
  });
2355
2226
  });
2227
+
2228
+ describe("clip timing edits sync GSAP exactly once", () => {
2229
+ const SCENE_PATH = "compositions/scene.html";
2230
+ const SCENE_SOURCE = [
2231
+ `<div data-hf-id="hf-stage" data-hf-root data-composition-id="scene" data-duration="10">`,
2232
+ ` <div id="scene" data-hf-id="hf-scene" data-start="1" data-duration="4"><h1 data-hf-id="hf-title">Hi</h1></div>`,
2233
+ ` <div id="side" data-hf-id="hf-side" data-start="0" data-duration="8"></div>`,
2234
+ `</div>`,
2235
+ `<script>`,
2236
+ `const tl = gsap.timeline({ paused: true });`,
2237
+ `tl.to("#scene", { x: 1, duration: 1 }, 1);`,
2238
+ `tl.from("#scene h1", { y: 20, duration: 1 }, 1.5);`,
2239
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2240
+ `window.__timelines = [tl];`,
2241
+ `</script>`,
2242
+ ].join("\n");
2243
+
2244
+ // A project whose files live in memory, with the GSAP route running the real server writer on them.
2245
+ function stubProjectFiles(initial: Record<string, string>) {
2246
+ const files = { ...initial };
2247
+ const pathAfter = (url: string, marker: string) => decodeURIComponent(url.split(marker)[1]!);
2248
+ const applyServerMutation = (path: string, body: Record<string, unknown>) => {
2249
+ const before = files[path]!;
2250
+ const doc = new DOMParser().parseFromString(before, "text/html");
2251
+ const root = doc.querySelector("template")?.content ?? doc;
2252
+ const old = [...root.querySelectorAll("script")]
2253
+ .map((script) => script.textContent ?? "")
2254
+ .find((text) => text.includes("gsap.timeline"))!;
2255
+ const next =
2256
+ body.type === "shift-positions"
2257
+ ? shiftPositionsInScript(old, String(body.targetSelector), Number(body.delta), root)
2258
+ : scalePositionsInScript(
2259
+ old,
2260
+ String(body.targetSelector),
2261
+ Number(body.oldStart),
2262
+ Number(body.oldDuration),
2263
+ Number(body.newStart),
2264
+ Number(body.newDuration),
2265
+ root,
2266
+ );
2267
+ const after = before.replace(old, next);
2268
+ files[path] = after;
2269
+ return {
2270
+ ok: true,
2271
+ mutated: after !== before,
2272
+ changed: after !== before,
2273
+ scriptText: next,
2274
+ before,
2275
+ after,
2276
+ };
2277
+ };
2278
+ const fetchMock = vi.fn(
2279
+ async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
2280
+ const url = requestUrl(input);
2281
+ if (url.includes("/gsap-mutation-capabilities"))
2282
+ return jsonResponse({ atomicOwnershipPairs: true });
2283
+ if (url.includes("/gsap-mutations/")) {
2284
+ return jsonResponse(
2285
+ applyServerMutation(pathAfter(url, "/gsap-mutations/"), JSON.parse(String(init?.body))),
2286
+ );
2287
+ }
2288
+ if (url.includes("/files/"))
2289
+ return jsonResponse({ content: files[pathAfter(url, "/files/")] });
2290
+ throw new Error(`Unexpected fetch: ${url}`);
2291
+ },
2292
+ );
2293
+ vi.stubGlobal("fetch", fetchMock);
2294
+ const writeProjectFile = vi.fn(async (path: string, content: string) => {
2295
+ files[path] = content;
2296
+ });
2297
+ return { files, fetchMock, writeProjectFile };
2298
+ }
2299
+
2300
+ async function setupScene(withSdk: boolean, source = SCENE_SOURCE) {
2301
+ const project = stubProjectFiles({ [SCENE_PATH]: source });
2302
+ const iframe = createPreviewIframe([
2303
+ { id: "scene", track: 0 },
2304
+ { id: "side", track: 1 },
2305
+ ]);
2306
+ const scene = timelineElement({
2307
+ id: "scene",
2308
+ track: 0,
2309
+ zIndex: 0,
2310
+ start: 1,
2311
+ duration: 4,
2312
+ sourceFile: SCENE_PATH,
2313
+ });
2314
+ const side = timelineElement({
2315
+ id: "side",
2316
+ track: 1,
2317
+ zIndex: 0,
2318
+ start: 0,
2319
+ duration: 8,
2320
+ sourceFile: SCENE_PATH,
2321
+ });
2322
+ usePlayerStore.getState().setDuration(10);
2323
+ const hook = renderTimelineEditingHook({
2324
+ timelineElements: [scene, side],
2325
+ iframe,
2326
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
2327
+ projectId: "p1",
2328
+ activeCompPath: SCENE_PATH,
2329
+ writeProjectFile: project.writeProjectFile,
2330
+ recordEdit: vi.fn(async () => {}),
2331
+ sdkSession: withSdk ? await openComposition(source) : undefined,
2332
+ publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
2333
+ });
2334
+ const tweens = () =>
2335
+ project.files[SCENE_PATH]!.split("\n").filter((line) => line.startsWith("tl."));
2336
+ return { ...project, hook, scene, side, tweens };
2337
+ }
2338
+
2339
+ it("a move then a stretch through the SDK sync once in a template file with a config script first", async () => {
2340
+ const withConfig = `<template id="scene-template">\n${SCENE_SOURCE.replace(
2341
+ "<script>",
2342
+ "<script>gsap.config({ nullTargetWarn: false });</script>\n<script>",
2343
+ )}\n</template>`;
2344
+ const h = await setupScene(true, withConfig);
2345
+ await act(async () => {
2346
+ await h.hook.move(h.scene, { start: 3, track: h.scene.track });
2347
+ await flushAsyncWork();
2348
+ });
2349
+ const moved = { ...h.scene, start: 3 };
2350
+ await act(async () => {
2351
+ await h.hook.resize(moved, { start: 3, duration: 6, playbackStart: undefined });
2352
+ await flushAsyncWork();
2353
+ });
2354
+ expect(h.tweens()).toEqual([
2355
+ `tl.to("#scene", { x: 1, duration: 1.5 }, 3);`,
2356
+ `tl.from("#scene h1", { y: 20, duration: 1.5 }, 3.75);`,
2357
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2358
+ ]);
2359
+ h.hook.unmount();
2360
+ });
2361
+
2362
+ for (const withSdk of [true, false]) {
2363
+ const via = withSdk ? "through the SDK" : "through the server (no SDK session)";
2364
+
2365
+ it(`a move ${via} lands each tween once`, async () => {
2366
+ const h = await setupScene(withSdk);
2367
+ await act(async () => {
2368
+ await h.hook.move(h.scene, { start: 3, track: h.scene.track });
2369
+ await flushAsyncWork();
2370
+ });
2371
+ expect(h.tweens()).toEqual([
2372
+ `tl.to("#scene", { x: 1, duration: 1 }, 3);`,
2373
+ `tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
2374
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2375
+ ]);
2376
+ h.hook.unmount();
2377
+ });
2378
+
2379
+ it(`a resize ${via} scales each tween once`, async () => {
2380
+ const h = await setupScene(withSdk);
2381
+ await act(async () => {
2382
+ await h.hook.resize(h.scene, { start: 1, duration: 8, playbackStart: undefined });
2383
+ await flushAsyncWork();
2384
+ });
2385
+ expect(h.tweens()).toEqual([
2386
+ `tl.to("#scene", { x: 1, duration: 2 }, 1);`,
2387
+ `tl.from("#scene h1", { y: 20, duration: 2 }, 2);`,
2388
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2389
+ ]);
2390
+ h.hook.unmount();
2391
+ });
2392
+
2393
+ it(`a group move ${via} lands each tween once`, async () => {
2394
+ const h = await setupScene(withSdk);
2395
+ await act(async () => {
2396
+ await h.hook.groupMove([
2397
+ { element: h.scene, start: 3 },
2398
+ { element: h.side, start: 1 },
2399
+ ]);
2400
+ await flushAsyncWork();
2401
+ });
2402
+ expect(h.tweens()).toEqual([
2403
+ `tl.to("#scene", { x: 1, duration: 1 }, 3);`,
2404
+ `tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
2405
+ `tl.to("#side", { x: 5, duration: 1 }, 3);`,
2406
+ ]);
2407
+ h.hook.unmount();
2408
+ });
2409
+ }
2410
+ });