@hyperframes/studio 0.8.103 → 0.8.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-DAoaajuM.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-BDK4rTMb.js} +1 -1
  3. package/dist/assets/{index-BPJRf2X9.js → index-De2aO4XM.js} +178 -178
  4. package/dist/assets/{index-DuCZ3pQw.js → index-WV9LroAD.js} +1 -1
  5. package/dist/index.d.ts +181 -168
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +428 -305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditOverlay.tsx +5 -6
  11. package/src/components/editor/PropertyPanel.tsx +3 -3
  12. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  13. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  14. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  15. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  16. package/src/components/editor/manualEdits.test.ts +3 -9
  17. package/src/components/editor/manualEdits.ts +0 -1
  18. package/src/components/editor/manualEditsDom.ts +0 -10
  19. package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
  20. package/src/components/editor/manualEditsDomPatches.ts +1 -23
  21. package/src/components/editor/manualEditsSnapshot.ts +2 -0
  22. package/src/components/editor/manualEditsTypes.ts +2 -0
  23. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  24. package/src/components/editor/manualOffsetDrag.ts +6 -27
  25. package/src/components/editor/plainTranslate.ts +9 -4
  26. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  27. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  28. package/src/components/editor/propertyPanelTypes.ts +1 -0
  29. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  30. package/src/components/editor/rotationDraft.test.ts +111 -0
  31. package/src/components/editor/rotationDraft.ts +145 -0
  32. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  33. package/src/components/editor/useDomEditNudge.ts +6 -2
  34. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  35. package/src/hooks/draggedGsapPosition.ts +28 -0
  36. package/src/hooks/elementOffsetStager.ts +26 -6
  37. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  38. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  39. package/src/hooks/plainRotation.test.ts +122 -0
  40. package/src/hooks/plainRotation.ts +42 -0
  41. package/src/hooks/useDomEditSession.ts +5 -5
  42. package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
  43. package/src/hooks/useDomGeometryCommit.ts +17 -17
  44. package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
  45. package/src/hooks/useDomGeometryCommits.ts +28 -36
  46. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  47. package/src/hooks/useGsapAwareEditing.ts +43 -42
  48. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  49. package/src/hooks/useInlineTextEdit.ts +2 -2
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
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
+ import type { RotationCommit } from "../components/editor/rotationDraft";
7
8
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
8
9
  import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
9
10
  import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
@@ -41,6 +42,7 @@ vi.mock("./useSafeGsapCommitMutation", () => ({
41
42
  }));
42
43
 
43
44
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
45
+ import { tryGsapRotationIntercept } from "./gsapRuntimeBridge";
44
46
 
