@hyperframes/studio 0.7.92 → 0.7.93

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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -59,6 +59,67 @@ const stalePositionAnim = {
59
59
  afterEach(() => vi.restoreAllMocks());
60
60
 
61
61
  describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => {
62
+ async function runHelperOwnedDrag(fetchFallbackAnimations?: () => Promise<GsapAnimation[]>) {
63
+ const helperTween = {
64
+ ...stalePositionAnim,
65
+ provenance: { kind: "helper", fn: "slam", callSite: 1 },
66
+ } as GsapAnimation;
67
+ const commitMutation = vi.fn();
68
+ const result = await tryGsapDragIntercept(
69
+ selection,
70
+ { x: 10, y: 10 },
71
+ [helperTween],
72
+ fakeIframe("puck-b", []),
73
+ commitMutation,
74
+ fetchFallbackAnimations,
75
+ );
76
+ return { result, commitMutation };
77
+ }
78
+
79
+ it("blocks a live runtime position tween when no editable source mapping exists", async () => {
80
+ const liveTween = {
81
+ targets: () => [{ id: "puck-b" }],
82
+ vars: { y: 18, duration: 1 },
83
+ duration: () => 1,
84
+ startTime: () => 0,
85
+ };
86
+ const commitMutation = vi.fn();
87
+
88
+ const result = await tryGsapDragIntercept(
89
+ selection,
90
+ { x: 25, y: -10 },
91
+ [],
92
+ fakeIframe("puck-b", [liveTween]),
93
+ commitMutation,
94
+ vi.fn().mockResolvedValue([]),
95
+ );
96
+
97
+ expect(result).toEqual({ status: "blocked", reason: "source-uneditable" });
98
+ expect(commitMutation).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("blocks a selector that cannot safely address one writable element", async () => {
102
+ const selectorless = {
103
+ ...selection,
104
+ id: undefined,
105
+ selector: undefined,
106
+ } as unknown as DomEditSelection;
107
+ const result = await tryGsapDragIntercept(selectorless, { x: 1, y: 1 }, [], null, vi.fn());
108
+ expect(result).toEqual({ status: "blocked", reason: "no-selector" });
109
+ });
110
+
111
+ it("requires explicit unroll for helper-authored source and performs no mutation", async () => {
112
+ const { result, commitMutation } = await runHelperOwnedDrag();
113
+ expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
114
+ expect(commitMutation).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("does not let an empty fallback response bypass cached helper provenance", async () => {
118
+ const { result, commitMutation } = await runHelperOwnedDrag(vi.fn().mockResolvedValue([]));
119
+ expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
120
+ expect(commitMutation).not.toHaveBeenCalled();
121
+ });
122
+
62
123
  it("commits a static set (not the stale tween) when the runtime has no live position motion", async () => {
63
124
  const commitMutation = vi.fn();
64
125
  // Runtime empty (tween deleted) — readRuntimeKeyframes returns null, so the
@@ -73,7 +134,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
73
134
  commitMutation,
74
135
  );
75
136
 
76
- expect(handled).toBe(true);
137
+ expect(handled).toEqual({ status: "persisted" });
77
138
  // No existing `set` for the selector → one `add` mutation with `method:"set"`.
78
139
  expect(commitMutation).toHaveBeenCalledTimes(1);
79
140
  const [, mutation] = commitMutation.mock.calls[0];
@@ -112,7 +173,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
112
173
  commitMutation,
113
174
  );
114
175
 
115
- expect(handled).toBe(true);
176
+ expect(handled).toEqual({ status: "persisted" });
116
177
  const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-properties");
117
178
  expect(updates).toHaveLength(1);
118
179
  expect(updates[0][1]).toEqual({
@@ -151,7 +212,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
151
212
  commitMutation,
152
213
  );
153
214
 
154
- expect(handled).toBe(true);
215
+ expect(handled).toEqual({ status: "persisted" });
155
216
  // One atomic in-place update, NOT an `add`/`add-keyframe`.
156
217
  const types = commitMutation.mock.calls.map(([, m]) => m.type);
157
218
  expect(types).toEqual(["update-properties"]);
@@ -179,6 +240,124 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
179
240
  });
180
241
 
181
242
  describe("tryGsapRotationIntercept — instant holds", () => {
243
+ it("requires explicit unroll for helper-authored rotation before mutating", async () => {
244
+ const helperRotation = {
245
+ id: "#puck-b-to-rotation",
246
+ targetSelector: "#puck-b",
247
+ propertyGroup: "rotation",
248
+ method: "to",
249
+ properties: { rotation: 45 },
250
+ position: 0,
251
+ duration: 1,
252
+ provenance: { kind: "helper", fn: "spin", callSite: 1 },
253
+ } as unknown as GsapAnimation;
254
+ const commitMutation = vi.fn();
255
+
256
+ await expect(
257
+ tryGsapRotationIntercept(selection, 75, [helperRotation], null, commitMutation),
258
+ ).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
259
+ expect(commitMutation).not.toHaveBeenCalled();
260
+ });
261
+
262
+ // Mirrors resize by design: both geometry routes must reuse one ownership parse.
263
+ // fallow-ignore-next-line code-duplication
264
+ it("reuses the ownership parse instead of fetching a resolved rotation group twice", async () => {
265
+ const rotationHold = {
266
+ id: "#puck-b-set-rotation",
267
+ targetSelector: "#puck-b",
268
+ propertyGroup: "rotation",
269
+ method: "set",
270
+ properties: { rotation: 45 },
271
+ } as unknown as GsapAnimation;
272
+ const fetchAnimations = vi.fn().mockResolvedValue([rotationHold]);
273
+ const commitMutation = vi.fn();
274
+
275
+ await expect(
276
+ tryGsapRotationIntercept(selection, 75, [], null, commitMutation, fetchAnimations),
277
+ ).resolves.toEqual({ status: "persisted" });
278
+ expect(fetchAnimations).toHaveBeenCalledTimes(1);
279
+ expect(commitMutation).toHaveBeenCalledWith(
280
+ selection,
281
+ expect.objectContaining({ type: "update-property", animationId: rotationHold.id }),
282
+ expect.anything(),
283
+ );
284
+ });
285
+
286
+ it("rejects a selectorless rotation instead of reporting a handled no-op", async () => {
287
+ const selectorless = {
288
+ ...selection,
289
+ id: undefined,
290
+ selector: undefined,
291
+ } as unknown as DomEditSelection;
292
+ const commitMutation = vi.fn();
293
+
294
+ await expect(
295
+ tryGsapRotationIntercept(selectorless, 75, [], null, commitMutation),
296
+ ).resolves.toEqual({ status: "blocked", reason: "no-selector" });
297
+ expect(commitMutation).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it("blocks helper-authored 3D rotation channels before mutating the group", async () => {
301
+ const helperRotationX = {
302
+ id: "#puck-b-to-rotation",
303
+ targetSelector: "#puck-b",
304
+ propertyGroup: "rotation",
305
+ method: "to",
306
+ properties: { rotationX: 45 },
307
+ duration: 1,
308
+ provenance: { kind: "helper", fn: "tilt", callSite: 1 },
309
+ } as unknown as GsapAnimation;
310
+ const commitMutation = vi.fn();
311
+
312
+ await expect(
313
+ tryGsapRotationIntercept(selection, 75, [helperRotationX], null, commitMutation),
314
+ ).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
315
+ expect(commitMutation).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
319
+ const helperSkew = {
320
+ id: "#puck-b-to-rotation",
321
+ targetSelector: "#puck-b",
322
+ propertyGroup: "rotation",
323
+ method: "to",
324
+ properties: { skewX: 12 },
325
+ duration: 1,
326
+ provenance: { kind: "helper", fn: "skew", callSite: 1 },
327
+ } as unknown as GsapAnimation;
328
+ const commitMutation = vi.fn();
329
+
330
+ await expect(
331
+ tryGsapRotationIntercept(selection, 75, [helperSkew], null, commitMutation),
332
+ ).resolves.toEqual({ status: "persisted" });
333
+ expect(commitMutation).toHaveBeenCalledWith(
334
+ selection,
335
+ expect.objectContaining({ type: "add", properties: { rotation: 75 } }),
336
+ expect.anything(),
337
+ );
338
+ });
339
+
340
+ it("blocks when runtime rotation exists but the authored tween cannot be resolved", async () => {
341
+ const liveRotation = {
342
+ targets: () => [{ id: "puck-b" }],
343
+ vars: { rotation: 45, duration: 1 },
344
+ duration: () => 1,
345
+ startTime: () => 0,
346
+ };
347
+ const commitMutation = vi.fn();
348
+
349
+ await expect(
350
+ tryGsapRotationIntercept(
351
+ selection,
352
+ 75,
353
+ [],
354
+ fakeIframe("puck-b", [liveRotation]),
355
+ commitMutation,
356
+ ),
357
+ ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
358
+ expect(commitMutation).not.toHaveBeenCalled();
359
+ });
360
+
182
361
  it("updates a duration-zero fromTo hold instead of converting it to keyframes", async () => {
183
362
  const rotationHold = {
184
363
  id: "#puck-b-fromTo-0-rotation",
@@ -201,7 +380,7 @@ describe("tryGsapRotationIntercept — instant holds", () => {
201
380
  commitMutation,
202
381
  );
203
382
 
204
- expect(handled).toBe(true);
383
+ expect(handled).toEqual({ status: "persisted" });
205
384
  expect(commitMutation).toHaveBeenCalledTimes(1);
206
385
  expect(commitMutation.mock.calls[0]![1]).toEqual({
207
386
  type: "update-property",
@@ -243,40 +422,29 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
243
422
  },
244
423
  } as unknown as GsapAnimation;
245
424
 
246
- it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
247
- usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
425
+ async function runAutoKeyframeDrag(enabled: boolean) {
426
+ usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 2 });
248
427
  const commitMutation = vi.fn();
249
- const iframe = fakeIframe("puck-b", []);
250
-
251
428
  const handled = await tryGsapDragIntercept(
252
429
  selection,
253
430
  { x: -50, y: 0 },
254
431
  [keyframedPositionAnim],
255
- iframe,
432
+ fakeIframe("puck-b", []),
256
433
  commitMutation,
257
434
  );
435
+ return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
436
+ }
258
437
 
259
- expect(handled).toBe(true);
260
- const types = commitMutation.mock.calls.map(([, m]) => m.type);
438
+ it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
439
+ const { handled, types } = await runAutoKeyframeDrag(false);
440
+ expect(handled).toEqual({ status: "persisted" });
261
441
  expect(types).toContain("replace-with-keyframes");
262
442
  expect(types).not.toContain("add-keyframe");
263
443
  });
264
444
 
265
445
  it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
266
- usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
267
- const commitMutation = vi.fn();
268
- const iframe = fakeIframe("puck-b", []);
269
-
270
- const handled = await tryGsapDragIntercept(
271
- selection,
272
- { x: -50, y: 0 },
273
- [keyframedPositionAnim],
274
- iframe,
275
- commitMutation,
276
- );
277
-
278
- expect(handled).toBe(true);
279
- const types = commitMutation.mock.calls.map(([, m]) => m.type);
446
+ const { handled, types } = await runAutoKeyframeDrag(true);
447
+ expect(handled).toEqual({ status: "persisted" });
280
448
  expect(types).not.toContain("replace-with-keyframes");
281
449
  });
282
450
  });
@@ -336,7 +504,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
336
504
  it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
337
505
  const { commitMutation, handled } = await dragMotionPath(null);
338
506
 
339
- expect(handled).toBe(true);
507
+ expect(handled).toEqual({ status: "persisted" });
340
508
  expect(commitMutation).toHaveBeenCalledWith(
341
509
  selection,
342
510
  {
@@ -363,7 +531,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
363
531
  it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
364
532
  const { commitMutation, handled } = await dragMotionPath(50);
365
533
 
366
- expect(handled).toBe(true);
534
+ expect(handled).toEqual({ status: "persisted" });
367
535
  expect(commitMutation).toHaveBeenCalledWith(
368
536
  selection,
369
537
  {
@@ -26,18 +26,23 @@ import {
26
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
27
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
28
28
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
29
- import { isInstantHold, selectorFromSelection } from "./gsapShared";
29
+ import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
30
30
  import {
31
31
  findGsapPositionAnimation,
32
32
  pickClosestToPlayhead,
33
33
  readGsapPositionFromIframe,
34
34
  } from "./gsapPositionDetection";
35
35
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
36
+ import {
37
+ animationWritesAnyProperty,
38
+ directEditOutcomeForProperties,
39
+ type GsapEditOutcome,
40
+ } from "./gsapEditOutcome";
36
41
 
37
42
  // Position channels — used to scope the "has a live position tween?" check so a
38
43
  // sibling rotation/scale animation never forces a static position hold into the
39
44
  // keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
40
- export const POSITION_CHANNELS = [
45
+ export const POSITION_CHANNELS: string[] = [
41
46
  "x",
42
47
  "y",
43
48
  "xPercent",
@@ -50,6 +55,10 @@ export const POSITION_CHANNELS = [
50
55
  "translateX",
51
56
  "translateY",
52
57
  ];
58
+ const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
59
+
60
+ const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
61
+ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
53
62
 
54
63
  // ── Property-group tween resolution ───────────────────────────────────────
55
64
 
@@ -121,11 +130,40 @@ export type { GsapDragCommitCallbacks };
121
130
  /**
122
131
  * Attempt to handle a drag commit via the GSAP script mutation path.
123
132
  *
124
- * Returns a Promise that resolves to true if the drag was handled via GSAP
125
- * (caller should skip the CSS path), or false if no GSAP position animation
126
- * exists.
133
+ * Returns an explicit persisted/blocked outcome. Callers must reject blocked
134
+ * outcomes so the gesture layer restores its runtime and overlay drafts.
127
135
  */
128
136
  // fallow-ignore-next-line complexity
137
+ async function preflightGsapDragIntercept(
138
+ selection: DomEditSelection,
139
+ animations: GsapAnimation[],
140
+ iframe: HTMLIFrameElement | null,
141
+ fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
142
+ ): Promise<GsapEditOutcome> {
143
+ const selector = selectorFromSelection(selection);
144
+ if (!selector) return { status: "blocked", reason: "no-selector" };
145
+
146
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
147
+ // The fallback API currently represents both a definitive empty parse and an
148
+ // exhausted fetch failure as `[]`. Keep the selected cache in the preflight
149
+ // set as well: ignoring it would let a transient fetch failure bypass helper /
150
+ // runtime-source ownership and reach a destructive split or property write.
151
+ const allKnownAnimations = [...animations, ...fetchedAnimations];
152
+ const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
153
+ if (editability.status === "blocked") return editability;
154
+ const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
155
+ const posAnim = findGsapPositionAnimation(sourceAnimations, selector);
156
+ const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
157
+
158
+ if (hasLivePosition && !posAnim) {
159
+ return { status: "blocked", reason: "source-uneditable" };
160
+ }
161
+ if (!posAnim && !writeTargetSelector(selection)) {
162
+ return { status: "blocked", reason: "no-selector" };
163
+ }
164
+ return { status: "persisted" };
165
+ }
166
+
129
167
  export async function tryGsapDragIntercept(
130
168
  selection: DomEditSelection,
131
169
  offset: { x: number; y: number },
@@ -133,12 +171,20 @@ export async function tryGsapDragIntercept(
133
171
  iframe: HTMLIFrameElement | null,
134
172
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
135
173
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
136
- options?: { altKey?: boolean },
137
- ): Promise<boolean> {
138
- const selector = selectorFromSelection(selection);
139
- if (!selector) {
140
- return false;
174
+ options?: { altKey?: boolean; preflightOnly?: boolean; preflightPassed?: boolean },
175
+ ): Promise<GsapEditOutcome> {
176
+ if (!options?.preflightPassed) {
177
+ const preflight = await preflightGsapDragIntercept(
178
+ selection,
179
+ animations,
180
+ iframe,
181
+ fetchFallbackAnimations,
182
+ );
183
+ if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
141
184
  }
185
+ const selector = selectorFromSelection(selection);
186
+ // The preflight above proves this; retain a defensive result for DOM churn.
187
+ if (!selector) return { status: "blocked", reason: "no-selector" };
142
188
 
143
189
  // Self-heal: enforce a single position write BEFORE committing. A corrupted
144
190
  // file can carry 2+ conflicting position writes for one selector (e.g. a
@@ -218,11 +264,11 @@ export async function tryGsapDragIntercept(
218
264
  commitMutation,
219
265
  fetchAnimations: fetchFallbackAnimations,
220
266
  });
221
- return true;
267
+ return { status: "persisted" };
222
268
  }
223
269
 
224
270
  if (!posAnim) {
225
- return false;
271
+ return { status: "blocked", reason: "source-uneditable" };
226
272
  }
227
273
 
228
274
  // Verify the anim ID is still valid in the current file. The React-state
@@ -251,7 +297,7 @@ export async function tryGsapDragIntercept(
251
297
  } else {
252
298
  await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
253
299
  }
254
- return true;
300
+ return { status: "persisted" };
255
301
  }
256
302
 
257
303
  // ── Runtime property readers (re-exported for external callers) ───────────
@@ -267,28 +313,47 @@ export async function tryGsapRotationIntercept(
267
313
  iframe: HTMLIFrameElement | null,
268
314
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
269
315
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
270
- ): Promise<boolean> {
271
- const selector = selectorFromSelection(selection);
272
- if (!selector) return false;
316
+ ): Promise<GsapEditOutcome> {
317
+ const selector = selectorFromSelection(selection) ?? writeTargetSelector(selection);
318
+ if (!selector) return { status: "blocked", reason: "no-selector" };
319
+
320
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
321
+ const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
322
+ const editability = directEditOutcomeForProperties(
323
+ [...animations, ...fetchedAnimations],
324
+ ROTATION_CHANNEL_SET,
325
+ );
326
+ if (editability.status === "blocked") return editability;
327
+ const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
328
+ ? fetchFallbackAnimations
329
+ : undefined;
273
330
 
274
331
  // Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
275
332
  const resolved = await resolveGroupTween(
276
333
  "rotation",
277
- animations,
334
+ workingAnimations,
278
335
  selection,
279
336
  commitMutation,
280
- fetchFallbackAnimations,
337
+ postSplitFetch,
281
338
  );
282
- const resolvedAnimations = resolved?.animations ?? animations;
339
+ const resolvedAnimations = resolved?.animations ?? workingAnimations;
283
340
 
284
341
  // Fallback: legacy heuristic for hand-written scripts
285
- let anim = resolved?.anim ?? null;
342
+ let anim =
343
+ resolved?.anim && animationWritesAnyProperty(resolved.anim, ROTATION_CHANNEL_SET)
344
+ ? resolved.anim
345
+ : null;
286
346
  if (!anim) {
287
- anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
288
- if (!anim && fetchFallbackAnimations) {
289
- const fresh = await fetchFallbackAnimations();
290
- anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
291
- }
347
+ anim =
348
+ workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
349
+ }
350
+
351
+ const liveSelector = selectorFromSelection(selection);
352
+ const hasLiveRotationTween = liveSelector
353
+ ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
354
+ : false;
355
+ if (!anim && hasLiveRotationTween) {
356
+ return { status: "blocked", reason: "source-uneditable" };
292
357
  }
293
358
 
294
359
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -307,7 +372,7 @@ export async function tryGsapRotationIntercept(
307
372
  commitMutation,
308
373
  fetchAnimations: fetchFallbackAnimations,
309
374
  });
310
- return true;
375
+ return { status: "persisted" };
311
376
  }
312
377
 
313
378
  const pct = computeCurrentPercentage(selection, anim);
@@ -325,7 +390,7 @@ export async function tryGsapRotationIntercept(
325
390
  { commitMutation, fetchAnimations: fetchFallbackAnimations },
326
391
  "Rotate animation",
327
392
  );
328
- return true;
393
+ return { status: "persisted" };
329
394
  }
330
395
 
331
396
  // fallow-ignore-next-line code-duplication
@@ -363,7 +428,7 @@ export async function tryGsapRotationIntercept(
363
428
  },
364
429
  { label: `Rotate (keyframe ${pct}%)`, softReload: true },
365
430
  );
366
- return true;
431
+ return { status: "persisted" };
367
432
  }
368
433
 
369
434
  export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";