fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-02 03:38:56 -07:00
parent f19f052efa
commit 6d19a866bb
4 changed files with 230 additions and 23 deletions
+1 -1
View File
@@ -1903,7 +1903,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
tweenCallToAnimation(call, scope, script, identifierBindings),
);
applyTimelineDefaults(rawAnims, detection.defaults);
resolveTimelinePositions(rawAnims);
resolveTimelinePositions(rawAnims, collectAddLabelDefs(ast, ref, scope, calls));
const animations = assignStableIds(rawAnims);
const located = calls.map((call, i) => ({
id: animations[i]!.id,
+48
View File
@@ -1790,6 +1790,54 @@ export function materializeKeyframesFromScript(
return ms.toString();
}
/**
* Rewrites a tween as `to()` with these keyframes where it stands: the call keeps its place in the
* script and its position argument, so a tween placed after it ('>', '<', '+=', none) stays put.
*/
export function replaceTweenWithKeyframesInScript(
script: string,
animationId: string,
edit: {
targetSelector: string;
position: number;
duration: number;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
},
): string | null {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((l) => l.id === animationId);
const call = target?.call;
if (!target || call?.varsArg?.type !== "ObjectExpression") return null;
const { animation } = target;
if (animation.method !== "to" && animation.method !== "from" && animation.method !== "fromTo")
return null;
const ms = new MagicString(script);
const start = animation.resolvedStart ?? animation.position;
const moved = typeof start !== "number" || Math.abs(start - edit.position) > 5e-4;
const kept = preservedVarsEntries(call.varsArg, script).filter(
(e) => !/^\s*duration\s*:/.test(e) && !(moved && /^\s*delay\s*:/.test(e)),
);
const sorted = [...edit.keyframes].sort((a, b) => a.percentage - b.percentage);
const parts = [`keyframes: ${buildKeyframeObjectCode(sorted, edit.easeEach)}`, ...kept];
parts.push(`duration: ${valueToCode(edit.duration)}`);
if (edit.ease) parts.push(`ease: ${JSON.stringify(edit.ease)}`);
if (animation.hasUnresolvedSelector || animation.targetSelector !== edit.targetSelector) {
const selectorArg = call.node.arguments[0];
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(edit.targetSelector));
}
convertMethodToTo(ms, animation, call, call.varsArg);
overwriteVarsArg(ms, call, `{ ${parts.join(", ")} }`);
if (moved) overwritePosition(ms, call, edit.position);
return ms.toString();
}
// ── Add animation with keyframes ──────────────────────────────────────────────
/** Insert a new keyframed `to()` call and return the new animation ID. */
@@ -0,0 +1,159 @@
// @vitest-environment node
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import { afterEach, describe, expect, it } from "vitest";
import { registerFileRoutes } from "./files";
import type { StudioApiAdapter } from "../types";
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
interface Anim {
id: string;
targetSelector: string;
resolvedStart?: number;
method: string;
}
const notHold = (a: Anim) => a.method !== "set";
function app(script: string): Hono {
const dir = mkdtempSync(join(tmpdir(), "hf-replace-in-place-"));
dirs.push(dir);
const html = `<!DOCTYPE html><html><body data-duration="10">
<div id="a"></div><div id="b"></div><div id="c"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline({ paused: true });
${script}
window.__timelines = { main: tl };
</script>
</body></html>`;
writeFileSync(join(dir, "comp.html"), html);
const hono = new Hono();
registerFileRoutes(hono, {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/o.mp4",
}),
} as StudioApiAdapter);
return hono;
}
async function animations(hono: Hono): Promise<Anim[]> {
const res = await hono.request("http://localhost/projects/demo/gsap-animations/comp.html");
return ((await res.json()) as { animations: Anim[] }).animations;
}
// The script's calls, without the hold sets the server keeps before a first position keyframe.
const calls = (code: string) =>
code.split("\n").filter((line) => line.startsWith("tl.") && !line.includes("hf-hold"));
/** A move on `selector` at the playhead, sent the way the playhead writer sends it. */
async function moveAtPlayhead(script: string, selector: string) {
const hono = app(script);
const before = await animations(hono);
const edited = before.find((a) => a.targetSelector === selector)!;
const res = await hono.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "replace-with-keyframes",
animationId: edited.id,
targetSelector: selector,
position: edited.resolvedStart,
duration: 1,
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 40 } },
{ percentage: 100, properties: { x: 100 } },
],
easeEach: "none",
}),
});
const { after: html } = (await res.json()) as { after: string };
const after = await animations(hono);
const starts = (list: Anim[]) => list.map((a) => [a.targetSelector, a.resolvedStart]);
return { html, starts: starts(after.filter(notHold)), was: starts(before) };
}
describe("an edit at the playhead rewrites its tween where it stands", () => {
it.each([
[
"no position argument",
`tl.to("#a", { x: 100, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`,
],
[
"'<' and '+='",
`tl.to("#a", { x: 100, duration: 1 }, 1);\ntl.to("#b", { y: 50, duration: 1 }, "<");\ntl.to("#c", { y: 9, duration: 1 }, "+=0.5");`,
],
[
"'>'",
`tl.to("#a", { x: 100, duration: 1 }, 0.5);\ntl.to("#b", { y: 50, duration: 1 }, ">");`,
],
[
"a label",
`tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");\ntl.to("#b", { y: 50, duration: 1 }, "intro+=0.5");`,
],
[
"a from() entrance",
`tl.from("#a", { x: -60, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`,
],
])("leaves every other tween's source and start alone after %s", async (_, script) => {
const { html, starts, was } = await moveAtPlayhead(script, "#a");
expect(starts).toEqual(was);
const others = calls(script).filter((line) => !line.includes('"#a"'));
expect(calls(html).filter((line) => !line.includes('"#a"'))).toEqual(others);
expect(calls(html).findIndex((line) => line.includes('"#a"'))).toBe(
calls(script).findIndex((line) => line.includes('"#a"')),
);
});
it("keeps a chained call's next link in place", async () => {
const script = `tl.to("#a", { x: 100, duration: 1 }).to("#b", { y: 50, duration: 1 });`;
const { html, starts, was } = await moveAtPlayhead(script, "#a");
expect(starts).toEqual(was);
expect(calls(html)).toHaveLength(1);
expect(calls(html)[0]).toMatch(
/^tl\.to\("#a", \{ keyframes: .*\}\)\.to\("#b", \{ y: 50, duration: 1 \}\);$/,
);
});
it.each([
[
"a relative one",
`tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, "<");`,
`"<");`,
],
[
"a label",
`tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");`,
`"intro");`,
],
[
"the last tween's number",
`tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, 1);`,
`}, 1);`,
],
])("keeps the edited tween's own position argument when it is %s", async (_, script, tail) => {
const { html, starts, was } = await moveAtPlayhead(script, "#a");
expect(starts).toEqual(was);
const edited = calls(html).find((line) => line.includes('"#a"'))!;
expect(edited.endsWith(tail)).toBe(true);
expect(calls(html).indexOf(edited)).toBe(calls(script).findIndex((l) => l.includes('"#a"')));
});
});
+22 -22
View File
@@ -70,6 +70,7 @@ import {
addMotionPathToScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
replaceTweenWithKeyframesInScript,
splitAnimationsInScript,
splitIntoPropertyGroupsFromScript,
shiftPositionsInScript,
@@ -1203,6 +1204,25 @@ export type GsapMutationRequest =
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
function replaceInPlaceUnlessSetOrUnknown(
scriptText: string,
body: Extract<GsapMutationRequest, { type: "replace-with-keyframes" }>,
): string {
const edit = { ...body, easeEach: resolveReplacementEaseEach(scriptText, body) };
const inPlace = replaceTweenWithKeyframesInScript(scriptText, body.animationId, edit);
if (inPlace !== null) return inPlace;
const script = removeAnimationFromScript(scriptText, body.animationId);
return addAnimationWithKeyframesToScript(
script,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
edit.easeEach,
).script;
}
function resolveReplacementEaseEach(
scriptText: string,
request: { animationId: string; easeEach?: string },
@@ -1679,17 +1699,7 @@ function executeGsapMutationAcorn(
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body);
}
case "split-animations": {
if (
@@ -2051,17 +2061,7 @@ async function executeGsapMutationRecast(
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body);
}
case "split-animations": {
if (