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.
This commit is contained in:
Miguel Ángel
2026-06-12 00:19:41 -04:00
committed by GitHub
parent c435a4ee46
commit 191a33dc2c
2 changed files with 100 additions and 13 deletions
+82 -11
View File
@@ -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<string, unknown>,
options: { label: string; softReload?: boolean },
@@ -43,6 +46,9 @@ export function useGestureCommit({
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(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<DomEditSelection | null>(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 01, scale ~0.0110)
// 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<string, number | string>,
}));
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;
@@ -126,8 +126,12 @@ function applyRuntimePreview(
function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): 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 };
};