fix(studio): close the review findings that survived the stack

Selector reads now go through one inverse of `idSelector`. Every writer emits
`[id="01-hook-hero"]` for an id a `#id` selector can't address, but the readers
still matched `#id` only, so the post-commit keyframe-cache refresh, the AST
load and the remove-all-keyframes clear all silently skipped exactly the ids
`idSelector` was added to support.

A keyframe merged from two tweens with different eases kept whichever ease
iterated last. Readers that don't check `easeAmbiguous` showed a curve from a
different animation than an edit would target, so the ambiguous flag now clears
`ease` instead of leaving an arbitrary one behind.

One tolerance for "the playhead is on this keyframe". The motion-path drag used
0.05% while the toolbar and the playhead apply used 1%, so a drag that landed a
fraction of a percent off an authored waypoint skipped the update-point branch
and appended a near-duplicate. `buildTemporalArcKeyframes` now owns the
invariant and replaces any keyframe inside the tolerance, rather than trusting
each caller's own pre-check.

The pending-retime bookkeeping matches on keyframe identity, not just on
"something is near that percentage" — an evenly spaced row cleared the entry off
an unrelated sibling. The neighbour clamp composes pending destinations in
before sorting, so a second drag can't cross a neighbour that already moved.

Also: `keyframeCache`/`gsapAnimations` setters return the same state for a write
that changes nothing (every no-op re-rendered every subscriber), the auto-expand
set drops clips that left the source so an undo/paste under the same id expands
again, `invalidateGsapCache` has a stable identity instead of re-creating the
whole timeline edit context each render, the studio test hook deletes its window
key rather than leaving it enumerable as undefined, and the past-last-row
extrapolation documents why it uses TRACK_H where the pre-first-row branch uses
row 0's own height.

Covers `idFromSelector` round-trips, the insert boundary band across plain,
expanded and unusable row heights, and the collapsed selection key for a
colon-bearing element id.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:46 +02:00
parent 675cbe194d
commit b8ff8bf0f3
12 changed files with 136 additions and 42 deletions
@@ -5,7 +5,7 @@ import {
isPlayheadWithinTween,
type EnableKeyframesSession,
} from "../hooks/useEnableKeyframes";
import { computeElementPercentage } from "../hooks/gsapShared";
import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
import {
getNextTimelineZoomPercent,
@@ -54,8 +54,8 @@ function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage:
if (!animation?.keyframes) return false;
const keyframes = animation.keyframes.keyframes;
return (
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= 1 ||
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= 1
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
);
}
@@ -80,7 +80,7 @@ function resolveKeyframeToggleState(
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
const active = animation.keyframes.keyframes.some(
(keyframe) => Math.abs(keyframe.percentage - percentage) <= 1,
(keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
);
return {
state: pathEndpoint ? "none" : active ? "active" : "inactive",