@hyperframes/studio 0.6.109 → 0.6.111

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 (66) hide show
  1. package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
  2. package/dist/assets/index-B7S86vK1.js +370 -0
  3. package/dist/assets/index-DP8pPIk2.css +1 -0
  4. package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
  5. package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +8 -5
  8. package/src/App.tsx +133 -141
  9. package/src/components/StudioHeader.tsx +44 -0
  10. package/src/components/StudioOverlays.tsx +70 -0
  11. package/src/components/editor/SourceEditor.tsx +19 -16
  12. package/src/components/editor/gsapAnimationConstants.ts +24 -0
  13. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  14. package/src/components/editor/manualEditingAvailability.ts +25 -7
  15. package/src/components/storyboard/FramePoster.tsx +56 -0
  16. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  17. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  18. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  19. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  20. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  21. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  22. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  23. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  24. package/src/components/storyboard/StoryboardView.tsx +78 -0
  25. package/src/components/storyboard/frameStatus.ts +36 -0
  26. package/src/contexts/ViewModeContext.tsx +98 -0
  27. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  28. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  29. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  30. package/src/hooks/timelineEditingHelpers.ts +2 -0
  31. package/src/hooks/useAppHotkeys.ts +20 -0
  32. package/src/hooks/useDomEditCommits.ts +43 -14
  33. package/src/hooks/useDomEditSession.test.ts +41 -0
  34. package/src/hooks/useDomEditSession.ts +38 -6
  35. package/src/hooks/useDomGeometryCommits.ts +5 -9
  36. package/src/hooks/useElementLifecycleOps.ts +30 -0
  37. package/src/hooks/useGsapAnimationOps.ts +78 -51
  38. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  39. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  40. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  41. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  42. package/src/hooks/useGsapScriptCommits.ts +95 -40
  43. package/src/hooks/useGsapTweenCache.ts +0 -27
  44. package/src/hooks/useRazorSplit.ts +4 -10
  45. package/src/hooks/useSdkSession.test.ts +12 -0
  46. package/src/hooks/useSdkSession.ts +91 -25
  47. package/src/hooks/useStoryboard.ts +80 -0
  48. package/src/hooks/useTimelineEditing.ts +163 -68
  49. package/src/utils/gsapSoftReload.ts +14 -0
  50. package/src/utils/sdkCutover.gate.test.ts +61 -0
  51. package/src/utils/sdkCutover.test.ts +839 -0
  52. package/src/utils/sdkCutover.ts +465 -0
  53. package/src/utils/sdkOpMapping.ts +43 -0
  54. package/src/utils/sdkResolverShadow.test.ts +366 -0
  55. package/src/utils/sdkResolverShadow.ts +313 -0
  56. package/src/utils/timelineElementSplit.test.ts +61 -1
  57. package/src/utils/timelineElementSplit.ts +18 -0
  58. package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
  59. package/dist/assets/index-BVqybwMG.css +0 -1
  60. package/dist/assets/index-ho_f4axK.js +0 -296
  61. package/src/utils/sdkShadow.test.ts +0 -606
  62. package/src/utils/sdkShadow.ts +0 -517
  63. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  64. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  65. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  66. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -1,8 +1,15 @@
1
1
  import { useCallback } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
3
+ import type { Composition } from "@hyperframes/sdk";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { executeOptimistic } from "../utils/optimisticUpdate";
6
+ import {
7
+ sdkGsapKeyframePersist,
8
+ sdkGsapRemoveKeyframePersist,
9
+ sdkGsapRemoveAllKeyframesPersist,
10
+ sdkGsapConvertToKeyframesPersist,
11
+ type CutoverDeps,
12
+ } from "../utils/sdkCutover";
6
13
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
7
14
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
8
15
  import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
@@ -31,7 +38,12 @@ function executeOptimisticKeyframeCacheUpdate(options: {
31
38
  });
32
39
  }
