@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -16,6 +16,7 @@ import {
16
16
  type ElementMatchSelection,
17
17
  } from "../utils/studioHelpers";
18
18
  import type { TimelineElement } from "../player";
19
+ import { studioApiFetch } from "../utils/studioApiFetch";
19
20
 
20
21
  interface UseGroupCommitsParams extends DomEditCommitBaseParams {
21
22
  /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
@@ -132,7 +133,7 @@ async function commitStructuralMutation(
132
133
  writeFile: deps.writeProjectFile,
133
134
  recordEdit: deps.editHistory.recordEdit,
134
135
  rewrite: async (originalContent) => {
135
- const mutateResponse = await fetch(
136
+ const mutateResponse = await studioApiFetch(
136
137
  buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
137
138
  {
138
139
  method: "POST",
@@ -0,0 +1,160 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
8
+ import { usePlayerStore } from "../player/store/playerStore";
9
+ import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
10
+ import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
11
+ import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
12
+
13
+ vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
15
+ trackStudioEditBlocked: vi.fn(),
16
+ trackStudioSaveFailure: vi.fn(),
17
+ }));
18
+
19
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ beforeEach(() => {
22
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 1, activeKeyframePct: null });
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.clearAllMocks();
27
+ document.body.innerHTML = "";
28
+ Reflect.deleteProperty(window, "__timelines");
29
+ });
30
+
31
+ const positionTween = (id: string, extra: Partial<GsapAnimation> = {}) =>
32
+ ({
33
+ id: `#${id}-to-0-position`,
34
+ targetSelector: `#${id}`,
35
+ propertyGroup: "position",
36
+ method: "to",
37
+ properties: { x: 100 },
38
+ position: 0,
39
+ resolvedStart: 0,
40
+ duration: 2,
41
+ ease: "none",
42
+ ...extra,
43
+ }) as unknown as GsapAnimation;
44
+
45
+ /** Three members GSAP is visibly moving, one `to` tween each in the file. */
46
+ function mountGroup(animations: GsapAnimation[]) {
47
+ const elements = ["a", "b", "c"].map((id) => {
48
+ const el = Object.assign(document.createElement("div"), { id });
49
+ document.body.append(el);
50
+ return el;
51
+ });
52
+ const live = elements.map((el) => ({
53
+ targets: () => [el],
54
+ vars: { x: 100, duration: 2 },
55
+ duration: () => 2,
56
+ startTime: () => 0,
57
+ }));
58
+ const timelines = { root: { getChildren: () => live, duration: () => 2 } };
59
+ Reflect.set(window, "__timelines", timelines);
60
+ const iframe = {
61
+ contentWindow: { gsap: { getProperty: () => 0, set: vi.fn() }, __timelines: timelines },
62
+ contentDocument: document,
63
+ } as unknown as HTMLIFrameElement;
64
+ const commitMutation = Object.assign(vi.fn().mockResolvedValue(undefined), {
65
+ batch: vi.fn().mockResolvedValue(undefined),
66
+ });
67
+ const showToast = vi.fn();
68
+ const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
69
+ const { editing, root } = mountGsapAwareEditing({
70
+ gsapCommitMutation: commitMutation,
71
+ activeCompPath: "index.html",
72
+ previewIframeRef: { current: iframe },
73
+ showToast,
74
+ makeFetchFallback: () => async () => animations,
75
+ stageElementPositionOffset,
76
+ });
77
+ const groupCommit = (updates: DomEditGroupPathOffsetCommit[]) =>
78
+ editing().handleGsapAwareGroupPathOffsetCommit(updates);
79
+ const updates = elements.map((element) => ({
80
+ selection: {
81
+ element,
82
+ id: element.id,
83
+ selector: `#${element.id}`,
84
+ } as unknown as DomEditSelection,
85
+ next: { x: 30, y: 0 },
86
+ }));
87
+ const written = () => [
88
+ ...commitMutation.mock.calls.map((call) => call[1]),
89
+ ...commitMutation.batch.mock.calls.flatMap(([calls]) =>
90
+ calls.map((c: { mutation: unknown }) => c.mutation),
91
+ ),
92
+ ];
93
+ return { elements, iframe, updates, groupCommit, commitMutation, written, showToast, root };
94
+ }
95
+
96
+ describe("a group drag plans every member before its first write", () => {
97
+ it("refuses the whole group when one member's tween cannot take a keyframe", async () => {
98
+ const h = mountGroup([
99
+ positionTween("a"),
100
+ positionTween("b", { ease: undefined }),
101
+ positionTween("c"),
102
+ ]);
103
+ const styles = h.elements.map((el) => el.getAttribute("style"));
104
+
105
+ await expect(h.groupCommit(h.updates)).rejects.toMatchObject({
106
+ name: "GsapEditBlockedError",
107
+ reason: "keyframes-uneditable",
108
+ detail: "unknown-ease",
109
+ });
110
+
111
+ expect(h.written()).toEqual([]);
112
+ expect(h.commitMutation.batch).not.toHaveBeenCalled();
113
+ const gsap = (h.iframe.contentWindow as unknown as { gsap: { set: unknown } }).gsap;
114
+ expect(gsap.set).not.toHaveBeenCalled();
115
+ expect(h.elements.map((el) => el.getAttribute("style"))).toEqual(styles);
116
+ expect(h.showToast).toHaveBeenCalledTimes(1);
117
+ expect(h.showToast).toHaveBeenCalledWith(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"], "error");
118
+ expect(trackStudioEditBlocked).toHaveBeenCalledTimes(1);
119
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "b" }));
120
+ act(() => h.root.unmount());
121
+ });
122
+
123
+ it("refuses members animated in two files, since the batch writes one file", async () => {
124
+ const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
125
+ const updates = h.updates.map((u, i) => ({
126
+ ...u,
127
+ selection: { ...u.selection, sourceFile: i === 2 ? "scenes/intro.html" : "index.html" },
128
+ }));
129
+
130
+ await expect(h.groupCommit(updates)).rejects.toMatchObject({ reason: "mixed-files" });
131
+
132
+ expect(h.written()).toEqual([]);
133
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "c" }));
134
+ act(() => h.root.unmount());
135
+ });
136
+
137
+ it("writes a group whose members name the open file or leave it implied", async () => {
138
+ const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
139
+ const updates = h.updates.map((u, i) => ({
140
+ ...u,
141
+ selection: { ...u.selection, sourceFile: i === 0 ? "" : "index.html" },
142
+ }));
143
+
144
+ await h.groupCommit(updates);
145
+
146
+ expect(h.written()).toHaveLength(3);
147
+ act(() => h.root.unmount());
148
+ });
149
+
150
+ it("writes every member when each plan holds", async () => {
151
+ const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
152
+
153
+ await h.groupCommit(h.updates);
154
+
155
+ const ids = h.written().map((m) => (m as { animationId?: string }).animationId);
156
+ expect(ids).toEqual(["#a-to-0-position", "#b-to-0-position", "#c-to-0-position"]);
157
+ expect(h.showToast).not.toHaveBeenCalled();
158
+ act(() => h.root.unmount());
159
+ });
160
+ });
@@ -74,6 +74,7 @@ function mountResizeHandler(
74
74
  size: { width: number; height: number },
75
75
  offset?: { x: number; y: number },
76
76
  restore?: () => void,
77
+ route?: { plainTranslate: boolean },
77
78
  ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
78
79
  | null = null;
79
80
  let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
@@ -119,18 +120,21 @@ function mountResizeHandler(
119
120
  type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
120
121
 
121
122
  function mountGroupHandler({
122
- gsapCommitMutation,
123
- makeFetchFallback,
123
+ gsapCommitMutation = vi.fn().mockResolvedValue(undefined),
124
+ makeFetchFallback = () => vi.fn().mockResolvedValue([]),
124
125
  trackGsapInteractionFailure = vi.fn(),
125
126
  stageElementPositionOffset = vi.fn(),
126
127
  handleDomRotationCommit = vi.fn(),
127
- }: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
128
- Partial<
129
- Pick<
130
- AwareEditingParams,
131
- "trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit"
132
- >
133
- >) {
128
+ }: Partial<
129
+ Pick<
130
+ AwareEditingParams,
131
+ | "gsapCommitMutation"
132
+ | "makeFetchFallback"
133
+ | "trackGsapInteractionFailure"
134
+ | "stageElementPositionOffset"
135
+ | "handleDomRotationCommit"
136
+ >
137
+ > = {}) {
134
138
  let groupCommit!: (
135
139
  updates: DomEditGroupPathOffsetCommit[],
136
140
  ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
@@ -216,8 +220,6 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
216
220
  const save = vi.fn().mockResolvedValue(undefined);
217
221
  const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
218
222
  const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({
219
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
220
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
221
223
  stageElementPositionOffset,
222
224
  });
223
225
  const box = {
@@ -233,14 +235,25 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
233
235
  });
234
236
  });
