fix(studio): resolve a panel edit through the lane groups it can see

animIdForProp matched on the parser's whole-tween propertyGroup, which is
undefined for a legacy mixed tween such as {x, opacity}. Such a tween never
matched, so an edit to either property fell through to the selection's
default animation, a different tween than the lane the user is editing.
Resolve through animationLaneGroups, the same per-keyframe helper the
rendered lanes and the reserved row heights already count groups with.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 20:37:21 +02:00
parent 59a818e80a
commit be3451aae4
3 changed files with 72 additions and 7 deletions
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import {
getTimelinePropertyLanes,
resolveAnimIdForProperty,
TimelinePropertyLanes,
type TimelinePropertyLanesProps,
} from "./TimelinePropertyLanes";
@@ -555,3 +556,49 @@ describe("TimelinePropertyLanes", () => {
act(() => root.unmount());
});
});
describe("resolveAnimIdForProperty", () => {
/** A legacy mixed tween: the parser leaves propertyGroup undefined for it. */
const mixed = {
id: "mixed-1",
targetSelector: "#box",
method: "to",
position: 0,
properties: {},
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0, opacity: 0 } },
{ percentage: 100, properties: { x: 40, opacity: 1 } },
],
},
} as unknown as GsapAnimation;
it("routes both groups of a mixed tween to that tween, not the fallback", () => {
expect(mixed.propertyGroup).toBeUndefined();
expect(resolveAnimIdForProperty("x", [mixed], "fallback")).toBe("mixed-1");
expect(resolveAnimIdForProperty("opacity", [mixed], "fallback")).toBe("mixed-1");
});
it("falls back only when no tween animates the property's group", () => {
expect(resolveAnimIdForProperty("rotation", [mixed], "fallback")).toBe("fallback");
expect(resolveAnimIdForProperty("rotation", [mixed], undefined)).toBe("");
});
it("prefers a single-group tween that owns the lane", () => {
const opacityOnly = {
...mixed,
id: "opacity-1",
propertyGroup: "visual",
keyframes: {
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
},
} as unknown as GsapAnimation;
expect(resolveAnimIdForProperty("opacity", [opacityOnly, mixed], "fallback")).toBe("opacity-1");
expect(resolveAnimIdForProperty("x", [opacityOnly, mixed], "fallback")).toBe("mixed-1");
});
});
@@ -77,6 +77,28 @@ export function animationLaneGroups(animation: GsapAnimation): PropertyGroupName
return Array.from(groups);
}
/**
* Which tween a panel edit to `prop` belongs to.
*
* Matches on the groups the tween's KEYFRAMES animate, not on the parser's
* whole-tween `propertyGroup` verdict: that field is undefined for a legacy
* mixed tween such as `{ x, opacity }`, so matching it dropped every such
* tween and sent the edit to the selection's default animation instead, which
* is a different tween than the lane the user is looking at.
* {@link animationLaneGroups} is the single owner the rendered lanes count
* groups through, so resolving here through the same helper keeps the panel
* and the lanes on one answer.
*/
export function resolveAnimIdForProperty(
prop: string,
animations: readonly GsapAnimation[] | undefined,
fallbackAnimId: string | undefined,
): string {
const group = classifyPropertyGroup(prop);
const groupAnim = animations?.find((a) => animationLaneGroups(a).includes(group));
return groupAnim?.id ?? fallbackAnimId ?? "";
}
/** A tween contributes a property lane when it animates at least one property
* on at least one editable keyframe (real or synthesized). */
export function animationContributesLane(animation: GsapAnimation): boolean {