45
47
  afterEach(() => {
46
48
  vi.clearAllMocks();
@@ -54,8 +56,10 @@ function gsapPositioned(tag: string): HTMLElement {
54
56
  function mountResizeHandler(
55
57
  animations: GsapAnimation[],
56
58
  targetAnimations: GsapAnimation[] = animations,
59
+ gsapOwnsBox = true,
60
+ hasGsapWriter = true,
57
61
  ) {
58
- const element = document.createElement("div");
62
+ const element = gsapOwnsBox ? gsapPositioned("div") : document.createElement("div");
59
63
  const selection = { element, id: "clip", selector: "#clip" } as unknown as DomEditSelection;
60
64
  const fallback = vi.fn().mockResolvedValue(undefined);
61
65
  const anchorSave = vi.fn().mockResolvedValue(undefined);
@@ -63,6 +67,7 @@ function mountResizeHandler(
63
67
  const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback }));
64
68
  const commitMutation = vi.fn().mockResolvedValue(undefined);
65
69
  const commitPatch = vi.fn().mockResolvedValue(undefined);
70
+ const fetchAnimations = vi.fn(() => vi.fn().mockResolvedValue(targetAnimations));
66
71
  let resize:
67
72
  | ((
68
73
  selection: DomEditSelection,
@@ -76,14 +81,15 @@ function mountResizeHandler(
76
81
  const editing = useGsapAwareEditing({
77
82
  domEditSelection: selection,
78
83
  selectedGsapAnimations: animations,
79
- gsapCommitMutation: commitMutation,
84
+ gsapCommitMutation: hasGsapWriter ? commitMutation : null,
80
85
  previewIframeRef: { current: null },
81
86
  showToast: vi.fn(),
82
87
  bumpGsapCache: vi.fn(),
83
- makeFetchFallback: () => vi.fn().mockResolvedValue(targetAnimations),
88
+ makeFetchFallback: fetchAnimations,
84
89
  trackGsapInteractionFailure: vi.fn(),
85
90
  stageElementPositionOffset: elementOffset,
86
91
  handleDomBoxSizeCommit: fallback,
92
+ handleDomRotationCommit: vi.fn(),
87
93
  commitPositionPatchToHtml: commitPatch,
88
94
  addGsapAnimation: vi.fn(),
89
95
  convertToKeyframes: vi.fn(),
@@ -103,6 +109,7 @@ function mountResizeHandler(
103
109
  anchorRollback,
104
110
  commitMutation,
105
111
  commitPatch,
112
+ fetchAnimations,
106
113
  resize: resize!,
107
114
  property: property!,
108
115
  root,
@@ -116,13 +123,21 @@ function mountGroupHandler({
116
123
  makeFetchFallback,
117
124
  trackGsapInteractionFailure = vi.fn(),
118
125
  stageElementPositionOffset = vi.fn(),
126
+ handleDomRotationCommit = vi.fn(),
119
127
  }: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
120
- Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure" | "stageElementPositionOffset">>) {
128
+ Partial<
129
+ Pick<
130
+ AwareEditingParams,
131
+ "trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit"
132
+ >
133
+ >) {
121
134
  let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
122
135
  let pathOffsetCommit!: (
123
136
  selection: DomEditSelection,
124
137
  next: { x: number; y: number },
138
+ route?: { plainTranslate?: boolean },
125
139
  ) => Promise<void>;
140
+ let rotationCommit!: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
126
141
  function Harness() {
127
142
  const editing = useGsapAwareEditing({
128
143
  domEditSelection: null,
@@ -135,6 +150,7 @@ function mountGroupHandler({
135
150
  trackGsapInteractionFailure,
136
151
  stageElementPositionOffset,
137
152
  handleDomBoxSizeCommit: vi.fn(),
153
+ handleDomRotationCommit,
138
154
  commitPositionPatchToHtml: vi.fn(),
139
155
  addGsapAnimation: vi.fn(),
140
156
  convertToKeyframes: vi.fn(),
@@ -143,13 +159,19 @@ function mountGroupHandler({
143
159
  });
144
160
  groupCommit = editing.handleGsapAwareGroupPathOffsetCommit;
145
161
  pathOffsetCommit = editing.handleGsapAwarePathOffsetCommit;
162
+ rotationCommit = editing.handleGsapAwareRotationCommit;
146
163
  return null;
147
164
  }
148
165
  const root = mountReactHarness(<Harness />);
149
166
  return {
150
167
  groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates),
151
- pathOffsetCommit: (selection: DomEditSelection, next: { x: number; y: number }) =>
152
- pathOffsetCommit(selection, next),
168
+ pathOffsetCommit: (
169
+ selection: DomEditSelection,
170
+ next: { x: number; y: number },
171
+ route?: { plainTranslate?: boolean },
172
+ ) => pathOffsetCommit(selection, next, route),
173
+ rotationCommit: (selection: DomEditSelection, next: RotationCommit) =>
174
+ rotationCommit(selection, next),
153
175
  root,
154
176
  };
155
177
  }
@@ -167,7 +189,7 @@ describe("useGsapAwareEditing moves of an element GSAP does not position", () =>
167
189
  });
168
190
  const box = { element: document.createElement("div"), id: "box", selector: "#box" };
169
191
  await act(() => pathOffsetCommit(box as unknown as DomEditSelection, { x: 130.25, y: 90 }));
170
- expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 });
192
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 }, true);
171
193
  expect(save).toHaveBeenCalledTimes(1);
172
194
  expect(gsapCommitMutation).not.toHaveBeenCalled();
173
195
  expect(makeFetchFallback).not.toHaveBeenCalled();
@@ -176,6 +198,127 @@ describe("useGsapAwareEditing moves of an element GSAP does not position", () =>
176
198
  });
177
199
  });
178
200
 
201
+ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
202
+ it.each([
203
+ ["the CSS route, on an element GSAP has since taken over", true, () => gsapPositioned("div")],
204
+ [
205
+ "the GSAP route, on an element that now looks GSAP-free",
206
+ false,
207
+ () => document.createElement("div"),
208
+ ],
209
+ ])("%s", async (_, plainTranslate, makeElement) => {
210
+ mocks.drag.mockResolvedValue({ status: "persisted" });
211
+ const save = vi.fn().mockResolvedValue(undefined);
212
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
213
+ const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({
214
+ gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
215
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
216
+ stageElementPositionOffset,
217
+ });
218
+ const box = {
219
+ element: makeElement(),
220
+ id: "box",
221
+ selector: "#box",
222
+ } as unknown as DomEditSelection;
223
+ await act(() => pathOffsetCommit(box, { x: 40, y: 20 }, { plainTranslate }));
224
+ await act(() => groupCommit([{ selection: box, next: { x: 40, y: 20 }, plainTranslate }]));
225
+ expect(stageElementPositionOffset.mock.calls.length).toBe(plainTranslate ? 2 : 0);
226
+ expect(mocks.drag.mock.calls.some((call) => call[0] === box)).toBe(!plainTranslate);
227
+ act(() => root.unmount());
228
+ });
229
+ });
230
+
231
+ describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
232
+ it("writes no member when one CSS-route member has been folded since the press", async () => {
233
+ const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
234
+ const { groupCommit, root } = mountGroupHandler({
235
+ gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
236
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
237
+ stageElementPositionOffset,
238
+ });
239
+ const card = {
240
+ element: document.createElement("div"),
241
+ id: "card",
242
+ } as unknown as DomEditSelection;
243
+ const folded = Object.assign(document.createElement("div"), {
244
+ _gsap: { renderTransform: () => {}, x: "94px", y: "66px" },
245
+ });
246
+ const box = { element: folded, id: "box" } as unknown as DomEditSelection;
247
+ await expect(
248
+ act(() =>
249
+ groupCommit([
250
+ { selection: card, next: { x: 1, y: 2 }, plainTranslate: true },
251
+ { selection: box, next: { x: 1, y: 2 }, plainTranslate: true },
252
+ ]),
253
+ ),
254
+ ).rejects.toThrow(/animation took over/);
255
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
256
+ act(() => root.unmount());
257
+ });
258
+ });
259
+
260
+ describe("useGsapAwareEditing rotation routing", () => {
261
+ function rotate(element: HTMLElement) {
262
+ const handleDomRotationCommit = vi.fn().mockResolvedValue(undefined);
263
+ const gsapCommitMutation = vi.fn();
264
+ const h = mountGroupHandler({
265
+ gsapCommitMutation,
266
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
267
+ handleDomRotationCommit,
268
+ });
269
+ const box = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
270
+ return { ...h, box, handleDomRotationCommit, gsapCommitMutation };
271
+ }
272
+
273
+ it("turns an element GSAP does not turn by the CSS writer, with no GSAP write", async () => {
274
+ const h = rotate(document.createElement("div"));
275
+ await act(() => h.rotationCommit(h.box, { angle: 55 }));
276
+ expect(h.handleDomRotationCommit).toHaveBeenCalledWith(h.box, { angle: 55 });
277
+ expect(tryGsapRotationIntercept).not.toHaveBeenCalled();
278
+ expect(h.gsapCommitMutation).not.toHaveBeenCalled();
279
+ act(() => h.root.unmount());
280
+ });
281
+
282
+ it("leaves an element a GSAP tween turns to the GSAP rotation route", async () => {
283
+ vi.mocked(tryGsapRotationIntercept).mockResolvedValue({ status: "persisted" });
284
+ const element = document.createElement("div");
285
+ const h = rotate(element);
286
+ const tween = { vars: { rotation: 30 }, targets: () => [element] };
287
+ Object.assign(window, { __timelines: { main: { getChildren: () => [tween] } } });
288
+ try {
289
+ await act(() => h.rotationCommit(h.box, { angle: 55 }));
290
+ } finally {
291
+ delete (window as { __timelines?: unknown }).__timelines;
292
+ }
293
+ expect(tryGsapRotationIntercept).toHaveBeenCalledTimes(1);
294
+ expect(h.handleDomRotationCommit).not.toHaveBeenCalled();
295
+ act(() => h.root.unmount());
296
+ });
297
+
298
+ it("saves a turn the press drew as CSS by the CSS writer, even once a GSAP tween turns the element", async () => {
299
+ const element = document.createElement("div");
300
+ const h = rotate(element);
301
+ const tween = { vars: { rotation: 30 }, targets: () => [element] };
302
+ Object.assign(window, { __timelines: { main: { getChildren: () => [tween] } } });
303
+ const plain = {
304
+ property: "rotate",
305
+ before: "",
306
+ after: "",
307
+ share: 0,
308
+ sign: 1,
309
+ inline: false,
310
+ } as const;
311
+ try {
312
+ await act(() => h.rotationCommit(h.box, { angle: 55, plain }));
313
+ } finally {
314
+ delete (window as { __timelines?: unknown }).__timelines;
315
+ }
316
+ expect(h.handleDomRotationCommit).toHaveBeenCalledWith(h.box, { angle: 55, plain });
317
+ expect(tryGsapRotationIntercept).not.toHaveBeenCalled();
318
+ act(() => h.root.unmount());
319
+ });
320
+ });
321
+
179
322
  describe("useGsapAwareEditing shared-tween moves", () => {
180
323
  it("saves a single drag through the element's own offset", async () => {
181
324
  mocks.drag.mockResolvedValue({ status: "element-offset" });
@@ -189,7 +332,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
189
332
  });
190
333
  const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
191
334
  await act(() => pathOffsetCommit(word as unknown as DomEditSelection, { x: 40, y: 20 }));
192
- expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 });
335
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }, false);
193
336
  expect(save).toHaveBeenCalledTimes(1);
