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
@@ -2,7 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveEditableTweenDuration } from "./gsapShared";
import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import {
@@ -12,17 +12,27 @@ import {
materializeIfDynamic,
} from "./gsapDragCommit";
/**
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
* within {@link KEYFRAME_PCT_MATCH} of the insert is REPLACED, not kept: the
* server takes a replace-with-keyframes list verbatim, so an append-only build
* could hand it two keyframes a fraction of a percent apart. The invariant lives
* here rather than in each caller's own pre-check, which is how the two callers
* ended up with different tolerances in the first place.
*/
export function buildTemporalArcKeyframes(
anim: GsapAnimation,
percentage: number,
properties: Record<string, number>,
) {
return [
...(anim.keyframes?.keyframes ?? []).map((keyframe) => ({
percentage: keyframe.percentage,
properties: { ...keyframe.properties },
...(keyframe.ease ? { ease: keyframe.ease } : {}),
})),
...(anim.keyframes?.keyframes ?? [])
.filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
.map((keyframe) => ({
percentage: keyframe.percentage,
properties: { ...keyframe.properties },
...(keyframe.ease ? { ease: keyframe.ease } : {}),
})),
{ percentage, properties },
].sort((a, b) => a.percentage - b.percentage);
}
@@ -279,7 +289,12 @@ export async function commitGsapPositionFromDrag(
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
const keyframes = anim.keyframes?.keyframes ?? [];
const pointIndex = keyframes.findIndex((kf) => Math.abs(kf.percentage - pct) < 0.05);
// Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
// tighter one here meant a drag that landed a fraction of a percent off an
// authored waypoint skipped the update-point branch and appended instead.
const pointIndex = keyframes.findIndex(
(kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
);
if (pointIndex >= 0) {
await callbacks.commitMutation(
selection,
+9
View File
@@ -70,6 +70,15 @@ export function isInstantHold(animation: GsapAnimation): boolean {
// `CSS.escape`, it needs no browser global (this runs in node tests too).
const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
/**
* How close (in tween-%) a playhead has to be to count as sitting ON an existing
* keyframe. Every "is there already a keyframe here?" test shares this: with two
* different tolerances in play, one path decided "no keyframe here, append one"
* while another decided "yes, edit that one", and a drag near a waypoint left two
* keyframes a fraction of a percent apart.
*/
export const KEYFRAME_PCT_MATCH = 1;
export function idSelector(id: string): string {
// A `#id` selector is only valid for a CSS identifier. IDs that start with a
// digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
@@ -11,18 +11,13 @@ import {
elementCacheKeys,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { toClipKeyframes } from "./gsapShared";
import { idFromSelector, toClipKeyframes } from "./gsapShared";
import {
deduplicateKeyframes,
isStaticPositionHold,
synthesizeFlatTweenKeyframes,
} from "./gsapTweenSynth";
function extractIdFromSelector(selector: string): string | null {
const match = selector.match(/^#([\w-]+)/);
return match ? match[1] : null;
}
/**
* Resolve a tween's target selector to the ids of the element(s) it animates.
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
@@ -36,10 +31,13 @@ export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
const bareId = selector.match(/^#([\w-]+)$/);
if (bareId) return [bareId[1]];
// A whole-selector id match (either shape) addresses exactly one element.
const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector)
? idFromSelector(selector)
: null;
if (bareId) return [bareId];
if (!doc) {
const lead = extractIdFromSelector(selector);
const lead = idFromSelector(selector);
return lead ? [lead] : [];
}
const ids = new Set<string>();
@@ -51,7 +49,7 @@ export function resolveSelectorElementIds(
if (el.id) ids.add(el.id);
}
} catch {
const lead = extractIdFromSelector(sel);
const lead = idFromSelector(sel);
if (lead) ids.add(lead);
}
}
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
import {
selectorFromSelection,
computeElementPercentage,
KEYFRAME_PCT_MATCH,
isInstantHold,
resolveEditableTweenDuration,
} from "./gsapShared";
@@ -302,7 +303,9 @@ async function applyKeyframeAtPlayhead(
}
const pct =
start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
const existing = kfAnim.keyframes?.keyframes.find(
(k) => Math.abs(k.percentage - pct) <= KEYFRAME_PCT_MATCH,
);
if (existing) {
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
return;
@@ -406,7 +409,7 @@ export async function applyArcKeyframeAtPlayhead(
const nodes = arcAnim.keyframes?.keyframes ?? [];
const playheadPercentage = absoluteToPercentage(t, start, duration);
const timedNodeIndex = nodes.findIndex(
(node) => Math.abs(node.percentage - playheadPercentage) <= 1,
(node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH,
);
if (timedNodeIndex !== -1) {
if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) {
@@ -15,6 +15,7 @@ import {
} from "../utils/sdkCutover";
import type { KeyframeCacheEntry } from "../player/store/playerStore";
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import { idFromSelector } from "./gsapShared";
import {
clearKeyframeCacheForElement,
readKeyframeSnapshot,
@@ -338,7 +339,7 @@ export function useGsapKeyframeOps({
// remove-all-keyframes collapses the tween to a static hold and the commit
// path doesn't return parsed animations, so the keyframe cache is never
// refreshed — clear it here so the timeline diamonds disappear immediately.
const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null;
const elementId = selection.id ?? idFromSelector(selection.selector);
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
if (sdkSession && sdkDeps) {
const handled = await sdkGsapRemoveAllKeyframesPersist(