fix(studio): dragging a keyframed layer from its middle moves the layer, not a path node - #4941
Merged
Merged
Conversation
miguel-heygen
force-pushed
the
fix/studio-motion-path-node-is-the-layer
branch
from
October 3, 2026 19:28
c787899 to
b2572c2
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
Edit accuracy: accurate 1556 (base branch 1556), smooth 1462 of thoseThe gate passes. Quarantined, measured but not gated (0) |
…rame in place GSAP eases a keyframe run as a whole with keyframes.ease || ease. The playhead writer now edits an existing keyframe under that ease and writes the ease back as the tween's own; adding a keyframe in eased form stays refused, since the ease moves where its time lands.
…er, not a path node The motion path drew an axis the tween does not animate at 0, though GSAP folds the layer's CSS translate into it, so each node sat beside the layer and its grab ring took presses aimed at the layer. The path now uses GSAP's live position, the same base the layer drag reads. A press on the node GSAP renders the layer at, or inside the layer's box but off another node's drawn dot, starts the layer's own move and write.
The hand-off to the layer's box falls back to the node drag when the box starts no gesture or takes no pointer, and a selected node's larger dot stays the node's.
miguel-heygen
force-pushed
the
fix/studio-motion-path-node-is-the-layer
branch
from
October 3, 2026 20:36
d3ab01c to
9c1b196
Compare
… a quoted variable GSAP never gives a tween with keyframes its timeline's default ease, so the parser no longer does. A keyframes-level ease or easeEach the file names by variable is kept as that variable, and every ease the writer emits goes through valueToCode, so ease: E is written back as E instead of the string "__raw:E".
…ed press is not a node drag GSAP renders a run eased as a whole at ease(t), so only its 0% and 100% keyframes sit at their clock time; the writer refuses the others as before. A layer press the box answers with its blocked notice counts as handled, so the node does not drag after it.
…variable eases stay pinned startGesture paused the box's position updates and playback before the drag member setup could refuse; a press handed off from a path node then left the box frozen, since its release lands outside the box. It now pauses only once the setup succeeds. Tests pin that adding, converting and unrolling write an ease the file names by variable as that variable.
…on convert and unroll
…othing to move leaves playback alone
miguel-heygen
marked this pull request as ready for review
October 4, 2026 10:33
jrusso1020
approved these changes
Oct 4, 2026
jrusso1020
left a comment
Collaborator
There was a problem hiding this comment.
Approving at 124e7a8e. I read the full changed files. No other reviews were on the PR when I looked.
Checked and holds
- Both GSAP claims match gsap 3.15
src/gsap-core.js._setKeyframeDefaultsskipsease(line 132), so the parser change inapplyTimelineDefaultsmatches GSAP. The run ease istl._ease = _parseEase(keyframes.ease || vars.ease || "none")(line 2374), which matchesrunEase = data.ease ?? anim.ease. - Edits on an eased run are limited to the first and last keyframe.
ease(0)=0andease(1)=1hold for every standard ease, so editing the 0% or 100% keyframe in place is exact. The other cases are refused: a keyframe between the ends, a missing keyframe at the playhead (hit?.percentage !== 0 && !== 100is true forundefined), and a playhead outside the tween. - Writing the ease at the tween level cannot duplicate a key.
easeis not inNON_EDITABLE_VAR_KEYS, sokeptnever carries the original, and the keyframes-leveleasemoves up as the same curve. - The
__raw:round trip is sound.valueToCodestrips the prefix, and every acorn-writer ease site now goes through it. The recastgsap-parser-recastwriters still useJSON.stringifyfor eases, but studio-server imports its writers fromgsap-writer-acorn, so those sites are not on this path. - The press-start order is right.
rafPausedRefis now set only when the gesture is armed, and the blocked path callspreventDefault, sopressSelectedLayerreads a refused press as handled. The nudge pause now happens before the member snapshot. - The tests pass locally. I ran the 8 studio test files in the PR body (61 tests) and the 3 parser files (120 passed, 4 skipped).
Non-blocking, worth a look before or after merge
- The node-to-layer hand-off also fires when the playhead is outside the tween. After the tween ends, GSAP holds the layer on the 100% node; before it starts, the layer sits on the 0% node.
pressBelongsToLayermatches that node through thelivecheck before the dot rule runs, so even a press dead on its dot, outside the box, goes to the layer. Off-range, the layer move is the "add a keyframe at the playhead and keep the authored ends" branch.- Reproduced with a probe:
- Setup: a
0%: {x:0}, 100%: {x:20}tween over 0 to 4 s, playhead at 6 s, and the last node pressed at its dot. pressBelongsToLayerreturnstrue.- Dropping at x 30 plans
duration: 6and keyframes0% x0, 66.667% x20, 100% x30. - On main, the same drag edits the 100% keyframe in place and keeps the 4 s duration.
- Setup: a
- A click on that node no longer parks the playhead on it. The click goes to the box.
- Suggested narrowing: hand off the live node only when the playhead is inside the tween's span, or when
ref.pct === activeKeyframePct. Off-range, apply the dot rule. A press on the ring inside the box still moves the layer; a press on the dot drags the keyframe, as AE does. - If the stretch is intended, a line in Bounds would settle it.
- Reproduced with a probe:
- A variable
easeEachnow reaches the UI as__raw:E.AnimationCardbuilds its ease label fromkeyframes.easeEachas-is, so the card shows the raw marker. Before, it fell back to the tween'sease.trackHeaderLaneValues.easedProgresscallsgsap.parseEase("__raw:E"), getsundefined, and samples linearly.- Neither was right before either, but stripping the marker for display, or labeling it "Custom", would be tidier.
— Rames
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What a person sees
Select a layer whose position is keyframed, park the playhead on one of its keyframes, and drag the layer from its middle. Often the layer does not follow the pointer, then jumps on release to a spot 30 to 190 px from where it was dropped. Sometimes the edit lands on a different keyframe (the 3 s one when the playhead is at 2 s).
The press was landing on the motion path's keyframe node, not on the layer. Each node has a 14 px grab ring, and the path was drawn 30 composition px away from the layer whenever its CSS
translateset an axis the tween does not animate. At 50% and 100% zoom, that ring covers the layer's middle. The node's own drag then wroteywithout the translate (60 instead of 90), kept the old keyframe array, and showed only the node moving.The fix
buildMotionPathGeometrydefaulted an axis the tween does not animate to 0. GSAP folds the CSStranslateinto that axis, so the default is now GSAP's live value, read with the samereadGsapPositionFromIframethe layer drag uses as its base. The node at the playhead now sits exactly on the layer's anchor.pressSelectedLayer). That starts the same move gesture, live preview andcommitValueAtPlayheadwrite as a press on the layer, which matches the rule that an edit changes or adds the keyframe at the playhead.keyframes.ease || ease(gsap 3.15, the keyframes branch of the Tween constructor) and renders the run atease(t). At 0% and 100% that is the keyframe's own time, so the writer now edits that keyframe in place and writes the ease back as the tween's ownease, the same curve; the rewrite used to drop it.preventDefault) and takes the pointer (notpointer-events: none, as while text is edited); otherwise the node drags as before. A layer press the box answers with its "can't move" notice now also counts as handled, so the notice is not followed by a node drag, and the box pauses its own updates only once the move really starts, so a refused press can no longer leave it frozen. Playback still pauses on that press, before the drag setup records which timelines are running, as any press on the canvas does; an arrow-key nudge now pauses before that snapshot too, so neither can restart a timeline the pause stopped. A selected node draws its dot 1.5x as large, and that larger dot is the node's (dotRadius, shared withMotionPathNode).defaults.easeto a tween with keyframes (_setKeyframeDefaultsskipsease), but the parser copied it in. Writing it back would have changed the whole curve, so the parser now matches GSAP.JSON.stringify, soease: Ecame back as the string"__raw:E"and the timeline threw on reload. Every ease the writer emits (adding, converting a flat tween, unrolling a loop) now goes through the samevalueToCodeits other values use, and a keyframes-levelease/easeEachthat is a variable is kept by the parser instead of dropped.Bounds:
ease(t), not at the clock time the lane draws those keyframes at. A follow-up PR gives "where GSAP actually is" one owner (array step times and the ease mapping) that the parser, the lane, the playhead park and the writer all read.keyframes: { x: [..] }) are still refused; they draw no nodes today.Unchanged here: a drag of a node that is not at the playhead still writes through
update-keyframe, which adds a newly animated channel to that one keyframe only. That gets its own PR stacked on this one.Evidence
Edit-accuracy bench on one box,
move-keys-px-r0-root-z{50,100,200}-{on,mid}, with #4934's soft-reload fix applied (it is now on main and in this branch):z200-midpressed the 2 s node's ring 7 px from the layer's middle, which the dot rule now gives to the layerForced presses on
move-keys-px-r0-root-z100-on, 3 runs each: on the base, a press on the playhead node fails every run (tracking, drop, reload and undo off by 60 px); on this branch both the node press and the layer-body press are accurate in every run.Tests:
motionPathGeometry.test.ts,useMotionPathData.test.tsx,motionPathLayerNode.test.ts,MotionPathOverlay.layerPress.test.tsx,gsapValueAtPlayhead.test.ts,domEditOverlayStartGesture.blocked.test.ts,useDomEditNudge.test.tsx, and in parsersgsapParserAcorn.full.test.ts,gsapWriter.acorn.test.tsandgsapWriter.varEase.test.ts. With the source changes reverted, each of them fails. The parsers and studio-server suites and Studio'shooksandcomponents/editortest directories pass. After merging current main, the changed test files pass 3 runs in a row and Studio typechecks clean.The captures below are the bench's
move-keys-px-r0-root-z100-onfixture in headless Chromium 152, playhead at 2 s on the layer's keyframe, the press forced onto the path node at the playhead, one frame per pointer step plus a cursor dot. Main often draws no motion path on load (#4957 fixes that), so both are loads where the path was drawn.Before
Current main. The playhead node sits 10 px above the layer's middle. Dragging it moves only the node inside the layer box; the layer stays where it was, and the bench fails tracking, drop, reload and undo.
before-4941.mp4
After
This branch on current main. The playhead node sits on the layer's middle, the same drag moves the layer with the node under the pointer, and every accuracy check passes.
after-4941.mp4