@hyperframes/studio 0.6.90 → 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/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +54 -13
- package/src/components/TimelineToolbar.tsx +52 -35
- 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.test.ts +12 -0
- package/src/components/editor/manualEditingAvailability.ts +16 -0
- 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 +22 -32
- package/src/components/nle/TimelineEditorNotice.tsx +2 -25
- 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/useAppHotkeys.ts +48 -1
- package/src/hooks/useContextMenuDismiss.ts +29 -0
- package/src/hooks/useDomEditCommits.ts +7 -1
- package/src/hooks/useDomEditSession.ts +20 -4
- 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 +298 -0
- package/src/hooks/useTimelineEditing.ts +15 -98
- package/src/player/components/ClipContextMenu.tsx +14 -25
- package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
- package/src/player/components/PlayheadIndicator.tsx +43 -0
- package/src/player/components/Timeline.tsx +45 -38
- package/src/player/components/TimelineCanvas.tsx +29 -22
- package/src/player/components/TimelineClipDiamonds.tsx +3 -1
- package/src/player/components/timelineCallbacks.ts +44 -0
- package/src/player/components/timelineDragDrop.ts +2 -14
- package/src/player/components/useTimelineZoom.ts +18 -0
- package/src/player/store/playerStore.ts +20 -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 +32 -0
- package/dist/assets/index-BKuDHMYl.js +0 -146
- package/dist/assets/index-D2NkPomd.css +0 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
|
|
6
|
+
STUDIO_GSAP_PANEL_ENABLED,
|
|
7
|
+
} from "../components/editor/manualEditingAvailability";
|
|
5
8
|
import { type DomEditSelection } from "../components/editor/domEditing";
|
|
6
9
|
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
|
7
10
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
@@ -259,6 +262,7 @@ export function useDomEditSession({
|
|
|
259
262
|
updateGsapProperty,
|
|
260
263
|
updateGsapMeta,
|
|
261
264
|
deleteGsapAnimation,
|
|
265
|
+
deleteAllForSelector,
|
|
262
266
|
addGsapAnimation,
|
|
263
267
|
addGsapProperty,
|
|
264
268
|
removeGsapProperty,
|
|
@@ -326,7 +330,15 @@ export function useDomEditSession({
|
|
|
326
330
|
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
|
|
327
331
|
const handleGsapAwarePathOffsetCommit = useCallback(
|
|
328
332
|
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
329
|
-
|
|
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
|
+
}
|
|
341
|
+
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
330
342
|
const handled = await tryGsapDragIntercept(
|
|
331
343
|
selection,
|
|
332
344
|
next,
|
|
@@ -353,6 +365,7 @@ export function useDomEditSession({
|
|
|
353
365
|
previewIframeRef,
|
|
354
366
|
projectId,
|
|
355
367
|
gsapSourceFile,
|
|
368
|
+
showToast,
|
|
356
369
|
],
|
|
357
370
|
);
|
|
358
371
|
|
|
@@ -372,7 +385,7 @@ export function useDomEditSession({
|
|
|
372
385
|
|
|
373
386
|
const handleGsapAwareBoxSizeCommit = useCallback(
|
|
374
387
|
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
375
|
-
if (gsapCommitMutation) {
|
|
388
|
+
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
376
389
|
const handled = await tryGsapResizeIntercept(
|
|
377
390
|
selection,
|
|
378
391
|
next,
|
|
@@ -396,7 +409,7 @@ export function useDomEditSession({
|
|
|
396
409
|
|
|
397
410
|
const handleGsapAwareRotationCommit = useCallback(
|
|
398
411
|
async (selection: DomEditSelection, next: { angle: number }) => {
|
|
399
|
-
if (gsapCommitMutation) {
|
|
412
|
+
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
400
413
|
const handled = await tryGsapRotationIntercept(
|
|
401
414
|
selection,
|
|
402
415
|
next.angle,
|
|
@@ -422,6 +435,7 @@ export function useDomEditSession({
|
|
|
422
435
|
handleGsapUpdateProperty,
|
|
423
436
|
handleGsapUpdateMeta,
|
|
424
437
|
handleGsapDeleteAnimation,
|
|
438
|
+
handleGsapDeleteAllForElement,
|
|
425
439
|
handleGsapAddAnimation,
|
|
426
440
|
handleGsapAddProperty,
|
|
427
441
|
handleGsapRemoveProperty,
|
|
@@ -439,6 +453,7 @@ export function useDomEditSession({
|
|
|
439
453
|
updateGsapProperty,
|
|
440
454
|
updateGsapMeta,
|
|
441
455
|
deleteGsapAnimation,
|
|
456
|
+
deleteAllForSelector,
|
|
442
457
|
addGsapAnimation,
|
|
443
458
|
addGsapProperty,
|
|
444
459
|
removeGsapProperty,
|
|
@@ -547,6 +562,7 @@ export function useDomEditSession({
|
|
|
547
562
|
handleGsapUpdateProperty,
|
|
548
563
|
handleGsapUpdateMeta,
|
|
549
564
|
handleGsapDeleteAnimation,
|
|
565
|
+
handleGsapDeleteAllForElement,
|
|
550
566
|
handleGsapAddAnimation,
|
|
551
567
|
handleGsapAddProperty,
|
|
552
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;
|
|
@@ -7,10 +7,13 @@ import { useGestureRecording } from "./useGestureRecording";
|
|
|
7
7
|
import { simplifyGestureSamples } from "../utils/rdpSimplify";
|
|
8
8
|
import { usePlayerStore } from "../player";
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
10
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
10
12
|
|
|
11
13
|
// Minimal subset of the session used by gesture commit
|
|
12
14
|
interface GestureSessionRef {
|
|
13
15
|
domEditSelection: DomEditSelection | null;
|
|
16
|
+
selectedGsapAnimations?: GsapAnimation[];
|
|
14
17
|
commitMutation?: (
|
|
15
18
|
mutation: Record<string, unknown>,
|
|
16
19
|
options: { label: string; softReload?: boolean },
|
|
@@ -43,6 +46,9 @@ export function useGestureCommit({
|
|
|
43
46
|
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
44
47
|
const recordingStartTimeRef = useRef(0);
|
|
45
48
|
const commitInFlightRef = useRef(false);
|
|
49
|
+
// Capture selection at recording start so commit always targets the recorded element,
|
|
50
|
+
// even if the user's selection changes mid-recording.
|
|
51
|
+
const capturedSelectionRef = useRef<DomEditSelection | null>(null);
|
|
46
52
|
|
|
47
53
|
// Unmount: clear auto-stop interval
|
|
48
54
|
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
|
|
@@ -50,7 +56,9 @@ export function useGestureCommit({
|
|
|
50
56
|
// fallow-ignore-next-line complexity
|
|
51
57
|
const stopAndCommitRecording = useCallback(async () => {
|
|
52
58
|
clearInterval(recordingAutoStopRef.current);
|
|
53
|
-
if (commitInFlightRef.current)
|
|
59
|
+
if (commitInFlightRef.current) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
54
62
|
commitInFlightRef.current = true;
|
|
55
63
|
gestureStateRef.current = "idle";
|
|
56
64
|
isGestureRecordingRef.current = false;
|
|
@@ -59,7 +67,7 @@ export function useGestureCommit({
|
|
|
59
67
|
store.setIsPlaying(false);
|
|
60
68
|
try {
|
|
61
69
|
const liveSession = domEditSessionRef.current;
|
|
62
|
-
const sel =
|
|
70
|
+
const sel = capturedSelectionRef.current;
|
|
63
71
|
if (!sel) {
|
|
64
72
|
if (frozenSamples.length > 2) {
|
|
65
73
|
showToast("Selection lost during recording", "error");
|
|
@@ -77,7 +85,13 @@ export function useGestureCommit({
|
|
|
77
85
|
return;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
|
|
88
|
+
// Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10)
|
|
89
|
+
// need a much tighter tolerance than positional properties (x/y in px).
|
|
90
|
+
const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
|
|
91
|
+
if (key === "opacity") return 0.01;
|
|
92
|
+
if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
|
|
93
|
+
return 5;
|
|
94
|
+
});
|
|
81
95
|
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
|
|
82
96
|
|
|
83
97
|
// Ensure a 0% keyframe exists with the element's start-of-recording position
|
|
@@ -97,19 +111,90 @@ export function useGestureCommit({
|
|
|
97
111
|
percentage: pct,
|
|
98
112
|
properties: simplified.get(pct) as Record<string, number | string>,
|
|
99
113
|
}));
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
{
|
|
103
|
-
type: "add-with-keyframes",
|
|
104
|
-
targetSelector: selector,
|
|
105
|
-
position: Math.round(recStart * 1000) / 1000,
|
|
106
|
-
duration: Math.round(duration * 1000) / 1000,
|
|
107
|
-
keyframes,
|
|
108
|
-
},
|
|
109
|
-
{ label: "Gesture recording", softReload: true },
|
|
114
|
+
const hasPositionProps = keyframes.some((kf) =>
|
|
115
|
+
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
|
|
110
116
|
);
|
|
117
|
+
const allAnims = liveSession.selectedGsapAnimations ?? [];
|
|
118
|
+
const existingPositionTween = hasPositionProps
|
|
119
|
+
? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
|
|
120
|
+
: undefined;
|
|
121
|
+
if (existingPositionTween) {
|
|
122
|
+
const tweenStart = existingPositionTween.resolvedStart ?? 0;
|
|
123
|
+
const tweenDur = existingPositionTween.duration ?? duration;
|
|
124
|
+
const tweenEnd = tweenStart + tweenDur;
|
|
125
|
+
const recEnd = recStart + duration;
|
|
126
|
+
|
|
127
|
+
// Only merge if the recording overlaps the existing tween's time range.
|
|
128
|
+
// No overlap → fall through to add-with-keyframes (creates a separate tween).
|
|
129
|
+
const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
|
|
130
|
+
|
|
131
|
+
if (overlaps) {
|
|
132
|
+
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
|
|
133
|
+
const rangeStartPct =
|
|
134
|
+
tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
|
|
135
|
+
const rangeEndPct =
|
|
136
|
+
tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
|
|
137
|
+
|
|
138
|
+
const preserved = existingKfs
|
|
139
|
+
.filter(
|
|
140
|
+
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
|
|
141
|
+
)
|
|
142
|
+
.map((kf) => ({
|
|
143
|
+
percentage: kf.percentage,
|
|
144
|
+
properties: kf.properties,
|
|
145
|
+
...(kf.ease ? { ease: kf.ease } : {}),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
const mapped = keyframes.map((kf) => ({
|
|
149
|
+
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
|
|
150
|
+
properties: kf.properties,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
|
|
154
|
+
|
|
155
|
+
await liveSession.commitMutation(
|
|
156
|
+
{
|
|
157
|
+
type: "replace-with-keyframes",
|
|
158
|
+
animationId: existingPositionTween.id,
|
|
159
|
+
targetSelector: selector,
|
|
160
|
+
position:
|
|
161
|
+
typeof existingPositionTween.position === "number"
|
|
162
|
+
? existingPositionTween.position
|
|
163
|
+
: tweenStart,
|
|
164
|
+
duration: tweenDur,
|
|
165
|
+
keyframes: merged,
|
|
166
|
+
},
|
|
167
|
+
{ label: "Gesture recording (merge)", softReload: true },
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
await liveSession.commitMutation(
|
|
171
|
+
{
|
|
172
|
+
type: "add-with-keyframes",
|
|
173
|
+
targetSelector: selector,
|
|
174
|
+
position: Math.round(recStart * 1000) / 1000,
|
|
175
|
+
duration: Math.round(duration * 1000) / 1000,
|
|
176
|
+
keyframes,
|
|
177
|
+
},
|
|
178
|
+
{ label: "Gesture recording (new range)", softReload: true },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
await liveSession.commitMutation(
|
|
183
|
+
{
|
|
184
|
+
type: "add-with-keyframes",
|
|
185
|
+
targetSelector: selector,
|
|
186
|
+
position: Math.round(recStart * 1000) / 1000,
|
|
187
|
+
duration: Math.round(duration * 1000) / 1000,
|
|
188
|
+
keyframes,
|
|
189
|
+
},
|
|
190
|
+
{ label: "Gesture recording", softReload: true },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
111
193
|
}
|
|
112
194
|
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error("[GR:error]", err);
|
|
197
|
+
showToast(`Gesture commit failed: ${err}`, "error");
|
|
113
198
|
} finally {
|
|
114
199
|
store.requestSeek(recordingStartTimeRef.current);
|
|
115
200
|
gestureRecording.clearSamples();
|
|
@@ -139,6 +224,7 @@ export function useGestureCommit({
|
|
|
139
224
|
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
140
225
|
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
|
|
141
226
|
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
|
|
227
|
+
capturedSelectionRef.current = sel;
|
|
142
228
|
gestureRecording.startRecording(sel.element, iframe, elementEnd);
|
|
143
229
|
gestureStateRef.current = "recording";
|
|
144
230
|
isGestureRecordingRef.current = true;
|
|
@@ -126,8 +126,12 @@ function applyRuntimePreview(
|
|
|
126
126
|
|
|
127
127
|
function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
|
|
128
128
|
const sampleProps = { ...properties };
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
// Subtract both the CSS var offset AND the pointer-element snap offset
|
|
130
|
+
// so the first sample doesn't include the snap-to-cursor jump.
|
|
131
|
+
if ("x" in sampleProps)
|
|
132
|
+
sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1);
|
|
133
|
+
if ("y" in sampleProps)
|
|
134
|
+
sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1);
|
|
131
135
|
r.samples.push({ time, properties: sampleProps });
|
|
132
136
|
r.trail.push({ x: r.pointer.x, y: r.pointer.y });
|
|
133
137
|
}
|
|
@@ -307,6 +311,18 @@ export function useGestureRecording() {
|
|
|
307
311
|
};
|
|
308
312
|
|
|
309
313
|
const handleWheel = (e: WheelEvent) => {
|
|
314
|
+
// Capture startPointer on first wheel if no pointermove has fired yet,
|
|
315
|
+
// preventing an enormous bogus first keyframe from stale startPointer.
|
|
316
|
+
if (!r.hasMoved) {
|
|
317
|
+
r.startPointer = { x: r.pointer.x, y: r.pointer.y };
|
|
318
|
+
r.pointerElementOffset = {
|
|
319
|
+
x: r.pointer.x - elCenterViewport.x,
|
|
320
|
+
y: r.pointer.y - elCenterViewport.y,
|
|
321
|
+
};
|
|
322
|
+
r.basePosition.x += r.pointerElementOffset.x / iframeScale;
|
|
323
|
+
r.basePosition.y += r.pointerElementOffset.y / iframeScale;
|
|
324
|
+
r.hasMoved = true;
|
|
325
|
+
}
|
|
310
326
|
r.scrollDelta += e.deltaY;
|
|
311
327
|
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
|
|
312
328
|
};
|
|
@@ -51,6 +51,7 @@ function ensureElementAddressable(selection: DomEditSelection): {
|
|
|
51
51
|
|
|
52
52
|
interface MutationResult {
|
|
53
53
|
ok: boolean;
|
|
54
|
+
changed?: boolean;
|
|
54
55
|
parsed?: ParsedGsap;
|
|
55
56
|
before?: string;
|
|
56
57
|
after?: string;
|
|
@@ -131,9 +132,16 @@ export function useGsapScriptCommits({
|
|
|
131
132
|
const pid = projectIdRef.current;
|
|
132
133
|
if (!pid) return;
|
|
133
134
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
134
|
-
|
|
135
135
|
const result = await mutateGsapScript(pid, targetPath, mutation);
|
|
136
|
-
if (!result
|
|
136
|
+
if (!result) {
|
|
137
|
+
if (options.skipReload) return;
|
|
138
|
+
throw new Error(`Mutation failed: ${mutation.type}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (result.changed === false) {
|
|
142
|
+
if (options.skipReload) return;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
137
145
|
|
|
138
146
|
domEditSaveTimestampRef.current = Date.now();
|
|
139
147
|
|
|
@@ -252,6 +260,16 @@ export function useGsapScriptCommits({
|
|
|
252
260
|
},
|
|
253
261
|
[commitMutation],
|
|
254
262
|
);
|
|
263
|
+
const deleteAllForSelector = useCallback(
|
|
264
|
+
(selection: DomEditSelection, targetSelector: string) => {
|
|
265
|
+
void commitMutation(
|
|
266
|
+
selection,
|
|
267
|
+
{ type: "delete-all-for-selector", targetSelector },
|
|
268
|
+
{ label: "Delete all animations for element" },
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
[commitMutation],
|
|
272
|
+
);
|
|
255
273
|
const addGsapAnimation = useCallback(
|
|
256
274
|
// fallow-ignore-next-line complexity
|
|
257
275
|
async (
|
|
@@ -441,7 +459,9 @@ export function useGsapScriptCommits({
|
|
|
441
459
|
apply: () => {
|
|
442
460
|
const prev = readKeyframeSnapshot(sf, elementId);
|
|
443
461
|
if (prev) {
|
|
444
|
-
const newKeyframes = prev.keyframes.filter(
|
|
462
|
+
const newKeyframes = prev.keyframes.filter(
|
|
463
|
+
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
|
|
464
|
+
);
|
|
445
465
|
writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
|
|
446
466
|
}
|
|
447
467
|
return prev;
|
|
@@ -548,6 +568,7 @@ export function useGsapScriptCommits({
|
|
|
548
568
|
updateGsapProperty,
|
|
549
569
|
updateGsapMeta,
|
|
550
570
|
deleteGsapAnimation,
|
|
571
|
+
deleteAllForSelector,
|
|
551
572
|
addGsapAnimation,
|
|
552
573
|
addGsapProperty,
|
|
553
574
|
removeGsapProperty,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
4
|
|
|
@@ -13,6 +13,7 @@ export function useGsapSelectionHandlers({
|
|
|
13
13
|
updateGsapProperty,
|
|
14
14
|
updateGsapMeta,
|
|
15
15
|
deleteGsapAnimation,
|
|
16
|
+
deleteAllForSelector,
|
|
16
17
|
addGsapAnimation,
|
|
17
18
|
addGsapProperty,
|
|
18
19
|
removeGsapProperty,
|
|
@@ -40,6 +41,7 @@ export function useGsapSelectionHandlers({
|
|
|
40
41
|
updates: { duration?: number; ease?: string; position?: number },
|
|
41
42
|
) => void;
|
|
42
43
|
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
|
|
44
|
+
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
|
|
43
45
|
addGsapAnimation: (
|
|
44
46
|
sel: DomEditSelection,
|
|
45
47
|
method: "to" | "from" | "set" | "fromTo",
|
|
@@ -79,6 +81,9 @@ export function useGsapSelectionHandlers({
|
|
|
79
81
|
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
|
80
82
|
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
|
|
81
83
|
}) {
|
|
84
|
+
const lastSelectionRef = useRef<DomEditSelection | null>(null);
|
|
85
|
+
if (domEditSelection) lastSelectionRef.current = domEditSelection;
|
|
86
|
+
|
|
82
87
|
const handleGsapUpdateProperty = useCallback(
|
|
83
88
|
(animId: string, prop: string, value: number | string) => {
|
|
84
89
|
if (!domEditSelection) return;
|
|
@@ -97,12 +102,22 @@ export function useGsapSelectionHandlers({
|
|
|
97
102
|
|
|
98
103
|
const handleGsapDeleteAnimation = useCallback(
|
|
99
104
|
(animId: string) => {
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
const sel = domEditSelection ?? lastSelectionRef.current;
|
|
106
|
+
if (!sel) return;
|
|
107
|
+
deleteGsapAnimation(sel, animId);
|
|
102
108
|
},
|
|
103
109
|
[domEditSelection, deleteGsapAnimation],
|
|
104
110
|
);
|
|
105
111
|
|
|
112
|
+
const handleGsapDeleteAllForElement = useCallback(
|
|
113
|
+
(targetSelector: string) => {
|
|
114
|
+
const sel = domEditSelection ?? lastSelectionRef.current;
|
|
115
|
+
if (!sel) return;
|
|
116
|
+
deleteAllForSelector(sel, targetSelector);
|
|
117
|
+
},
|
|
118
|
+
[domEditSelection, deleteAllForSelector],
|
|
119
|
+
);
|
|
120
|
+
|
|
106
121
|
const handleGsapAddAnimation = useCallback(
|
|
107
122
|
(method: "to" | "from" | "set" | "fromTo") => {
|
|
108
123
|
if (!domEditSelection) return;
|
|
@@ -205,6 +220,7 @@ export function useGsapSelectionHandlers({
|
|
|
205
220
|
handleGsapUpdateProperty,
|
|
206
221
|
handleGsapUpdateMeta,
|
|
207
222
|
handleGsapDeleteAnimation,
|
|
223
|
+
handleGsapDeleteAllForElement,
|
|
208
224
|
handleGsapAddAnimation,
|
|
209
225
|
handleGsapAddProperty,
|
|
210
226
|
handleGsapRemoveProperty,
|
|
@@ -264,9 +264,11 @@ export function useGsapAnimationsForElement(
|
|
|
264
264
|
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
265
265
|
);
|
|
266
266
|
const elStart = timelineEl?.start ?? 0;
|
|
267
|
-
const elDuration = timelineEl?.duration ??
|
|
267
|
+
const elDuration = timelineEl?.duration ?? 1;
|
|
268
268
|
|
|
269
|
-
const allKeyframes:
|
|
269
|
+
const allKeyframes: Array<
|
|
270
|
+
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
|
|
271
|
+
> = [];
|
|
270
272
|
let format: GsapKeyframesData["format"] = "percentage";
|
|
271
273
|
let ease: string | undefined;
|
|
272
274
|
let easeEach: string | undefined;
|
|
@@ -275,7 +277,8 @@ export function useGsapAnimationsForElement(
|
|
|
275
277
|
if (!kf) continue;
|
|
276
278
|
// Convert tween-relative percentages to clip-relative so diamonds
|
|
277
279
|
// render at the correct position within the timeline clip.
|
|
278
|
-
const tweenPos =
|
|
280
|
+
const tweenPos =
|
|
281
|
+
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
279
282
|
const tweenDur = anim.duration ?? elDuration;
|
|
280
283
|
for (const k of kf.keyframes) {
|
|
281
284
|
const absTime = tweenPos + (k.percentage / 100) * tweenDur;
|
|
@@ -283,7 +286,12 @@ export function useGsapAnimationsForElement(
|
|
|
283
286
|
elDuration > 0
|
|
284
287
|
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
|
285
288
|
: k.percentage;
|
|
286
|
-
allKeyframes.push({
|
|
289
|
+
allKeyframes.push({
|
|
290
|
+
...k,
|
|
291
|
+
percentage: clipPct,
|
|
292
|
+
tweenPercentage: k.percentage,
|
|
293
|
+
propertyGroup: anim.propertyGroup,
|
|
294
|
+
});
|
|
287
295
|
}
|
|
288
296
|
format = kf.format;
|
|
289
297
|
if (kf.ease) ease = kf.ease;
|
|
@@ -305,6 +313,9 @@ export function useGsapAnimationsForElement(
|
|
|
305
313
|
};
|
|
306
314
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
307
315
|
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
|
|
316
|
+
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
|
|
317
|
+
// so write a duplicate entry under the bare key for cross-component lookups.
|
|
318
|
+
setKeyframeCache(elementId, merged);
|
|
308
319
|
}, [elementId, sourceFile, animations]);
|
|
309
320
|
|
|
310
321
|
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
|
@@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
327
338
|
version: number,
|
|
328
339
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
329
340
|
): void {
|
|
341
|
+
const elementCount = usePlayerStore((s) => s.elements.length);
|
|
330
342
|
const lastFetchKeyRef = useRef("");
|
|
331
343
|
|
|
332
344
|
const runtimeScanDoneRef = useRef("");
|
|
333
345
|
const astFetchDoneRef = useRef("");
|
|
334
346
|
|
|
335
347
|
useEffect(() => {
|
|
336
|
-
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
|
348
|
+
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
|
|
337
349
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
338
350
|
lastFetchKeyRef.current = fetchKey;
|
|
339
351
|
runtimeScanDoneRef.current = "";
|
|
@@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
358
370
|
if (!id) continue;
|
|
359
371
|
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
360
372
|
if (!kfData) continue;
|
|
361
|
-
|
|
362
|
-
|
|
373
|
+
const tweenPos =
|
|
374
|
+
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
363
375
|
const tweenDur = anim.duration ?? 1;
|
|
364
376
|
const timelineEl = elements.find(
|
|
365
377
|
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
|
366
378
|
);
|
|
367
379
|
const elStart = timelineEl?.start ?? 0;
|
|
368
|
-
const elDuration = timelineEl?.duration ??
|
|
380
|
+
const elDuration = timelineEl?.duration ?? 1;
|
|
369
381
|
const clipKeyframes = kfData.keyframes.map((kf) => {
|
|
370
382
|
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
|
371
383
|
const clipPct =
|
|
372
384
|
elDuration > 0
|
|
373
385
|
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
|
374
386
|
: kf.percentage;
|
|
375
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
...kf,
|
|
389
|
+
percentage: clipPct,
|
|
390
|
+
tweenPercentage: kf.percentage,
|
|
391
|
+
propertyGroup: anim.propertyGroup,
|
|
392
|
+
};
|
|
376
393
|
});
|
|
377
394
|
const existing = mergedByElement.get(id);
|
|
378
395
|
if (existing) {
|
|
@@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
388
405
|
}
|
|
389
406
|
astFetchDoneRef.current = fetchKey;
|
|
390
407
|
});
|
|
391
|
-
|
|
408
|
+
// elementCount is in the deps because new timeline elements (e.g. after a
|
|
409
|
+
// sub-composition expand) need their keyframe cache populated immediately;
|
|
410
|
+
// without it the effect won't re-run when elements appear/disappear.
|
|
411
|
+
}, [projectId, sourceFile, version, elementCount]);
|
|
392
412
|
|
|
393
413
|
// Separate effect for runtime keyframe discovery — polls until the iframe
|
|
394
414
|
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|