@hyperframes/studio 0.6.105 → 0.6.107
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/{index-BITwbxi-.css → index-BrhJl2JY.css} +1 -1
- package/dist/assets/index-CVHV-S-B.js +296 -0
- package/dist/assets/{index-NIdeStFw.js → index-P52mbTRb.js} +1 -1
- package/dist/assets/{index-By4Ku5HI.js → index-zIK7PWvk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +8 -24
- package/src/components/editor/ComputedTweenNotice.tsx +40 -0
- package/src/components/editor/GsapAnimationSection.tsx +5 -24
- package/src/components/editor/PropertyPanel.tsx +2 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +33 -0
- package/src/components/editor/propertyPanelHelpers.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +90 -3
- package/src/hooks/gsapRuntimeBridge.ts +70 -19
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +198 -123
- package/src/hooks/gsapScriptCommitTypes.ts +11 -0
- package/src/hooks/serializeByKey.test.ts +83 -0
- package/src/hooks/serializeByKey.ts +33 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommits.ts +9 -1
- package/src/hooks/useGsapAnimationOps.ts +5 -1
- package/src/hooks/useGsapAwareEditing.ts +21 -0
- package/src/hooks/useGsapKeyframeOps.ts +21 -1
- package/src/hooks/useGsapScriptCommits.ts +33 -5
- package/src/hooks/useGsapTweenCache.ts +49 -7
- package/src/utils/sdkShadow.test.ts +187 -1
- package/src/utils/sdkShadow.ts +67 -10
- package/src/utils/sdkShadowGsapFidelity.ts +46 -18
- package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
- package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
- package/src/utils/sdkShadowNumeric.ts +11 -0
- package/dist/assets/index-CKd39gmy.js +0 -296
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read GSAP keyframe data from the live runtime in the preview iframe.
|
|
3
3
|
* Used to discover dynamic keyframes that the AST parser can't resolve
|
|
4
|
-
* (loops,
|
|
4
|
+
* (data-driven loops, fetched values, computed selectors).
|
|
5
|
+
*
|
|
6
|
+
* Keyframe percentages returned here are TWEEN-RELATIVE (0–100 within the
|
|
7
|
+
* tween), matching the static parser. Callers convert to clip-relative via
|
|
8
|
+
* `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
|
|
9
|
+
* does that conversion itself when given a `clipById` map.
|
|
5
10
|
*/
|
|
6
|
-
import {
|
|
11
|
+
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
12
|
+
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
7
13
|
import { roundTo3 } from "../utils/rounding";
|
|
8
14
|
|
|
9
15
|
interface RuntimeTween {
|
|
@@ -18,155 +24,224 @@ interface RuntimeTimeline {
|
|
|
18
24
|
duration?: () => number;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
27
|
+
type Pct = { percentage: number; properties: Record<string, number | string> };
|
|
28
|
+
type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
|
|
29
|
+
|
|
30
|
+
export interface RuntimeKeyframeEntry {
|
|
31
|
+
keyframes: Pct[];
|
|
27
32
|
easeEach?: string;
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
/** Present when the live tween uses motionPath — drives the Arc Motion panel. */
|
|
34
|
+
arcPath?: ArcPathConfig;
|
|
35
|
+
/** Absolute start time of the source tween (seconds). */
|
|
36
|
+
tweenStart: number;
|
|
37
|
+
/** Duration of the source tween (seconds). */
|
|
38
|
+
tweenDuration: number;
|
|
39
|
+
}
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
/** Clip start/duration per element id, to convert tween-relative % to clip-relative. */
|
|
42
|
+
export type ClipDims = Map<string, { start: number; duration: number }>;
|
|
43
|
+
|
|
44
|
+
const FLAT_SKIP_KEYS = new Set([
|
|
45
|
+
"ease",
|
|
46
|
+
"duration",
|
|
47
|
+
"delay",
|
|
48
|
+
"stagger",
|
|
49
|
+
"motionPath",
|
|
50
|
+
"overwrite",
|
|
51
|
+
"immediateRender",
|
|
52
|
+
"onComplete",
|
|
53
|
+
"onUpdate",
|
|
54
|
+
"onStart",
|
|
55
|
+
"keyframes",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTimeline> | null {
|
|
59
|
+
if (!iframe?.contentWindow) return null;
|
|
32
60
|
try {
|
|
33
|
-
|
|
34
|
-
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
|
35
|
-
|
|
61
|
+
return (
|
|
62
|
+
(iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> })
|
|
63
|
+
.__timelines ?? null
|
|
64
|
+
);
|
|
36
65
|
} catch {
|
|
37
66
|
return null;
|
|
38
67
|
}
|
|
39
|
-
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isXY(p: unknown): p is { x: number; y: number } {
|
|
71
|
+
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
|
75
|
+
function coordsFromMotionPath(mp: unknown): {
|
|
76
|
+
coords: Array<{ x: number; y: number }>;
|
|
77
|
+
curviness: number;
|
|
78
|
+
autoRotate: boolean | number;
|
|
79
|
+
isCubic: boolean;
|
|
80
|
+
} | null {
|
|
81
|
+
if (!mp || typeof mp !== "object") return null;
|
|
82
|
+
const obj = mp as Record<string, unknown>;
|
|
83
|
+
const pathVal = Array.isArray(mp) ? mp : obj.path;
|
|
84
|
+
if (!Array.isArray(pathVal)) return null;
|
|
85
|
+
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
|
|
86
|
+
if (coords.length < 2) return null;
|
|
87
|
+
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
|
|
88
|
+
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
|
|
89
|
+
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build an arcPath config from a live `vars.motionPath` value. */
|
|
93
|
+
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
|
|
94
|
+
const parsed = coordsFromMotionPath(mp);
|
|
95
|
+
if (!parsed) return undefined;
|
|
96
|
+
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
|
|
100
|
+
const properties: Record<string, number | string> = {};
|
|
101
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
102
|
+
if (FLAT_SKIP_KEYS.has(k)) continue;
|
|
103
|
+
if (typeof v === "number") properties[k] = roundTo3(v);
|
|
104
|
+
else if (typeof v === "string") properties[k] = v;
|
|
105
|
+
}
|
|
106
|
+
if (Object.keys(properties).length === 0) return null;
|
|
107
|
+
return [
|
|
108
|
+
{ percentage: 0, properties },
|
|
109
|
+
{ percentage: 100, properties },
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Tween-relative keyframes + optional arcPath for one live tween, or null. */
|
|
114
|
+
function readTween(vars: Record<string, unknown>): ReadTween | null {
|
|
115
|
+
if (vars.keyframes && typeof vars.keyframes === "object") {
|
|
116
|
+
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
|
117
|
+
if (parsed) return parsed;
|
|
118
|
+
}
|
|
119
|
+
const mp = coordsFromMotionPath(vars.motionPath);
|
|
120
|
+
if (mp) {
|
|
121
|
+
const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
|
|
122
|
+
if (shape) {
|
|
123
|
+
const n = shape.waypoints.length;
|
|
124
|
+
const keyframes = shape.waypoints.map((wp, i) => ({
|
|
125
|
+
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
|
|
126
|
+
properties: { x: wp.x, y: wp.y },
|
|
127
|
+
}));
|
|
128
|
+
return { keyframes, arcPath: shape.arcPath };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const flat = flatTweenKeyframes(vars);
|
|
132
|
+
return flat ? { keyframes: flat } : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function matchesElement(tween: RuntimeTween, el: Element): boolean {
|
|
136
|
+
if (!tween.targets) return false;
|
|
137
|
+
for (const t of tween.targets()) {
|
|
138
|
+
if (t === el || (el.id && (t as Element).id === el.id)) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
40
142
|
|
|
143
|
+
function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
|
|
144
|
+
const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
145
|
+
const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
146
|
+
return {
|
|
147
|
+
start: Number.isFinite(rawStart) ? rawStart : 0,
|
|
148
|
+
duration: Number.isFinite(rawDur) ? rawDur : 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
|
|
154
|
+
* Returns tween-relative percentages; callers convert to clip-relative.
|
|
155
|
+
*/
|
|
156
|
+
export function readRuntimeKeyframes(
|
|
157
|
+
iframe: HTMLIFrameElement | null,
|
|
158
|
+
selector: string,
|
|
159
|
+
compositionId?: string,
|
|
160
|
+
): ReadTween | null {
|
|
161
|
+
const timelines = timelinesOf(iframe);
|
|
162
|
+
if (!timelines) return null;
|
|
41
163
|
const tlId = compositionId || Object.keys(timelines)[0];
|
|
42
164
|
if (!tlId) return null;
|
|
43
165
|
const timeline = timelines[tlId];
|
|
44
166
|
if (!timeline?.getChildren) return null;
|
|
45
167
|
|
|
46
|
-
let
|
|
168
|
+
let targetEl: Element | null = null;
|
|
47
169
|
try {
|
|
48
|
-
|
|
170
|
+
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
|
|
49
171
|
} catch {
|
|
50
172
|
return null;
|
|
51
173
|
}
|
|
52
|
-
if (!doc) return null;
|
|
53
|
-
|
|
54
|
-
const targetEl = doc.querySelector(selector);
|
|
55
174
|
if (!targetEl) return null;
|
|
56
175
|
|
|
57
176
|
for (const tween of timeline.getChildren(true)) {
|
|
58
|
-
if (!tween.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
|
|
62
|
-
matches = true;
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if (!matches) continue;
|
|
67
|
-
|
|
68
|
-
const vars = tween.vars;
|
|
69
|
-
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
|
70
|
-
|
|
71
|
-
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
|
72
|
-
if (parsed) return parsed;
|
|
177
|
+
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
178
|
+
const read = readTween(tween.vars);
|
|
179
|
+
if (read) return read;
|
|
73
180
|
}
|
|
74
181
|
return null;
|
|
75
182
|
}
|
|
76
183
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
>();
|
|
92
|
-
if (!iframe?.contentWindow) return result;
|
|
184
|
+
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
|
|
185
|
+
function toClipRelative(
|
|
186
|
+
keyframes: Pct[],
|
|
187
|
+
tweenStart: number,
|
|
188
|
+
tweenDuration: number,
|
|
189
|
+
clip: { start: number; duration: number } | undefined,
|
|
190
|
+
): Pct[] {
|
|
191
|
+
if (!clip || clip.duration <= 0) return keyframes;
|
|
192
|
+
return keyframes.map((kf) => {
|
|
193
|
+
const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
|
|
194
|
+
return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
93
197
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
198
|
+
function buildEntry(
|
|
199
|
+
read: ReadTween,
|
|
200
|
+
start: number,
|
|
201
|
+
duration: number,
|
|
202
|
+
clip: { start: number; duration: number } | undefined,
|
|
203
|
+
): RuntimeKeyframeEntry {
|
|
204
|
+
return {
|
|
205
|
+
keyframes: toClipRelative(read.keyframes, start, duration, clip),
|
|
206
|
+
tweenStart: start,
|
|
207
|
+
tweenDuration: duration,
|
|
208
|
+
...(read.easeEach ? { easeEach: read.easeEach } : {}),
|
|
209
|
+
...(read.arcPath ? { arcPath: read.arcPath } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Record one tween's keyframes under each target id (first-tween-per-id wins). */
|
|
214
|
+
function addScanEntry(
|
|
215
|
+
result: Map<string, RuntimeKeyframeEntry>,
|
|
216
|
+
tween: RuntimeTween,
|
|
217
|
+
clipById?: ClipDims,
|
|
218
|
+
): void {
|
|
219
|
+
if (!tween.targets || !tween.vars) return;
|
|
220
|
+
const read = readTween(tween.vars);
|
|
221
|
+
if (!read) return;
|
|
222
|
+
const { start, duration } = tweenTiming(tween);
|
|
223
|
+
for (const target of tween.targets()) {
|
|
224
|
+
const id = (target as HTMLElement).id;
|
|
225
|
+
if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
|
|
101
226
|
}
|
|
102
|
-
|
|
227
|
+
}
|
|
103
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Scan every live tween, grouping keyframes by element id. Percentages are
|
|
231
|
+
* tween-relative unless `clipById` is supplied, in which case each entry's
|
|
232
|
+
* keyframes are converted to clip-relative. First keyframe-bearing tween per
|
|
233
|
+
* element wins (the common single-primary-tween case).
|
|
234
|
+
*/
|
|
235
|
+
export function scanAllRuntimeKeyframes(
|
|
236
|
+
iframe: HTMLIFrameElement | null,
|
|
237
|
+
clipById?: ClipDims,
|
|
238
|
+
): Map<string, RuntimeKeyframeEntry> {
|
|
239
|
+
const result = new Map<string, RuntimeKeyframeEntry>();
|
|
240
|
+
const timelines = timelinesOf(iframe);
|
|
241
|
+
if (!timelines) return result;
|
|
104
242
|
for (const timeline of Object.values(timelines)) {
|
|
105
243
|
if (!timeline?.getChildren) continue;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
for (const tween of timeline.getChildren(true)) {
|
|
109
|
-
if (!tween.targets || !tween.vars) continue;
|
|
110
|
-
const vars = tween.vars;
|
|
111
|
-
|
|
112
|
-
if (vars.keyframes && typeof vars.keyframes === "object") {
|
|
113
|
-
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
|
114
|
-
if (parsed) {
|
|
115
|
-
for (const target of tween.targets()) {
|
|
116
|
-
const id = (target as HTMLElement).id;
|
|
117
|
-
if (id && !result.has(id)) {
|
|
118
|
-
result.set(id, parsed);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Flat tweens: synthesize start + end keyframe entries
|
|
126
|
-
if (!tlDuration || tlDuration <= 0) continue;
|
|
127
|
-
const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
|
|
128
|
-
if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
|
|
129
|
-
const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
130
|
-
|
|
131
|
-
const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
|
|
132
|
-
const endPct =
|
|
133
|
-
tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
|
|
134
|
-
const properties: Record<string, number | string> = {};
|
|
135
|
-
const skip = new Set([
|
|
136
|
-
"ease",
|
|
137
|
-
"duration",
|
|
138
|
-
"delay",
|
|
139
|
-
"stagger",
|
|
140
|
-
"motionPath",
|
|
141
|
-
"overwrite",
|
|
142
|
-
"immediateRender",
|
|
143
|
-
"onComplete",
|
|
144
|
-
"onUpdate",
|
|
145
|
-
"onStart",
|
|
146
|
-
]);
|
|
147
|
-
for (const [k, v] of Object.entries(vars)) {
|
|
148
|
-
if (skip.has(k)) continue;
|
|
149
|
-
if (typeof v === "number") properties[k] = roundTo3(v);
|
|
150
|
-
else if (typeof v === "string") properties[k] = v;
|
|
151
|
-
}
|
|
152
|
-
if (Object.keys(properties).length === 0) continue;
|
|
153
|
-
|
|
154
|
-
for (const target of tween.targets()) {
|
|
155
|
-
const id = (target as HTMLElement).id;
|
|
156
|
-
if (!id) continue;
|
|
157
|
-
const existing = result.get(id);
|
|
158
|
-
const entries = existing ?? { keyframes: [] };
|
|
159
|
-
entries.keyframes.push({ percentage: startPct, properties });
|
|
160
|
-
if (endPct !== startPct) {
|
|
161
|
-
entries.keyframes.push({ percentage: endPct, properties });
|
|
162
|
-
}
|
|
163
|
-
if (!existing) result.set(id, entries);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
for (const entry of result.values()) {
|
|
169
|
-
entry.keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
244
|
+
for (const tween of timeline.getChildren(true)) addScanEntry(result, tween, clipById);
|
|
170
245
|
}
|
|
171
246
|
return result;
|
|
172
247
|
}
|
|
@@ -3,6 +3,7 @@ import type { Composition } from "@hyperframes/sdk";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
5
|
import type { ShadowGsapOp } from "../utils/sdkShadow";
|
|
6
|
+
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
6
7
|
|
|
7
8
|
export interface MutationResult {
|
|
8
9
|
ok: boolean;
|
|
@@ -19,8 +20,18 @@ export interface CommitMutationOptions {
|
|
|
19
20
|
softReload?: boolean;
|
|
20
21
|
skipReload?: boolean;
|
|
21
22
|
beforeReload?: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Serialize this commit against others sharing the same key. Used to chain
|
|
25
|
+
* per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
|
|
26
|
+
* one file can't interleave — which would pair the shadow fidelity diff with a
|
|
27
|
+
* stale server result and report false ease mismatches. Commits without a key
|
|
28
|
+
* (and under distinct keys) run concurrently as before.
|
|
29
|
+
*/
|
|
30
|
+
serializeKey?: string;
|
|
22
31
|
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
|
|
23
32
|
shadowGsapOp?: ShadowGsapOp;
|
|
33
|
+
/** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
|
|
34
|
+
shadowKeyframeOp?: ShadowKeyframeOp;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export type CommitMutation = (
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createKeyedSerializer } from "./serializeByKey";
|
|
3
|
+
|
|
4
|
+
function deferred<T>() {
|
|
5
|
+
let resolve!: (value: T) => void;
|
|
6
|
+
let reject!: (reason?: unknown) => void;
|
|
7
|
+
const promise = new Promise<T>((res, rej) => {
|
|
8
|
+
resolve = res;
|
|
9
|
+
reject = rej;
|
|
10
|
+
});
|
|
11
|
+
return { promise, resolve, reject };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("createKeyedSerializer", () => {
|
|
15
|
+
it("runs same-key tasks strictly in order (second awaits the first)", async () => {
|
|
16
|
+
const run = createKeyedSerializer();
|
|
17
|
+
const order: string[] = [];
|
|
18
|
+
const first = deferred<void>();
|
|
19
|
+
|
|
20
|
+
const p1 = run("k", async () => {
|
|
21
|
+
order.push("1-start");
|
|
22
|
+
await first.promise;
|
|
23
|
+
order.push("1-end");
|
|
24
|
+
});
|
|
25
|
+
const p2 = run("k", async () => {
|
|
26
|
+
order.push("2-start");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Second task must not start until the first finishes.
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
expect(order).toEqual(["1-start"]);
|
|
32
|
+
|
|
33
|
+
first.resolve();
|
|
34
|
+
await Promise.all([p1, p2]);
|
|
35
|
+
expect(order).toEqual(["1-start", "1-end", "2-start"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not block tasks under different keys", async () => {
|
|
39
|
+
const run = createKeyedSerializer();
|
|
40
|
+
const order: string[] = [];
|
|
41
|
+
const blockA = deferred<void>();
|
|
42
|
+
|
|
43
|
+
const pa = run("a", async () => {
|
|
44
|
+
order.push("a-start");
|
|
45
|
+
await blockA.promise;
|
|
46
|
+
order.push("a-end");
|
|
47
|
+
});
|
|
48
|
+
const pb = run("b", async () => {
|
|
49
|
+
order.push("b-start");
|
|
50
|
+
order.push("b-end");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Different key runs to completion while "a" is still blocked.
|
|
54
|
+
await pb;
|
|
55
|
+
expect(order).toEqual(["a-start", "b-start", "b-end"]);
|
|
56
|
+
|
|
57
|
+
blockA.resolve();
|
|
58
|
+
await pa;
|
|
59
|
+
expect(order).toEqual(["a-start", "b-start", "b-end", "a-end"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not wedge a key when a prior task rejects", async () => {
|
|
63
|
+
const run = createKeyedSerializer();
|
|
64
|
+
const order: string[] = [];
|
|
65
|
+
|
|
66
|
+
const p1 = run("k", async () => {
|
|
67
|
+
order.push("1");
|
|
68
|
+
throw new Error("boom");
|
|
69
|
+
});
|
|
70
|
+
await expect(p1).rejects.toThrow("boom");
|
|
71
|
+
|
|
72
|
+
const p2 = run("k", async () => {
|
|
73
|
+
order.push("2");
|
|
74
|
+
});
|
|
75
|
+
await p2;
|
|
76
|
+
expect(order).toEqual(["1", "2"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("propagates the task's resolved value to its caller", async () => {
|
|
80
|
+
const run = createKeyedSerializer();
|
|
81
|
+
await expect(run("k", async () => 42)).resolves.toBe(42);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key task serializer. Tasks sharing a key run strictly in order: a new
|
|
3
|
+
* task for a key awaits the prior task for that key before starting, so their
|
|
4
|
+
* effects (e.g. overlapping read-modify-write POSTs to one file) can't
|
|
5
|
+
* interleave. Tasks under different keys are independent and never block each
|
|
6
|
+
* other.
|
|
7
|
+
*
|
|
8
|
+
* Used to serialize GSAP meta-update commits per animationId so the shadow
|
|
9
|
+
* fidelity diff always pairs an op with the server result that includes it —
|
|
10
|
+
* without globally serializing unrelated commits.
|
|
11
|
+
*/
|
|
12
|
+
export function createKeyedSerializer() {
|
|
13
|
+
const inFlight = new Map<string, Promise<unknown>>();
|
|
14
|
+
|
|
15
|
+
return function run<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
16
|
+
const prior = inFlight.get(key) ?? Promise.resolve();
|
|
17
|
+
// Chain onto the prior task regardless of how it settled; a rejected prior
|
|
18
|
+
// commit must not wedge the key forever.
|
|
19
|
+
const next = prior.then(task, task);
|
|
20
|
+
inFlight.set(key, next);
|
|
21
|
+
// Once this task settles, drop it from the map if nothing newer replaced it,
|
|
22
|
+
// so completed keys don't leak.
|
|
23
|
+
void next.then(
|
|
24
|
+
() => {
|
|
25
|
+
if (inFlight.get(key) === next) inFlight.delete(key);
|
|
26
|
+
},
|
|
27
|
+
() => {
|
|
28
|
+
if (inFlight.get(key) === next) inFlight.delete(key);
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
return next;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -334,6 +334,7 @@ export function useDomEditSession({
|
|
|
334
334
|
commitAnimatedProperty,
|
|
335
335
|
handleSetArcPath,
|
|
336
336
|
handleUpdateArcSegment,
|
|
337
|
+
handleUnroll,
|
|
337
338
|
commitMutation,
|
|
338
339
|
} = useGsapAwareEditing({
|
|
339
340
|
domEditSelection,
|
|
@@ -420,6 +421,7 @@ export function useDomEditSession({
|
|
|
420
421
|
commitAnimatedProperty,
|
|
421
422
|
handleSetArcPath,
|
|
422
423
|
handleUpdateArcSegment,
|
|
424
|
+
handleUnroll,
|
|
423
425
|
invalidateGsapCache: bumpGsapCache,
|
|
424
426
|
previewIframeRef,
|
|
425
427
|
commitMutation,
|
|
@@ -42,7 +42,15 @@ export function useDomGeometryCommits({
|
|
|
42
42
|
}: UseDomGeometryCommitsParams) {
|
|
43
43
|
const handleDomPathOffsetCommit = useCallback(
|
|
44
44
|
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
45
|
-
|
|
45
|
+
const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element);
|
|
46
|
+
console.log(
|
|
47
|
+
"[drag:7] handleDomPathOffsetCommit (CSS path)",
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
sel: selection.id,
|
|
50
|
+
gsapBlocked,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
if (gsapBlocked) {
|
|
46
54
|
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
47
55
|
showToast(error.message, "error");
|
|
48
56
|
return Promise.reject(error);
|
|
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
|
|
|
40
40
|
animationId,
|
|
41
41
|
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
|
|
42
42
|
};
|
|
43
|
+
// coalesceKey groups rapid meta edits into one history entry. Request
|
|
44
|
+
// serialization is now handled per-file at the commitMutation chokepoint
|
|
45
|
+
// (useGsapScriptCommits), so no per-op serializeKey is needed here.
|
|
46
|
+
const metaKey = `gsap:${animationId}:meta`;
|
|
43
47
|
commitMutationSafely(
|
|
44
48
|
selection,
|
|
45
49
|
{ type: "update-meta", animationId, updates },
|
|
46
|
-
{ label: "Edit GSAP animation", coalesceKey:
|
|
50
|
+
{ label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
|
|
47
51
|
);
|
|
48
52
|
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
49
53
|
},
|
|
@@ -98,6 +98,17 @@ export function useGsapAwareEditing({
|
|
|
98
98
|
const handleGsapAwarePathOffsetCommit = useCallback(
|
|
99
99
|
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
100
100
|
const hasGsapAnims = selectedGsapAnimations.length > 0;
|
|
101
|
+
console.log(
|
|
102
|
+
"[drag:3] handleGsapAwarePathOffsetCommit",
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
sel: selection.id,
|
|
105
|
+
offset: next,
|
|
106
|
+
hasGsapAnims,
|
|
107
|
+
interceptEnabled: STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
|
|
108
|
+
animCount: selectedGsapAnimations.length,
|
|
109
|
+
animIds: selectedGsapAnimations.map((a) => a.id).slice(0, 5),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
101
112
|
if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
|
|
102
113
|
showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
|
|
103
114
|
throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
@@ -230,6 +241,15 @@ export function useGsapAwareEditing({
|
|
|
230
241
|
[domEditSelection, gsapCommitMutation],
|
|
231
242
|
);
|
|
232
243
|
|
|
244
|
+
// Unroll all computed (helper/loop) tweens in the active timeline into literal
|
|
245
|
+
// tweens, so the clicked keyframe becomes directly editable. Visual no-op.
|
|
246
|
+
const handleUnroll = useCallback(() => {
|
|
247
|
+
void commitMutation(
|
|
248
|
+
{ type: "unroll-timeline" },
|
|
249
|
+
{ label: "Unroll to literal tweens", softReload: true },
|
|
250
|
+
);
|
|
251
|
+
}, [commitMutation]);
|
|
252
|
+
|
|
233
253
|
return {
|
|
234
254
|
handleGsapAwarePathOffsetCommit,
|
|
235
255
|
handleGsapAwareBoxSizeCommit,
|
|
@@ -237,6 +257,7 @@ export function useGsapAwareEditing({
|
|
|
237
257
|
commitAnimatedProperty,
|
|
238
258
|
handleSetArcPath,
|
|
239
259
|
handleUpdateArcSegment,
|
|
260
|
+
handleUnroll,
|
|
240
261
|
commitMutation,
|
|
241
262
|
};
|
|
242
263
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
3
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
5
|
import { executeOptimistic } from "../utils/optimisticUpdate";
|
|
5
6
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
@@ -58,6 +59,13 @@ export function useGsapKeyframeOps({
|
|
|
58
59
|
percentage,
|
|
59
60
|
properties: { [property]: value },
|
|
60
61
|
};
|
|
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
|
+
};
|
|
61
69
|
void executeOptimisticKeyframeCacheUpdate({
|
|
62
70
|
sourceFile,
|
|
63
71
|
elementId: selection.id,
|
|
@@ -71,6 +79,7 @@ export function useGsapKeyframeOps({
|
|
|
71
79
|
commitMutation(selection, mutation, {
|
|
72
80
|
label: `Add keyframe at ${percentage}%`,
|
|
73
81
|
softReload: true,
|
|
82
|
+
shadowKeyframeOp,
|
|
74
83
|
}),
|
|
75
84
|
}).catch((error) => {
|
|
76
85
|
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
|
|
@@ -86,10 +95,16 @@ export function useGsapKeyframeOps({
|
|
|
86
95
|
percentage: number,
|
|
87
96
|
properties: Record<string, number | string>,
|
|
88
97
|
) => {
|
|
98
|
+
const shadowKeyframeOp: ShadowKeyframeOp = {
|
|
99
|
+
kind: "add",
|
|
100
|
+
animationId,
|
|
101
|
+
percentage,
|
|
102
|
+
properties,
|
|
103
|
+
};
|
|
89
104
|
return commitMutation(
|
|
90
105
|
selection,
|
|
91
106
|
{ type: "add-keyframe", animationId, percentage, properties },
|
|
92
|
-
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
|
107
|
+
{ label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
|
|
93
108
|
);
|
|
94
109
|
},
|
|
95
110
|
[commitMutation],
|
|
@@ -99,6 +114,10 @@ export function useGsapKeyframeOps({
|
|
|
99
114
|
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
|
100
115
|
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
|
101
116
|
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 };
|
|
102
121
|
void executeOptimisticKeyframeCacheUpdate({
|
|
103
122
|
sourceFile,
|
|
104
123
|
elementId: selection.id,
|
|
@@ -112,6 +131,7 @@ export function useGsapKeyframeOps({
|
|
|
112
131
|
commitMutation(selection, mutation, {
|
|
113
132
|
label: `Remove keyframe at ${percentage}%`,
|
|
114
133
|
softReload: true,
|
|
134
|
+
shadowKeyframeOp,
|
|
115
135
|
}),
|
|
116
136
|
}).catch((error) => {
|
|
117
137
|
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
|