From 6d19a866bb48a8539af80cd10910bcd457bd7c95 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 01:19:24 -0700 Subject: [PATCH] fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times --- packages/parsers/src/gsapParserAcorn.ts | 2 +- packages/parsers/src/gsapWriterAcorn.ts | 48 ++++++ .../src/routes/files.replaceInPlace.test.ts | 159 ++++++++++++++++++ packages/studio-server/src/routes/files.ts | 44 ++--- 4 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 packages/studio-server/src/routes/files.replaceInPlace.test.ts diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index f4d1f3956c..7ca9187384 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -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, diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 8077725bb9..8c6d3acd1f 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -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; + 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. */ diff --git a/packages/studio-server/src/routes/files.replaceInPlace.test.ts b/packages/studio-server/src/routes/files.replaceInPlace.test.ts new file mode 100644 index 0000000000..7cd3eb368d --- /dev/null +++ b/packages/studio-server/src/routes/files.replaceInPlace.test.ts @@ -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 = ` +
+ +`; + 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 { + 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"'))); + }); +}); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 585bb38e23..0f3c3513c1 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -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, +): 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 (