194
337
  expect(commitMutation).not.toHaveBeenCalled();
195
338
  act(() => root.unmount());
@@ -219,8 +362,18 @@ describe("useGsapAwareEditing shared-tween moves", () => {
219
362
  ] as unknown as DomEditGroupPathOffsetCommit[]),
220
363
  );
221
364
  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);
365
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(
366
+ word,
367
+ { x: 40, y: 20 },
368
+ false,
369
+ groupKey,
370
+ );
371
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(
372
+ plain,
373
+ { x: 40, y: 20 },
374
+ true,
375
+ groupKey,
376
+ );
224
377
  expect(mocks.drag.mock.calls.some((call) => call[0] === plain)).toBe(false);
225
378
  act(() => root.unmount());
226
379
  });
@@ -252,6 +405,36 @@ describe("useGsapAwareEditing anchored resize", () => {
252
405
  act(() => h.root.unmount());
253
406
  });
254
407
 
408
+ it("resizes a box GSAP does not own through the CSS writer, with no GSAP write or fetch", async () => {
409
+ const h = mountResizeHandler([], [], false);
410
+ const restore = vi.fn();
411
+ await act(() =>
412
+ h.resize(h.selection, { width: 300, height: 200 }, { x: -50.5, y: -25 }, restore),
413
+ );
414
+ expect(h.fallback).toHaveBeenCalledWith(
415
+ h.selection,
416
+ { width: 300, height: 200 },
417
+ { x: -50.5, y: -25 },
418
+ restore,
419
+ );
420
+ expect(mocks.resize).not.toHaveBeenCalled();
421
+ expect(mocks.drag).not.toHaveBeenCalled();
422
+ expect(h.commitMutation).not.toHaveBeenCalled();
423
+ expect(h.fetchAnimations).not.toHaveBeenCalled();
424
+ act(() => h.root.unmount());
425
+ });
426
+
427
+ it("fails a GSAP-owned resize loudly when there is no GSAP writer, never writing CSS", async () => {
428
+ const h = mountResizeHandler([], [], true, false);
429
+ const restore = vi.fn();
430
+ await expect(
431
+ act(() => h.resize(h.selection, { width: 300, height: 200 }, undefined, restore)),
432
+ ).rejects.toThrow("no GSAP writer");
433
+ expect(h.fallback).not.toHaveBeenCalled();
434
+ expect(restore).toHaveBeenCalledTimes(1);
435
+ act(() => h.root.unmount());
436
+ });
437
+
255
438
  it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
