Skip to content

fix(studio): dragging a keyframed layer from its middle moves the layer, not a path node - #4941

Merged
miguel-heygen merged 15 commits into
mainfrom
fix/studio-motion-path-node-is-the-layer
Oct 4, 2026
Merged

miguel-heygen merged 15 commits into
mainfrom
fix/studio-motion-path-node-is-the-layer

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 translate set 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 wrote y without the translate (60 instead of 90), kept the old keyframe array, and showed only the node moving.

The fix

  • The path is drawn where GSAP renders the layer. buildMotionPathGeometry defaulted an axis the tween does not animate to 0. GSAP folds the CSS translate into that axis, so the default is now GSAP's live value, read with the same readGsapPositionFromIframe the layer drag uses as its base. The node at the playhead now sits exactly on the layer's anchor.
  • The node at the playhead is the layer. A press on the node GSAP renders the layer at is handed to the selected layer's box (pressSelectedLayer). That starts the same move gesture, live preview and commitValueAtPlayhead write as a press on the layer, which matches the rule that an edit changes or adds the keyframe at the playhead.
  • Inside the layer's box, only a node's drawn dot is the node's. A press on another node's wider grab ring, inside the selected layer's box, moves the layer. A press on the dot itself still drags that keyframe, as in After Effects. Outside the box, the whole ring stays the node's.
  • A move on the first or last keyframe of an eased tween changes that keyframe. The playhead writer refused any keyframe tween eased as a whole, so dropping the layer on such a keyframe saved nothing. GSAP eases a keyframe run as a whole with keyframes.ease || ease (gsap 3.15, the keyframes branch of the Tween constructor) and renders the run at ease(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 own ease, the same curve; the rewrite used to drop it.
  • The hand-off falls back to the node. The press goes to the layer only when the layer's box handles it (it calls preventDefault) and takes the pointer (not pointer-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 with MotionPathNode).
  • A timeline's default ease no longer lands on keyframed tweens. GSAP never passes defaults.ease to a tween with keyframes (_setKeyframeDefaults skips ease), but the parser copied it in. Writing it back would have changed the whole curve, so the parser now matches GSAP.
  • An ease the file names by variable is written back as that variable. The writer serialized every ease with JSON.stringify, so ease: E came 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 same valueToCode its other values use, and a keyframes-level ease/easeEach that is a variable is kept by the parser instead of dropped.

Bounds:

  • In a tween eased as a whole, a keyframe between the first and last is still refused, and so is adding a keyframe: the ease shows the layer at 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.
  • Plain-list keyframes (keyframes: { x: [..] }) are still refused; they draw no nodes today.
  • A press on the playhead node while the layer's move check is still pending does what a press on the layer does then: nothing yet.

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):

tree accurate presses that landed on a node
base 3/6 and 5/6 (two runs) 4/6, every one failed
this branch, before the dot rule 5/6 1/6 failed: z200-mid pressed the 2 s node's ring 7 px from the layer's middle, which the dot rule now gives to the layer

Forced 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 parsers gsapParserAcorn.full.test.ts, gsapWriter.acorn.test.ts and gsapWriter.varEase.test.ts. With the source changes reverted, each of them fails. The parsers and studio-server suites and Studio's hooks and components/editor test 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-on fixture 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

@mintlify

mintlify Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Oct 4, 2026, 9:21 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 1556 (base branch 1556), smooth 1462 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

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.
… 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.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. _setKeyframeDefaults skips ease (line 132), so the parser change in applyTimelineDefaults matches GSAP. The run ease is tl._ease = _parseEase(keyframes.ease || vars.ease || "none") (line 2374), which matches runEase = data.ease ?? anim.ease.
  • Edits on an eased run are limited to the first and last keyframe. ease(0)=0 and ease(1)=1 hold 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 && !== 100 is true for undefined), and a playhead outside the tween.
  • Writing the ease at the tween level cannot duplicate a key. ease is not in NON_EDITABLE_VAR_KEYS, so kept never carries the original, and the keyframes-level ease moves up as the same curve.
  • The __raw: round trip is sound. valueToCode strips the prefix, and every acorn-writer ease site now goes through it. The recast gsap-parser-recast writers still use JSON.stringify for eases, but studio-server imports its writers from gsap-writer-acorn, so those sites are not on this path.
  • The press-start order is right. rafPausedRef is now set only when the gesture is armed, and the blocked path calls preventDefault, so pressSelectedLayer reads 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

  1. 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. pressBelongsToLayer matches that node through the live check 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.
      • pressBelongsToLayer returns true.
      • Dropping at x 30 plans duration: 6 and keyframes 0% x0, 66.667% x20, 100% x30.
      • On main, the same drag edits the 100% keyframe in place and keeps the 4 s duration.
    • 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.
  2. A variable easeEach now reaches the UI as __raw:E.
    • AnimationCard builds its ease label from keyframes.easeEach as-is, so the card shows the raw marker. Before, it fell back to the tween's ease.
    • trackHeaderLaneValues.easedProgress calls gsap.parseEase("__raw:E"), gets undefined, and samples linearly.
    • Neither was right before either, but stripping the marker for display, or labeling it "Custom", would be tidier.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 7faf2cc Oct 4, 2026
149 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-motion-path-node-is-the-layer branch October 4, 2026 10:57

This branch was successfully deployed

1 active deployment
staging - docs — 124e7a8e Deployed Oct 4, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants