@hyperframes/studio 0.8.102 → 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 (73) hide show
  1. package/dist/assets/{hyperframes-player-BuRiFtrF.js → hyperframes-player-DAoaajuM.js} +1 -1
  2. package/dist/assets/{index-BT3sauee.js → index-BDK4rTMb.js} +1 -1
  3. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  4. package/dist/assets/{index-C2utDZt-.js → index-De2aO4XM.js} +179 -179
  5. package/dist/assets/{index-BM35fjFf.js → index-WV9LroAD.js} +1 -1
  6. package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
  7. package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
  8. package/dist/chunk-DI4LCJHL.js.map +1 -0
  9. package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
  10. package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
  11. package/dist/index.d.ts +183 -170
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +550 -389
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/DomEditOverlay.tsx +7 -7
  19. package/src/components/editor/PropertyPanel.tsx +5 -5
  20. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  21. package/src/components/editor/domEditOverlayGestures.ts +14 -20
  22. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  23. package/src/components/editor/domEditOverlayStartGesture.ts +17 -27
  24. package/src/components/editor/manualEdits.test.ts +3 -9
  25. package/src/components/editor/manualEdits.ts +0 -1
  26. package/src/components/editor/manualEditsDom.ts +0 -10
  27. package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
  28. package/src/components/editor/manualEditsDomPatches.ts +1 -23
  29. package/src/components/editor/manualEditsSnapshot.ts +2 -0
  30. package/src/components/editor/manualEditsTypes.ts +2 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +57 -0
  32. package/src/components/editor/manualOffsetDrag.ts +11 -32
  33. package/src/components/editor/plainTranslate.test.ts +38 -22
  34. package/src/components/editor/plainTranslate.ts +75 -40
  35. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  36. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  37. package/src/components/editor/propertyPanelTransformCommit.test.ts +80 -0
  38. package/src/components/editor/propertyPanelTransformCommit.ts +11 -6
  39. package/src/components/editor/propertyPanelTypes.ts +1 -0
  40. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  41. package/src/components/editor/rotationDraft.test.ts +111 -0
  42. package/src/components/editor/rotationDraft.ts +145 -0
  43. package/src/components/editor/useColorGradingController.test.ts +36 -1
  44. package/src/components/editor/useColorGradingController.ts +2 -2
  45. package/src/components/editor/useDomEditNudge.test.tsx +34 -0
  46. package/src/components/editor/useDomEditNudge.ts +12 -5
  47. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  48. package/src/hooks/draggedGsapPosition.ts +28 -0
  49. package/src/hooks/elementOffsetStager.ts +26 -6
  50. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  51. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  52. package/src/hooks/plainRotation.test.ts +122 -0
  53. package/src/hooks/plainRotation.ts +42 -0
  54. package/src/hooks/useDomEditSession.ts +5 -5
  55. package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
  56. package/src/hooks/useDomGeometryCommit.ts +17 -17
  57. package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
  58. package/src/hooks/useDomGeometryCommits.ts +28 -36
  59. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  60. package/src/hooks/useGsapAwareEditing.ts +43 -42
  61. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  62. package/src/hooks/useInlineTextEdit.ts +2 -2
  63. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  64. package/src/hooks/usePreviewInteraction.ts +3 -2
  65. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  66. package/src/player/components/TimelineClipFades.tsx +53 -50
  67. package/src/styles/theme.css +1 -1
  68. package/src/utils/projectRouting.test.ts +9 -0
  69. package/src/utils/projectRouting.ts +3 -1
  70. package/dist/chunk-S65AQH6M.js.map +0 -1
  71. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  72. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  73. /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
@@ -1,7 +1,11 @@
1
+ import type { RotationCommit } from "../components/editor/rotationDraft";
1
2
  import { useCallback, useMemo, useRef, useState } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import type { DomEditSelection } from "../components/editor/domEditing";
4
- import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
5
+ import type {
6
+ DomEditGroupPathOffsetCommit,
7
+ MoveCommitOptions,
8
+ } from "../components/editor/DomEditOverlay";
5
9
  import { isPreviewBooted } from "../player/store/playerStore";
6
10
  import type { DomEditCommitOutcome } from "./domEditCommitRunner";
7
11
  import type { UseDomStyleCommitOptions } from "./useDomStyleCommit";
@@ -13,8 +17,8 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
13
17
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
14
18
  import { useDomEditPersist } from "./useDomEditPersist";
15
19
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
20
+ import { useDomGeometryCommits } from "./useDomGeometryCommits";
16
21
  import { useMountEffect } from "./useMountEffect";
17
- import { stageElementOffset } from "./elementOffsetStager";
18
22
 