235
237
 
238
+ describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
239
+ it.each([
240
+ ["the CSS route, on an element GSAP has since taken over", true],
241
+ ["the GSAP route, on an element that now looks GSAP-free", false],
242
+ ])("%s", async (_, plainTranslate) => {
243
+ mocks.resize.mockResolvedValue({ status: "persisted" });
244
+ const h = mountResizeHandler([], [], plainTranslate);
245
+ const size = { width: 300, height: 200 };
246
+ await act(() => h.resize(h.selection, size, undefined, vi.fn(), { plainTranslate }));
247
+ expect(h.fallback).toHaveBeenCalledTimes(plainTranslate ? 1 : 0);
248
+ expect(mocks.resize).toHaveBeenCalledTimes(plainTranslate ? 0 : 1);
249
+ act(() => h.root.unmount());
250
+ });
251
+ });
252
+
236
253
  describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
237
254
  it("writes no member when one CSS-route member has been folded since the press", async () => {
238
255
  const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
239
- const { groupCommit, root } = mountGroupHandler({
240
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
241
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
242
- stageElementPositionOffset,
243
- });
256
+ const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset });
244
257
  const card = {
245
258
  element: document.createElement("div"),
246
259
  id: "card",
@@ -268,7 +281,6 @@ describe("useGsapAwareEditing rotation routing", () => {
268
281
  const gsapCommitMutation = vi.fn();
269
282
  const h = mountGroupHandler({
270
283
  gsapCommitMutation,
271
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
272
284
  handleDomRotationCommit,
273
285
  });
274
286
  const box = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
@@ -300,6 +312,15 @@ describe("useGsapAwareEditing rotation routing", () => {
300
312
  act(() => h.root.unmount());
301
313
  });
302
314
 
315
+ it("saves a turn the press sent down the GSAP route there, even once the element looks GSAP-free", async () => {
316
+ vi.mocked(tryGsapRotationIntercept).mockResolvedValue({ status: "persisted" });
317
+ const h = rotate(document.createElement("div"));
318
+ await act(() => h.rotationCommit(h.box, { angle: 55, plain: null }));
319
+ expect(tryGsapRotationIntercept).toHaveBeenCalledTimes(1);
320
+ expect(h.handleDomRotationCommit).not.toHaveBeenCalled();
321
+ act(() => h.root.unmount());
322
+ });
323
+
303
324
  it("saves a turn the press drew as CSS by the CSS writer, even once a GSAP tween turns the element", async () => {
304
325
  const element = document.createElement("div");
305
326
  const h = rotate(element);
@@ -332,7 +353,6 @@ describe("useGsapAwareEditing shared-tween moves", () => {
332
353
  const commitMutation = vi.fn();
333
354
  const { pathOffsetCommit, root } = mountGroupHandler({
334
355
  gsapCommitMutation: commitMutation,
335
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
336
356
  stageElementPositionOffset,
337
357
  });
338
358
  const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
@@ -351,11 +371,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
351
371
  });
352
372
  const save = vi.fn().mockResolvedValue(undefined);
353
373
  const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
354
- const { groupCommit, root } = mountGroupHandler({
355
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
356
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
357
- stageElementPositionOffset,
358
- });
374
+ const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset });
359
375
  const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
360
376
  const box = { element: gsapPositioned("div"), id: "box", selector: "#box" };
361
377
  const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
@@ -406,6 +422,7 @@ describe("useGsapAwareEditing anchored resize", () => {
406
422
  null,
407
423
  expect.any(Function),
408
424
  expect.any(Function),
425
+ undefined,
409
426
  );
410
427
  act(() => h.root.unmount());
411
428
  });
@@ -658,7 +675,6 @@ describe("useGsapAwareEditing anchored resize", () => {
658
675
  );
659
676
  const { groupCommit, root } = mountGroupHandler({
660
677
  gsapCommitMutation: commitMutation,
661
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
662
678
  });
663
679
  const updates = [
664
680
  {
@@ -759,11 +775,7 @@ describe("useGsapAwareEditing anchored resize", () => {
759
775
  mocks.drag.mockImplementation(async (selection) => {
760
776
  throw selection.id === "a" ? failures[0] : failures[1];
761
777
  });
762
- const { groupCommit, root } = mountGroupHandler({
763
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
764
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
765
- trackGsapInteractionFailure,
766
- });
778
+ const { groupCommit, root } = mountGroupHandler({ trackGsapInteractionFailure });
767
779
  const updates = [
768
780
  {
769
781
  selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
@@ -782,6 +794,7 @@ describe("useGsapAwareEditing anchored resize", () => {
782
794
  updates[0]?.selection,
783
795
  "drag",
784
796
  "Move animated layer (group)",
797
+ true,
785
798
  );
786
799
  mocks.drag.mockReset();
787
800
  if (priorDragImplementation) mocks.drag.mockImplementation(priorDragImplementation);
@@ -0,0 +1,42 @@
1
+ import { vi } from "vitest";
2
+ import { mountReactHarness } from "./domSelectionTestHarness";
3
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
4
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
5
+
6
+ type Params = Parameters<typeof useGsapAwareEditing>[0];
7
+
8
+ /**
9
+ * Mounts useGsapAwareEditing with no-op callbacks for every param not given, and the real
10
+ * failure telemetry toasting through `showToast`. `editing()` reads the latest render.
11
+ */
12
+ export function mountGsapAwareEditing(
13
+ params: Partial<Omit<Params, "trackGsapInteractionFailure">> & Pick<Params, "showToast">,
14
+ ) {
15
+ let editing!: ReturnType<typeof useGsapAwareEditing>;
16
+ function Harness() {
17
+ editing = useGsapAwareEditing({
18
+ domEditSelection: null,
19
+ selectedGsapAnimations: [],
20
+ gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
21
+ previewIframeRef: { current: null },
22
+ bumpGsapCache: vi.fn(),
23
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
24
+ stageElementPositionOffset: vi.fn(),
25
+ handleDomBoxSizeCommit: vi.fn(),
26
+ handleDomRotationCommit: vi.fn(),
27
+ commitPositionPatchToHtml: vi.fn(),
28
+ addGsapAnimation: vi.fn(),
29
+ convertToKeyframes: vi.fn(),
30
+ setArcPath: vi.fn(),
31
+ updateArcSegment: vi.fn(),
32
+ ...params,
33
+ trackGsapInteractionFailure: useGsapInteractionFailureTelemetry(
34
+ "index.html",
35
+ params.showToast,
36
+ ),
37
+ });
38
+ return null;
39
+ }
40
+ const root = mountReactHarness(<Harness />);
41
+ return { editing: () => editing, root };
42
+ }
@@ -30,9 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
30
  import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
31
31
  import { runGestureTransaction } from "./gestureTransaction";
32
32
  import {
33
- gsapWritesBox,
34
- gsapWritesPosition,
35
- gsapWritesRotation,
33
+ editsPlainCss,
36
34
  hasNonHoldTweenForElement,
37
35
  POSITION_CHANNELS,
38
36
  } from "./gsapRuntimeKeyframes";
@@ -49,6 +47,7 @@ export interface UseGsapAwareEditingParams {
49
47
  domEditSelection: DomEditSelection | null;
50
48
  selectedGsapAnimations: GsapAnimation[];
51
49
  gsapCommitMutation: CommitMutation | null;
50
+ activeCompPath?: string | null;
52
51
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
53
52
  showToast: (message: string, tone?: "error" | "info") => void;
54
53
  bumpGsapCache: () => void;
@@ -61,6 +60,7 @@ export interface UseGsapAwareEditingParams {
61
60
  selection: DomEditSelection | null,
62
61
  mutationType: string,
63
62
  label: string,
63
+ toast?: boolean,
64
64
  ) => void;
65
65
  // DOM fallbacks (from useDomEditCommits)
66
66
  stageElementPositionOffset: (
@@ -117,6 +117,7 @@ export function useGsapAwareEditing({
117
117
  domEditSelection,
118
118
  selectedGsapAnimations,
119
119
  gsapCommitMutation,
120
+ activeCompPath,
120
121
  previewIframeRef,
121
122
  showToast,
122
123
  bumpGsapCache,
@@ -148,7 +149,7 @@ export function useGsapAwareEditing({
148
149
  modifiers?: MoveCommitOptions,
149
150
  ) => {
150
151
  const writes = observeGsapGesture(gsapCommitMutation);
151
- if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) {
152
+ if (modifiers?.plainTranslate ?? editsPlainCss(selection.element, "move")) {
152
153
  const result = await stageElementPositionOffset(selection, next, true).save();
153
154
  return writes.finish(result?.changed === true);
154
155
  }
@@ -190,6 +191,7 @@ export function useGsapAwareEditing({
190
191
 
191
192
  const handleGsapAwareGroupPathOffsetCommit = useGsapAwareGroupMove({
192
193
  gsapCommitMutation,
194
+ activeCompPath,
193
195
  previewIframeRef,
194
196
  makeFetchFallback,
195
197
  trackGsapInteractionFailure,
@@ -203,9 +205,10 @@ export function useGsapAwareEditing({
203
205
  next: { width: number; height: number },
204
206
  offset?: { x: number; y: number },
205
207
  restore: () => void = () => undefined,
208
+ route?: { plainTranslate: boolean },
206
209
  ) => {
207
210
  const writes = observeGsapGesture(gsapCommitMutation);
208
- if (!gsapWritesBox(selection.element)) {
211
+ if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
209
212
  const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
210
213
  return writes.finish(result?.changed === true);
211
214
  }
@@ -254,8 +257,13 @@ export function useGsapAwareEditing({
254
257
  );
255
258
  // Saved after the size, under its undo key, so the two are one step.
256
259
  await saveMove(dragOutcome, async () => {
257
- const plain = !gsapWritesPosition(selection.element);
258
- anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
260
+ const plainAfterSettle = editsPlainCss(selection.element, "move");
261
+ anchorMove = stageElementPositionOffset(
262
+ selection,
263
+ offset,
264
+ plainAfterSettle,
265
+ coalesceKey,
266
+ );
259
267
  });
260
268
  }
261
269
  };
@@ -285,6 +293,7 @@ export function useGsapAwareEditing({
285
293
  previewIframeRef.current,
286
294
  commitMutation,
287
295
  makeFetchFallback(selection),
296
+ offset,
288
297
  );
289
298
  assertGsapEditPersisted(outcome);
290
299
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
@@ -355,7 +364,7 @@ export function useGsapAwareEditing({
355
364
  const handleGsapAwareRotationCommit = useCallback(
356
365
  async (selection: DomEditSelection, next: RotationCommit) => {
357
366
  const writes = observeGsapGesture(gsapCommitMutation);
358
- if (next.plain || !gsapWritesRotation(selection.element)) {
367
+ if (next.plain === undefined ? editsPlainCss(selection.element, "rotate") : next.plain) {
359
368
  const result = await handleDomRotationCommit(selection, next);
360
369
  return writes.finish(result?.changed === true);
361
370
  }
@@ -5,28 +5,18 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
6
6
  import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
7
7
  import { observeGsapGesture } from "./gsapGestureOutcome";
8
- import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
8
+ import { editsPlainCss } from "./gsapRuntimeKeyframes";
9
9
  import { refuseGsapTakeover } from "./elementOffsetStager";
10
10
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
11
  import { assertGsapEditPersisted } from "./gsapEditOutcome";
12
+ import { firstPreflightFailure } from "./gsapGroupPreflight";
12
13
 
13
14
  // A distinct key keeps consecutive group drags in separate undo entries.
14
15
  let groupDragCommitCounter = 0;
15
16
 
16
- function firstPreflightFailure(
17
- results: PromiseSettledResult<void>[],
18
- updates: DomEditGroupPathOffsetCommit[],
19
- ): { error: unknown; selection: DomEditSelection } | null {
20
- for (const [index, result] of results.entries()) {
21
- if (result.status !== "rejected") continue;
22
- const selection = updates[index]?.selection;
23
- if (selection) return { error: result.reason, selection };
24
- }
25
- return null;
26
- }
27
-
28
17
  export function useGsapAwareGroupMove({
29
18
  gsapCommitMutation,
19
+ activeCompPath,
30
20
  previewIframeRef,
31
21
  makeFetchFallback,
32
22
  trackGsapInteractionFailure,
@@ -35,6 +25,7 @@ export function useGsapAwareGroupMove({
35
25
  }: Pick<
36
26
  UseGsapAwareEditingParams,
37
27
  | "gsapCommitMutation"
28
+ | "activeCompPath"
38
29
  | "previewIframeRef"
39
30
  | "makeFetchFallback"
40
31
  | "trackGsapInteractionFailure"
@@ -44,10 +35,12 @@ export function useGsapAwareGroupMove({
44
35
  const handleGsapAwareGroupPathOffsetCommit = useCallback(
45
36
  async (
46
37
  updates: DomEditGroupPathOffsetCommit[],
38
+ options: { refusalToast?: boolean } = {},
47
39
  ): Promise<import("../utils/previewFeatureUsage").GeometryCommitResult> => {
48
40
  const writes = observeGsapGesture(gsapCommitMutation);
49
41
  const writer = writes.commit;
50
42
  if (!writer) return { ok: true, changed: false };
43
+ const toastRefusal = options.refusalToast !== false;
51
44
  // One coalesce key across slow writes keeps the group drag in one undo entry.
52
45
  const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
53
46
  // Hold the render until every member is saved, or unwritten members snap back.
@@ -87,16 +80,16 @@ export function useGsapAwareGroupMove({
87
80
  // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
88
81
  // write nothing and share one in-flight parse per file, so they run together.
89
82
  const preflightResults = await Promise.allSettled(
90
- updates.map(async ({ selection, plainTranslate }) => {
91
- if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
92
- refuseGsapTakeover(selection.element, showToast);
83
+ updates.map(async ({ selection, next, plainTranslate }) => {
84
+ if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
85
+ refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
93
86
  return void offsetMembers.set(selection, true);
94
87
  }
95
88
  const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
96
89
  preflightAnimations.set(selection, animations);
97
90
  const outcome = await tryGsapDragIntercept(
98
91
  selection,
99
- { x: 0, y: 0 },
92
+ next,
100
93
  animations,
101
94
  previewIframeRef.current,
102
95
  coalescedCommit,
@@ -107,13 +100,19 @@ export function useGsapAwareGroupMove({
107
100
  assertGsapEditPersisted(outcome);
108
101
  }),
109
102
  );
110
- const preflightFailure = firstPreflightFailure(preflightResults, updates);
103
+ const preflightFailure = firstPreflightFailure(
104
+ preflightResults,
105
+ updates,
106
+ offsetMembers,
107
+ activeCompPath,
108
+ );
111
109
  if (preflightFailure) {
112
110
  trackGsapInteractionFailure(
113
111
  preflightFailure.error,
114
112
  preflightFailure.selection,
115
113
  "drag",
116
114
  "Move animated layer (group)",
115
+ toastRefusal,
117
116
  );
118
117
  throw preflightFailure.error;
119
118
  }
@@ -167,6 +166,7 @@ export function useGsapAwareGroupMove({
167
166
  },
168
167
  [
169
168
  gsapCommitMutation,
169
+ activeCompPath,
170
170
  previewIframeRef,
171
171
  makeFetchFallback,
172
172
  trackGsapInteractionFailure,
@@ -4,8 +4,8 @@ import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioS
4
4
  import { isGsapEditBlockedError } from "./gsapEditOutcome";
5
5
  import { wasAlreadyToasted } from "./domEditPersistFailure";
6
6
 
7
- function failureToast(error: unknown): string | null {
8
- if (wasAlreadyToasted(error)) return null;
7
+ function failureToast(error: unknown, toast: boolean): string | null {
8
+ if (!toast || wasAlreadyToasted(error)) return null;
9
9
  return isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.";
10
10
  }
11
11
 
@@ -14,7 +14,13 @@ export function useGsapInteractionFailureTelemetry(
14
14
  showToast: (message: string, tone?: "error" | "info") => void,
15
15
  ) {
16
16
  return useCallback(
17
- (error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
17
+ (
18
+ error: unknown,
19
+ selection: DomEditSelection | null,
20
+ mutationType: string,
21
+ label: string,
22
+ toast?: boolean,
23
+ ) => {
18
24
  const report = isGsapEditBlockedError(error)
19
25
  ? trackStudioEditBlocked
20
26
  : trackStudioSaveFailure;
@@ -28,7 +34,7 @@ export function useGsapInteractionFailureTelemetry(
28
34
  targetSelector: selection?.selector,
29
35
  targetSourceFile: selection?.sourceFile,
30
36
  });
31
- const message = failureToast(error);
37
+ const message = failureToast(error, toast !== false);
32
38
  if (message) showToast(message, "error");
33
39
  },
34
40
  [activeCompPath, showToast],