@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.
- package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
- package/dist/assets/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/gsapAnimationConstants.ts +24 -0
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useGsapTweenCache.ts +0 -27
- package/src/hooks/useRazorSplit.ts +4 -10
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/src/utils/timelineElementSplit.test.ts +61 -1
- package/src/utils/timelineElementSplit.ts +18 -0
- package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
- package/dist/assets/index-BVqybwMG.css +0 -1
- package/dist/assets/index-ho_f4axK.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- 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 {
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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) >
|
|
177
|
+
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 2,
|
|
128
178
|
),
|
|
129
179
|
}),
|
|
130
|
-
persist: () =>
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
(selection: DomEditSelection, animationId: string, property: string) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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 {
|