@hyperframes/studio 0.6.105 → 0.6.106
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-By4Ku5HI.js → index-CBS_7p5W.js} +1 -1
- package/dist/assets/{index-NIdeStFw.js → index-CRCE5Dz_.js} +1 -1
- package/dist/assets/index-koqvg-_0.js +296 -0
- 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/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommits.ts +9 -1
- package/src/hooks/useGsapAwareEditing.ts +21 -0
- package/src/hooks/useGsapTweenCache.ts +49 -7
- 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
|
}
|
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -211,6 +211,7 @@ export function useGsapAnimationsForElement(
|
|
|
211
211
|
keyframes: runtime.keyframes,
|
|
212
212
|
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
|
|
213
213
|
},
|
|
214
|
+
...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
|
|
214
215
|
};
|
|
215
216
|
});
|
|
216
217
|
}
|
|
@@ -243,6 +244,7 @@ export function useGsapAnimationsForElement(
|
|
|
243
244
|
keyframes: runtimeEntry.keyframes,
|
|
244
245
|
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
|
|
245
246
|
},
|
|
247
|
+
...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
|
|
246
248
|
},
|
|
247
249
|
];
|
|
248
250
|
}
|
|
@@ -358,19 +360,30 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
358
360
|
|
|
359
361
|
const sf = sourceFile;
|
|
360
362
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
361
|
-
if (!parsed)
|
|
363
|
+
if (!parsed) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
362
366
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
363
|
-
// Drop the file's stale entries (including the bare keys consumers read)
|
|
364
|
-
// before repopulating, so an element whose keyframes were removed and is
|
|
365
|
-
// absent from this scan doesn't keep showing diamonds.
|
|
366
367
|
clearKeyframeCacheForFile(sf);
|
|
367
368
|
const { elements } = usePlayerStore.getState();
|
|
369
|
+
console.log(
|
|
370
|
+
"[kf:static] elements in store:",
|
|
371
|
+
elements
|
|
372
|
+
.map((e) => e.domId)
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
.join(", "),
|
|
375
|
+
);
|
|
368
376
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
369
377
|
for (const anim of parsed.animations) {
|
|
370
378
|
const id = extractIdFromSelector(anim.targetSelector);
|
|
371
379
|
if (!id) continue;
|
|
380
|
+
if (anim.hasUnresolvedKeyframes) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
372
383
|
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
373
|
-
if (!kfData)
|
|
384
|
+
if (!kfData) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
374
387
|
const tweenPos =
|
|
375
388
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
376
389
|
const tweenDur = anim.duration ?? 1;
|
|
@@ -402,6 +415,12 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
402
415
|
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
403
416
|
}
|
|
404
417
|
}
|
|
418
|
+
console.log(
|
|
419
|
+
"[kf:static] merged elements:",
|
|
420
|
+
[...mergedByElement.keys()].join(", "),
|
|
421
|
+
"kf counts:",
|
|
422
|
+
[...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
|
|
423
|
+
);
|
|
405
424
|
for (const [id, kfData] of mergedByElement) {
|
|
406
425
|
setKeyframeCache(`${sf}#${id}`, kfData);
|
|
407
426
|
setKeyframeCache(id, kfData);
|
|
@@ -428,14 +447,37 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
428
447
|
const iframe =
|
|
429
448
|
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
|
430
449
|
if (!iframe) return false;
|
|
431
|
-
|
|
450
|
+
// Clip dims per element so the scan converts tween-relative keyframes to
|
|
451
|
+
// clip-relative (matching the static path) instead of timeline-relative.
|
|
452
|
+
const clipById = new Map<string, { start: number; duration: number }>();
|
|
453
|
+
for (const el of usePlayerStore.getState().elements) {
|
|
454
|
+
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
|
|
455
|
+
}
|
|
456
|
+
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
|
457
|
+
console.log(
|
|
458
|
+
"[kf:runtime] scanned",
|
|
459
|
+
scanned.size,
|
|
460
|
+
"elements:",
|
|
461
|
+
[...scanned.keys()].join(", "),
|
|
462
|
+
);
|
|
432
463
|
if (scanned.size === 0) return false;
|
|
433
464
|
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
|
434
465
|
for (const [id, data] of scanned) {
|
|
435
466
|
const cacheKey = `${sf}#${id}`;
|
|
436
467
|
const fallbackKey = `index.html#${id}`;
|
|
437
|
-
|
|
468
|
+
const alreadyCached =
|
|
469
|
+
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
|
|
470
|
+
if (alreadyCached) {
|
|
438
471
|
continue;
|
|
472
|
+
}
|
|
473
|
+
console.log(
|
|
474
|
+
"[kf:runtime] adding runtime entry:",
|
|
475
|
+
id,
|
|
476
|
+
"kfs:",
|
|
477
|
+
data.keyframes.length,
|
|
478
|
+
"arc:",
|
|
479
|
+
!!data.arcPath,
|
|
480
|
+
);
|
|
439
481
|
const entry = {
|
|
440
482
|
format: "percentage" as const,
|
|
441
483
|
keyframes: data.keyframes,
|