33
40
 
34
- interface GsapKeyframeOpsParams {
41
+ interface SdkKeyframeDeps {
42
+ sdkSession?: Composition | null;
43
+ sdkDeps?: CutoverDeps | null;
44
+ }
45
+
46
+ interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
35
47
  activeCompPath: string | null;
36
48
  commitMutation: CommitMutation;
37
49
  commitMutationSafely: SafeGsapCommitMutation;
@@ -43,6 +55,8 @@ export function useGsapKeyframeOps({
43
55
  commitMutation,
44
56
  commitMutationSafely,
45
57
  trackGsapSaveFailure,
58
+ sdkSession,
59
+ sdkDeps,
46
60
  }: GsapKeyframeOpsParams) {
47
61
  const addKeyframe = useCallback(
48
62
  (
@@ -59,111 +73,181 @@ export function useGsapKeyframeOps({
59
73
  percentage,
60
74
  properties: { [property]: value },
61
75
  };
62
- // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
63
- const shadowKeyframeOp: ShadowKeyframeOp = {
64
- kind: "add",
65
- animationId,
66
- percentage,
67
- properties: { [property]: value },
68
- };
69
76
  void executeOptimisticKeyframeCacheUpdate({
70
77
  sourceFile,
71
78
  elementId: selection.id,
72
- apply: (prev) => ({
73
- ...prev,
74
- keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
75
- (a, b) => a.percentage - b.percentage,
76
- ),
77
- }),
78
- persist: () =>
79
- commitMutation(selection, mutation, {
79
+ // Merge into an existing keyframe at this percentage rather than
80
+ // appending a duplicate — matches addKeyframeToScript, which writes one
81
+ // keyframe per percentage (merging properties).
82
+ apply: (prev) => {
83
+ // Match addKeyframeToScript's merge tolerance (PCT_TOLERANCE = 2 in
84
+ // gsapWriterAcorn): a keyframe added within 2% of an existing one
85
+ // merges on disk, so the optimistic cache must merge it too — else the
86
+ // UI shows a phantom keyframe that vanishes on the next reload.
87
+ const idx = prev.keyframes.findIndex(
88
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) <= 2,
89
+ );
90
+ if (idx >= 0) {
91
+ const keyframes = prev.keyframes.slice();
92
+ keyframes[idx] = {
93
+ ...keyframes[idx],
94
+ properties: { ...keyframes[idx].properties, [property]: value },
95
+ };
96
+ return { ...prev, keyframes };
97
+ }
98
+ return {
99
+ ...prev,
100
+ keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
101
+ (a, b) => a.percentage - b.percentage,
102
+ ),
103
+ };
104
+ },
105
+ persist: async () => {
106
+ if (sdkSession && sdkDeps) {
107
+ const handled = await sdkGsapKeyframePersist(
108
+ sourceFile,
109
+ animationId,
110
+ percentage,
111
+ { [property]: value },
112
+ sdkSession,
113
+ sdkDeps,
114
+ {
115
+ label: `Add keyframe at ${percentage}%`,
116
+ coalesceKey: `gsap:${animationId}:kf:${percentage}`,
117
+ },
118
+ );
119
+ if (handled) return;
120
+ }
121
+ await commitMutation(selection, mutation, {
80
122
  label: `Add keyframe at ${percentage}%`,
81
123
  softReload: true,
82
- shadowKeyframeOp,
83
- }),
124
+ });
125
+ },
84
126
  }).catch((error) => {
85
127
  trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
86
128
  });
87
129
  },
88
- [activeCompPath, commitMutation, trackGsapSaveFailure],
130
+ [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
89
131
  );
90
132
 
91
133
  const addKeyframeBatch = useCallback(
92
- (
134
+ async (
93
135
  selection: DomEditSelection,
94
136
  animationId: string,
95
137
  percentage: number,
96
138
  properties: Record<string, number | string>,
97
139
  ) => {
98
- const shadowKeyframeOp: ShadowKeyframeOp = {
99
- kind: "add",
100
- animationId,
101
- percentage,
102
- properties,
103
- };
140
+ if (sdkSession && sdkDeps) {
141
+ const sourceFile = selection.sourceFile || activeCompPath || "index.html";
142
+ const handled = await sdkGsapKeyframePersist(
143
+ sourceFile,
144
+ animationId,
145
+ percentage,
146
+ properties,
147
+ sdkSession,
148
+ sdkDeps,
149
+ { label: `Add keyframe at ${percentage}%` },
150
+ );
151
+ if (handled) return;
152
+ }
104
153
  return commitMutation(
105
154
  selection,
106
155
  { type: "add-keyframe", animationId, percentage, properties },
107
- { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
156
+ { label: `Add keyframe at ${percentage}%`, softReload: true },
108
157
  );
109
158
  },
110
- [commitMutation],
159
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
111
160
  );
112
161
 
113
162
  const removeKeyframe = useCallback(
114
163
  (selection: DomEditSelection, animationId: string, percentage: number) => {
115
164
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
116
165
  const mutation = { type: "remove-keyframe", animationId, percentage };
117
- // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
118
- // so the runner resolves percentage → keyframeIndex against the pre-op
119
- // script and no-ops on ambiguity (duplicate-percentage keyframes).
120
- const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
121
166
  void executeOptimisticKeyframeCacheUpdate({
122
167
  sourceFile,
123
168
  elementId: selection.id,
124
169
  apply: (prev) => ({
125
170
  ...prev,
171
+ // Match the writer's removal tolerance (PCT_TOLERANCE = 2 in
172
+ // gsapWriterAcorn): removing at e.g. 49% drops a keyframe at 50% on
173
+ // disk, so the optimistic cache must drop it too — else the stranded
174
+ // entry is a phantom that vanishes on the next reload (mirror of the
175
+ // add-path tolerance fix).
126
176
  keyframes: prev.keyframes.filter(
127
- (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
177
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 2,
128
178
  ),
129
179
  }),
130
- persist: () =>
131
- commitMutation(selection, mutation, {
180
+ persist: async () => {
181
+ if (sdkSession && sdkDeps) {
182
+ const handled = await sdkGsapRemoveKeyframePersist(
183
+ sourceFile,
184
+ animationId,
185
+ percentage,
186
+ sdkSession,
187
+ sdkDeps,
188
+ { label: `Remove keyframe at ${percentage}%` },
189
+ );
190
+ if (handled) return;
191
+ }
192
+ await commitMutation(selection, mutation, {
132
193
  label: `Remove keyframe at ${percentage}%`,
133
194
  softReload: true,
134
- shadowKeyframeOp,
135
- }),
195
+ });
196
+ },
136
197
  }).catch((error) => {
137
198
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
138
199
  });
139
200
  },
140
- [activeCompPath, commitMutation, trackGsapSaveFailure],
201
+ [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
141
202
  );
142
203
 
143
204
  const convertToKeyframes = useCallback(
144
- (
205
+ async (
145
206
  selection: DomEditSelection,
146
207
  animationId: string,
147
208
  resolvedFromValues?: Record<string, number | string>,
148
209
  ) => {
210
+ if (sdkSession && sdkDeps) {
211
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
212
+ const handled = await sdkGsapConvertToKeyframesPersist(
213
+ targetPath,
214
+ animationId,
215
+ resolvedFromValues,
216
+ sdkSession,
217
+ sdkDeps,
218
+ { label: "Convert to keyframes" },
219
+ );
220
+ if (handled) return;
221
+ }
149
222
  return commitMutation(
150
223
  selection,
151
224
  { type: "convert-to-keyframes", animationId, resolvedFromValues },
152
225
  { label: "Convert to keyframes" },
153
226
  );
154
227
  },
155
- [commitMutation],
228
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
156
229
  );
157
230
 
158
231
  const removeAllKeyframes = useCallback(
159
- (selection: DomEditSelection, animationId: string) => {
232
+ async (selection: DomEditSelection, animationId: string) => {
233
+ if (sdkSession && sdkDeps) {
234
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
235
+ const handled = await sdkGsapRemoveAllKeyframesPersist(
236
+ targetPath,
237
+ animationId,
238
+ sdkSession,
239
+ sdkDeps,
240
+ { label: "Remove all keyframes" },
241
+ );
242
+ if (handled) return;
243
+ }
160
244
  commitMutationSafely(
161
245
  selection,
162
246
  { type: "remove-all-keyframes", animationId },
163
247
  { label: "Remove all keyframes", softReload: true },
164
248
  );
165
249
  },
166
- [commitMutationSafely],
250
+ [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
167
251
  );
168
252
 
169
253
  const commitKeyframeAtTime = useCallback(
@@ -0,0 +1,70 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect } from "vitest";
3
+ import { openComposition } from "@hyperframes/sdk";
4
+ import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
5
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
6
+ import { mergeTweenProperties } from "./useGsapPropertyDebounce";
7
+ import { extractGsapScriptText } from "../utils/gsapSoftReload";
8
+
9
+ const HTML = `<!DOCTYPE html><html><head></head><body>
10
+ <div id="box" data-hf-id="hf-box" style="opacity:1"></div>
11
+ <script data-hf-gsap>
12
+ const tl = gsap.timeline({ paused: true });
13
+ window.__timelines = { main: tl };
14
+ tl.to('#box', { duration: 1, x: 100, y: 50, opacity: 1 });
15
+ </script>
16
+ </body></html>`;
17
+
18
+ const FROMTO_HTML = `<!DOCTYPE html><html><head></head><body>
19
+ <div id="box" data-hf-id="hf-box" style="opacity:1"></div>
20
+ <script data-hf-gsap>
21
+ const tl = gsap.timeline({ paused: true });
22
+ window.__timelines = { main: tl };
23
+ tl.fromTo('#box', { x: 0, y: 0 }, { duration: 1, x: 100, y: 50 });
24
+ </script>
25
+ </body></html>`;
26
+
27
+ function tweenProps(comp: { serialize(): string }) {
28
+ const parsed = parseGsapScriptAcorn(extractGsapScriptText(comp.serialize()) ?? "");
29
+ const anim = parsed.animations[0];
30
+ return { id: anim?.id, properties: anim?.properties, fromProperties: anim?.fromProperties };
31
+ }
32
+
33
+ describe("setGsapTween replace semantics (finding #1)", () => {
34
+ it("REGRESSION: a single-key set drops the tween's other animated props", async () => {
35
+ // This documents the bug the merge fixes: setGsapTween REPLACES the property
36
+ // set, so sending only the edited key loses the siblings.
37
+ const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
38
+ const id = tweenProps(comp).id ?? "";
39
+ comp.setGsapTween(id, { properties: { x: 200 } });
40
+ const after = tweenProps(comp);
41
+ expect(after.properties).toEqual({ x: 200 });
42
+ expect(after.properties).not.toHaveProperty("y");
43
+ expect(after.properties).not.toHaveProperty("opacity");
44
+ });
45
+ });
46
+
47
+ describe("mergeTweenProperties (finding #1)", () => {
48
+ it("editing x preserves y and opacity through a real SDK write", async () => {
49
+ const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
50
+ const id = tweenProps(comp).id ?? "";
51
+ // Mirror the send site: merge the single edited prop into the existing set.
52
+ const merged = mergeTweenProperties(comp, id, { x: 200 }, "to");
53
+ expect(merged).toEqual({ x: 200, y: 50, opacity: 1 });
54
+ comp.setGsapTween(id, { properties: merged });
55
+ const after = tweenProps(comp);
56
+ expect(after.properties).toMatchObject({ x: 200, y: 50, opacity: 1 });
57
+ });
58
+
59
+ it("editing a from-property preserves the other from-properties", async () => {
60
+ const comp = await openComposition(FROMTO_HTML, { persist: createMemoryAdapter() });
61
+ const id = tweenProps(comp).id ?? "";
62
+ const merged = mergeTweenProperties(comp, id, { x: 25 }, "from");
63
+ expect(merged).toEqual({ x: 25, y: 0 });
64
+ });
65
+
66
+ it("returns the single edit unchanged when the tween id is unknown", async () => {
67
+ const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
68
+ expect(mergeTweenProperties(comp, "no-such-id", { x: 5 }, "to")).toEqual({ x: 5 });
69
+ });
70
+ });
@@ -1,11 +1,56 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
+ import type { Composition } from "@hyperframes/sdk";
3
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
2
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import {
6
+ sdkGsapTweenPersist,
7
+ sdkGsapRemovePropertyPersist,
8
+ type CutoverDeps,
9
+ } from "../utils/sdkCutover";
10
+ import { extractGsapScriptText } from "../utils/gsapSoftReload";
3
11
  import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
4
12
  import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
5
13
 
6
14
  const DEBOUNCE_MS = 150;
7
15
 
8
- export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMutation) {
16
+ /**
17
+ * The SDK `setGsapTween` 'set' path REPLACES a tween's editable property set
18
+ * (engine `handleSetGsapTween` → `updateAnimationInScript`), so sending only the
19
+ * single edited key would silently drop the tween's other animated props. Mirror
20
+ * the legacy server path (`{ ...anim.properties, [property]: val }`): read the
21
+ * tween's CURRENT properties from the in-memory SDK doc and merge the one edit in,
22
+ * so REPLACE semantics preserve siblings. Returns the single-key map unchanged
23
+ * when the tween/script can't be found (best-effort; before===after then falls
24
+ * back to the server path).
25
+ */
26
+ export function mergeTweenProperties(
27
+ sdkSession: Composition,
28
+ animationId: string,
29
+ edited: Record<string, number | string>,
30
+ kind: "to" | "from",
31
+ ): Record<string, number | string> {
32
+ try {
33
+ const script = extractGsapScriptText(sdkSession.serialize());
34
+ if (!script) return { ...edited };
35
+ const anim = parseGsapScriptAcorn(script).animations.find((a) => a.id === animationId);
36
+ if (!anim) return { ...edited };
37
+ const existing = kind === "from" ? (anim.fromProperties ?? {}) : anim.properties;
38
+ return { ...existing, ...edited };
39
+ } catch {
40
+ return { ...edited };
41
+ }
42
+ }
43
+
44
+ interface SdkPropertyDeps {
45
+ sdkSession?: Composition | null;
46
+ sdkDeps?: CutoverDeps | null;
47
+ activeCompPath?: string | null;
48
+ }
49
+
50
+ export function useGsapPropertyDebounce(
51
+ commitMutationSafely: SafeGsapCommitMutation,
52
+ sdk?: SdkPropertyDeps,
53
+ ) {
9
54
  const pendingPropertyEditRef = useRef<{
10
55
  selection: DomEditSelection;
11
56
  animationId: string;
@@ -14,11 +59,38 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
14
59
  } | null>(null);
15
60
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16
61
 
17
- const flushPendingPropertyEdit = useCallback(() => {
62
+ // The caller passes `sdk` as a fresh object literal every render. Keying any
63
+ // callback's deps on it (esp. flushPendingPropertyEdit, whose identity drives
64
+ // the unmount-flush cleanup effect) re-fires the cleanup on EVERY parent
65
+ // re-render — so a playhead tick mid-slider-drag would flush + record an undo
66
+ // entry per render. Hold the latest value in a ref instead so every callback
67
+ // reads current deps without re-subscribing on identity churn.
68
+ const sdkRef = useRef(sdk);
69
+ sdkRef.current = sdk;
70
+
71
+ const flushPendingPropertyEdit = useCallback(async () => {
18
72
  const pending = pendingPropertyEditRef.current;
19
73
  if (!pending) return;
20
74
  pendingPropertyEditRef.current = null;
21
75
  const { selection, animationId, property, value } = pending;
76
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
77
+ if (sdkSession && sdkDeps) {
78
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
79
+ const handled = await sdkGsapTweenPersist(
80
+ targetPath,
81
+ {
82
+ kind: "set",
83
+ animationId,
84
+ properties: {
85
+ properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"),
86
+ },
87
+ },
88
+ sdkSession,
89
+ sdkDeps,
90
+ { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
91
+ );
92
+ if (handled) return;
93
+ }
22
94
  commitMutationSafely(
23
95
  selection,
24
96
  { type: "update-property", animationId, property, value },
@@ -39,7 +111,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
39
111
  ) => {
40
112
  pendingPropertyEditRef.current = { selection, animationId, property, value };
41
113
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
42
- debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS);
114
+ debounceTimerRef.current = setTimeout(() => {
115
+ void flushPendingPropertyEdit();
116
+ }, DEBOUNCE_MS);
43
117
  },
44
118
  [flushPendingPropertyEdit],
45
119
  );
@@ -47,12 +121,14 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
47
121
  useEffect(() => {
48
122
  return () => {
49
123
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
50
- flushPendingPropertyEdit();
124
+ void flushPendingPropertyEdit();
51
125
  };
52
126
  }, [flushPendingPropertyEdit]);
53
127
 
128
+ // fallow-ignore-next-line complexity
54
129
  const addGsapProperty = useCallback(
55
- (selection: DomEditSelection, animationId: string, property: string) => {
130
+ // fallow-ignore-next-line complexity
131
+ async (selection: DomEditSelection, animationId: string, property: string) => {
56
132
  let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
57
133
  const el = selection.element;
58
134
  if (property === "width" || property === "height") {
@@ -60,7 +136,33 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
60
136
  defaultValue = Math.round(property === "width" ? rect.width : rect.height);
61
137
  } else if (property === "opacity" || property === "autoAlpha") {
62
138
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
63
- defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1;
139
+ // Use `|| 1` only as a non-finite fallback, not a falsy fallback: an
140
+ // element currently at opacity 0 must seed 0, not 1.
141
+ const current = cs ? Number.parseFloat(cs.opacity) : Number.NaN;
142
+ defaultValue = Number.isFinite(current) ? current : 1;
143
+ }
144
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
145
+ if (sdkSession && sdkDeps) {
146
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
147
+ const handled = await sdkGsapTweenPersist(
148
+ targetPath,
149
+ {
150
+ kind: "set",
151
+ animationId,
152
+ properties: {
153
+ properties: mergeTweenProperties(
154
+ sdkSession,
155
+ animationId,
156
+ { [property]: defaultValue },
157
+ "to",
158
+ ),
159
+ },
160
+ },
161
+ sdkSession,
162
+ sdkDeps,
163
+ { label: `Add GSAP ${property}` },
164
+ );
165
+ if (handled) return;
64
166
  }
65
167
  commitMutationSafely(
66
168
  selection,
@@ -71,24 +173,82 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
71
173
  [commitMutationSafely],
72
174
  );
73
175
 
74
- const removeGsapProperty = useCallback(
75
- (selection: DomEditSelection, animationId: string, property: string) => {
76
- commitMutationSafely(
77
- selection,
78
- { type: "remove-property", animationId, property },
79
- { label: `Remove GSAP ${property}` },
80
- );
176
+ const removeProperty = useCallback(
177
+ async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => {
178
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
179
+ if (sdkSession && sdkDeps) {
180
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
181
+ const handled = await sdkGsapRemovePropertyPersist(
182
+ targetPath,
183
+ animationId,
184
+ property,
185
+ from,
186
+ sdkSession,
187
+ sdkDeps,
188
+ { label: `Remove GSAP ${from ? `from-${property}` : property}` },
189
+ );
190
+ if (handled) return;
191
+ }
192
+ if (from) {
193
+ commitMutationSafely(
194
+ selection,
195
+ { type: "remove-from-property", animationId, property },
196
+ {
197
+ label: `Remove GSAP from-${property}`,
198
+ },
199
+ );
200
+ } else {
201
+ commitMutationSafely(
202
+ selection,
203
+ { type: "remove-property", animationId, property },
204
+ {
205
+ label: `Remove GSAP ${property}`,
206
+ },
207
+ );
208
+ }
81
209
  },
82
210
  [commitMutationSafely],
83
211
  );
84
212
 
213
+ const removeGsapProperty = useCallback(
214
+ (selection: DomEditSelection, animationId: string, property: string) =>
215
+ removeProperty(selection, animationId, property, false),
216
+ [removeProperty],
217
+ );
218
+
85
219
  const updateGsapFromProperty = useCallback(
86
- (
220
+ async (
87
221
  selection: DomEditSelection,
88
222
  animationId: string,
89
223
  property: string,
90
224
  value: number | string,
91
225
  ) => {
226
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
227
+ if (sdkSession && sdkDeps) {
228
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
229
+ const handled = await sdkGsapTweenPersist(
230
+ targetPath,
231
+ {
232
+ kind: "set",
233
+ animationId,
234
+ properties: {
235
+ fromProperties: mergeTweenProperties(
236
+ sdkSession,
237
+ animationId,
238
+ { [property]: value },
239
+ "from",
240
+ ),
241
+ },
242
+ },
243
+ sdkSession,
244
+ sdkDeps,
245
+ {
246
+ label: `Edit GSAP from-${property}`,
247
+ coalesceKey: `gsap:${animationId}:from:${property}`,
248
+ },
249
+ );
250
+ if (handled) return;
251
+ }
92
252
  commitMutationSafely(
93
253
  selection,
94
254
  { type: "update-from-property", animationId, property, value },
@@ -102,8 +262,31 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
102
262
  );
103
263
 
104
264
  const addGsapFromProperty = useCallback(
105
- (selection: DomEditSelection, animationId: string, property: string) => {
265
+ async (selection: DomEditSelection, animationId: string, property: string) => {
106
266
  const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
267
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
268
+ if (sdkSession && sdkDeps) {
269
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
270
+ const handled = await sdkGsapTweenPersist(
271
+ targetPath,
272
+ {
273
+ kind: "set",
274
+ animationId,
275
+ properties: {
276
+ fromProperties: mergeTweenProperties(
277
+ sdkSession,
278
+ animationId,
279
+ { [property]: defaultValue },
280
+ "from",
281
+ ),
282
+ },
283
+ },
284
+ sdkSession,
285
+ sdkDeps,
286
+ { label: `Add GSAP from-${property}` },
287
+ );
288
+ if (handled) return;
289
+ }
107
290
  commitMutationSafely(
108
291
  selection,
109
292
  { type: "add-from-property", animationId, property, defaultValue },
@@ -114,14 +297,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
114
297
  );
115
298
 
116
299
  const removeGsapFromProperty = useCallback(
117
- (selection: DomEditSelection, animationId: string, property: string) => {
118
- commitMutationSafely(
119
- selection,
120
- { type: "remove-from-property", animationId, property },
121
- { label: `Remove GSAP from-${property}` },
122
- );
123
- },
124
- [commitMutationSafely],
300
+ (selection: DomEditSelection, animationId: string, property: string) =>
301
+ removeProperty(selection, animationId, property, true),
302
+ [removeProperty],
125
303
  );
126
304
 
127
305
  return {