19
23
  /**
20
24
  * Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
@@ -29,7 +33,7 @@ export interface DomGeometryCommits {
29
33
  commitPathOffset: (
30
34
  selection: DomEditSelection,
31
35
  next: { x: number; y: number },
32
- modifiers?: { altKey?: boolean },
36
+ modifiers?: MoveCommitOptions,
33
37
  ) => Promise<DomEditCommitOutcome>;
34
38
  commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise<DomEditCommitOutcome>;
35
39
  commitBoxSize: (
@@ -40,15 +44,13 @@ export interface DomGeometryCommits {
40
44
  ) => Promise<DomEditCommitOutcome>;
41
45
  commitRotation: (
42
46
  selection: DomEditSelection,
43
- next: { angle: number },
47
+ next: RotationCommit,
44
48
  ) => Promise<DomEditCommitOutcome>;
45
49
  waitForPendingSaves: () => Promise<void>;
46
50
  }
47
51
 
48
52
  const noop = () => {};
49
53
  const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
50
- // ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
51
- const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
52
54
 
53
55
  /**
54
56
  * Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
@@ -107,16 +109,13 @@ export function useDomGeometryCommit({
107
109
  },
108
110
  [commitPositionPatchToHtml, queue],
109
111
  );
110
- const stageElementPositionOffset = useCallback(
111
- (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
112
- stageElementOffset(
113
- { commitPositionPatchToHtml: commitWithFreshQueue, showToast },
114
- selection,
115
- next,
116
- coalesceKey,
117
- ),
118
- [commitWithFreshQueue, showToast],
119
- );
112
+ const { stageElementPositionOffset, handleDomBoxSizeCommit, handleDomRotationCommit } =
113
+ useDomGeometryCommits({
114
+ previewIframeRef: iframeRef,
115
+ showToast,
116
+ commitPositionPatchToHtml: commitWithFreshQueue,
117
+ readOnlyPreview: false,
118
+ });
120
119
  const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
121
120
  const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
122
121
  const {
@@ -135,7 +134,8 @@ export function useDomGeometryCommit({
135
134
  makeFetchFallback,
136
135
  trackGsapInteractionFailure,
137
136
  stageElementPositionOffset,
138
- handleDomBoxSizeCommit: noDomBoxSizeRoute,
137
+ handleDomBoxSizeCommit,
138
+ handleDomRotationCommit,
139
139
  commitPositionPatchToHtml: commitWithFreshQueue,
140
140
  addGsapAnimation: gsap.addGsapAnimation,
141
141
  convertToKeyframes: gsap.convertToKeyframes,
@@ -162,7 +162,7 @@ describe("useDomGeometryCommits element position offset", () => {
162
162
  .mockResolvedValue(undefined);
163
163
  const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
164
164
 
165
- await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }).save();
165
+ await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }, false).save();
166
166
 
167
167
  const patches = commitPositionPatchToHtml.mock.calls[0]![1];
168
168
  expect(patches).toEqual([
@@ -175,6 +175,61 @@ describe("useDomGeometryCommits element position offset", () => {
175
175
  });
176
176
  });
177
177
 
178
+ describe("useDomGeometryCommits resize anchor", () => {
179
+ it("saves the anchor as the element's own plain px translate, sub-pixel, with the size", async () => {
180
+ const element = document.createElement("div");
181
+ element.id = "anchored";
182
+ element.style.setProperty("translate", "40px 30px");
183
+ document.body.append(element);
184
+ const selection = {
185
+ id: "anchored",
186
+ selector: "#anchored",
187
+ element,
188
+ } as unknown as DomEditSelection;
189
+ const commit = vi
190
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
191
+ .mockResolvedValue(undefined);
192
+ const { commits, unmount } = mountCommits(commit);
193
+
194
+ await commits().handleDomBoxSizeCommit(
195
+ selection,
196
+ { width: 340, height: 227 },
197
+ { x: -10.25, y: 3.5 },
198
+ );
199
+
200
+ expect(commit).toHaveBeenCalledTimes(1);
201
+ const patches = commit.mock.calls[0]![1];
202
+ expect(patches).toContainEqual({ type: "inline-style", property: "width", value: "340px" });
203
+ expect(patches).toContainEqual({
204
+ type: "inline-style",
205
+ property: "translate",
206
+ value: "-10.25px 3.5px",
207
+ });
208
+ expect(patches.some((p) => p.property.startsWith("--hf-studio-offset"))).toBe(false);
209
+ expect(element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
210
+ unmount();
211
+ });
212
+ });
213
+
214
+ describe("useDomGeometryCommits resize rollback", () => {
215
+ it("rolls the whole gesture back once when the resize save fails", async () => {
216
+ const element = document.createElement("div");
217
+ document.body.append(element);
218
+ const selection = { id: "r", selector: "#r", element } as unknown as DomEditSelection;
219
+ const failure = new Error("save failed");
220
+ const commit = vi
221
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
222
+ .mockRejectedValue(failure);
223
+ const restore = vi.fn();
224
+ const { commits, unmount } = mountCommits(commit);
225
+ await expect(
226
+ commits().handleDomBoxSizeCommit(selection, { width: 50, height: 40 }, undefined, restore),
227
+ ).rejects.toBe(failure);
228
+ expect(restore).toHaveBeenCalledTimes(1);
229
+ unmount();
230
+ });
231
+ });
232
+
178
233
  describe("useDomGeometryCommits resize of a cropped element", () => {
179
234
  it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
180
235
  const element = withInlineLayoutBox(document.createElement("div"));
@@ -1,9 +1,9 @@
1
+ import type { RotationCommit } from "../components/editor/rotationDraft";
1
2
  import { useCallback } from "react";
2
3
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
3
4
  import {
4
5
  applyStudioPathOffset,
5
6
  applyStudioBoxSize,
6
- applyStudioRotation,
7
7
  captureStudioPathOffset,
8
8
  captureStudioBoxSize,
9
9
  captureStudioRotation,
@@ -15,11 +15,12 @@ import {
15
15
  clearStudioRotation,
16
16
  } from "../components/editor/manualEdits";
17
17
  import { stageElementOffset } from "./elementOffsetStager";
18
+ import { savePlainRotation } from "./plainRotation";
18
19
  import { prepareCropResize } from "../components/editor/cropResize";
20
+ import { translatePatch, writeTranslatePx } from "../components/editor/plainTranslate";
19
21
  import {
20
22
  buildPathOffsetPatches,
21
23
  buildBoxSizePatches,
22
- buildRotationPatches,
23
24
  buildClearPathOffsetPatches,
24
25
  buildClearBoxSizePatches,
25
26
  buildClearRotationPatches,
@@ -61,11 +62,17 @@ export function useDomGeometryCommits({
61
62
  readOnlyPreview,
62
63
  }: UseDomGeometryCommitsParams) {
63
64
  const stageElementPositionOffset = useCallback(
64
- (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
65
+ (
66
+ selection: DomEditSelection,
67
+ next: { x: number; y: number },
68
+ plainTranslate: boolean,
69
+ coalesceKey?: string,
70
+ ) =>
65
71
  stageElementOffset(
66
72
  { commitPositionPatchToHtml, showToast, readOnlyPreview },
67
73
  selection,
68
74
  next,
75
+ plainTranslate,
69
76
  coalesceKey,
70
77
  ),
71
78
  [commitPositionPatchToHtml, readOnlyPreview, showToast],
@@ -98,56 +105,41 @@ export function useDomGeometryCommits({
98
105
  selection: DomEditSelection,
99
106
  next: { width: number; height: number },
100
107
  offset?: { x: number; y: number },
108
+ restore?: () => void,
101
109
  ) => {
102
110
  if (readOnlyPreview) return Promise.resolve();
103
- const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
104
- if (gsapFallback) return gsapFallback;
105
- const beforeSize = captureStudioBoxSize(selection.element);
106
- const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
107
- const stageCrop = prepareCropResize(selection.element);
108
- applyStudioBoxSize(selection.element, next);
111
+ const element = selection.element;
112
+ const beforeSize = captureStudioBoxSize(element);
113
+ const beforeTranslate = element.style.getPropertyValue("translate");
114
+ const stageCrop = prepareCropResize(element);
115
+ applyStudioBoxSize(element, next);
109
116
  const crop = stageCrop();
110
- // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
111
- // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
112
- // SINGLE commit: one persist = one undo entry, and there is no
113
- // intermediate re-stamp where the new size is in source but the anchor
114
- // offset is not (that frame was the release "jump"). Both builders read
115
- // the already-mutated live element, so concatenation is safe.
116
- const patches = buildBoxSizePatches(selection.element);
117
+ // One commit, one undo entry: the size, the crop that follows it, and the translate
118
+ // (as a move writes it) that keeps the centre planted.
119
+ const patches = buildBoxSizePatches(element);
117
120
  if (crop) patches.push(crop.patch);
118
121
  if (offset) {
119
- applyStudioPathOffset(selection.element, offset);
120
- patches.push(...buildPathOffsetPatches(selection.element));
122
+ writeTranslatePx(element, offset);
123
+ patches.push(translatePatch(offset));
121
124
  }
122
125
  return commitPositionPatchToHtml(selection, patches, {
123
126
  label: "Resize layer box",
124
127
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
125
128
  }).catch((error) => {
126
- restoreStudioBoxSize(selection.element, beforeSize);
127
- if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
129
+ restoreStudioBoxSize(element, beforeSize);
130
+ if (offset) element.style.setProperty("translate", beforeTranslate);
128
131
  crop?.revert();
132
+ restore?.();
129
133
  throw error;
130
134
  });
131
135
  },
132
- [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
136
+ [commitPositionPatchToHtml, readOnlyPreview],
133
137
  );
134
138
 
135
139
  const handleDomRotationCommit = useCallback(
136
- (selection: DomEditSelection, next: { angle: number }) => {
137
- if (readOnlyPreview) return Promise.resolve();
138
- const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
139
- if (gsapFallback) return gsapFallback;
140
- const before = captureStudioRotation(selection.element);
141
- applyStudioRotation(selection.element, next);
142
- return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
143
- label: "Rotate layer",
144
- coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
145
- }).catch((error) => {
146
- restoreStudioRotation(selection.element, before);
147
- throw error;
148
- });
149
- },
150
- [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
140
+ (selection: DomEditSelection, next: RotationCommit) =>
141
+ savePlainRotation({ commitPositionPatchToHtml, readOnlyPreview }, selection, next),
142
+ [commitPositionPatchToHtml, readOnlyPreview],
151
143
  );
152
144
 
153
145
  const handleDomManualEditsReset = useCallback(
@@ -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
  );