Skip to content

Commit 191a33d

Browse files
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.
1 parent c435a4e commit 191a33d

2 files changed

Lines changed: 100 additions & 13 deletions

File tree

packages/studio/src/hooks/useGestureCommit.ts

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import { useGestureRecording } from "./useGestureRecording";
77
import { simplifyGestureSamples } from "../utils/rdpSimplify";
88
import { usePlayerStore } from "../player";
99
import type { DomEditSelection } from "../components/editor/domEditing";
10+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11+
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
1012

1113
// Minimal subset of the session used by gesture commit
1214
interface GestureSessionRef {
1315
domEditSelection: DomEditSelection | null;
16+
selectedGsapAnimations?: GsapAnimation[];
1417
commitMutation?: (
1518
mutation: Record<string, unknown>,
1619
options: { label: string; softReload?: boolean },
@@ -43,6 +46,9 @@ export function useGestureCommit({
4346
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
4447
const recordingStartTimeRef = useRef(0);
4548
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);
4652

4753
// Unmount: clear auto-stop interval
4854
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
@@ -59,7 +65,7 @@ export function useGestureCommit({
5965
store.setIsPlaying(false);
6066
try {
6167
const liveSession = domEditSessionRef.current;
62-
const sel = liveSession.domEditSelection;
68+
const sel = capturedSelectionRef.current;
6369
if (!sel) {
6470
if (frozenSamples.length > 2) {
6571
showToast("Selection lost during recording", "error");
@@ -77,7 +83,13 @@ export function useGestureCommit({
7783
return;
7884
}
7985

80-
const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
86+
// Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10)
87+
// need a much tighter tolerance than positional properties (x/y in px).
88+
const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
89+
if (key === "opacity") return 0.01;
90+
if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
91+
return 5;
92+
});
8193
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
8294

8395
// Ensure a 0% keyframe exists with the element's start-of-recording position
@@ -98,16 +110,74 @@ export function useGestureCommit({
98110
properties: simplified.get(pct) as Record<string, number | string>,
99111
}));
100112

101-
await liveSession.commitMutation(
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 },
113+
// Check if the recorded gesture contains position properties.
114+
// If so, and a position-group tween already exists for this element,
115+
// replace it atomically instead of adding a duplicate.
116+
const hasPositionProps = keyframes.some((kf) =>
117+
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
110118
);
119+
const existingPositionTween = hasPositionProps
120+
? liveSession.selectedGsapAnimations?.find(
121+
(a) => a.propertyGroup === "position" && a.targetSelector === selector,
122+
)
123+
: undefined;
124+
125+
if (existingPositionTween) {
126+
const tweenStart = existingPositionTween.resolvedStart ?? 0;
127+
const tweenDur = existingPositionTween.duration ?? duration;
128+
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
129+
130+
// Map the recording window (recStart..recStart+duration) into the
131+
// existing tween's 0-100% space so we can merge instead of nuke.
132+
const rangeStartPct = tweenDur > 0 ? ((recStart - tweenStart) / tweenDur) * 100 : 0;
133+
const rangeEndPct =
134+
tweenDur > 0 ? ((recStart + duration - tweenStart) / tweenDur) * 100 : 100;
135+
136+
// Keep existing keyframes that fall outside the recording window
137+
const preserved = existingKfs
138+
.filter(
139+
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
140+
)
141+
.map((kf) => ({
142+
percentage: kf.percentage,
143+
properties: kf.properties,
144+
...(kf.ease ? { ease: kf.ease } : {}),
145+
}));
146+
147+
// Map recorded keyframes (0-100% of recording) into tween percentage space
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", softReload: true },
179+
);
180+
}
111181
}
112182
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
113183
} finally {
@@ -139,6 +209,7 @@ export function useGestureCommit({
139209
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
140210
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
141211
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
212+
capturedSelectionRef.current = sel;
142213
gestureRecording.startRecording(sel.element, iframe, elementEnd);
143214
gestureStateRef.current = "recording";
144215
isGestureRecordingRef.current = true;

packages/studio/src/hooks/useGestureRecording.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,12 @@ function applyRuntimePreview(
126126

127127
function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
128128
const sampleProps = { ...properties };
129-
if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x;
130-
if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y;
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);
131135
r.samples.push({ time, properties: sampleProps });
132136
r.trail.push({ x: r.pointer.x, y: r.pointer.y });
133137
}
@@ -307,6 +311,18 @@ export function useGestureRecording() {
307311
};
308312

309313
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+
}
310326
r.scrollDelta += e.deltaY;
311327
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
312328
};

0 commit comments

Comments
 (0)