@hyperframes/studio 0.6.88 → 0.6.90
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-BKuDHMYl.js +146 -0
- package/dist/assets/index-D2NkPomd.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +33 -193
- package/src/components/StudioLeftSidebar.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +54 -31
- package/src/components/editor/AnimationCard.tsx +15 -3
- package/src/components/editor/DomEditOverlay.test.ts +34 -1
- package/src/components/editor/FileTree.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +17 -3
- package/src/components/editor/LayersPanel.tsx +19 -4
- package/src/components/editor/PropertyPanel.tsx +82 -170
- package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
- package/src/components/editor/gsapAnimatesProperty.ts +52 -0
- package/src/components/editor/manualEditsDom.ts +11 -57
- package/src/components/editor/manualOffsetDrag.test.ts +18 -1
- package/src/components/editor/manualOffsetDrag.ts +16 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
- package/src/components/editor/propertyPanelHelpers.ts +76 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useLayerDrag.ts +6 -3
- package/src/components/renders/RenderQueueItem.tsx +47 -46
- package/src/components/sidebar/CompositionsTab.tsx +15 -2
- package/src/components/sidebar/LeftSidebar.tsx +11 -0
- package/src/hooks/gsapDragCommit.ts +294 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
- package/src/hooks/gsapRuntimeBridge.ts +49 -402
- package/src/hooks/gsapRuntimeReaders.ts +201 -0
- package/src/hooks/timelineEditingHelpers.ts +148 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
- package/src/hooks/useBlockHandlers.ts +150 -0
- package/src/hooks/useClipboard.ts +1 -10
- package/src/hooks/useDomEditPreviewSync.ts +126 -0
- package/src/hooks/useDomEditSession.ts +11 -79
- package/src/hooks/useGestureCommit.ts +166 -0
- package/src/hooks/useGestureRecording.ts +271 -169
- package/src/hooks/useGsapScriptCommits.ts +7 -80
- package/src/hooks/useLintModal.ts +97 -25
- package/src/hooks/useTimelineEditing.ts +10 -132
- package/src/player/components/TimelineCanvas.tsx +24 -7
- package/src/player/components/useTimelinePlayhead.ts +2 -1
- package/src/player/store/playerStore.ts +12 -0
- package/src/utils/gsapSoftReload.ts +18 -1
- package/src/utils/studioUrlState.test.ts +9 -0
- package/dist/assets/index-B9_ctmee.js +0 -143
- package/dist/assets/index-CGlIm_-E.css +0 -1
|
@@ -18,6 +18,128 @@ interface AccumulatedState {
|
|
|
18
18
|
z: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
interface BasePosition {
|
|
22
|
+
baseX: number;
|
|
23
|
+
baseY: number;
|
|
24
|
+
baseOpacity: number;
|
|
25
|
+
baseScale: number;
|
|
26
|
+
cssOffX: number;
|
|
27
|
+
cssOffY: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GsapRuntime {
|
|
31
|
+
seek: (t: number) => void;
|
|
32
|
+
set: (target: string, vars: Record<string, number>) => void;
|
|
33
|
+
selector: string;
|
|
34
|
+
element: HTMLElement;
|
|
35
|
+
startTime: number;
|
|
36
|
+
maxSeekTime: number;
|
|
37
|
+
savedVisibility: string;
|
|
38
|
+
savedTranslate: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Extracted helpers — pure functions, no refs, no React.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function readBasePosition(element: HTMLElement, iframeEl: HTMLIFrameElement): BasePosition {
|
|
46
|
+
let baseOpacity = 1;
|
|
47
|
+
let baseScale = 1;
|
|
48
|
+
let baseX = 0;
|
|
49
|
+
let baseY = 0;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const gsap = (
|
|
53
|
+
iframeEl.contentWindow as Window & {
|
|
54
|
+
gsap?: { getProperty: (el: Element, prop: string) => number };
|
|
55
|
+
}
|
|
56
|
+
).gsap;
|
|
57
|
+
if (gsap?.getProperty) {
|
|
58
|
+
baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
|
|
59
|
+
baseScale = Number(gsap.getProperty(element, "scaleX")) || 1;
|
|
60
|
+
baseX = Number(gsap.getProperty(element, "x")) || 0;
|
|
61
|
+
baseY = Number(gsap.getProperty(element, "y")) || 0;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* cross-origin guard */
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Path-offset CSS vars live on the element regardless of whether
|
|
68
|
+
// translate is currently var-based or "none" (GSAP-baked).
|
|
69
|
+
const cssOffX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
|
|
70
|
+
const cssOffY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
|
|
71
|
+
const translateVal = element.style.translate ?? "";
|
|
72
|
+
if (translateVal.includes("var(")) {
|
|
73
|
+
baseX += cssOffX;
|
|
74
|
+
baseY += cssOffY;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { baseX, baseY, baseOpacity, baseScale, cssOffX, cssOffY };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function connectGsapRuntime(
|
|
81
|
+
element: HTMLElement,
|
|
82
|
+
iframeEl: HTMLIFrameElement,
|
|
83
|
+
selector: string | null,
|
|
84
|
+
elementEndTime: number | undefined,
|
|
85
|
+
): GsapRuntime | null {
|
|
86
|
+
try {
|
|
87
|
+
const win = iframeEl.contentWindow as Window & {
|
|
88
|
+
gsap?: { set: (t: string, v: Record<string, number>) => void };
|
|
89
|
+
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
|
|
90
|
+
__player?: { getTime: () => number };
|
|
91
|
+
};
|
|
92
|
+
const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
|
|
93
|
+
if (win?.gsap?.set && tl?.seek && selector) {
|
|
94
|
+
const tlDuration = tl.duration();
|
|
95
|
+
return {
|
|
96
|
+
seek: tl.seek.bind(tl),
|
|
97
|
+
set: win.gsap.set.bind(win.gsap),
|
|
98
|
+
selector,
|
|
99
|
+
element,
|
|
100
|
+
startTime: win.__player?.getTime() ?? 0,
|
|
101
|
+
maxSeekTime:
|
|
102
|
+
elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
|
|
103
|
+
savedVisibility: element.style.visibility,
|
|
104
|
+
savedTranslate: element.style.getPropertyValue("translate"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* cross-origin or missing runtime */
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function applyRuntimePreview(
|
|
114
|
+
runtime: GsapRuntime,
|
|
115
|
+
time: number,
|
|
116
|
+
properties: Record<string, number>,
|
|
117
|
+
): void {
|
|
118
|
+
const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
|
|
119
|
+
runtime.seek(seekTime);
|
|
120
|
+
runtime.element.style.setProperty("translate", "none");
|
|
121
|
+
runtime.set(runtime.selector, { ...properties });
|
|
122
|
+
runtime.element.style.visibility = "visible";
|
|
123
|
+
liveTime.notify(seekTime);
|
|
124
|
+
usePlayerStore.getState().setCurrentTime(seekTime);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
|
|
128
|
+
const sampleProps = { ...properties };
|
|
129
|
+
if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x;
|
|
130
|
+
if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y;
|
|
131
|
+
r.samples.push({ time, properties: sampleProps });
|
|
132
|
+
r.trail.push({ x: r.pointer.x, y: r.pointer.y });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function computeIframeScale(iframeEl: HTMLIFrameElement): number {
|
|
136
|
+
const iframeRect = iframeEl.getBoundingClientRect();
|
|
137
|
+
const doc = iframeEl.contentDocument;
|
|
138
|
+
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
|
139
|
+
const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
|
|
140
|
+
return declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
21
143
|
function resolveGestureProperties(
|
|
22
144
|
dx: number,
|
|
23
145
|
dy: number,
|
|
@@ -63,6 +185,53 @@ function resolveGestureProperties(
|
|
|
63
185
|
};
|
|
64
186
|
}
|
|
65
187
|
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Grouped mutable state carried across the recording session.
|
|
190
|
+
// Replaces 14 individual useRef calls with a single ref object.
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
interface RecordingRefs {
|
|
194
|
+
pointer: { x: number; y: number };
|
|
195
|
+
startPointer: { x: number; y: number };
|
|
196
|
+
hasMoved: boolean;
|
|
197
|
+
scrollDelta: number;
|
|
198
|
+
modifiers: Modifiers;
|
|
199
|
+
accumulated: AccumulatedState;
|
|
200
|
+
basePosition: { x: number; y: number };
|
|
201
|
+
cssVarOffset: { x: number; y: number };
|
|
202
|
+
scale: number;
|
|
203
|
+
pointerElementOffset: { x: number; y: number };
|
|
204
|
+
runtime: GsapRuntime | null;
|
|
205
|
+
rafId: number;
|
|
206
|
+
samples: GestureSample[];
|
|
207
|
+
trail: Array<{ x: number; y: number }>;
|
|
208
|
+
cleanup: (() => void) | null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function createRecordingRefs(): RecordingRefs {
|
|
212
|
+
return {
|
|
213
|
+
pointer: { x: 0, y: 0 },
|
|
214
|
+
startPointer: { x: 0, y: 0 },
|
|
215
|
+
hasMoved: false,
|
|
216
|
+
scrollDelta: 0,
|
|
217
|
+
modifiers: { shift: false, alt: false, meta: false },
|
|
218
|
+
accumulated: { opacity: 1, scale: 1, z: 0 },
|
|
219
|
+
basePosition: { x: 0, y: 0 },
|
|
220
|
+
cssVarOffset: { x: 0, y: 0 },
|
|
221
|
+
scale: 1,
|
|
222
|
+
pointerElementOffset: { x: 0, y: 0 },
|
|
223
|
+
runtime: null,
|
|
224
|
+
rafId: 0,
|
|
225
|
+
samples: [],
|
|
226
|
+
trail: [],
|
|
227
|
+
cleanup: null,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Hook
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
66
235
|
export function useGestureRecording() {
|
|
67
236
|
const [isRecording, setIsRecording] = useState(false);
|
|
68
237
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
|
@@ -71,34 +240,18 @@ export function useGestureRecording() {
|
|
|
71
240
|
// startRecording and stopRecording check this ref, not the useState value.
|
|
72
241
|
const isRecordingRef = useRef(false);
|
|
73
242
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const basePositionRef = useRef({ x: 0, y: 0 });
|
|
80
|
-
const scaleRef = useRef(1);
|
|
81
|
-
const hasMovedRef = useRef(false);
|
|
82
|
-
const pointerElementOffsetRef = useRef({ x: 0, y: 0 });
|
|
83
|
-
const runtimeRef = useRef<{
|
|
84
|
-
seek: (t: number) => void;
|
|
85
|
-
set: (target: string, vars: Record<string, number>) => void;
|
|
86
|
-
selector: string;
|
|
87
|
-
element: HTMLElement;
|
|
88
|
-
startTime: number;
|
|
89
|
-
maxSeekTime: number;
|
|
90
|
-
} | null>(null);
|
|
91
|
-
|
|
92
|
-
const rafIdRef = useRef(0);
|
|
93
|
-
const samplesRef = useRef<GestureSample[]>([]);
|
|
94
|
-
const trailRef = useRef<Array<{ x: number; y: number }>>([]);
|
|
95
|
-
const cleanupRef = useRef<(() => void) | null>(null);
|
|
243
|
+
const refs = useRef<RecordingRefs>(createRecordingRefs());
|
|
244
|
+
|
|
245
|
+
// Stable reference aliases for the return value — consumers read these directly.
|
|
246
|
+
const samplesRef = useRef<GestureSample[]>(refs.current.samples);
|
|
247
|
+
const trailRef = useRef<Array<{ x: number; y: number }>>(refs.current.trail);
|
|
96
248
|
|
|
97
249
|
// Unmount safety: cancel RAF + remove listeners if component tears down mid-recording.
|
|
98
250
|
useEffect(() => {
|
|
251
|
+
const r = refs.current;
|
|
99
252
|
return () => {
|
|
100
|
-
|
|
101
|
-
|
|
253
|
+
r.cleanup?.();
|
|
254
|
+
r.cleanup = null;
|
|
102
255
|
isRecordingRef.current = false;
|
|
103
256
|
};
|
|
104
257
|
}, []);
|
|
@@ -108,109 +261,58 @@ export function useGestureRecording() {
|
|
|
108
261
|
if (isRecordingRef.current) return;
|
|
109
262
|
isRecordingRef.current = true;
|
|
110
263
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
264
|
+
const r = refs.current;
|
|
265
|
+
r.samples = [];
|
|
266
|
+
r.trail = [];
|
|
267
|
+
r.hasMoved = false;
|
|
268
|
+
r.scrollDelta = 0;
|
|
269
|
+
samplesRef.current = r.samples;
|
|
270
|
+
trailRef.current = r.trail;
|
|
114
271
|
setRecordingDuration(0);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
).gsap;
|
|
127
|
-
if (gsap?.getProperty) {
|
|
128
|
-
baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
|
|
129
|
-
baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1;
|
|
130
|
-
baseX = Number(gsap.getProperty(element, "x")) || 0;
|
|
131
|
-
baseY = Number(gsap.getProperty(element, "y")) || 0;
|
|
132
|
-
}
|
|
133
|
-
} catch {
|
|
134
|
-
/* cross-origin guard */
|
|
135
|
-
}
|
|
136
|
-
// When reapplyPathOffsets has run (translate restored to var-based),
|
|
137
|
-
// GSAP's cache was stripped — gsapX is 0 but the element is visually
|
|
138
|
-
// at CSSLeft + translate(offset). gsap.set wipes translate, so we need
|
|
139
|
-
// baseX to include the offset. When translate is "none" (GSAP owns it),
|
|
140
|
-
// gsapX already includes the baked offset — don't add.
|
|
141
|
-
const translateVal = element.style.translate ?? "";
|
|
142
|
-
if (translateVal.includes("var(")) {
|
|
143
|
-
const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
|
|
144
|
-
const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
|
|
145
|
-
baseX += offX;
|
|
146
|
-
baseY += offY;
|
|
272
|
+
|
|
273
|
+
// --- Phase 1: Read base position from GSAP + CSS vars ---
|
|
274
|
+
const base = readBasePosition(element, iframeEl);
|
|
275
|
+
r.cssVarOffset = { x: base.cssOffX, y: base.cssOffY };
|
|
276
|
+
r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 };
|
|
277
|
+
r.basePosition = { x: base.baseX, y: base.baseY };
|
|
278
|
+
|
|
279
|
+
if (base.cssOffX || base.cssOffY) {
|
|
280
|
+
element.style.setProperty("--hf-studio-offset-x", "0px");
|
|
281
|
+
element.style.setProperty("--hf-studio-offset-y", "0px");
|
|
147
282
|
}
|
|
148
|
-
accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 };
|
|
149
|
-
basePositionRef.current = { x: baseX, y: baseY };
|
|
150
283
|
|
|
284
|
+
// --- Phase 2: Connect to the iframe GSAP runtime ---
|
|
151
285
|
const selector = element.id ? `#${element.id}` : null;
|
|
152
|
-
|
|
153
|
-
const win = iframeEl.contentWindow as Window & {
|
|
154
|
-
gsap?: { set: (t: string, v: Record<string, number>) => void };
|
|
155
|
-
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
|
|
156
|
-
__player?: { getTime: () => number };
|
|
157
|
-
};
|
|
158
|
-
const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
|
|
159
|
-
if (win?.gsap?.set && tl?.seek && selector) {
|
|
160
|
-
const tlDuration = tl.duration();
|
|
161
|
-
runtimeRef.current = {
|
|
162
|
-
seek: tl.seek.bind(tl),
|
|
163
|
-
set: win.gsap.set.bind(win.gsap),
|
|
164
|
-
selector,
|
|
165
|
-
element,
|
|
166
|
-
startTime: win.__player?.getTime() ?? 0,
|
|
167
|
-
maxSeekTime:
|
|
168
|
-
elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
} catch {
|
|
172
|
-
runtimeRef.current = null;
|
|
173
|
-
}
|
|
286
|
+
r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
|
|
174
287
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
|
178
|
-
const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
|
|
179
|
-
scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
|
|
288
|
+
// --- Phase 3: Compute iframe viewport → composition scale ---
|
|
289
|
+
r.scale = computeIframeScale(iframeEl);
|
|
180
290
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
291
|
+
// --- Phase 4: Element center for pointer-element offset ---
|
|
292
|
+
// element.getBoundingClientRect() is in the iframe's viewport.
|
|
293
|
+
// Convert to the studio (parent) viewport using the iframe's position and scale.
|
|
294
|
+
const iframeRect = iframeEl.getBoundingClientRect();
|
|
183
295
|
const elRect = element.getBoundingClientRect();
|
|
296
|
+
const iframeScale = r.scale || 1;
|
|
184
297
|
const elCenterViewport = {
|
|
185
|
-
x: elRect.left + elRect.width / 2,
|
|
186
|
-
y: elRect.top + elRect.height / 2,
|
|
298
|
+
x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale,
|
|
299
|
+
y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale,
|
|
187
300
|
};
|
|
188
|
-
|
|
301
|
+
r.pointerElementOffset = { x: 0, y: 0 };
|
|
189
302
|
|
|
303
|
+
// --- Phase 5: Attach event listeners ---
|
|
190
304
|
const handlePointerMove = (e: PointerEvent) => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
shift: e.shiftKey,
|
|
194
|
-
alt: e.altKey,
|
|
195
|
-
meta: e.metaKey || e.ctrlKey,
|
|
196
|
-
};
|
|
305
|
+
r.pointer = { x: e.clientX, y: e.clientY };
|
|
306
|
+
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
|
|
197
307
|
};
|
|
198
308
|
|
|
199
309
|
const handleWheel = (e: WheelEvent) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
shift: e.shiftKey,
|
|
203
|
-
alt: e.altKey,
|
|
204
|
-
meta: e.metaKey || e.ctrlKey,
|
|
205
|
-
};
|
|
310
|
+
r.scrollDelta += e.deltaY;
|
|
311
|
+
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
|
|
206
312
|
};
|
|
207
313
|
|
|
208
314
|
const handleKeyChange = (e: KeyboardEvent) => {
|
|
209
|
-
|
|
210
|
-
shift: e.shiftKey,
|
|
211
|
-
alt: e.altKey,
|
|
212
|
-
meta: e.metaKey || e.ctrlKey,
|
|
213
|
-
};
|
|
315
|
+
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
|
|
214
316
|
};
|
|
215
317
|
|
|
216
318
|
document.addEventListener("pointermove", handlePointerMove, { passive: true });
|
|
@@ -218,86 +320,70 @@ export function useGestureRecording() {
|
|
|
218
320
|
document.addEventListener("keydown", handleKeyChange, { passive: true });
|
|
219
321
|
document.addEventListener("keyup", handleKeyChange, { passive: true });
|
|
220
322
|
|
|
221
|
-
startPointerRef.current = { ...pointerRef.current };
|
|
222
323
|
const startMs = performance.now();
|
|
223
324
|
|
|
224
|
-
|
|
325
|
+
r.startPointer = { ...r.pointer };
|
|
225
326
|
const captureStart = (e: PointerEvent) => {
|
|
226
|
-
if (!
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
startCaptured = true;
|
|
235
|
-
hasMovedRef.current = true;
|
|
327
|
+
if (!r.hasMoved) {
|
|
328
|
+
r.startPointer = { x: e.clientX, y: e.clientY };
|
|
329
|
+
const offX = e.clientX - elCenterViewport.x;
|
|
330
|
+
const offY = e.clientY - elCenterViewport.y;
|
|
331
|
+
r.pointerElementOffset = { x: offX, y: offY };
|
|
332
|
+
r.basePosition.x += offX / iframeScale;
|
|
333
|
+
r.basePosition.y += offY / iframeScale;
|
|
334
|
+
r.hasMoved = true;
|
|
236
335
|
}
|
|
237
336
|
};
|
|
238
337
|
document.addEventListener("pointermove", captureStart, { passive: true, once: true });
|
|
239
338
|
|
|
339
|
+
// --- Phase 6: RAF tick loop ---
|
|
240
340
|
const tick = () => {
|
|
241
341
|
if (!isRecordingRef.current) return;
|
|
242
342
|
const now = performance.now();
|
|
243
343
|
const time = (now - startMs) / 1000;
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (!
|
|
251
|
-
|
|
344
|
+
|
|
345
|
+
const scale = r.scale || 1;
|
|
346
|
+
const dx = (r.pointer.x - r.startPointer.x) / scale;
|
|
347
|
+
const dy = (r.pointer.y - r.startPointer.y) / scale;
|
|
348
|
+
const scrollDelta = r.scrollDelta;
|
|
349
|
+
|
|
350
|
+
if (!r.hasMoved && dx === 0 && dy === 0 && scrollDelta === 0) {
|
|
351
|
+
r.rafId = requestAnimationFrame(tick);
|
|
252
352
|
return;
|
|
253
353
|
}
|
|
254
|
-
|
|
354
|
+
r.hasMoved = true;
|
|
255
355
|
|
|
256
356
|
const { properties, nextState } = resolveGestureProperties(
|
|
257
357
|
dx,
|
|
258
358
|
dy,
|
|
259
359
|
scrollDelta,
|
|
260
|
-
|
|
261
|
-
|
|
360
|
+
r.modifiers,
|
|
361
|
+
r.accumulated,
|
|
262
362
|
);
|
|
263
|
-
if ("x" in properties) properties.x = Math.round(
|
|
264
|
-
if ("y" in properties) properties.y = Math.round(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// which triggers React state updates). After seek renders all elements
|
|
271
|
-
// at the correct time, gsap.set overrides the recorded element so it
|
|
272
|
-
// follows the pointer. The browser paints the set values on this frame;
|
|
273
|
-
// next tick's seek will overwrite, but we re-apply immediately.
|
|
274
|
-
if (runtimeRef.current) {
|
|
363
|
+
if ("x" in properties) properties.x = Math.round(r.basePosition.x + properties.x);
|
|
364
|
+
if ("y" in properties) properties.y = Math.round(r.basePosition.y + properties.y);
|
|
365
|
+
|
|
366
|
+
r.accumulated = nextState;
|
|
367
|
+
r.scrollDelta = 0;
|
|
368
|
+
|
|
369
|
+
if (r.runtime) {
|
|
275
370
|
try {
|
|
276
|
-
|
|
277
|
-
runtimeRef.current.startTime + time,
|
|
278
|
-
runtimeRef.current.maxSeekTime,
|
|
279
|
-
);
|
|
280
|
-
runtimeRef.current.seek(seekTime);
|
|
281
|
-
runtimeRef.current.set(runtimeRef.current.selector, { ...properties });
|
|
282
|
-
runtimeRef.current.element.style.visibility = "visible";
|
|
283
|
-
liveTime.notify(seekTime);
|
|
284
|
-
usePlayerStore.getState().setCurrentTime(seekTime);
|
|
371
|
+
applyRuntimePreview(r.runtime, time, properties);
|
|
285
372
|
} catch {
|
|
286
|
-
|
|
373
|
+
r.runtime = null;
|
|
287
374
|
}
|
|
288
375
|
}
|
|
289
376
|
|
|
290
|
-
|
|
291
|
-
trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y });
|
|
377
|
+
recordSample(r, time, properties);
|
|
292
378
|
setRecordingDuration(time);
|
|
293
|
-
|
|
379
|
+
r.rafId = requestAnimationFrame(tick);
|
|
294
380
|
};
|
|
295
381
|
|
|
296
382
|
setIsRecording(true);
|
|
297
|
-
|
|
383
|
+
r.rafId = requestAnimationFrame(tick);
|
|
298
384
|
|
|
299
|
-
|
|
300
|
-
cancelAnimationFrame(
|
|
385
|
+
r.cleanup = () => {
|
|
386
|
+
cancelAnimationFrame(r.rafId);
|
|
301
387
|
document.removeEventListener("pointermove", handlePointerMove);
|
|
302
388
|
document.removeEventListener("wheel", handleWheel);
|
|
303
389
|
document.removeEventListener("keydown", handleKeyChange);
|
|
@@ -311,21 +397,37 @@ export function useGestureRecording() {
|
|
|
311
397
|
const stopRecording = useCallback((): GestureSample[] => {
|
|
312
398
|
if (!isRecordingRef.current) return [];
|
|
313
399
|
isRecordingRef.current = false;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
400
|
+
const r = refs.current;
|
|
401
|
+
if (r.runtime) {
|
|
402
|
+
const { element: el, savedVisibility, savedTranslate } = r.runtime;
|
|
403
|
+
el.style.visibility = savedVisibility;
|
|
404
|
+
el.style.setProperty("translate", savedTranslate || "");
|
|
405
|
+
}
|
|
406
|
+
if (r.cssVarOffset.x || r.cssVarOffset.y) {
|
|
407
|
+
const el = r.runtime?.element;
|
|
408
|
+
if (el) {
|
|
409
|
+
el.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
|
|
410
|
+
el.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
r.runtime = null;
|
|
414
|
+
r.cleanup?.();
|
|
415
|
+
r.cleanup = null;
|
|
416
|
+
const frozen = r.samples.slice();
|
|
318
417
|
setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0);
|
|
319
418
|
setIsRecording(false);
|
|
320
419
|
return frozen;
|
|
321
420
|
}, []); // No deps — uses refs only
|
|
322
421
|
|
|
323
422
|
const clearSamples = useCallback(() => {
|
|
324
|
-
|
|
325
|
-
|
|
423
|
+
const r = refs.current;
|
|
424
|
+
r.samples = [];
|
|
425
|
+
r.trail = [];
|
|
426
|
+
samplesRef.current = r.samples;
|
|
427
|
+
trailRef.current = r.trail;
|
|
326
428
|
setRecordingDuration(0);
|
|
327
|
-
|
|
328
|
-
|
|
429
|
+
r.accumulated = { opacity: 1, scale: 1, z: 0 };
|
|
430
|
+
r.scrollDelta = 0;
|
|
329
431
|
}, []);
|
|
330
432
|
|
|
331
433
|
return {
|
|
@@ -4,8 +4,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
5
|
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
6
6
|
import { executeOptimistic } from "../utils/optimisticUpdate";
|
|
7
|
-
import {
|
|
7
|
+
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
8
8
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
9
|
+
import {
|
|
10
|
+
updateKeyframeCacheFromParsed,
|
|
11
|
+
readKeyframeSnapshot,
|
|
12
|
+
writeKeyframeCache,
|
|
13
|
+
} from "./gsapKeyframeCacheHelpers";
|
|
9
14
|
|
|
10
15
|
const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
11
16
|
opacity: 1,
|
|
@@ -72,84 +77,6 @@ async function mutateGsapScript(
|
|
|
72
77
|
return null;
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
|
-
function updateKeyframeCacheFromParsed(
|
|
76
|
-
animations: GsapAnimation[],
|
|
77
|
-
targetPath: string,
|
|
78
|
-
selectionId: string | undefined,
|
|
79
|
-
mutation: Record<string, unknown>,
|
|
80
|
-
): void {
|
|
81
|
-
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
|
82
|
-
const idsWithKeyframes = new Set<string>();
|
|
83
|
-
const merged = new Map<string, KeyframeCacheEntry>();
|
|
84
|
-
for (const anim of animations) {
|
|
85
|
-
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
|
86
|
-
if (!id || !anim.keyframes) continue;
|
|
87
|
-
idsWithKeyframes.add(id);
|
|
88
|
-
|
|
89
|
-
// Convert tween-relative percentages to clip-relative so diamonds
|
|
90
|
-
// render at the correct position within the timeline clip.
|
|
91
|
-
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
|
92
|
-
const tweenDur = anim.duration ?? 1;
|
|
93
|
-
const timelineEl = elements.find(
|
|
94
|
-
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
|
95
|
-
);
|
|
96
|
-
const elStart = timelineEl?.start ?? 0;
|
|
97
|
-
const elDuration = timelineEl?.duration ?? 4;
|
|
98
|
-
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
|
99
|
-
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
|
100
|
-
const clipPct =
|
|
101
|
-
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
|
|
102
|
-
return { ...kf, percentage: clipPct };
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const existing = merged.get(id);
|
|
106
|
-
if (existing) {
|
|
107
|
-
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
|
|
108
|
-
for (const kf of [...existing.keyframes, ...clipKeyframes]) {
|
|
109
|
-
const prev = byPct.get(kf.percentage);
|
|
110
|
-
if (prev) {
|
|
111
|
-
prev.properties = { ...prev.properties, ...kf.properties };
|
|
112
|
-
if (kf.ease) prev.ease = kf.ease;
|
|
113
|
-
} else {
|
|
114
|
-
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
|
118
|
-
} else {
|
|
119
|
-
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
for (const [id, entry] of merged) {
|
|
123
|
-
setKeyframeCache(`${targetPath}#${id}`, entry);
|
|
124
|
-
setKeyframeCache(id, entry);
|
|
125
|
-
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
|
|
126
|
-
}
|
|
127
|
-
const targetId =
|
|
128
|
-
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
|
129
|
-
selectionId;
|
|
130
|
-
if (targetId && !idsWithKeyframes.has(targetId)) {
|
|
131
|
-
setKeyframeCache(`${targetPath}#${targetId}`, undefined);
|
|
132
|
-
if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
function buildCacheKey(sourceFile: string, elementId: string): string {
|
|
136
|
-
return `${sourceFile}#${elementId}`;
|
|
137
|
-
}
|
|
138
|
-
function readKeyframeSnapshot(
|
|
139
|
-
sourceFile: string,
|
|
140
|
-
elementId: string | null | undefined,
|
|
141
|
-
): KeyframeCacheEntry | undefined {
|
|
142
|
-
if (!elementId) return undefined;
|
|
143
|
-
return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
|
|
144
|
-
}
|
|
145
|
-
function writeKeyframeCache(
|
|
146
|
-
sourceFile: string,
|
|
147
|
-
elementId: string | null | undefined,
|
|
148
|
-
data: KeyframeCacheEntry | undefined,
|
|
149
|
-
): void {
|
|
150
|
-
if (!elementId) return;
|
|
151
|
-
usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
|
|
152
|
-
}
|
|
153
80
|
interface GsapScriptCommitsParams {
|
|
154
81
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
155
82
|
activeCompPath: string | null;
|
|
@@ -319,7 +246,7 @@ export function useGsapScriptCommits({
|
|
|
319
246
|
(selection: DomEditSelection, animationId: string) => {
|
|
320
247
|
void commitMutation(
|
|
321
248
|
selection,
|
|
322
|
-
{ type: "delete", animationId },
|
|
249
|
+
{ type: "delete", animationId, stripStudioEdits: true },
|
|
323
250
|
{ label: "Delete GSAP animation" },
|
|
324
251
|
);
|
|
325
252
|
},
|