@hyperframes/studio 0.6.91 → 0.6.92
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-DSLrl2tB.js → index-CDy8BuGq.js} +24 -24
- package/dist/assets/index-CmRIkCwI.js +251 -0
- package/dist/assets/index-rm9tn9nH.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioPreviewArea.tsx +48 -13
- package/src/components/TimelineToolbar.tsx +0 -21
- package/src/components/editor/DomEditOverlay.tsx +79 -0
- package/src/components/editor/PropertyPanel.tsx +19 -10
- package/src/components/editor/gsapAnimatesProperty.ts +30 -0
- package/src/components/editor/manualEditingAvailability.ts +10 -6
- package/src/components/editor/manualEditsDom.ts +25 -5
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
- package/src/components/editor/manualEditsDomPatches.ts +17 -1
- package/src/components/editor/manualEditsSnapshot.ts +16 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
- package/src/components/editor/useOffScreenIndicators.ts +197 -0
- package/src/components/nle/NLELayout.tsx +16 -14
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +119 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
- package/src/hooks/gsapRuntimeBridge.ts +266 -41
- package/src/hooks/gsapRuntimeReaders.ts +16 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
- package/src/hooks/useDomEditCommits.ts +7 -1
- package/src/hooks/useDomEditSession.ts +13 -0
- package/src/hooks/useEnableKeyframes.ts +3 -1
- package/src/hooks/useGestureCommit.ts +99 -13
- package/src/hooks/useGestureRecording.ts +18 -2
- package/src/hooks/useGsapScriptCommits.ts +24 -3
- package/src/hooks/useGsapSelectionHandlers.ts +19 -3
- package/src/hooks/useGsapTweenCache.ts +30 -10
- package/src/hooks/useRazorSplit.ts +2 -7
- package/src/player/components/ClipContextMenu.tsx +9 -4
- package/src/player/components/KeyframeDiamondContextMenu.tsx +14 -93
- package/src/player/components/Timeline.tsx +7 -3
- package/src/player/components/TimelineClipDiamonds.tsx +3 -1
- package/src/player/store/playerStore.ts +12 -0
- package/src/utils/globalTimeCompiler.test.ts +2 -2
- package/src/utils/globalTimeCompiler.ts +2 -1
- package/src/utils/gsapSoftReload.test.ts +16 -0
- package/src/utils/gsapSoftReload.ts +43 -8
- package/src/utils/rdpSimplify.ts +3 -2
- package/src/utils/timelineElementSplit.test.ts +50 -0
- package/src/utils/timelineElementSplit.ts +16 -0
- package/dist/assets/index-CgYcO2PV.js +0 -146
- package/dist/assets/index-D2NkPomd.css +0 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* absolute positions back into the GSAP script, regardless of tween type,
|
|
9
9
|
* easing, or seek position.
|
|
10
10
|
*/
|
|
11
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
computeCurrentPercentage,
|
|
19
19
|
materializeIfDynamic,
|
|
20
20
|
} from "./gsapDragCommit";
|
|
21
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
21
22
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
22
23
|
|
|
23
24
|
// ── Runtime reads ──────────────────────────────────────────────────────────
|
|
@@ -87,7 +88,7 @@ function findGsapPositionAnimation(
|
|
|
87
88
|
if (a.keyframes) score += 5;
|
|
88
89
|
if (selector && a.targetSelector === selector) score += 8;
|
|
89
90
|
else if (a.targetSelector.includes(",")) score -= 5;
|
|
90
|
-
const pos = typeof a.position === "number" ? a.position : 0;
|
|
91
|
+
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
91
92
|
const dur = a.duration ?? 0;
|
|
92
93
|
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
|
|
93
94
|
return { anim: a, score };
|
|
@@ -104,6 +105,74 @@ function selectorForSelection(selection: DomEditSelection): string | null {
|
|
|
104
105
|
return null;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// ── Property-group tween resolution ───────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Find the tween for a given property group, splitting a legacy mixed tween
|
|
112
|
+
* if necessary. Returns the resolved animation or null if none exists.
|
|
113
|
+
*
|
|
114
|
+
* Resolution order:
|
|
115
|
+
* 1. Tween already tagged with `propertyGroup === group`
|
|
116
|
+
* 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
|
|
117
|
+
* re-fetch, then return the group tween
|
|
118
|
+
* 3. null — caller must handle the missing-tween case
|
|
119
|
+
*/
|
|
120
|
+
async function resolveGroupTween(
|
|
121
|
+
group: PropertyGroupName,
|
|
122
|
+
animations: GsapAnimation[],
|
|
123
|
+
selection: DomEditSelection,
|
|
124
|
+
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
125
|
+
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
126
|
+
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
|
|
127
|
+
// 1. Already-split group tween — prefer the one with the most keyframes
|
|
128
|
+
// to avoid targeting a stub when a gesture-recorded tween also exists.
|
|
129
|
+
const groupAnims = animations.filter((a) => a.propertyGroup === group);
|
|
130
|
+
const groupAnim =
|
|
131
|
+
groupAnims.length > 1
|
|
132
|
+
? groupAnims.sort(
|
|
133
|
+
(a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0),
|
|
134
|
+
)[0]
|
|
135
|
+
: (groupAnims[0] ?? null);
|
|
136
|
+
if (groupAnim) return { anim: groupAnim, animations };
|
|
137
|
+
|
|
138
|
+
// 2. Legacy mixed tween — split it, then re-fetch
|
|
139
|
+
const legacyMixed = animations.find((a) => !a.propertyGroup);
|
|
140
|
+
if (legacyMixed) {
|
|
141
|
+
await commitMutation(
|
|
142
|
+
selection,
|
|
143
|
+
{ type: "split-into-property-groups", animationId: legacyMixed.id },
|
|
144
|
+
{ label: "Split mixed tween into property groups", skipReload: true },
|
|
145
|
+
);
|
|
146
|
+
if (fetchFallbackAnimations) {
|
|
147
|
+
const fresh = await fetchFallbackAnimations();
|
|
148
|
+
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
|
|
149
|
+
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Try fallback fetch (no split needed, just wasn't in the initial list)
|
|
154
|
+
if (!legacyMixed && fetchFallbackAnimations) {
|
|
155
|
+
const fresh = await fetchFallbackAnimations();
|
|
156
|
+
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
|
|
157
|
+
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
|
|
158
|
+
|
|
159
|
+
// Fallback: legacy mixed in the fresh list
|
|
160
|
+
const freshLegacy = fresh.find((a) => !a.propertyGroup);
|
|
161
|
+
if (freshLegacy) {
|
|
162
|
+
await commitMutation(
|
|
163
|
+
selection,
|
|
164
|
+
{ type: "split-into-property-groups", animationId: freshLegacy.id },
|
|
165
|
+
{ label: "Split mixed tween into property groups", skipReload: true },
|
|
166
|
+
);
|
|
167
|
+
const reFetched = await fetchFallbackAnimations();
|
|
168
|
+
const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
|
|
169
|
+
if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
107
176
|
// ── High-level intercept ───────────────────────────────────────────────────
|
|
108
177
|
|
|
109
178
|
export type { GsapDragCommitCallbacks };
|
|
@@ -127,10 +196,24 @@ export async function tryGsapDragIntercept(
|
|
|
127
196
|
const selector = selectorForSelection(selection);
|
|
128
197
|
if (!selector) return false;
|
|
129
198
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
199
|
+
// Resolve the position-group tween, splitting legacy mixed tweens if needed.
|
|
200
|
+
const resolved = await resolveGroupTween(
|
|
201
|
+
"position",
|
|
202
|
+
animations,
|
|
203
|
+
selection,
|
|
204
|
+
commitMutation,
|
|
205
|
+
fetchFallbackAnimations,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Fallback: use the legacy scoring heuristic for compositions that don't
|
|
209
|
+
// have group-tagged tweens at all (e.g. hand-written scripts).
|
|
210
|
+
let posAnim = resolved?.anim ?? null;
|
|
211
|
+
if (!posAnim) {
|
|
212
|
+
posAnim = findGsapPositionAnimation(animations, selector);
|
|
213
|
+
if (!posAnim && fetchFallbackAnimations) {
|
|
214
|
+
const fresh = await fetchFallbackAnimations();
|
|
215
|
+
posAnim = findGsapPositionAnimation(fresh, selector);
|
|
216
|
+
}
|
|
134
217
|
}
|
|
135
218
|
if (!posAnim) return false;
|
|
136
219
|
|
|
@@ -143,6 +226,7 @@ export async function tryGsapDragIntercept(
|
|
|
143
226
|
|
|
144
227
|
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
|
|
145
228
|
commitMutation,
|
|
229
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
146
230
|
});
|
|
147
231
|
return true;
|
|
148
232
|
}
|
|
@@ -151,6 +235,22 @@ export async function tryGsapDragIntercept(
|
|
|
151
235
|
|
|
152
236
|
export { readGsapProperty, readAllAnimatedProperties };
|
|
153
237
|
|
|
238
|
+
// ── Identity-prop synthesis ───────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
241
|
+
|
|
242
|
+
/** Build identity (zero / one) values for each property in `source`. */
|
|
243
|
+
function synthesizeIdentityProps(
|
|
244
|
+
source: Record<string, number | string>,
|
|
245
|
+
): Record<string, number | string> {
|
|
246
|
+
const id: Record<string, number | string> = {};
|
|
247
|
+
for (const [k, v] of Object.entries(source)) {
|
|
248
|
+
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
249
|
+
else id[k] = v;
|
|
250
|
+
}
|
|
251
|
+
return id;
|
|
252
|
+
}
|
|
253
|
+
|
|
154
254
|
// ── Resize intercept ──────────────────────────────────────────────────────
|
|
155
255
|
|
|
156
256
|
export async function tryGsapResizeIntercept(
|
|
@@ -161,46 +261,155 @@ export async function tryGsapResizeIntercept(
|
|
|
161
261
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
162
262
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
163
263
|
): Promise<boolean> {
|
|
164
|
-
|
|
165
|
-
|
|
264
|
+
// If the element already has a scale-group tween, resize should modify scale
|
|
265
|
+
// (the user is resizing something whose visual size is driven by scale).
|
|
266
|
+
// Otherwise, use the size group (width/height).
|
|
267
|
+
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
|
|
268
|
+
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
|
269
|
+
const resolved = await resolveGroupTween(
|
|
270
|
+
resizeGroup,
|
|
271
|
+
animations,
|
|
272
|
+
selection,
|
|
273
|
+
commitMutation,
|
|
274
|
+
fetchFallbackAnimations,
|
|
166
275
|
);
|
|
167
|
-
if (!anim && fetchFallbackAnimations) {
|
|
168
|
-
const fresh = await fetchFallbackAnimations();
|
|
169
|
-
anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes);
|
|
170
|
-
}
|
|
171
|
-
if (!anim) return false;
|
|
172
|
-
|
|
173
|
-
const pct = computeCurrentPercentage(selection, anim);
|
|
174
276
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
277
|
+
let anim = resolved?.anim ?? null;
|
|
278
|
+
if (!anim) {
|
|
279
|
+
// No size-group tween exists — create one. Use the element's timing
|
|
280
|
+
// from any existing animation, or fall back to element data attributes.
|
|
281
|
+
const refAnim = animations[0];
|
|
282
|
+
const elStart =
|
|
283
|
+
refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0);
|
|
284
|
+
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5;
|
|
285
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
286
|
+
const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0;
|
|
287
|
+
const sel = selectorForSelection(selection);
|
|
288
|
+
if (!sel) return false;
|
|
179
289
|
await commitMutation(
|
|
180
290
|
selection,
|
|
181
|
-
{
|
|
182
|
-
|
|
291
|
+
{
|
|
292
|
+
type: "add-with-keyframes",
|
|
293
|
+
targetSelector: sel,
|
|
294
|
+
position: Math.round(elStart * 1000) / 1000,
|
|
295
|
+
duration: Math.round(elDuration * 1000) / 1000,
|
|
296
|
+
keyframes: [
|
|
297
|
+
{
|
|
298
|
+
percentage: Math.max(0, Math.min(100, pct)),
|
|
299
|
+
properties: { width: Math.round(size.width), height: Math.round(size.height) },
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
{ label: "Resize (new size keyframe)", softReload: true },
|
|
183
304
|
);
|
|
305
|
+
return true;
|
|
184
306
|
}
|
|
185
307
|
|
|
308
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
309
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
310
|
+
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
311
|
+
const coalesceKey = `gsap:resize:${anim.id}`;
|
|
312
|
+
|
|
186
313
|
const selector = selectorForSelection(selection);
|
|
187
314
|
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
188
315
|
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
const
|
|
192
|
-
|
|
316
|
+
let resizeProps: Record<string, number>;
|
|
317
|
+
if (resizeGroup === "scale") {
|
|
318
|
+
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
|
|
319
|
+
// The resize draft modifies el.style.width, so read the ORIGINAL width
|
|
320
|
+
// saved by the draft system before it ran.
|
|
321
|
+
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
|
|
322
|
+
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
|
|
323
|
+
const newScale = Math.round((size.width / cssW) * 1000) / 1000;
|
|
324
|
+
resizeProps = { scale: newScale };
|
|
325
|
+
} else {
|
|
326
|
+
resizeProps = {
|
|
327
|
+
width: Math.round(size.width),
|
|
328
|
+
height: Math.round(size.height),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
332
|
+
const ts = resolveTweenStart(anim);
|
|
333
|
+
const td = resolveTweenDuration(anim);
|
|
334
|
+
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
|
335
|
+
// Outside-range uses the extend path which handles everything atomically.
|
|
336
|
+
if (!outsideRange) {
|
|
337
|
+
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
338
|
+
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
339
|
+
if (newId) anim = { ...anim, id: newId };
|
|
340
|
+
} else if (!anim.keyframes) {
|
|
341
|
+
const resolvedFromValues = selector
|
|
342
|
+
? readAllAnimatedProperties(iframe, selector, anim)
|
|
343
|
+
: undefined;
|
|
344
|
+
await commitMutation(
|
|
345
|
+
selection,
|
|
346
|
+
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
347
|
+
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
193
350
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
351
|
+
|
|
352
|
+
if (outsideRange && ts !== null) {
|
|
353
|
+
// For flat tweens, synthesize the keyframes from the tween's properties
|
|
354
|
+
const kfs =
|
|
355
|
+
anim.keyframes?.keyframes ??
|
|
356
|
+
(() => {
|
|
357
|
+
const fromProps =
|
|
358
|
+
anim.method === "from" || anim.method === "fromTo"
|
|
359
|
+
? { ...anim.properties }
|
|
360
|
+
: synthesizeIdentityProps(anim.properties);
|
|
361
|
+
const toProps =
|
|
362
|
+
anim.method === "from"
|
|
363
|
+
? synthesizeIdentityProps(anim.properties)
|
|
364
|
+
: { ...anim.properties };
|
|
365
|
+
return [
|
|
366
|
+
{ percentage: 0, properties: fromProps },
|
|
367
|
+
{ percentage: 100, properties: toProps },
|
|
368
|
+
];
|
|
369
|
+
})();
|
|
370
|
+
const newStart = Math.min(ct, ts);
|
|
371
|
+
const newEnd = Math.max(ct, ts + td);
|
|
372
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
373
|
+
const existingKfs = kfs;
|
|
374
|
+
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
|
375
|
+
for (const kf of existingKfs) {
|
|
376
|
+
const absTime = ts + (kf.percentage / 100) * td;
|
|
377
|
+
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
378
|
+
const props = { ...kf.properties };
|
|
379
|
+
// Only backfill properties that the animation already had (x, y, scale).
|
|
380
|
+
// Don't backfill width/height — they should only appear on the resize keyframe.
|
|
381
|
+
for (const k of Object.keys(resizeProps)) {
|
|
382
|
+
if (k in props) continue;
|
|
383
|
+
if (k === "width" || k === "height") continue;
|
|
384
|
+
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
385
|
+
}
|
|
386
|
+
remapped.push({ percentage: newPct, properties: props });
|
|
387
|
+
}
|
|
388
|
+
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
|
|
389
|
+
remapped.push({ percentage: targetPct, properties: resizeProps });
|
|
390
|
+
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
391
|
+
|
|
392
|
+
await commitMutation(
|
|
393
|
+
selection,
|
|
394
|
+
{
|
|
395
|
+
type: "replace-with-keyframes",
|
|
396
|
+
animationId: anim.id,
|
|
397
|
+
targetSelector: anim.targetSelector,
|
|
398
|
+
position: Math.round(newStart * 1000) / 1000,
|
|
399
|
+
duration: Math.round(newDuration * 1000) / 1000,
|
|
400
|
+
keyframes: remapped,
|
|
401
|
+
},
|
|
402
|
+
{ label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey },
|
|
403
|
+
);
|
|
404
|
+
return true;
|
|
197
405
|
}
|
|
198
406
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
407
|
+
const SIZE_PROPS = new Set(["width", "height"]);
|
|
408
|
+
const backfillDefaults: Record<string, number> = {};
|
|
409
|
+
for (const k of Object.keys(runtimeProps)) {
|
|
410
|
+
if (SIZE_PROPS.has(k)) continue;
|
|
411
|
+
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
412
|
+
}
|
|
204
413
|
|
|
205
414
|
await commitMutation(
|
|
206
415
|
selection,
|
|
@@ -208,10 +417,10 @@ export async function tryGsapResizeIntercept(
|
|
|
208
417
|
type: "add-keyframe",
|
|
209
418
|
animationId: anim.id,
|
|
210
419
|
percentage: pct,
|
|
211
|
-
properties,
|
|
420
|
+
properties: resizeProps,
|
|
212
421
|
backfillDefaults,
|
|
213
422
|
},
|
|
214
|
-
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
|
423
|
+
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
|
|
215
424
|
);
|
|
216
425
|
return true;
|
|
217
426
|
}
|
|
@@ -226,10 +435,23 @@ export async function tryGsapRotationIntercept(
|
|
|
226
435
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
227
436
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
228
437
|
): Promise<boolean> {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
438
|
+
// Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
|
|
439
|
+
const resolved = await resolveGroupTween(
|
|
440
|
+
"rotation",
|
|
441
|
+
animations,
|
|
442
|
+
selection,
|
|
443
|
+
commitMutation,
|
|
444
|
+
fetchFallbackAnimations,
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Fallback: legacy heuristic for hand-written scripts
|
|
448
|
+
let anim = resolved?.anim ?? null;
|
|
449
|
+
if (!anim) {
|
|
450
|
+
anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
|
|
451
|
+
if (!anim && fetchFallbackAnimations) {
|
|
452
|
+
const fresh = await fetchFallbackAnimations();
|
|
453
|
+
anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
|
|
454
|
+
}
|
|
233
455
|
}
|
|
234
456
|
if (!anim) return false;
|
|
235
457
|
|
|
@@ -261,14 +483,17 @@ export async function tryGsapRotationIntercept(
|
|
|
261
483
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
262
484
|
if (newId) anim = { ...anim, id: newId };
|
|
263
485
|
} else if (!anim.keyframes) {
|
|
486
|
+
const resolvedFromValues = selector
|
|
487
|
+
? readAllAnimatedProperties(iframe, selector, anim, "rotation")
|
|
488
|
+
: undefined;
|
|
264
489
|
await commitMutation(
|
|
265
490
|
selection,
|
|
266
|
-
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
491
|
+
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
267
492
|
{ label: "Convert to keyframes for rotation", skipReload: true },
|
|
268
493
|
);
|
|
269
494
|
}
|
|
270
495
|
|
|
271
|
-
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
|
496
|
+
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
|
|
272
497
|
|
|
273
498
|
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
|
274
499
|
if (!("rotation" in runtimeProps)) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit.
|
|
3
3
|
*/
|
|
4
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
|
+
import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
5
6
|
|
|
6
7
|
interface IframeGsap {
|
|
7
8
|
getProperty: (el: Element, prop: string) => number;
|
|
@@ -19,7 +20,8 @@ export function readGsapProperty(
|
|
|
19
20
|
const el = iframe.contentDocument?.querySelector(selector);
|
|
20
21
|
if (!el) return null;
|
|
21
22
|
const val = Number(gsap.getProperty(el, prop));
|
|
22
|
-
|
|
23
|
+
if (!Number.isFinite(val)) return null;
|
|
24
|
+
return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
|
|
23
25
|
} catch {
|
|
24
26
|
return null;
|
|
25
27
|
}
|
|
@@ -51,6 +53,7 @@ export function readAllAnimatedProperties(
|
|
|
51
53
|
iframe: HTMLIFrameElement | null,
|
|
52
54
|
selector: string,
|
|
53
55
|
anim: GsapAnimation,
|
|
56
|
+
group?: PropertyGroupName,
|
|
54
57
|
): Record<string, number> {
|
|
55
58
|
const result: Record<string, number> = {};
|
|
56
59
|
if (!iframe?.contentWindow) return result;
|
|
@@ -81,6 +84,13 @@ export function readAllAnimatedProperties(
|
|
|
81
84
|
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
// When a group filter is specified, only keep properties belonging to that group.
|
|
88
|
+
if (group) {
|
|
89
|
+
for (const p of propKeys) {
|
|
90
|
+
if (classifyPropertyGroup(p) !== group) propKeys.delete(p);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
for (const prop of propKeys) {
|
|
85
95
|
const val = Number(gsap.getProperty(el, prop));
|
|
86
96
|
if (Number.isFinite(val)) {
|
|
@@ -147,9 +157,13 @@ export function readAllAnimatedProperties(
|
|
|
147
157
|
sepia: 0,
|
|
148
158
|
invert: 0,
|
|
149
159
|
};
|
|
160
|
+
// Collect all properties that ANY tween on this element explicitly targets.
|
|
161
|
+
// Only capture baseline values for these — GSAP reports non-default values
|
|
162
|
+
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
|
|
163
|
+
const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
|
|
150
164
|
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
|
|
151
165
|
if (prop in result) continue;
|
|
152
|
-
if (
|
|
166
|
+
if (!allTweenedProps.has(prop)) continue;
|
|
153
167
|
const val = Number(gsap.getProperty(el, prop));
|
|
154
168
|
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
|
|
155
169
|
result[prop] = Math.round(val * 1000) / 1000;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useCallback } from "react";
|
|
10
10
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
11
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
12
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
14
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
@@ -38,7 +39,7 @@ interface CommitAnimatedPropertyDeps {
|
|
|
38
39
|
|
|
39
40
|
function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number {
|
|
40
41
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
41
|
-
const tweenPos = typeof anim?.position === "number" ? anim.position : 0;
|
|
42
|
+
const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0);
|
|
42
43
|
const tweenDur = anim?.duration ?? 0;
|
|
43
44
|
if (tweenDur > 0) {
|
|
44
45
|
return Math.max(
|
|
@@ -56,18 +57,19 @@ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): n
|
|
|
56
57
|
function pickBestAnimation(
|
|
57
58
|
animations: GsapAnimation[],
|
|
58
59
|
selector: string | null,
|
|
60
|
+
property?: string,
|
|
59
61
|
): GsapAnimation | undefined {
|
|
60
62
|
if (animations.length <= 1) return animations[0];
|
|
61
63
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
64
|
+
const targetGroup = property ? classifyPropertyGroup(property) : undefined;
|
|
62
65
|
|
|
63
66
|
const scored = animations.map((a) => {
|
|
64
67
|
let score = 0;
|
|
68
|
+
if (targetGroup && a.propertyGroup === targetGroup) score += 20;
|
|
65
69
|
if (a.keyframes) score += 10;
|
|
66
|
-
// Prefer single-element selectors over comma-separated groups
|
|
67
70
|
if (selector && a.targetSelector === selector) score += 5;
|
|
68
71
|
else if (a.targetSelector.includes(",")) score -= 3;
|
|
69
|
-
|
|
70
|
-
const pos = typeof a.position === "number" ? a.position : 0;
|
|
72
|
+
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
71
73
|
const dur = a.duration ?? 0;
|
|
72
74
|
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8;
|
|
73
75
|
return { anim: a, score };
|
|
@@ -102,7 +104,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
102
104
|
const iframe = previewIframeRef.current;
|
|
103
105
|
const selector = selectorFor(selection);
|
|
104
106
|
|
|
105
|
-
let anim: GsapAnimation | undefined = pickBestAnimation(
|
|
107
|
+
let anim: GsapAnimation | undefined = pickBestAnimation(
|
|
108
|
+
selectedGsapAnimations,
|
|
109
|
+
selector,
|
|
110
|
+
property,
|
|
111
|
+
);
|
|
106
112
|
|
|
107
113
|
// Case 3: No animation — create one first
|
|
108
114
|
if (!anim) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
|
+
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
3
4
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
4
5
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
5
6
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
@@ -41,10 +42,13 @@ import type { EditHistoryKind } from "../utils/editHistory";
|
|
|
41
42
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
42
43
|
|
|
43
44
|
// ── Helpers ──
|
|
44
|
-
|
|
45
45
|
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
46
46
|
|
|
47
|
+
// fallow-ignore-next-line complexity
|
|
47
48
|
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
|
|
49
|
+
// When the GSAP drag intercept is disabled for debugging, treat every
|
|
50
|
+
// element as un-targeted so commits take the plain CSS persist path.
|
|
51
|
+
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
|
48
52
|
if (!iframe?.contentWindow) return false;
|
|
49
53
|
let timelines: Record<string, TimelineLike> | undefined;
|
|
50
54
|
try {
|
|
@@ -168,6 +172,7 @@ export function useDomEditCommits({
|
|
|
168
172
|
|
|
169
173
|
// fallow-ignore-next-line complexity
|
|
170
174
|
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
|
175
|
+
// fallow-ignore-next-line complexity
|
|
171
176
|
async (selection, operations, options) => {
|
|
172
177
|
const pid = projectIdRef.current;
|
|
173
178
|
if (!pid) throw new Error("No active project");
|
|
@@ -455,6 +460,7 @@ export function useDomEditCommits({
|
|
|
455
460
|
|
|
456
461
|
// fallow-ignore-next-line complexity
|
|
457
462
|
const handleDomEditElementDelete = useCallback(
|
|
463
|
+
// fallow-ignore-next-line complexity
|
|
458
464
|
async (selection: DomEditSelection) => {
|
|
459
465
|
const pid = projectIdRef.current;
|
|
460
466
|
if (!pid) return;
|
|
@@ -262,6 +262,7 @@ export function useDomEditSession({
|
|
|
262
262
|
updateGsapProperty,
|
|
263
263
|
updateGsapMeta,
|
|
264
264
|
deleteGsapAnimation,
|
|
265
|
+
deleteAllForSelector,
|
|
265
266
|
addGsapAnimation,
|
|
266
267
|
addGsapProperty,
|
|
267
268
|
removeGsapProperty,
|
|
@@ -329,6 +330,14 @@ export function useDomEditSession({
|
|
|
329
330
|
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
|
|
330
331
|
const handleGsapAwarePathOffsetCommit = useCallback(
|
|
331
332
|
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
333
|
+
const hasGsapAnims = selectedGsapAnimations.length > 0;
|
|
334
|
+
if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
|
|
335
|
+
showToast(
|
|
336
|
+
"This element is GSAP-animated — dragging via CSS would corrupt keyframes",
|
|
337
|
+
"error",
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
332
341
|
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
333
342
|
const handled = await tryGsapDragIntercept(
|
|
334
343
|
selection,
|
|
@@ -356,6 +365,7 @@ export function useDomEditSession({
|
|
|
356
365
|
previewIframeRef,
|
|
357
366
|
projectId,
|
|
358
367
|
gsapSourceFile,
|
|
368
|
+
showToast,
|
|
359
369
|
],
|
|
360
370
|
);
|
|
361
371
|
|
|
@@ -425,6 +435,7 @@ export function useDomEditSession({
|
|
|
425
435
|
handleGsapUpdateProperty,
|
|
426
436
|
handleGsapUpdateMeta,
|
|
427
437
|
handleGsapDeleteAnimation,
|
|
438
|
+
handleGsapDeleteAllForElement,
|
|
428
439
|
handleGsapAddAnimation,
|
|
429
440
|
handleGsapAddProperty,
|
|
430
441
|
handleGsapRemoveProperty,
|
|
@@ -442,6 +453,7 @@ export function useDomEditSession({
|
|
|
442
453
|
updateGsapProperty,
|
|
443
454
|
updateGsapMeta,
|
|
444
455
|
deleteGsapAnimation,
|
|
456
|
+
deleteAllForSelector,
|
|
445
457
|
addGsapAnimation,
|
|
446
458
|
addGsapProperty,
|
|
447
459
|
removeGsapProperty,
|
|
@@ -550,6 +562,7 @@ export function useDomEditSession({
|
|
|
550
562
|
handleGsapUpdateProperty,
|
|
551
563
|
handleGsapUpdateMeta,
|
|
552
564
|
handleGsapDeleteAnimation,
|
|
565
|
+
handleGsapDeleteAllForElement,
|
|
553
566
|
handleGsapAddAnimation,
|
|
554
567
|
handleGsapAddProperty,
|
|
555
568
|
handleGsapRemoveProperty,
|
|
@@ -52,10 +52,12 @@ function readElementPosition(
|
|
|
52
52
|
const element = sel.element;
|
|
53
53
|
if (!element?.isConnected || !gsap?.getProperty) return result;
|
|
54
54
|
|
|
55
|
+
const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
|
|
55
56
|
const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
|
|
56
57
|
for (const prop of props) {
|
|
57
58
|
const val = Number(gsap.getProperty(element, prop));
|
|
58
|
-
if (Number.isFinite(val))
|
|
59
|
+
if (!Number.isFinite(val)) continue;
|
|
60
|
+
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
return result;
|