@hyperframes/studio 0.6.91 → 0.6.93
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-BkwsVKGA.js} +24 -24
- package/dist/assets/index-DYRWmfMX.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 -13
- package/src/components/editor/gsapAnimatesProperty.ts +30 -0
- package/src/components/editor/manualEditingAvailability.test.ts +5 -5
- package/src/components/editor/manualEditingAvailability.ts +11 -7
- 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
|
@@ -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.
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
canSplitElement,
|
|
8
8
|
buildPatchTarget,
|
|
9
9
|
readFileContent,
|
|
10
|
-
|
|
10
|
+
isSplitTimeWithinBounds,
|
|
11
11
|
} from "../utils/timelineElementSplit";
|
|
12
12
|
import type { RecordEditInput } from "./useTimelineEditing";
|
|
13
13
|
|
|
@@ -171,12 +171,7 @@ export function useRazorSplit({
|
|
|
171
171
|
const pid = projectIdRef.current;
|
|
172
172
|
if (!pid || !canSplitElement(element)) return;
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
const clipEnd = element.start + element.duration;
|
|
176
|
-
if (
|
|
177
|
-
splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
|
|
178
|
-
splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
|
|
179
|
-
) {
|
|
174
|
+
if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
|
|
180
175
|
return;
|
|
181
176
|
}
|
|
182
177
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
4
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
@@ -24,8 +25,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
24
25
|
}: ClipContextMenuProps) {
|
|
25
26
|
const menuRef = useContextMenuDismiss(onClose);
|
|
26
27
|
|
|
27
|
-
const
|
|
28
|
-
const
|
|
28
|
+
const menuWidth = 200;
|
|
29
|
+
const menuHeight = 80;
|
|
30
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
31
|
+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
32
|
+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
29
33
|
|
|
30
34
|
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
|
|
31
35
|
const canSplit =
|
|
@@ -37,7 +41,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
37
41
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
38
42
|
: "Split (move playhead inside clip)";
|
|
39
43
|
|
|
40
|
-
return (
|
|
44
|
+
return createPortal(
|
|
41
45
|
<div
|
|
42
46
|
ref={menuRef}
|
|
43
47
|
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
@@ -78,6 +82,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
78
82
|
<span>Delete</span>
|
|
79
83
|
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
80
84
|
</button>
|
|
81
|
-
</div
|
|
85
|
+
</div>,
|
|
86
|
+
document.body,
|
|
82
87
|
);
|
|
83
88
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { memo
|
|
2
|
-
import {
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
3
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
4
|
|
|
5
5
|
export interface KeyframeDiamondContextMenuState {
|
|
@@ -7,6 +7,7 @@ export interface KeyframeDiamondContextMenuState {
|
|
|
7
7
|
y: number;
|
|
8
8
|
elementId: string;
|
|
9
9
|
percentage: number;
|
|
10
|
+
tweenPercentage?: number;
|
|
10
11
|
currentEase?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -15,105 +16,36 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
15
16
|
onClose: () => void;
|
|
16
17
|
onDelete: (elementId: string, percentage: number) => void;
|
|
17
18
|
onDeleteAll: (elementId: string) => void;
|
|
18
|
-
onChangeEase
|
|
19
|
-
onCopyProperties
|
|
19
|
+
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
20
|
+
onCopyProperties?: (elementId: string, percentage: number) => void;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
const EASE_PRESETS = [
|
|
23
|
-
"none",
|
|
24
|
-
"power1.out",
|
|
25
|
-
"power2.out",
|
|
26
|
-
"power3.out",
|
|
27
|
-
"power1.in",
|
|
28
|
-
"power2.in",
|
|
29
|
-
"power1.inOut",
|
|
30
|
-
"power2.inOut",
|
|
31
|
-
"back.out",
|
|
32
|
-
"elastic.out",
|
|
33
|
-
"bounce.out",
|
|
34
|
-
"expo.out",
|
|
35
|
-
] as const;
|
|
36
|
-
|
|
37
23
|
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
|
38
24
|
state,
|
|
39
25
|
onClose,
|
|
40
26
|
onDelete,
|
|
41
27
|
onDeleteAll,
|
|
42
|
-
onChangeEase,
|
|
43
|
-
onCopyProperties,
|
|
44
28
|
}: KeyframeDiamondContextMenuProps) {
|
|
45
29
|
const menuRef = useContextMenuDismiss(onClose);
|
|
46
|
-
const easeSubmenuRef = useRef<HTMLDivElement>(null);
|
|
47
|
-
|
|
48
|
-
const adjustedX = Math.min(state.x, window.innerWidth - 200);
|
|
49
|
-
const adjustedY = Math.min(state.y, window.innerHeight - 300);
|
|
50
30
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
31
|
+
const menuWidth = 200;
|
|
32
|
+
const menuHeight = 70;
|
|
33
|
+
const overflowY = state.y + menuHeight - window.innerHeight;
|
|
34
|
+
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
|
35
|
+
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
|
54
36
|
|
|
55
|
-
return (
|
|
37
|
+
return createPortal(
|
|
56
38
|
<div
|
|
57
39
|
ref={menuRef}
|
|
58
40
|
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
59
41
|
style={{ left: adjustedX, top: adjustedY }}
|
|
60
42
|
>
|
|
61
|
-
{/* Ease submenu */}
|
|
62
|
-
<div className="relative group">
|
|
63
|
-
<button
|
|
64
|
-
type="button"
|
|
65
|
-
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
66
|
-
>
|
|
67
|
-
<span>
|
|
68
|
-
Ease: <span className="text-neutral-500">{currentEaseLabel}</span>
|
|
69
|
-
</span>
|
|
70
|
-
<svg width="8" height="8" viewBox="0 0 8 8" className="text-neutral-500 ml-2">
|
|
71
|
-
<path d="M3 1l4 3-4 3" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
72
|
-
</svg>
|
|
73
|
-
</button>
|
|
74
|
-
<div
|
|
75
|
-
ref={easeSubmenuRef}
|
|
76
|
-
className="absolute left-full top-0 ml-0.5 hidden group-hover:block bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px] max-h-[300px] overflow-y-auto"
|
|
77
|
-
>
|
|
78
|
-
{EASE_PRESETS.map((ease) => (
|
|
79
|
-
<button
|
|
80
|
-
key={ease}
|
|
81
|
-
type="button"
|
|
82
|
-
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-neutral-800 cursor-pointer text-left ${
|
|
83
|
-
ease === state.currentEase ? "text-white font-medium" : "text-neutral-300"
|
|
84
|
-
}`}
|
|
85
|
-
onClick={() => {
|
|
86
|
-
onChangeEase(state.elementId, state.percentage, ease);
|
|
87
|
-
onClose();
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
{ease === state.currentEase && (
|
|
91
|
-
<svg
|
|
92
|
-
width="8"
|
|
93
|
-
height="8"
|
|
94
|
-
viewBox="0 0 8 8"
|
|
95
|
-
className="text-green-400 flex-shrink-0"
|
|
96
|
-
>
|
|
97
|
-
<path d="M1 4l2 2 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
98
|
-
</svg>
|
|
99
|
-
)}
|
|
100
|
-
<span className={ease === state.currentEase ? "" : "ml-[16px]"}>
|
|
101
|
-
{EASE_LABELS[ease] ?? ease}
|
|
102
|
-
</span>
|
|
103
|
-
</button>
|
|
104
|
-
))}
|
|
105
|
-
</div>
|
|
106
|
-
</div>
|
|
107
|
-
|
|
108
|
-
{/* Separator */}
|
|
109
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
110
|
-
|
|
111
43
|
{/* Delete */}
|
|
112
44
|
<button
|
|
113
45
|
type="button"
|
|
114
46
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
115
47
|
onClick={() => {
|
|
116
|
-
onDelete(state.elementId, state.percentage);
|
|
48
|
+
onDelete(state.elementId, state.tweenPercentage ?? state.percentage);
|
|
117
49
|
onClose();
|
|
118
50
|
}}
|
|
119
51
|
>
|
|
@@ -130,18 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
130
62
|
>
|
|
131
63
|
Delete All Keyframes
|
|
132
64
|
</button>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
<button
|
|
136
|
-
type="button"
|
|
137
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
138
|
-
onClick={() => {
|
|
139
|
-
onCopyProperties(state.elementId, state.percentage);
|
|
140
|
-
onClose();
|
|
141
|
-
}}
|
|
142
|
-
>
|
|
143
|
-
Copy Properties
|
|
144
|
-
</button>
|
|
145
|
-
</div>
|
|
65
|
+
</div>,
|
|
66
|
+
document.body,
|
|
146
67
|
);
|
|
147
68
|
});
|
|
@@ -448,6 +448,9 @@ export const Timeline = memo(function Timeline({
|
|
|
448
448
|
onSelectElement?.(el);
|
|
449
449
|
const absTime = el.start + (pct / 100) * el.duration;
|
|
450
450
|
onSeek?.(absTime);
|
|
451
|
+
const kfData = keyframeCache?.get(elKey);
|
|
452
|
+
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
|
|
453
|
+
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
|
451
454
|
}}
|
|
452
455
|
onShiftClickKeyframe={(elId, pct) => {
|
|
453
456
|
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
@@ -464,12 +467,13 @@ export const Timeline = memo(function Timeline({
|
|
|
464
467
|
onSeek?.(absTime);
|
|
465
468
|
}
|
|
466
469
|
const kfData = keyframeCache.get(elId);
|
|
467
|
-
const kf = kfData?.keyframes.find((k) => k.percentage
|
|
470
|
+
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
|
468
471
|
setKfContextMenu({
|
|
469
|
-
x: e.clientX,
|
|
470
|
-
y: e.clientY,
|
|
472
|
+
x: e.clientX + 4,
|
|
473
|
+
y: e.clientY + 2,
|
|
471
474
|
elementId: elId,
|
|
472
475
|
percentage: pct,
|
|
476
|
+
tweenPercentage: kf?.tweenPercentage,
|
|
473
477
|
currentEase: kf?.ease ?? kfData?.ease,
|
|
474
478
|
});
|
|
475
479
|
}}
|