fix(studio): lane every tween and attribute tweens to their real target

Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 19:02:25 +02:00
parent acad7b268e
commit 59a818e80a
13 changed files with 437 additions and 93 deletions
@@ -276,6 +276,69 @@ describe("updateKeyframeCacheFromParsed", () => {
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
});
// `#stat3 .block` animates the BLOCK inside #stat3, not #stat3. An unanchored
// `^#([\w-]+)/` prefix match filed it under "stat3", which both stole the
// child's diamonds and collided with #stat3's own tween at the shared
// percentage (dropping #stat3's ease as ambiguous).
it("attributes a descendant selector to the child, not to its ancestor", () => {
const parent: GsapAnimation = {
id: "stat3-fromTo",
targetSelector: "#stat3",
method: "fromTo",
position: 8.88,
resolvedStart: 8.88,
duration: 0.25,
fromProperties: { y: 20 },
properties: { y: 0 },
ease: "power2.out",
propertyGroup: "position",
};
const child: GsapAnimation = {
id: "block-from",
targetSelector: "#stat3 .block",
method: "from",
position: 9.13,
resolvedStart: 9.13,
duration: 0.3,
properties: { opacity: 0 },
ease: "power2.in",
propertyGroup: "visual",
};
usePlayerStore.setState({
elements: [
{ id: "stat3-clip", domId: "stat3", tag: "div", start: 8.88, duration: 1, track: 0 },
],
});
const doc = {
querySelectorAll: (selector: string) =>
(selector === "#stat3 .block"
? [{ id: "stat3-block" }]
: []) as unknown as NodeListOf<Element>,
} as unknown as Document;
updateKeyframeCacheFromParsed([parent, child], "scene.html", "stat3", {}, doc);
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3")).toEqual([parent]);
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3-block")).toEqual([child]);
// With the collision gone, #stat3's own curve survives instead of being
// deleted as an ambiguous same-percentage merge.
const parentKeyframes = cache().get("scene.html#stat3")?.keyframes ?? [];
expect(parentKeyframes.at(-1)?.ease).toBe("power2.out");
expect(parentKeyframes.some((keyframe) => "easeAmbiguous" in keyframe)).toBe(false);
});
it("attributes a descendant selector to nothing when there is no document", () => {
const child: GsapAnimation = {
...animWithKeyframes("block-from"),
targetSelector: "#stat3 .block",
};
updateKeyframeCacheFromParsed([child], "scene.html", "stat3", {});
expect(cache().has("scene.html#stat3")).toBe(false);
expect(usePlayerStore.getState().gsapAnimations.has("scene.html#stat3")).toBe(false);
});
it("does not cache a flat tween without animatable numeric properties", () => {
const animation: GsapAnimation = {
id: "flat-box",
@@ -4,7 +4,7 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
import { idFromSelector, resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
export function updateKeyframeCacheFromParsed(
@@ -12,58 +12,65 @@ export function updateKeyframeCacheFromParsed(
targetPath: string,
selectionId: string | undefined,
mutation: Record<string, unknown>,
doc?: Document | null,
): void {
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
const idsWithKeyframes = new Set<string>();
const merged = new Map<string, KeyframeCacheEntry>();
const sourceAnimations = new Map<string, GsapAnimation[]>();
for (const anim of animations) {
const id = idFromSelector(anim.targetSelector);
const kfSource =
anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
if (!id || kfSource.length === 0) continue;
idsWithKeyframes.add(id);
// Every tween that fed keyframeCache also lands in gsapAnimations, group or
// not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
// cache diamonds with no source animation behind them, so the collapsed row
// drew keyframes the expanded lanes couldn't render. Lane consumers do the
// group filtering themselves (animationContributesLane).
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
if (kfSource.length === 0) continue;
// Attribute the tween to every element it actually animates. A leading-id
// match filed `#stat3 .block` under `#stat3`: the child's diamonds landed on
// its ancestor AND collided with the ancestor's own tween at the shared
// percentage, which the same-% merge then resolved by dropping the ease.
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
idsWithKeyframes.add(id);
// Every tween that fed keyframeCache also lands in gsapAnimations, group or
// not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
// cache diamonds with no source animation behind them, so the collapsed row
// drew keyframes the expanded lanes couldn't render. Lane consumers do the
// group filtering themselves (animationContributesLane).
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip. The basis comes
// from the shared resolver, so this writer agrees with the AST load on both
// the sub-comp host fallback and the tween's own time frame.
const { elStart, elDuration } = resolveClipTimingBasis(
id,
targetPath,
elements,
domClipChildren,
);
const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip. The basis comes
// from the shared resolver, so this writer agrees with the AST load on both
// the sub-comp host fallback and the tween's own time frame.
const { elStart, elDuration } = resolveClipTimingBasis(
id,
targetPath,
elements,
domClipChildren,
);
const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
const existing = merged.get(id);
if (existing) {
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
// flag downstream lanes read); a second copy of that rule here is how the
// two writers drift.
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
merged.set(id, {
...anim.keyframes,
format: anim.keyframes?.format ?? "percentage",
keyframes: clipKeyframes,
});
const existing = merged.get(id);
if (existing) {
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
// flag downstream lanes read); a second copy of that rule here is how the
// two writers drift.
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
merged.set(id, {
...anim.keyframes,
format: anim.keyframes?.format ?? "percentage",
keyframes: clipKeyframes,
});
}
}
}
for (const [id, entry] of merged) {
for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
}
const targetId =
idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
if (targetId && !idsWithKeyframes.has(targetId)) {
clearKeyframeCacheForElement(targetPath, targetId);
const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
for (const targetId of targetIds) {
if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId);
}
}
+65
View File
@@ -110,6 +110,71 @@ export function idFromSelector(selector: string | undefined | null): string | nu
return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1");
}
/** Either shape {@link idSelector} emits, anchored to the WHOLE selector. */
const WHOLE_SELECTOR_ID = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/;
/**
* The id a selector addresses **as a whole**, or null. `"#stat3 .block"` animates
* the `.block` INSIDE `#stat3`, not `#stat3`, so the unanchored leading-id match
* of {@link idFromSelector} is wrong for attribution: it files the child's
* keyframes under its ancestor. `idFromSelector` stays unanchored on purpose
* (two non-attribution callers want the leading id); attribution goes through
* here, or through the DOM (see resolveSelectorElementIds).
*/
function wholeSelectorElementId(selector: string): string | null {
const trimmed = selector.trim();
return WHOLE_SELECTOR_ID.test(trimmed) ? idFromSelector(trimmed) : null;
}
/**
* Resolve a tween's target selector to the ids of the element(s) it animates.
* A whole-selector `#id` resolves directly; anything else (a class like `.dot`,
* a group `.a, .b`, or a descendant selector) is matched against the live
* preview DOM so class/selector tweens (e.g. `gsap.from(".dot", {stagger})`)
* attribute to every element they animate — not just one parsed from the string.
* With no DOM, only whole-selector ids resolve: a descendant selector has no
* answer that isn't a guess at its ancestor.
*/
export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
const bareId = wholeSelectorElementId(selector);
if (bareId) return [bareId];
const ids = new Set<string>();
for (const part of selector.split(",")) {
const sel = part.trim();
if (!sel) continue;
if (!doc) {
const whole = wholeSelectorElementId(sel);
if (whole) ids.add(whole);
continue;
}
try {
for (const el of Array.from(doc.querySelectorAll(sel))) {
if (el.id) ids.add(el.id);
}
} catch {
// An unsupported/invalid selector never reached the DOM, so the leading id
// is the best available answer (`[id="01-hook"]:has(>*)` still names it).
const lead = idFromSelector(sel);
if (lead) ids.add(lead);
}
}
return Array.from(ids);
}
/**
* The clip start in the frame the element's OWN tweens are measured in. An
* expanded sub-composition child sits on the master timeline at a host-absolute
* `start`, but its tweens are parsed from its own source file and are local to
* it, so the two must be brought into one frame before any clip-% math — or
* every keyframe rebases to a percentage far outside the clip.
*/
export function clipTimingStart(element: { start: number; expandedParentStart?: number }): number {
return element.start - (element.expandedParentStart ?? 0);
}
export function selectorFromSelection(selection: DomEditSelection): string | null {
if (selection.id) return idSelector(selection.id);
if (selection.selector) return selection.selector;
@@ -11,50 +11,15 @@ import {
elementCacheKeys,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { idFromSelector, resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
import {
deduplicateKeyframes,
isStaticPositionHold,
synthesizeFlatTweenKeyframes,
} from "./gsapTweenSynth";
/**
* 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
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
* element they animate — not just one parsed from the string. Falls back to a
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
*/
// fallow-ignore-next-line complexity
export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
// 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 = idFromSelector(selector);
return lead ? [lead] : [];
}
const ids = new Set<string>();
for (const part of selector.split(",")) {
const sel = part.trim();
if (!sel) continue;
try {
for (const el of Array.from(doc.querySelectorAll(sel))) {
if (el.id) ids.add(el.id);
}
} catch {
const lead = idFromSelector(sel);
if (lead) ids.add(lead);
}
}
return Array.from(ids);
}
export { resolveSelectorElementIds };
/**
* The slice of the parse response callers actually read. The endpoint returns
* the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
@@ -186,6 +186,9 @@ function syncCommittedGsapMutation({
targetPath,
selection.id ?? undefined,
mutation,
// The live preview document is what resolves a class / descendant tween to
// the elements it really animates; without it only whole-id selectors do.
iframe?.contentDocument,
);
}
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
@@ -86,16 +86,24 @@ describe("resolveSelectorElementIds", () => {
expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
});
it("falls back to a leading #id when there is no DOM", () => {
expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
// A DOM-less LEADING-id fallback attributed `#card .label` to `#card`, the
// ancestor it merely scopes to. Without a DOM there is nothing to resolve the
// descendant against, so the honest answer is no element at all.
it("resolves nothing for a compound selector when there is no DOM", () => {
expect(resolveSelectorElementIds("#card .label", null)).toEqual([]);
expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
});
it("still resolves every whole-id part of a group selector without a DOM", () => {
expect(resolveSelectorElementIds("#a, #b", null)).toEqual(["a", "b"]);
});
// The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
// dotted). The old local `#id`-only regex read no id at all for those, so they
// silently dropped out of both DOM-less paths.
it("falls back to a bracketed id when there is no DOM", () => {
expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual(["01-hook"]);
expect(resolveSelectorElementIds('[id="01-hook"]', null)).toEqual(["01-hook"]);
expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual([]);
});
it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {