@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
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, expect, it, beforeEach } from "vitest";
3
+ import { describe, expect, it, beforeEach, vi } from "vitest";
4
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
6
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -9,6 +9,7 @@ import {
9
9
  commitStaticGsapRotation,
10
10
  commitStaticGsapSize,
11
11
  findExistingPositionWrite,
12
+ materializeIfDynamic,
12
13
  parkPlayheadOnKeyframe,
13
14
  type GsapDragCommitCallbacks,
14
15
  } from "./gsapDragCommit";
@@ -27,6 +28,43 @@ const selection = (): DomEditSelection =>
27
28
  },
28
29
  }) as unknown as DomEditSelection;
29
30
 
31
+ function selectorlessSelection(): DomEditSelection {
32
+ return {
33
+ selector: ".shared",
34
+ element: document.createElement("div"),
35
+ } as unknown as DomEditSelection;
36
+ }
37
+
38
+ describe("lower GSAP commit helpers fail closed", () => {
39
+ it("rejects instead of silently dropping a static position write without a stable selector", async () => {
40
+ const commitMutation = vi.fn();
41
+ await expect(
42
+ commitStaticGsapPosition(
43
+ selectorlessSelection(),
44
+ { x: 10, y: 20 },
45
+ { x: 0, y: 0 },
46
+ ".shared",
47
+ null,
48
+ { commitMutation },
49
+ ),
50
+ ).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "no-selector" });
51
+ expect(commitMutation).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("rejects runtime-dynamic materialization instead of rewriting source during a gesture", async () => {
55
+ const commitMutation = vi.fn();
56
+ await expect(
57
+ materializeIfDynamic(
58
+ { ...flatTween(), hasUnresolvedKeyframes: true },
59
+ null,
60
+ commitMutation,
61
+ selection(),
62
+ ),
63
+ ).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "source-uneditable" });
64
+ expect(commitMutation).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+
30
68
  const flatTween = (): GsapAnimation =>
31
69
  ({
32
70
  id: "#puck-a-to",
@@ -9,14 +9,14 @@ import {
9
9
  STUDIO_ORIGINAL_HEIGHT_ATTR,
10
10
  } from "../components/editor/manualEditsTypes";
11
11
  import { usePlayerStore } from "../player/store/playerStore";
12
- import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
13
12
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
14
13
  import { roundTo3 } from "../utils/rounding";
15
- import { computeElementPercentage, idSelector, writeTargetSelector } from "./gsapShared";
14
+ import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
16
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
17
16
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
18
17
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
19
18
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
19
+ import { GsapEditBlockedError } from "./gsapEditOutcome";
20
20
  export {
21
21
  findExistingPositionWrite,
22
22
  findRotationSetAnimation,
@@ -87,7 +87,7 @@ async function replaceKeyframedPositionHold(
87
87
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
88
88
  ): Promise<void> {
89
89
  const target = newTweenTarget(selection);
90
- if (!target) return;
90
+ if (!target) throw new GsapEditBlockedError("no-selector");
91
91
  const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
92
92
  await commit(
93
93
  selection,
@@ -130,40 +130,12 @@ export async function materializeIfDynamic(
130
130
  selection: DomEditSelection,
131
131
  ): Promise<string | void> {
132
132
  if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
133
-
134
- if (anim.hasUnresolvedSelector) {
135
- const allScanned = scanAllRuntimeKeyframes(iframe);
136
- if (allScanned.size === 0) return;
137
- const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
138
- selector: idSelector(id),
139
- keyframes: data.keyframes,
140
- easeEach: data.easeEach,
141
- }));
142
- await commitMutation(
143
- selection,
144
- {
145
- type: "materialize-keyframes",
146
- animationId: anim.id,
147
- keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
148
- allElements,
149
- },
150
- { label: "Unroll dynamic animations", skipReload: true },
151
- );
152
- return `${anim.targetSelector}-to-0`;
153
- }
154
-
155
- const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
156
- if (!runtime || runtime.keyframes.length === 0) return;
157
- await commitMutation(
158
- selection,
159
- {
160
- type: "materialize-keyframes",
161
- animationId: anim.id,
162
- keyframes: runtime.keyframes,
163
- easeEach: runtime.easeEach,
164
- },
165
- { label: "Materialize dynamic keyframes", skipReload: true },
166
- );
133
+ // Geometry commits must never rewrite runtime/computed source implicitly.
134
+ // The explicit Unroll action owns that source-destructive transition.
135
+ void iframe;
136
+ void commitMutation;
137
+ void selection;
138
+ throw new GsapEditBlockedError("source-uneditable");
167
139
  }
168
140
 
169
141
  // ── Drag → GSAP position math ──────────────────────────────────────────────
@@ -219,7 +191,7 @@ export async function commitStaticGsapPosition(
219
191
  // The patch reuses the WRITTEN target so the runtime moves exactly the element
220
192
  // the source write names.
221
193
  const target = newTweenTarget(selection);
222
- if (!target) return;
194
+ if (!target) throw new GsapEditBlockedError("no-selector");
223
195
  await callbacks.commitMutation(
224
196
  selection,
225
197
  {
@@ -277,7 +249,7 @@ export async function commitStaticGsapRotation(
277
249
  }
278
250
  // New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
279
251
  const target = newTweenTarget(selection);
280
- if (!target) return;
252
+ if (!target) throw new GsapEditBlockedError("no-selector");
281
253
  await callbacks.commitMutation(
282
254
  selection,
283
255
  {
@@ -331,7 +303,7 @@ export async function commitStaticGsapSize(
331
303
  return;
332
304
  }
333
305
  const target = newTweenTarget(selection);
334
- if (!target) return;
306
+ if (!target) throw new GsapEditBlockedError("no-selector");
335
307
  await callbacks.commitMutation(
336
308
  selection,
337
309
  {
@@ -0,0 +1,72 @@
1
+ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+
3
+ export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
4
+
5
+ export type GsapEditOutcome =
6
+ | { status: "persisted" }
7
+ | { status: "blocked"; reason: GsapEditBlockReason };
8
+
9
+ const COPY: Record<GsapEditBlockReason, string> = {
10
+ "no-selector": "This layer needs a stable selector before Studio can save the edit.",
11
+ "unroll-required":
12
+ "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
13
+ "source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
14
+ };
15
+
16
+ export class GsapEditBlockedError extends Error {
17
+ constructor(readonly reason: GsapEditBlockReason) {
18
+ super(COPY[reason]);
19
+ this.name = "GsapEditBlockedError";
20
+ }
21
+ }
22
+
23
+ export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
24
+ if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
25
+ }
26
+
27
+ function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
28
+ const editability = editabilityForProvenance(animation.provenance);
29
+ if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
30
+ if (
31
+ editability === "source" ||
32
+ animation.hasUnresolvedKeyframes ||
33
+ animation.hasUnresolvedSelector
34
+ ) {
35
+ throw new GsapEditBlockedError("source-uneditable");
36
+ }
37
+ }
38
+
39
+ export function isGsapEditBlockedError(error: unknown): error is GsapEditBlockedError {
40
+ return error instanceof GsapEditBlockedError;
41
+ }
42
+
43
+ export function animationWritesAnyProperty(
44
+ animation: GsapAnimation,
45
+ properties: ReadonlySet<string>,
46
+ ): boolean {
47
+ return (
48
+ Object.keys(animation.properties ?? {}).some((property) => properties.has(property)) ||
49
+ Object.keys(animation.fromProperties ?? {}).some((property) => properties.has(property)) ||
50
+ !!animation.keyframes?.keyframes.some((keyframe) =>
51
+ Object.keys(keyframe.properties).some((property) => properties.has(property)),
52
+ )
53
+ );
54
+ }
55
+
56
+ /** Fail-closed ownership check shared by drag, resize, rotate, and inspector edits. */
57
+ export function directEditOutcomeForProperties(
58
+ animations: GsapAnimation[],
59
+ properties: ReadonlySet<string>,
60
+ ): GsapEditOutcome {
61
+ try {
62
+ for (const animation of animations) {
63
+ if (animationWritesAnyProperty(animation, properties)) {
64
+ assertGsapAnimationDirectlyEditable(animation);
65
+ }
66
+ }
67
+ return { status: "persisted" };
68
+ } catch (error) {
69
+ if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
70
+ throw error;
71
+ }
72
+ }
@@ -70,6 +70,8 @@ function keyframedScaleFixture(): GsapAnimation {
70
70
  } as unknown as GsapAnimation;
71
71
  }
72
72
 
73
+ // Resize/rotation hold tests intentionally pin the same no-conversion contract.
74
+ // fallow-ignore-next-line code-duplication
73
75
  it("updates a duration-zero size hold in place instead of converting it to keyframes", async () => {
74
76
  const el = document.createElement("div");
75
77
  el.id = "box";
@@ -96,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
96
98
  commitMutation,
97
99
  );
98
100
 
99
- expect(handled).toBe(true);
101
+ expect(handled).toEqual({ status: "persisted" });
100
102
  expect(commitMutation).toHaveBeenCalledTimes(1);
101
103
  expect(commitMutation.mock.calls[0]![1]).toEqual({
102
104
  type: "update-properties",
@@ -115,6 +117,92 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
115
117
  );
116
118
  });
117
119
 
120
+ // fallow-ignore-next-line code-duplication
121
+ it("requires explicit unroll for helper-authored resize before mutating", async () => {
122
+ const el = document.createElement("div");
123
+ el.id = "box";
124
+ document.body.append(el);
125
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
126
+ const helperSize = {
127
+ id: "#box-to-size",
128
+ targetSelector: "#box",
129
+ propertyGroup: "size",
130
+ method: "to",
131
+ properties: { width: 150, height: 150 },
132
+ duration: 1,
133
+ provenance: { kind: "helper", fn: "grow", callSite: 1 },
134
+ } as unknown as GsapAnimation;
135
+ const commitMutation = vi.fn();
136
+
137
+ await expect(
138
+ tryGsapResizeIntercept(
139
+ selection,
140
+ { width: 344, height: 344 },
141
+ [helperSize],
142
+ null,
143
+ commitMutation,
144
+ ),
145
+ ).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
146
+ expect(commitMutation).not.toHaveBeenCalled();
147
+ });
148
+
149
+ // fallow-ignore-next-line code-duplication
150
+ it("reuses the ownership parse instead of fetching a resolved size group twice", async () => {
151
+ const el = document.createElement("div");
152
+ el.id = "box";
153
+ document.body.append(el);
154
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
155
+ const sizeHold = {
156
+ id: "#box-set-size",
157
+ targetSelector: "#box",
158
+ propertyGroup: "size",
159
+ method: "set",
160
+ properties: { width: 150, height: 150 },
161
+ } as unknown as GsapAnimation;
162
+ const fetchAnimations = vi.fn().mockResolvedValue([sizeHold]);
163
+ const commitMutation = vi.fn();
164
+
165
+ await expect(
166
+ tryGsapResizeIntercept(
167
+ selection,
168
+ { width: 344, height: 344 },
169
+ [],
170
+ null,
171
+ commitMutation,
172
+ fetchAnimations,
173
+ ),
174
+ ).resolves.toEqual({ status: "persisted" });
175
+ expect(fetchAnimations).toHaveBeenCalledTimes(1);
176
+ expect(commitMutation).toHaveBeenCalledWith(
177
+ selection,
178
+ expect.objectContaining({ type: "update-properties", animationId: sizeHold.id }),
179
+ expect.anything(),
180
+ );
181
+ });
182
+
183
+ it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
184
+ const el = document.createElement("div");
185
+ el.id = "box";
186
+ document.body.append(el);
187
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
188
+ const liveSizeTween = {
189
+ targets: () => [el],
190
+ vars: { width: 300, duration: 1 },
191
+ duration: () => 1,
192
+ startTime: () => 0,
193
+ };
194
+ const iframe = {
195
+ contentWindow: { __timelines: { main: { getChildren: () => [liveSizeTween] } } },
196
+ contentDocument: document,
197
+ } as unknown as HTMLIFrameElement;
198
+ const commitMutation = vi.fn();
199
+
200
+ await expect(
201
+ tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
202
+ ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
203
+ expect(commitMutation).not.toHaveBeenCalled();
204
+ });
205
+
118
206
  it("computes a finite zero percentage for a zero-duration tween", () => {
119
207
  const animation = {
120
208
  id: "#box-to-0-size",
@@ -158,7 +246,7 @@ async function runResize(
158
246
  commitMutation as never,
159
247
  async () => [keyframedScaleFixture()],
160
248
  );
161
- expect(handled).toBe(true);
249
+ expect(handled).toEqual({ status: "persisted" });
162
250
  return committed;
163
251
  }
164
252
 
@@ -24,11 +24,16 @@ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
24
24
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
25
25
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
26
26
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
27
- import { isInstantHold, selectorFromSelection } from "./gsapShared";
27
+ import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
28
28
  import { roundTo3 } from "../utils/rounding";
29
29
  import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
30
30
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
31
31
  import { logResize } from "../utils/resizeDebug";
32
+ import {
33
+ animationWritesAnyProperty,
34
+ directEditOutcomeForProperties,
35
+ type GsapEditOutcome,
36
+ } from "./gsapEditOutcome";
32
37
 
33
38
  const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
34
39
 
@@ -54,21 +59,44 @@ export async function tryGsapResizeIntercept(
54
59
  iframe: HTMLIFrameElement | null,
55
60
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
56
61
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
57
- ): Promise<boolean> {
62
+ ): Promise<GsapEditOutcome> {
63
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
64
+ const allKnownAnimations = [...animations, ...fetchedAnimations];
58
65
  // If the element already has a scale-group tween, resize should modify scale
59
66
  // (the user is resizing something whose visual size is driven by scale).
60
67
  // Otherwise, use the size group (width/height).
61
- const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
68
+ const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
62
69
  const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
70
+ const resizeProperties =
71
+ resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
72
+ const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
73
+ if (editability.status === "blocked") return editability;
74
+ const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
75
+ // The initial ownership fetch already supplied the complete parse. Only retain
76
+ // the fetch callback when a legacy mixed tween may be split and must then be
77
+ // re-read; otherwise resolveGroupTween would perform the same network read twice.
78
+ const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
79
+ ? fetchFallbackAnimations
80
+ : undefined;
63
81
  const resolved = await resolveGroupTween(
64
82
  resizeGroup,
65
- animations,
83
+ workingAnimations,
66
84
  selection,
67
85
  commitMutation,
68
- fetchFallbackAnimations,
86
+ postSplitFetch,
69
87
  );
70
88
 
71
- let anim = resolved?.anim ?? null;
89
+ let anim =
90
+ resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
91
+ ? resolved.anim
92
+ : null;
93
+ const liveSelector = selectorFromSelection(selection);
94
+ const hasLiveResizeTween = liveSelector
95
+ ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
96
+ : false;
97
+ if (!anim && hasLiveResizeTween) {
98
+ return { status: "blocked", reason: "source-uneditable" };
99
+ }
72
100
  logResize("intercept-enter", {
73
101
  hasScaleGroup,
74
102
  resizeGroup,
@@ -77,16 +105,16 @@ export async function tryGsapResizeIntercept(
77
105
  size,
78
106
  });
79
107
  if (!anim || isInstantHold(anim)) {
80
- const sel = selectorFromSelection(selection);
81
- if (!sel) return false;
82
- const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
108
+ const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
109
+ if (!sel) return { status: "blocked", reason: "no-selector" };
110
+ const sizeSet = anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element);
83
111
 
84
112
  // If the element is animated (has a real tween, not just a static size
85
113
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
86
114
  // instead of a global set that resizes every frame.
87
115
  if (resizeGroup === "size") {
88
116
  const animatedTween = pickClosestToPlayhead(
89
- animations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
117
+ workingAnimations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
90
118
  );
91
119
  if (animatedTween) {
92
120
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
@@ -98,7 +126,7 @@ export async function tryGsapResizeIntercept(
98
126
  animatedTween,
99
127
  { commitMutation, fetchAnimations: fetchFallbackAnimations },
100
128
  );
101
- if (handled) return true;
129
+ if (handled) return { status: "persisted" };
102
130
  }
103
131
  }
104
132
 
@@ -107,11 +135,11 @@ export async function tryGsapResizeIntercept(
107
135
  commitMutation,
108
136
  fetchAnimations: fetchFallbackAnimations,
109
137
  });
110
- return true;
138
+ return { status: "persisted" };
111
139
  }
112
140
 
113
141
  const tweenDuration = resolveTweenDuration(anim);
114
- if (tweenDuration <= 0) return false;
142
+ if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
115
143
 
116
144
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
117
145
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -273,7 +301,7 @@ export async function tryGsapResizeIntercept(
273
301
  "Resize animation",
274
302
  );
275
303
  await finalizeScaleResizeCommit();
276
- return true;
304
+ return { status: "persisted" };
277
305
  }
278
306
 
279
307
  const ct = usePlayerStore.getState().currentTime;
@@ -385,7 +413,7 @@ export async function tryGsapResizeIntercept(
385
413
  },
386
414
  );
387
415
  await finalizeScaleResizeCommit();
388
- return true;
416
+ return { status: "persisted" };
389
417
  }
390
418
 
391
419
  const SIZE_PROPS = new Set(["width", "height"]);
@@ -407,7 +435,7 @@ export async function tryGsapResizeIntercept(
407
435
  { label: `Resize (keyframe ${pct}%)`, softReload: true },
408
436
  );
409
437
  await finalizeScaleResizeCommit();
410
- return true;
438
+ return { status: "persisted" };
411
439
  }
412
440
 
413
441
  // ── Rotation intercept ────────────────────────────────────────────────────