256
439
  mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
257
440
  const h = mountResizeHandler([]);
@@ -287,7 +470,7 @@ describe("useGsapAwareEditing anchored resize", () => {
287
470
  await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
288
471
  const key = h.commitMutation.mock.calls[0]![2].coalesceKey;
289
472
  expect(key).toMatch(/^tx:/);
290
- expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, key);
473
+ expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, false, key);
291
474
  expect(h.anchorSave.mock.invocationCallOrder[0]).toBeGreaterThan(
292
475
  h.commitMutation.mock.invocationCallOrder[0]!,
293
476
  );
@@ -7,6 +7,7 @@
7
7
  * Extracted from useDomEditSession to isolate the GSAP intercept routing
8
8
  * from the rest of the editing orchestration.
9
9
  */
10
+ import type { RotationCommit } from "../components/editor/rotationDraft";
10
11
  import { useCallback } from "react";
11
12
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
@@ -28,15 +29,18 @@ import type {
28
29
  import { setElementGsapPosition } from "../utils/elementGsap";
29
30
  import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
31
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
32
+ import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
31
33
  import { runGestureTransaction } from "./gestureTransaction";
32
34
  import {
35
+ gsapWritesBox,
33
36
  gsapWritesPosition,
37
+ gsapWritesRotation,
34
38
  hasNonHoldTweenForElement,
35
39
  POSITION_CHANNELS,
36
40
  } from "./gsapRuntimeKeyframes";
37
41
  import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
38
42
  import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
39
- import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
43
+ import { refuseGsapTakeover, type ElementOffsetStagerDeps } from "./elementOffsetStager";
40
44
  import {
41
45
  prepareCropResize,
42
46
  saveCropResize,
@@ -80,12 +84,15 @@ export interface UseGsapAwareEditingParams {
80
84
  stageElementPositionOffset: (
81
85
  selection: DomEditSelection,
82
86
  next: { x: number; y: number },
87
+ plainTranslate: boolean,
83
88
  coalesceKey?: string,
84
89
  ) => { save: () => Promise<void>; rollback: () => void };
90
+ handleDomRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
85
91
  handleDomBoxSizeCommit: (
86
92
  selection: DomEditSelection,
87
93
  next: { width: number; height: number },
88
94
  offset?: { x: number; y: number },
95
+ restore?: () => void,
89
96
  ) => Promise<void>;
90
97
  commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
91
98
  // GSAP script commit ops (from useGsapScriptCommits)
@@ -131,6 +138,7 @@ export function useGsapAwareEditing({
131
138
  trackGsapInteractionFailure,
132
139
  stageElementPositionOffset,
133
140
  handleDomBoxSizeCommit,
141
+ handleDomRotationCommit,
134
142
  commitPositionPatchToHtml,
135
143
  addGsapAnimation,
136
144
  convertToKeyframes,
@@ -151,10 +159,11 @@ export function useGsapAwareEditing({
151
159
  async (
152
160
  selection: DomEditSelection,
153
161
  next: { x: number; y: number },
154
- modifiers?: { altKey?: boolean },
162
+ modifiers?: MoveCommitOptions,
155
163
  ) => {
156
- if (!gsapWritesPosition(selection.element))
157
- return stageElementPositionOffset(selection, next).save();
164
+ // A gesture and the panel carry their route; webmcp's moveTo decides here.
165
+ if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element))
166
+ return stageElementPositionOffset(selection, next, true).save();
158
167
  if (gsapCommitMutation) {
159
168
  try {
160
169
  const ownedAnimations = getGsapAnimationsForSelection(selection);
@@ -170,7 +179,7 @@ export function useGsapAwareEditing({
170
179
  makeFetchFallback(selection),
171
180
  modifiers,
172
181
  );
173
- await saveMove(outcome, () => stageElementPositionOffset(selection, next).save());
182
+ await saveMove(outcome, () => stageElementPositionOffset(selection, next, false).save());
174
183
  } catch (error) {
175
184
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
176
185
  throw error;
@@ -197,13 +206,9 @@ export function useGsapAwareEditing({
197
206
  // it survives the N sequential server round-trips) onto each commit —
198
207
  // otherwise each member records its own entry and it takes N presses to undo.
199
208
  const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
200
- // Members are written one at a time, and a write that re-renders the preview
201
- // re-runs the whole script — which still holds the OLD position of every
202
- // member not yet written. Those members snap back to where they started and
203
- // stay there until their own write lands, which is the single element seen
204
- // jumping mid-commit while the rest of the group sat still. The drafted
205
- // positions are already on screen, so holding the render until the last
206
- // member has been written costs nothing and never shows a half-moved group.
209
+ // Members are written one at a time, and a re-render re-runs the script with the OLD
210
+ // position of every member not yet written, so they snap back until their own write
211
+ // lands. The drafts are already on screen: hold the render until the last member.
207
212
  let renderOnCommit = false;
208
213
  const previewFallbackLatch = { pending: false };
209
214
  const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
@@ -236,17 +241,17 @@ export function useGsapAwareEditing({
236
241
  return Promise.resolve();
237
242
  };
238
243
  const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
239
- const offsetMembers = new Set<DomEditSelection>();
240
- // Editability is user-atomic: prove every member can be written before
241
- // the first source mutation. Network failures after this point retain the
242
- // existing multi-request semantics, but a blocked member can never leave
243
- // earlier siblings partially moved.
244
- // Every member reads the same file, and a preflight writes nothing — so run
245
- // them together. The parse layer shares one in-flight request per file, which
246
- // turns N sequential round trips into one.
244
+ // Members saved on themselves, each with its route: true for its CSS translate.
245
+ const offsetMembers = new Map<DomEditSelection, boolean>();
246
+ // Editability is user-atomic: prove every member can be written before the first source
247
+ // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
248
+ // write nothing and share one in-flight parse per file, so they run together.
247
249
  const preflightResults = await Promise.allSettled(
248
- updates.map(async ({ selection }) => {
249
- if (!gsapWritesPosition(selection.element)) return void offsetMembers.add(selection);
250
+ updates.map(async ({ selection, plainTranslate }) => {
251
+ if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
252
+ refuseGsapTakeover(selection.element, showToast);
253
+ return void offsetMembers.set(selection, true);
254
+ }
250
255
  const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
251
256
  preflightAnimations.set(selection, animations);
252
257
  const outcome = await tryGsapDragIntercept(
@@ -258,7 +263,7 @@ export function useGsapAwareEditing({
258
263
  undefined,
259
264
  { preflightOnly: true, group: true },
260
265
  );
261
- if (outcome.status === "element-offset") offsetMembers.add(selection);
266
+ if (outcome.status === "element-offset") offsetMembers.set(selection, false);
262
267
  assertGsapEditPersisted(outcome);
263
268
  }),
264
269
  );
@@ -277,8 +282,9 @@ export function useGsapAwareEditing({
277
282
  );
278
283
  for (const [index, { selection, next }] of updates.entries()) {
279
284
  renderOnCommit = index === lastScriptWrite;
280
- if (offsetMembers.has(selection)) {
281
- await stageElementPositionOffset(selection, next, coalesceKey).save();
285
+ const plain = offsetMembers.get(selection);
286
+ if (plain !== undefined) {
287
+ await stageElementPositionOffset(selection, next, plain, coalesceKey).save();
282
288
  continue;
283
289
  }
284
290
  try {
@@ -318,6 +324,7 @@ export function useGsapAwareEditing({
318
324
  makeFetchFallback,
319
325
  trackGsapInteractionFailure,
320
326
  stageElementPositionOffset,
327
+ showToast,
321
328
  ],
322
329
  );
323
330
 
@@ -328,6 +335,8 @@ export function useGsapAwareEditing({
328
335
  offset?: { x: number; y: number },
329
336
  restore: () => void = () => undefined,
330
337
  ) => {
338
+ if (!gsapWritesBox(selection.element))
339
+ return handleDomBoxSizeCommit(selection, next, offset, restore);
331
340
  let targetAnimations: GsapAnimation[];
332
341
  try {
333
342
  const ownedAnimations = getGsapAnimationsForSelection(selection);
@@ -411,7 +420,8 @@ export function useGsapAwareEditing({
411
420
  );
412
421
  // Saved after the size, under its undo key, so the two are one step.
413
422
  await saveMove(dragOutcome, async () => {
414
- anchorMove = stageElementPositionOffset(selection, offset, coalesceKey);
423
+ const plain = !gsapWritesPosition(selection.element);
424
+ anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
415
425
  });
416
426
  }
417
427
  logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
@@ -421,13 +431,7 @@ export function useGsapAwareEditing({
421
431
  throw error;
422
432
  }
423
433
  }
424
- logResize("dom-route", {
425
- next,
426
- offset: offset ?? null,
427
- hadGsapMutation: !!gsapCommitMutation,
428
- });
429
- logResizeSettle(selection.element, "dom-route");
430
- await handleDomBoxSizeCommit(selection, next, offset);
434
+ throw new Error("Resize of a GSAP-owned box has no GSAP writer");
431
435
  },
432
436
  afterBufferedCommitsSaved: async () => {
433
437
  await anchorMove?.save();
@@ -456,17 +460,13 @@ export function useGsapAwareEditing({
456
460
  );
457
461
 
458
462
  const handleGsapAwareRotationCommit = useCallback(
459
- async (selection: DomEditSelection, next: { angle: number }) => {
463
+ async (selection: DomEditSelection, next: RotationCommit) => {
464
+ if (next.plain || !gsapWritesRotation(selection.element))
465
+ return handleDomRotationCommit(selection, next);
460
466
  if (gsapCommitMutation) {
461
467
  try {
462
- const ownedAnimations = getGsapAnimationsForSelection(selection);
463
- const targetAnimations = Array.isArray(ownedAnimations)
464
- ? ownedAnimations
465
- : await ownedAnimations;
466
- // Single source of truth for rotation too: tryGsapRotationIntercept handles
467
- // tweened elements (keyframes) and static ones (a tl.set), so there's no
468
- // CSS-var fallback. Selectorless/computed source rejects so the gesture
469
- // transaction can restore its draft instead of reporting a false success.
468
+ const targetAnimations = await getGsapAnimationsForSelection(selection);
469
+ // A keyframe or a tl.set; a computed source rejects, so the gesture restores its draft.
470
470
  const outcome = await tryGsapRotationIntercept(
471
471
  selection,
472
472
  next.angle,
@@ -488,6 +488,7 @@ export function useGsapAwareEditing({
488
488
  makeFetchFallback,
489
489
  trackGsapInteractionFailure,
490
490
  getGsapAnimationsForSelection,
491
+ handleDomRotationCommit,
491
492
  ],
492
493
  );
493
494
 
@@ -345,7 +345,7 @@ describe("useInlineTextEdit", () => {
345
345
  act(() => root.unmount());
346
346
  });
347
347
 
348
- it("cancels on Escape", () => {
348
+ it("commits on Escape", () => {
349
349
  const element = heading("Original");
350
350
  const { controls, root, onCommit } = mount();
351
351
  act(() => {
@@ -355,8 +355,38 @@ describe("useInlineTextEdit", () => {
355
355
  element.textContent = "half-typed";
356
356
  press(element, "Escape");
357
357
 
358
- expect(element.textContent).toBe("Original");
359
- expect(onCommit).not.toHaveBeenCalled();
358
+ expect(element.textContent).toBe("half-typed");
359
+ expect(onCommit).toHaveBeenCalledTimes(1);
360
+ expect(controls().session).toBeNull();
361
+ act(() => root.unmount());
362
+ });
363
+
364
+ // The save carries the styled markup it replaced, which is what undo puts back.
365
+ it("saves typing ended by Escape once, with the markup it replaced", async () => {
366
+ const element = heading();
367
+ element.innerHTML = 'Hello <span style="color: red">world</span>';
368
+ const { controls, root, onCommit } = mount();
369
+ act(() => {
370
+ controls().start(element);
371
+ });
372
+ await act(async () => {
373
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
374
+ });
375
+ expect(document.activeElement).toBe(element);
376
+
377
+ element.innerHTML = 'Hello <span style="color: red">world</span>!';
378
+ press(element, "Escape");
379
+
380
+ expect(onCommit.mock.calls).toEqual([
381
+ [
382
+ {
383
+ element,
384
+ html: 'Hello <span style="color: red">world</span>!',
385
+ previousHtml: 'Hello <span style="color: red">world</span>',
386
+ },
387
+ ],
388
+ ]);
389
+ expect(element.innerHTML).toBe('Hello <span style="color: red">world</span>!');
360
390
  act(() => root.unmount());
361
391
  });
362
392
 
@@ -203,7 +203,7 @@ export function useInlineTextEdit({
203
203
  }
204
204
  if (event.key === "Escape") {
205
205
  event.preventDefault();
206
- cancel();
206
+ commit();
207
207
  }
208
208
  };
209
209
  // Clicking away keeps the work, which is what every other field in Studio
@@ -243,7 +243,7 @@ export function useInlineTextEdit({
243
243
  element.removeEventListener("dragover", onDragOver);
244
244
  element.removeEventListener("drop", onDrop);
245
245
  };
246
- }, [session, commit, cancel]);
246
+ }, [session, commit]);
247
247
 
248
248
  // Unmount keeps the typed words, as blur does. Read through a ref so a new
249
249
  // onCommit never closes the session mid-edit.