fix(studio): target one element when adding a keyframe at the playhead

"Add keyframe at playhead" on an element with no id authored the bare class
buildStableSelector hands back, so one add on a `.group` wrote
`tl.to(".group", ...)`: a tween that animates all five siblings and that
resolveSelectorElementIds reads back as all five, collapsing their timeline
rows into one. It survived a reload, so the written file stayed un-editable.

writeTargetSelector is the write-side counterpart to selectorFromSelection
(which must keep returning the exact string findTweenAtTime compares against).
It resolves the element's own identity to a selector that addresses exactly
one element: `#id`, else `[data-hf-id="..."]`, else the selection's selector
when it is already unique, else a `:nth-child` path anchored on the nearest
identifiable ancestor (the selector + selectorIndex pair, resolved through the
DOM the index was counted in).

Applied to the two paths that author a NEW tween: the no-animation branch of
useEnableKeyframes and commitKeyframeAtTimeImpl. replace-with-keyframes still
writes the selection's own selector, since retargeting a tween the author
aimed at a whole group is a different decision from adding a keyframe.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 20:37:52 +02:00
parent be3451aae4
commit fd5555be75
5 changed files with 281 additions and 3 deletions
@@ -466,3 +466,47 @@ describe("useEnableKeyframes — flat tween transaction", () => {
});
});
});
describe("useEnableKeyframes — new tween on a class-only element", () => {
it("targets the selected sibling alone, not every element sharing its class", async () => {
window.location.hash = "#/project/test-project";
usePlayerStore.setState({ currentTime: 1 });
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ animations: [] }) })),
);
const scene = document.body.appendChild(document.createElement("div"));
scene.id = "scene";
scene.innerHTML = '<i class="group"></i>'.repeat(5);
const groups = Array.from(scene.querySelectorAll<HTMLElement>(".group"));
const commitMutation = vi.fn(async () => undefined);
const enable = renderEnableKeyframes({
// A bare `.group` here writes `tl.to(".group", …)`, which animates all five
// siblings and reads back as all five — one add wiped the timeline.
domEditSelection: {
selector: ".group",
selectorIndex: 3,
sourceFile: "index.html",
element: groups[3],
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection,
selectedGsapAnimations: [],
previewIframeRef: {
current: {
contentWindow: { gsap: { getProperty: () => 7 } },
} as unknown as HTMLIFrameElement,
},
handleGsapAddAnimation: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
commitMutation,
});
await act(async () => enable());
const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
expect(mutation?.targetSelector).toBeTruthy();
expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
});
});