From 191a33dc2cc4e63f4ab413d1cc68bedd981d1e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 12 Jun 2026 00:19:41 -0400 Subject: [PATCH] fix(studio): gesture recording replaces existing position keyframes (#1359) * fix(core): per-property-group keyframe foundations Add PropertyGroupName type system (position/scale/size/rotation/visual/other), PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup functions. Parser generates group-aware animation IDs, resolves position strings (+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs across all mutations. * fix(core): add split-into-property-groups and replace-with-keyframes mutations Server-side mutations for atomic property-group splitting and keyframe replacement. Client commitMutation returns early on changed:false instead of throwing. * fix(studio): per-property-group intercept routing + drag/resize fixes Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y} to position group, resize routes to scale group via data-hf-studio-original-width, rotation routes to rotation group. Add resolveGroupTween helper, from-extend with split-first-then-position-only pattern, autoKeyframeEnabled guards, GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs. * fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes Tag cached keyframes with propertyGroup for group-aware operations. Add tweenPercentage for accurate keyframe matching, activeKeyframePct for diamond-click targeting, context menu offset, selected diamond z-index, clearProps after kill in soft reload. * fix(studio): property panel group-aware keyframe routing Add animIdForProp helper routing keyframe diamonds to correct property-group animation. Wire StudioPreviewArea delete/move/toggle handlers to use propertyGroup for routing. Fix per-property epsilon in rdpSimplify. * fix(studio): gesture recording replaces existing position keyframes Gesture recording uses replace-with-keyframes mutation to replace existing position-group tween. Fix N1 sign inversion and N9 wheel startPointer with pointerElementOffset subtraction. --- packages/studio/src/hooks/useGestureCommit.ts | 93 ++++++++++++++++--- .../studio/src/hooks/useGestureRecording.ts | 20 +++- 2 files changed, 100 insertions(+), 13 deletions(-) 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 }; };