diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index 82e488246..f009c82b8 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -7,10 +7,13 @@ import { useGestureRecording } from "./useGestureRecording"; import { simplifyGestureSamples } from "../utils/rdpSimplify"; import { usePlayerStore } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; // Minimal subset of the session used by gesture commit interface GestureSessionRef { domEditSelection: DomEditSelection | null; + selectedGsapAnimations?: GsapAnimation[]; commitMutation?: ( mutation: Record, options: { label: string; softReload?: boolean }, @@ -43,6 +46,9 @@ export function useGestureCommit({ const recordingAutoStopRef = useRef>(undefined); const recordingStartTimeRef = useRef(0); const commitInFlightRef = useRef(false); + // Capture selection at recording start so commit always targets the recorded element, + // even if the user's selection changes mid-recording. + const capturedSelectionRef = useRef(null); // Unmount: clear auto-stop interval useEffect(() => () => clearInterval(recordingAutoStopRef.current), []); @@ -59,7 +65,7 @@ export function useGestureCommit({ store.setIsPlaying(false); try { const liveSession = domEditSessionRef.current; - const sel = liveSession.domEditSelection; + const sel = capturedSelectionRef.current; if (!sel) { if (frozenSamples.length > 2) { showToast("Selection lost during recording", "error"); @@ -77,7 +83,13 @@ export function useGestureCommit({ return; } - const simplified = simplifyGestureSamples(frozenSamples, duration, 5); + // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10) + // need a much tighter tolerance than positional properties (x/y in px). + const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => { + if (key === "opacity") return 0.01; + if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01; + return 5; + }); const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); // Ensure a 0% keyframe exists with the element's start-of-recording position @@ -98,16 +110,74 @@ export function useGestureCommit({ properties: simplified.get(pct) as Record, })); - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: selector, - position: Math.round(recStart * 1000) / 1000, - duration: Math.round(duration * 1000) / 1000, - keyframes, - }, - { label: "Gesture recording", softReload: true }, + // Check if the recorded gesture contains position properties. + // If so, and a position-group tween already exists for this element, + // replace it atomically instead of adding a duplicate. + const hasPositionProps = keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), ); + const existingPositionTween = hasPositionProps + ? liveSession.selectedGsapAnimations?.find( + (a) => a.propertyGroup === "position" && a.targetSelector === selector, + ) + : undefined; + + if (existingPositionTween) { + const tweenStart = existingPositionTween.resolvedStart ?? 0; + const tweenDur = existingPositionTween.duration ?? duration; + const existingKfs = existingPositionTween.keyframes?.keyframes ?? []; + + // Map the recording window (recStart..recStart+duration) into the + // existing tween's 0-100% space so we can merge instead of nuke. + const rangeStartPct = tweenDur > 0 ? ((recStart - tweenStart) / tweenDur) * 100 : 0; + const rangeEndPct = + tweenDur > 0 ? ((recStart + duration - tweenStart) / tweenDur) * 100 : 100; + + // Keep existing keyframes that fall outside the recording window + const preserved = existingKfs + .filter( + (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5, + ) + .map((kf) => ({ + percentage: kf.percentage, + properties: kf.properties, + ...(kf.ease ? { ease: kf.ease } : {}), + })); + + // Map recorded keyframes (0-100% of recording) into tween percentage space + const mapped = keyframes.map((kf) => ({ + percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct), + properties: kf.properties, + })); + + const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage); + + await liveSession.commitMutation( + { + type: "replace-with-keyframes", + animationId: existingPositionTween.id, + targetSelector: selector, + position: + typeof existingPositionTween.position === "number" + ? existingPositionTween.position + : tweenStart, + duration: tweenDur, + keyframes: merged, + }, + { label: "Gesture recording (merge)", softReload: true }, + ); + } else { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(recStart * 1000) / 1000, + duration: Math.round(duration * 1000) / 1000, + keyframes, + }, + { label: "Gesture recording", softReload: true }, + ); + } } showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); } finally { @@ -139,6 +209,7 @@ export function useGestureCommit({ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0; const elementEnd = elDur > 0 ? elStart + elDur : undefined; + capturedSelectionRef.current = sel; gestureRecording.startRecording(sel.element, iframe, elementEnd); gestureStateRef.current = "recording"; isGestureRecordingRef.current = true; diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index cdc7fd2f5..73bfd94ca 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -126,8 +126,12 @@ function applyRuntimePreview( function recordSample(r: RecordingRefs, time: number, properties: Record): void { const sampleProps = { ...properties }; - if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x; - if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y; + // Subtract both the CSS var offset AND the pointer-element snap offset + // so the first sample doesn't include the snap-to-cursor jump. + if ("x" in sampleProps) + sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1); + if ("y" in sampleProps) + sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1); r.samples.push({ time, properties: sampleProps }); r.trail.push({ x: r.pointer.x, y: r.pointer.y }); } @@ -307,6 +311,18 @@ export function useGestureRecording() { }; const handleWheel = (e: WheelEvent) => { + // Capture startPointer on first wheel if no pointermove has fired yet, + // preventing an enormous bogus first keyframe from stale startPointer. + if (!r.hasMoved) { + r.startPointer = { x: r.pointer.x, y: r.pointer.y }; + r.pointerElementOffset = { + x: r.pointer.x - elCenterViewport.x, + y: r.pointer.y - elCenterViewport.y, + }; + r.basePosition.x += r.pointerElementOffset.x / iframeScale; + r.basePosition.y += r.pointerElementOffset.y / iframeScale; + r.hasMoved = true; + } r.scrollDelta += e.deltaY; r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey }; };