@hyperframes/studio 0.8.98 → 0.8.100

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-fWt87gyh.js} +1 -1
  2. package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
  3. package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
  4. package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +718 -438
  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/PropertyPanel.tsx +3 -3
  13. package/src/components/editor/cropResize.test.ts +129 -0
  14. package/src/components/editor/cropResize.ts +143 -0
  15. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  16. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  18. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  19. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  20. package/src/components/editor/elementPositionOffset.ts +5 -4
  21. package/src/components/editor/manualEdits.test.ts +22 -8
  22. package/src/components/editor/manualEditsDom.ts +27 -11
  23. package/src/components/editor/manualEditsParsing.ts +0 -4
  24. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  25. package/src/components/editor/manualOffsetDrag.test.ts +84 -0
  26. package/src/components/editor/manualOffsetDrag.ts +50 -40
  27. package/src/components/editor/plainTranslate.test.ts +62 -0
  28. package/src/components/editor/plainTranslate.ts +85 -0
  29. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  30. package/src/components/editor/useDomEditNudge.ts +1 -1
  31. package/src/hooks/domSelectionTestHarness.ts +8 -0
  32. package/src/hooks/draggedGsapPosition.ts +4 -2
  33. package/src/hooks/elementOffsetStager.ts +33 -2
  34. package/src/hooks/gsapDragCommit.ts +6 -6
  35. package/src/hooks/gsapEditOutcome.ts +6 -0
  36. package/src/hooks/gsapResizeIntercept.ts +5 -5
  37. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  38. package/src/hooks/gsapRuntimeBridge.ts +3 -18
  39. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  40. package/src/hooks/gsapRuntimeKeyframes.ts +69 -21
  41. package/src/hooks/timelineTimingSync.ts +40 -4
  42. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  44. package/src/hooks/useCropOverlay.ts +8 -2
  45. package/src/hooks/useDomEditCommits.test.tsx +18 -4
  46. package/src/hooks/useDomEditCommits.ts +3 -1
  47. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  48. package/src/hooks/useDomEditSession.ts +2 -0
  49. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  50. package/src/hooks/useDomEditTextCommits.ts +35 -35
  51. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  52. package/src/hooks/useDomGeometryCommit.ts +1 -0
  53. package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
  54. package/src/hooks/useDomGeometryCommits.ts +5 -0
  55. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  56. package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
  57. package/src/hooks/useGsapAwareEditing.ts +48 -34
  58. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  59. package/src/hooks/useTimelineEditing.ts +13 -13
  60. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  61. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  62. package/src/utils/rounding.ts +5 -0
  63. 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
 
@@ -20,7 +22,6 @@ const mocks = vi.hoisted(() => ({
20
22
 
21
23
  vi.mock("./gsapResizeIntercept", () => ({ tryGsapResizeIntercept: mocks.resize }));
22
24
  vi.mock("./gsapRuntimeBridge", () => ({
23
- POSITION_CHANNELS: ["x", "y"],
24
25
  tryGsapDragIntercept: mocks.drag,
25
26
  tryGsapRotationIntercept: vi.fn(),
26
27
  }));
@@ -45,6 +46,11 @@ afterEach(() => {
45
46
  vi.clearAllMocks();
46
47
  });
47
48
 
49
+ /** An element GSAP already renders the transform of, so moves take the GSAP route. */
50
+ function gsapPositioned(tag: string): HTMLElement {
51
+ return Object.assign(document.createElement(tag), { _gsap: { renderTransform: () => {} } });
52
+ }
53
+
48
54
  function mountResizeHandler(
49
55
  animations: GsapAnimation[],
50
56
  targetAnimations: GsapAnimation[] = animations,
@@ -56,6 +62,7 @@ function mountResizeHandler(
56
62
  const anchorRollback = vi.fn();
57
63
  const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback }));
58
64
  const commitMutation = vi.fn().mockResolvedValue(undefined);
65
+ const commitPatch = vi.fn().mockResolvedValue(undefined);
59
66
  let resize:
60
67
  | ((
61
68
  selection: DomEditSelection,
@@ -64,8 +71,9 @@ function mountResizeHandler(
64
71
  restore?: () => void,
65
72
  ) => Promise<void>)
66
73
  | null = null;
74
+ let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
67
75
  function Harness() {
68
- resize = useGsapAwareEditing({
76
+ const editing = useGsapAwareEditing({
69
77
  domEditSelection: selection,
70
78
  selectedGsapAnimations: animations,
71
79
  gsapCommitMutation: commitMutation,
@@ -76,11 +84,14 @@ function mountResizeHandler(
76
84
  trackGsapInteractionFailure: vi.fn(),
77
85
  stageElementPositionOffset: elementOffset,
78
86
  handleDomBoxSizeCommit: fallback,
87
+ commitPositionPatchToHtml: commitPatch,
79
88
  addGsapAnimation: vi.fn(),
80
89
  convertToKeyframes: vi.fn(),
81
90
  setArcPath: vi.fn(),
82
91
  updateArcSegment: vi.fn(),
83
- }).handleGsapAwareBoxSizeCommit;
92
+ });
93
+ resize = editing.handleGsapAwareBoxSizeCommit;
94
+ property = editing.commitAnimatedProperty;
84
95
  return null;
85
96
  }
86
97
  const root = mountReactHarness(<Harness />);
@@ -91,7 +102,9 @@ function mountResizeHandler(
91
102
  anchorSave,
92
103
  anchorRollback,
93
104
  commitMutation,
105
+ commitPatch,
94
106
  resize: resize!,
107
+ property: property!,
95
108
  root,
96
109
  };
97
110
  }
@@ -122,6 +135,7 @@ function mountGroupHandler({
122
135
  trackGsapInteractionFailure,
123
136
  stageElementPositionOffset,
124
137
  handleDomBoxSizeCommit: vi.fn(),
138
+ commitPositionPatchToHtml: vi.fn(),
125
139
  addGsapAnimation: vi.fn(),
126
140
  convertToKeyframes: vi.fn(),
127
141
  setArcPath: vi.fn(),
@@ -140,6 +154,28 @@ function mountGroupHandler({
140
154
  };
141
155
  }
142
156
 
157
+ describe("useGsapAwareEditing moves of an element GSAP does not position", () => {
158
+ it("saves the move on the element itself, with no GSAP write and no animation read", async () => {
159
+ const save = vi.fn().mockResolvedValue(undefined);
160
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
161
+ const gsapCommitMutation = vi.fn();
162
+ const makeFetchFallback = vi.fn();
163
+ const { pathOffsetCommit, root } = mountGroupHandler({
164
+ gsapCommitMutation,
165
+ makeFetchFallback,
166
+ stageElementPositionOffset,
167
+ });
168
+ const box = { element: document.createElement("div"), id: "box", selector: "#box" };
169
+ await act(() => pathOffsetCommit(box as unknown as DomEditSelection, { x: 130.25, y: 90 }));
170
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 });
171
+ expect(save).toHaveBeenCalledTimes(1);
172
+ expect(gsapCommitMutation).not.toHaveBeenCalled();
173
+ expect(makeFetchFallback).not.toHaveBeenCalled();
174
+ expect(mocks.drag).not.toHaveBeenCalled();
175
+ act(() => root.unmount());
176
+ });
177
+ });
178
+
143
179
  describe("useGsapAwareEditing shared-tween moves", () => {
144
180
  it("saves a single drag through the element's own offset", async () => {
145
181
  mocks.drag.mockResolvedValue({ status: "element-offset" });
@@ -151,7 +187,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
151
187
  makeFetchFallback: () => vi.fn().mockResolvedValue([]),
152
188
  stageElementPositionOffset,
153
189
  });
154
- const word = { element: document.createElement("span"), hfId: "w0", selector: ".w" };
190
+ const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
155
191
  await act(() => pathOffsetCommit(word as unknown as DomEditSelection, { x: 40, y: 20 }));
156
192
  expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 });
157
193
  expect(save).toHaveBeenCalledTimes(1);
@@ -159,7 +195,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
159
195
  act(() => root.unmount());
160
196
  });
161
197
 
162
- it("saves a group member that shares a tween through its own offset, under the group key", async () => {
198
+ it("saves shared-tween and GSAP-free group members on themselves, under the group key", async () => {
163
199
  mocks.drag.mockImplementation(async (selection, _next, _a, _i, commit, _f, options) => {
164
200
  if (selection.hfId === "w0") return { status: "element-offset" };
165
201
  if (!options?.preflightOnly) await commit(selection, { type: "move" }, { label: "Move" });
@@ -172,19 +208,20 @@ describe("useGsapAwareEditing shared-tween moves", () => {
172
208
  makeFetchFallback: () => vi.fn().mockResolvedValue([]),
173
209
  stageElementPositionOffset,
174
210
  });
175
- const word = { element: document.createElement("span"), hfId: "w0", selector: ".w" };
176
- const box = { element: document.createElement("div"), id: "box", selector: "#box" };
211
+ const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
212
+ const box = { element: gsapPositioned("div"), id: "box", selector: "#box" };
213
+ const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
177
214
  await act(() =>
178
215
  groupCommit([
179
216
  { selection: word, next: { x: 40, y: 20 } },
180
217
  { selection: box, next: { x: 40, y: 20 } },
218
+ { selection: plain, next: { x: 40, y: 20 } },
181
219
  ] as unknown as DomEditGroupPathOffsetCommit[]),
182
220
  );
183
- expect(stageElementPositionOffset).toHaveBeenCalledWith(
184
- word,
185
- { x: 40, y: 20 },
186
- expect.stringMatching(/^group-drag:\d+$/),
187
- );
221
+ const groupKey = expect.stringMatching(/^group-drag:\d+$/);
222
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }, groupKey);
223
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(plain, { x: 40, y: 20 }, groupKey);
224
+ expect(mocks.drag.mock.calls.some((call) => call[0] === plain)).toBe(false);
188
225
  act(() => root.unmount());
189
226
  });
190
227
  });
@@ -197,7 +234,7 @@ describe("useGsapAwareEditing anchored resize", () => {
197
234
  const h = mountResizeHandler([humanAnimation], [targetAnimation]);
198
235
  const target = {
199
236
  ...h.selection,
200
- element: document.createElement("div"),
237
+ element: gsapPositioned("div"),
201
238
  id: "agent-target",
202
239
  selector: "#agent-target",
203
240
  } as DomEditSelection;
@@ -257,6 +294,92 @@ describe("useGsapAwareEditing anchored resize", () => {
257
294
  act(() => h.root.unmount());
258
295
  });
259
296
 
297
+ function mountCropped() {
298
+ const h = mountResizeHandler([]);
299
+ const el = withInlineLayoutBox(h.selection.element);
300
+ el.id = "clip";
301
+ el.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
302
+ document.body.append(el);
303
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
304
+ const crop = mountReactHarness(
305
+ <DomEditCropHandles selection={h.selection} overlayRect={rect} onStyleCommit={vi.fn()} />,
306
+ );
307
+ return { ...h, el, crop };
308
+ }
309
+
310
+ function resizeLandsWhen(where: "intercept" | "dispatch", h: { commitMutation: Mock }) {
311
+ const land = (el: HTMLElement) => {
312
+ el.style.width = "450px";
313
+ el.style.height = "300px";
314
+ };
315
+ mocks.resize.mockImplementation(async (selection, _next, _a, _i, commit) => {
316
+ if (where === "intercept") land(selection.element);
317
+ await commit(selection, { type: "size" }, { label: "Resize", softReload: true });
318
+ return { status: "persisted" };
319
+ });
320
+ if (where === "dispatch") h.commitMutation.mockImplementation(async (s) => land(s.element));
321
+ }
322
+
323
+ const scaled = "inset(0px 90px 0px 0px)";
324
+
325
+ it("saves the crop scaled with a size that lands only when saved (the W/H fields)", async () => {
326
+ const h = mountCropped();
327
+ resizeLandsWhen("dispatch", h);
328
+ await act(() => h.resize(h.selection, { width: 450, height: 300 }));
329
+
330
+ expect(h.commitPatch).toHaveBeenCalledWith(
331
+ h.selection,
332
+ [{ type: "inline-style", property: "clip-path", value: scaled }],
333
+ expect.objectContaining({ coalesceKey: h.commitMutation.mock.calls[0]![2].coalesceKey }),
334
+ );
335
+ act(() => h.crop.unmount());
336
+ expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled);
337
+ act(() => h.root.unmount());
338
+ });
339
+
340
+ it("scales from the box before a draft its caller applied (the agent tool)", async () => {
341
+ const h = mountCropped();
342
+ resizeLandsWhen("intercept", h);
343
+ applyStudioBoxSizeDraft(h.el, { width: 450, height: 300 });
344
+ await act(() => h.resize(h.selection, { width: 450, height: 300 }));
345
+
346
+ expect(h.commitPatch.mock.calls[0]![1]).toEqual([
347
+ { type: "inline-style", property: "clip-path", value: scaled },
348
+ ]);
349
+ act(() => h.crop.unmount());
350
+ act(() => h.root.unmount());
351
+ });
352
+
353
+ it("a W field on an animated element saves the scaled crop in the same undo step", async () => {
354
+ const h = mountCropped();
355
+ mocks.commitAnimatedProperties.mockImplementation(async (selection, _props, keyed) => {
356
+ await (keyed ?? h.commitMutation)(selection, { type: "set" }, { label: "Edit width" });
357
+ selection.element.style.width = "450px";
358
+ });
359
+ await act(() => h.property(h.selection, "width", 450));
360
+
361
+ const key = h.commitMutation.mock.calls[0]![2].coalesceKey;
362
+ expect(key).toBeTruthy();
363
+ expect(h.commitPatch).toHaveBeenCalledWith(
364
+ h.selection,
365
+ [{ type: "inline-style", property: "clip-path", value: "inset(0px 90px 0px 0px)" }],
366
+ expect.objectContaining({ coalesceKey: key }),
367
+ );
368
+ act(() => h.crop.unmount());
369
+ act(() => h.root.unmount());
370
+ });
371
+
372
+ it("shows the scaled crop when deselected before the save lands", async () => {
373
+ const h = mountCropped();
374
+ resizeLandsWhen("intercept", h);
375
+ const saved = h.resize(h.selection, { width: 450, height: 300 });
376
+ act(() => h.crop.unmount());
377
+ await act(() => saved);
378
+
379
+ expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled);
380
+ act(() => h.root.unmount());
381
+ });
382
+
260
383
  it("does not keep the anchor move when the size save fails", async () => {
261
384
  resizeWritesSize();
262
385
  const h = mountResizeHandler([]);
@@ -288,7 +411,7 @@ describe("useGsapAwareEditing anchored resize", () => {
288
411
  commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
289
412
  });
290
413
 
291
- expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70, 55);
414
+ expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70.2, 54.6);
292
415
  expect(mocks.setPosition.mock.invocationCallOrder[0]).toBeLessThan(
293
416
  mocks.resize.mock.invocationCallOrder[0]!,
294
417
  );
@@ -351,11 +474,11 @@ describe("useGsapAwareEditing anchored resize", () => {
351
474
  });
352
475
  const updates = [
353
476
  {
354
- selection: { element: document.createElement("div"), id: "a", selector: "#a" },
477
+ selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
355
478
  next: { x: 10, y: 10 },
356
479
  },
357
480
  {
358
- selection: { element: document.createElement("div"), id: "b", selector: "#b" },
481
+ selection: { element: gsapPositioned("div"), id: "b", selector: "#b" },
359
482
  next: { x: 10, y: 10 },
360
483
  },
361
484
  ] as unknown as DomEditGroupPathOffsetCommit[];
@@ -389,12 +512,12 @@ describe("useGsapAwareEditing anchored resize", () => {
389
512
  });
390
513
  const updates = [
391
514
  {
392
- selection: { element: document.createElement("div"), id: "ok", selector: "#ok" },
515
+ selection: { element: gsapPositioned("div"), id: "ok", selector: "#ok" },
393
516
  next: { x: 10, y: 10 },
394
517
  },
395
518
  {
396
519
  selection: {
397
- element: document.createElement("div"),
520
+ element: gsapPositioned("div"),
398
521
  id: "blocked",
399
522
  selector: "#blocked",
400
523
  },
@@ -430,7 +553,7 @@ describe("useGsapAwareEditing anchored resize", () => {
430
553
  });
431
554
  const updates = [
432
555
  {
433
- selection: { element: document.createElement("div"), id: "a", selector: "#a" },
556
+ selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
434
557
  next: { x: 10, y: 10 },
435
558
  },
436
559
  ] as unknown as DomEditGroupPathOffsetCommit[];
@@ -455,11 +578,11 @@ describe("useGsapAwareEditing anchored resize", () => {
455
578
  });
456
579
  const updates = [
457
580
  {
458
- selection: { element: document.createElement("div"), id: "a", selector: "#a" },
581
+ selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
459
582
  next: { x: 10, y: 10 },
460
583
  },
461
584
  {
462
- selection: { element: document.createElement("div"), id: "b", selector: "#b" },
585
+ selection: { element: gsapPositioned("div"), id: "b", selector: "#b" },
463
586
  next: { x: 20, y: 20 },
464
587
  },
465
588
  ] as unknown as DomEditGroupPathOffsetCommit[];
@@ -509,6 +632,7 @@ describe("useGsapAwareEditing anchored resize", () => {
509
632
  */
510
633
  it("applies the anchor when the resize leaves the drop point to the caller", async () => {
511
634
  mocks.resize.mockResolvedValue({ status: "persisted" });
635
+ mocks.drag.mockResolvedValue({ status: "persisted" });
512
636
  const scale = { propertyGroup: "scale" } as GsapAnimation;
513
637
  const h = mountResizeHandler([scale]);
514
638
  await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
@@ -10,11 +10,7 @@
10
10
  import { useCallback } from "react";
11
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
- import {
14
- POSITION_CHANNELS,
15
- tryGsapDragIntercept,
16
- tryGsapRotationIntercept,
17
- } from "./gsapRuntimeBridge";
13
+ import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
18
14
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
19
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
20
16
  import { readGsapPositionFromIframe } from "./gsapPositionDetection";
@@ -33,15 +29,19 @@ import { setElementGsapPosition } from "../utils/elementGsap";
33
29
  import { logResize, logResizeSettle } from "../utils/resizeDebug";
34
30
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
35
31
  import { runGestureTransaction } from "./gestureTransaction";
36
- import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
37
- import { assertGsapEditPersisted, type GsapEditOutcome } from "./gsapEditOutcome";
32
+ import {
33
+ gsapWritesPosition,
34
+ hasNonHoldTweenForElement,
35
+ POSITION_CHANNELS,
36
+ } from "./gsapRuntimeKeyframes";
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,
@@ -151,6 +153,8 @@ export function useGsapAwareEditing({
151
153
  next: { x: number; y: number },
152
154
  modifiers?: { altKey?: boolean },
153
155
  ) => {
156
+ if (!gsapWritesPosition(selection.element))
157
+ return stageElementPositionOffset(selection, next).save();
154
158
  if (gsapCommitMutation) {
155
159
  try {
156
160
  const ownedAnimations = getGsapAnimationsForSelection(selection);
@@ -183,11 +187,8 @@ export function useGsapAwareEditing({
183
187
  ],
184
188
  );
185
189
 
186
- // Multi-select (group) drag: route EACH element through the SAME GSAP intercept as
187
- // a single drag, so every position is written as GSAP code (tl.set / keyframes /
188
- // gsap.set) — NEVER the deprecated `--hf-studio-offset` CSS var, and GSAP-animated
189
- // elements are no longer blocked in a group. No CSS fallback: with no GSAP
190
- // composition there's nothing to write (a no-op, exactly like the single-drag path).
190
+ // Multi-select (group) drag: each member takes the single drag's writer, so a member GSAP
191
+ // does not position is saved on itself and the rest go through the GSAP intercept.
191
192
  const handleGsapAwareGroupPathOffsetCommit = useCallback(
192
193
  async (updates: DomEditGroupPathOffsetCommit[]) => {
193
194
  if (!gsapCommitMutation || updates.length === 0) return;
@@ -245,6 +246,7 @@ export function useGsapAwareEditing({
245
246
  // turns N sequential round trips into one.
246
247
  const preflightResults = await Promise.allSettled(
247
248
  updates.map(async ({ selection }) => {
249
+ if (!gsapWritesPosition(selection.element)) return void offsetMembers.add(selection);
248
250
  const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
249
251
  preflightAnimations.set(selection, animations);
250
252
  const outcome = await tryGsapDragIntercept(
@@ -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 ──