From 13fb5f9d3c49acca6847d4336c8e39967ed57fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 05:08:00 -0400 Subject: [PATCH 1/9] fix(parsers): moving a clip on the timeline moves its inner animations too --- packages/parsers/src/clipTweens.ts | 29 ++++++++++ packages/parsers/src/gsapParser.ts | 9 ++- .../parsers/src/gsapWriter.parity.test.ts | 55 +++++++++++++++++++ packages/parsers/src/gsapWriterAcorn.ts | 13 +++-- .../studio-server/src/routes/files.test.ts | 49 +++++++++++++++++ packages/studio-server/src/routes/files.ts | 12 ++-- 6 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 packages/parsers/src/clipTweens.ts diff --git a/packages/parsers/src/clipTweens.ts b/packages/parsers/src/clipTweens.ts new file mode 100644 index 0000000000..dcbe1678e1 --- /dev/null +++ b/packages/parsers/src/clipTweens.ts @@ -0,0 +1,29 @@ +// Tweens a timeline move or retime of a clip carries: its own selector, plus (given a DOM) any whose every +// target sits inside the clip with no nearer `data-start` clip. A tween also aiming outside stays put. +export function clipTweenMatcher( + clipSelector: string, + root?: ParentNode, +): (tweenSelector: string) => boolean { + const clips = root ? queryAll(root, clipSelector) : []; + return (tweenSelector) => { + if (tweenSelector === clipSelector) return true; + if (!root || clips.length === 0) return false; + const targets = queryAll(root, tweenSelector); + return ( + targets.length > 0 && + targets.every((target) => { + const owner = target.closest("[data-start]"); + return owner !== null && clips.includes(owner); + }) + ); + }; +} + +function queryAll(root: ParentNode, selector: string): Element[] { + try { + return Array.from(root.querySelectorAll(selector)); + } catch { + // Pseudo-selectors such as a proxy or dwell label never match the DOM. + return []; + } +} diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 777a2d712a..855dfaa97c 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -51,6 +51,7 @@ export { } from "./gsapConstants"; import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; import type { PropertyGroupName } from "./gsapConstants"; +import { clipTweenMatcher } from "./clipTweens"; import { findObjectArrayKeyframeIndex, getCompatibleObjectArrayKeyframeTiming, @@ -1543,7 +1544,9 @@ export function shiftPositionsInScript( script: string, targetSelector: string, delta: number, + root?: ParentNode, ): string { + const carries = clipTweenMatcher(targetSelector, root); let parsed: ParsedGsapAst; try { parsed = parseGsapAst(script); @@ -1553,7 +1556,7 @@ export function shiftPositionsInScript( } let changed = false; for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; + if (!carries(entry.animation.targetSelector)) continue; if (typeof entry.animation.position !== "number") continue; const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); applyUpdatesToCall(entry.call, { position: newPos }); @@ -1569,9 +1572,11 @@ export function scalePositionsInScript( oldDuration: number, newStart: number, newDuration: number, + root?: ParentNode, ): string { if (oldDuration <= 0 || newDuration <= 0) return script; const ratio = newDuration / oldDuration; + const carries = clipTweenMatcher(targetSelector, root); let parsed: ParsedGsapAst; try { parsed = parseGsapAst(script); @@ -1581,7 +1586,7 @@ export function scalePositionsInScript( } let changed = false; for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; + if (!carries(entry.animation.targetSelector)) continue; if (typeof entry.animation.position !== "number") continue; const newPos = Math.max( 0, diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index 9e0add6c8d..ea174e15b1 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -11,6 +11,7 @@ * Recast remains the default; acorn runs only when the flag is enabled. */ import { describe, expect, it } from "vitest"; +import { parseHTML } from "linkedom"; import { parseGsapScript, removeAllKeyframesFromScript as removeAllRecast, @@ -1973,3 +1974,57 @@ tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 0)).toBe(POSITIONS_MULTI); }); }); + +describe("shift/scalePositionsInScript carry the clip's inner tweens", () => { + const { document } = parseHTML(` +
+

Hi

+

Kid

+
x
+
+
Out
+`); + const script = `const tl = gsap.timeline({ paused: true }); +tl.from("#scene", { opacity: 0, duration: 1 }, 1); +tl.from("#scene h1", { y: 20, duration: 1 }, 1.5); +tl.to("#child", { x: 10, duration: 1 }, 2); +tl.to(["#child", "#scene h1"], { opacity: 0.5, duration: 1 }, 3); +tl.to(".inner", { scale: 2, duration: 1 }, 3.5); +tl.to("#sibling", { x: 5, duration: 1 }, 2); +tl.to(".title", { color: "red", duration: 1 }, 2.5); +tl.to("#deep", { y: 5, duration: 0.5 }, 2);`; + const timings = (out: string) => + parseGsapScriptAcorn(out).animations.map((a) => [a.targetSelector, a.position, a.duration]); + const writers = [ + ["acorn", shiftAcorn, scaleAcorn], + ["recast", shiftRecast, scaleRecast], + ] as const; + + for (const [name, shift, scale] of writers) { + it(`${name}: a shift moves the clip and its descendants, never outside or nested clips`, () => { + expect(timings(shift(script, "#scene", 2, document))).toEqual([ + ["#scene", 3, 1], + ["#scene h1", 3.5, 1], + ["#child", 4, 1], + ["#child, #scene h1", 5, 1], + [".inner", 5.5, 1], + ["#sibling", 2, 1], + [".title", 2.5, 1], + ["#deep", 2, 0.5], + ]); + }); + + it(`${name}: a scale retimes the clip and its descendants, never outside or nested clips`, () => { + expect(timings(scale(script, "#scene", 1, 4, 1, 8, document))).toEqual([ + ["#scene", 1, 2], + ["#scene h1", 2, 2], + ["#child", 3, 2], + ["#child, #scene h1", 5, 2], + [".inner", 6, 2], + ["#sibling", 2, 1], + [".title", 2.5, 1], + ["#deep", 2, 0.5], + ]); + }); + } +}); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 484c9f95a7..c7f979b2e9 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -32,6 +32,7 @@ import { } from "./gsapObjectArrayTiming.js"; import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js"; import * as acornWalk from "acorn-walk"; +import { clipTweenMatcher } from "./clipTweens.js"; // acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / // gsapInline.ts rather than re-deriving the full ESTree union for every access. @@ -445,7 +446,7 @@ function overwritePosition(ms: MagicString, call: TweenCallInfo, position: numbe } /** - * Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0), + * Shift every tween the clip at `targetSelector` carries by `delta` seconds (clamped ≥0), * rewriting each call's position argument. Mirrors recast's shiftPositionsInScript * (used by timeline clip-move to keep GSAP positions in sync with the clip start). */ @@ -453,13 +454,15 @@ export function shiftPositionsInScript( script: string, targetSelector: string, delta: number, + root?: ParentNode, ): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; + const carries = clipTweenMatcher(targetSelector, root); const ms = new MagicString(script); let changed = false; for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; + if (!carries(entry.animation.targetSelector)) continue; if (typeof entry.animation.position !== "number") continue; const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); overwritePosition(ms, entry.call, newPos); @@ -469,7 +472,7 @@ export function shiftPositionsInScript( } /** - * Linearly remap every tween targeting `targetSelector` from the old clip + * Linearly remap every tween the clip at `targetSelector` carries from the old clip * [oldStart, oldDuration] onto the new [newStart, newDuration] (position and, * when present, duration scaled by the duration ratio). Mirrors recast's * scalePositionsInScript (used by timeline clip-resize). @@ -481,15 +484,17 @@ export function scalePositionsInScript( oldDuration: number, newStart: number, newDuration: number, + root?: ParentNode, ): string { if (oldDuration <= 0 || newDuration <= 0) return script; const ratio = newDuration / oldDuration; const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; + const carries = clipTweenMatcher(targetSelector, root); const ms = new MagicString(script); let changed = false; for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; + if (!carries(entry.animation.targetSelector)) continue; if (typeof entry.animation.position !== "number") continue; const newPos = Math.max( 0, diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 26f98d6679..8b223cb52e 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -2510,6 +2510,55 @@ tl.to("#b", { duration: 1, x: 200 }, 2); expect(result.mutated).toBe(false); }); + it("moving or stretching a clip retimes the tweens inside it and leaves outside ones alone", async () => { + const comp = ``; + const projectDir = createProjectDir(); + writeComp(projectDir, "scene.html", comp); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const mutate = async (body: object) => { + const res = await app.request( + "http://localhost/projects/demo/gsap-mutations/compositions/scene.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + expect(res.status).toBe(200); + return ((await res.json()) as { after: string }).after; + }; + + const moved = await mutate({ type: "shift-positions", targetSelector: "#card", delta: 2 }); + expect(moved).toContain('tl.from("#card h1", { y: 20, duration: 1 }, 3);'); + expect(moved).toContain('tl.to("#line", { x: 10, duration: 1 }, 3.5);'); + expect(moved).toContain('tl.to("#side", { x: 5, duration: 1 }, 1);'); + + const stretched = await mutate({ + type: "scale-positions", + targetSelector: "#card", + oldStart: 3, + oldDuration: 2, + newStart: 3, + newDuration: 4, + }); + expect(stretched).toContain('tl.from("#card h1", { y: 20, duration: 2 }, 3);'); + expect(stretched).toContain('tl.to("#line", { x: 10, duration: 2 }, 4);'); + expect(stretched).toContain('tl.to("#side", { x: 5, duration: 1 }, 1);'); + }); + it("rejects a shift-positions-batch with a missing/non-array `shifts` field (400)", async () => { const projectDir = createProjectDir(); writeHtml( diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 06c5947ada..0c8ac1c67a 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -676,6 +676,7 @@ function updateReferences(projectDir: string, oldPath: string, newPath: string): function extractGsapScriptBlock(html: string): { scriptText: string; document: Document; + root: ParentNode; replaceScript: (newText: string) => string; } | null { const { document } = parseHTML(ensureHfIds(html)); @@ -695,6 +696,7 @@ function extractGsapScriptBlock(html: string): { return { scriptText: content, document, + root: script.closest("template") ?? document, replaceScript(newText: string): string { script.textContent = newText; return document.toString(); @@ -1707,13 +1709,13 @@ function executeGsapMutationAcorn( case "shift-positions": { const { targetSelector, delta } = body; if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText; - return shiftPositionsInScript(block.scriptText, targetSelector, delta); + return shiftPositionsInScript(block.scriptText, targetSelector, delta, block.root); } case "shift-positions-batch": { let script = block.scriptText; for (const s of body.shifts) { if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue; - script = shiftPositionsInScript(script, s.targetSelector, s.delta); + script = shiftPositionsInScript(script, s.targetSelector, s.delta, block.root); } return script; } @@ -1737,6 +1739,7 @@ function executeGsapMutationAcorn( oldDuration, newStart, newDuration, + block.root, ); } default: @@ -2079,14 +2082,14 @@ async function executeGsapMutationRecast( const { targetSelector, delta } = body; if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText; const { shiftPositionsInScript } = parser; - return shiftPositionsInScript(block.scriptText, targetSelector, delta); + return shiftPositionsInScript(block.scriptText, targetSelector, delta, block.root); } case "shift-positions-batch": { const { shiftPositionsInScript } = parser; let script = block.scriptText; for (const s of body.shifts) { if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue; - script = shiftPositionsInScript(script, s.targetSelector, s.delta); + script = shiftPositionsInScript(script, s.targetSelector, s.delta, block.root); } return script; } @@ -2111,6 +2114,7 @@ async function executeGsapMutationRecast( oldDuration, newStart, newDuration, + block.root, ); } default: From a24db12836af40c2246d3c65daf50cf4ab5accb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 07:22:18 -0400 Subject: [PATCH 2/9] fix(parsers): a timeline move keeps chained and partly known tweens where they belong --- packages/parsers/src/clipTweens.ts | 28 ++++++++---- packages/parsers/src/gsapParser.ts | 44 ++++++++++++++----- packages/parsers/src/gsapParserAcorn.ts | 23 ++++++++++ packages/parsers/src/gsapSerialize.ts | 1 + .../parsers/src/gsapWriter.parity.test.ts | 17 +++++-- packages/parsers/src/gsapWriterAcorn.ts | 22 +++++----- packages/sdk/src/engine/mutate.gsap.test.ts | 20 +++++++++ packages/sdk/src/engine/mutate.ts | 20 ++++----- .../studio-server/src/routes/files.test.ts | 7 ++- 9 files changed, 138 insertions(+), 44 deletions(-) diff --git a/packages/parsers/src/clipTweens.ts b/packages/parsers/src/clipTweens.ts index dcbe1678e1..c42be6b59c 100644 --- a/packages/parsers/src/clipTweens.ts +++ b/packages/parsers/src/clipTweens.ts @@ -1,24 +1,36 @@ -// Tweens a timeline move or retime of a clip carries: its own selector, plus (given a DOM) any whose every -// target sits inside the clip with no nearer `data-start` clip. A tween also aiming outside stays put. +import type { GsapAnimation } from "./gsapSerialize.js"; + +type TweenTarget = Pick; +type TweenTime = Pick; + +// Tweens a timeline move or retime of a clip carries: its own selector, plus (given a DOM) a fully known target +// set that is the clip or sits inside it with no nearer `data-start` clip. A tween also aiming outside stays put. export function clipTweenMatcher( clipSelector: string, root?: ParentNode, -): (tweenSelector: string) => boolean { +): (tween: TweenTarget) => boolean { const clips = root ? queryAll(root, clipSelector) : []; - return (tweenSelector) => { - if (tweenSelector === clipSelector) return true; - if (!root || clips.length === 0) return false; - const targets = queryAll(root, tweenSelector); + return ({ targetSelector, hasPartialSelector }) => { + if (targetSelector === clipSelector) return true; + if (!root || clips.length === 0 || hasPartialSelector) return false; + const targets = queryAll(root, targetSelector); return ( targets.length > 0 && targets.every((target) => { const owner = target.closest("[data-start]"); - return owner !== null && clips.includes(owner); + return clips.includes(target) || (owner !== null && clips.includes(owner)); }) ); }; } +/** A written position; an implicit one follows the tween before it and must stay unwritten. */ +export function hasExplicitTime( + animation: T, +): animation is T & { position: number } { + return typeof animation.position === "number" && !animation.implicitPosition; +} + function queryAll(root: ParentNode, selector: string): Element[] { try { return Array.from(root.querySelectorAll(selector)); diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 855dfaa97c..0e83cdbb5d 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -51,7 +51,7 @@ export { } from "./gsapConstants"; import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; import type { PropertyGroupName } from "./gsapConstants"; -import { clipTweenMatcher } from "./clipTweens"; +import { clipTweenMatcher, hasExplicitTime } from "./clipTweens"; import { findObjectArrayKeyframeIndex, getCompatibleObjectArrayKeyframeTiming, @@ -403,6 +403,23 @@ function lookupBinding(name: string, path: AstPath, bindings: TargetBindings): s return null; } +function hasUnresolvedArrayPart( + node: AstNode, + path: AstPath, + scope: ScopeBindings, + bindings: TargetBindings, + helpers: TargetHelpers, +): boolean { + return ( + node?.type === "ArrayExpression" && + node.elements.some( + (el: AstNode) => + !resolveTargetSelector(el, path, scope, bindings, helpers) || + hasUnresolvedArrayPart(el, path, scope, bindings, helpers), + ) + ); +} + /** * Resolve a tween's first argument to a CSS selector. Handles inline string * literals, element variables (lexically scoped), arrays of elements (joined @@ -586,6 +603,7 @@ interface TweenCallInfo { node: AstNode; method: GsapMethod; selector: string; + selectorPartial?: boolean; varsArg: AstNode; fromArg?: AstNode; positionArg?: AstNode; @@ -650,6 +668,9 @@ function findAllTweenCalls( const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings, targetHelpers) ?? "__unresolved__"; + const partial = hasUnresolvedArrayPart(args[0], path, scope, targetBindings, targetHelpers) + ? { selectorPartial: true } + : {}; if (method === "fromTo") { results.push({ @@ -657,6 +678,7 @@ function findAllTweenCalls( node, method: "fromTo", selector: selectorValue, + ...partial, fromArg: args[1], varsArg: args[2], positionArg: args[3], @@ -667,6 +689,7 @@ function findAllTweenCalls( node, method: method as GsapMethod, selector: selectorValue, + ...partial, varsArg: args[1], positionArg: args[2], ...(isGlobalSet ? { global: true } : {}), @@ -1135,6 +1158,7 @@ function tweenCallToAnimation( if (motionPathResult) anim.arcPath = motionPathResult.arcPath; if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; + if (call.selectorPartial) anim.hasPartialSelector = true; return anim; } @@ -1556,8 +1580,7 @@ export function shiftPositionsInScript( } let changed = false; for (const entry of parsed.located) { - if (!carries(entry.animation.targetSelector)) continue; - if (typeof entry.animation.position !== "number") continue; + if (!carries(entry.animation) || !hasExplicitTime(entry.animation)) continue; const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); applyUpdatesToCall(entry.call, { position: newPos }); changed = true; @@ -1586,13 +1609,14 @@ export function scalePositionsInScript( } let changed = false; for (const entry of parsed.located) { - if (!carries(entry.animation.targetSelector)) continue; - if (typeof entry.animation.position !== "number") continue; - const newPos = Math.max( - 0, - Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, - ); - const updates: Partial = { position: newPos }; + if (!carries(entry.animation) || typeof entry.animation.position !== "number") continue; + const updates: Partial = {}; + if (hasExplicitTime(entry.animation)) { + updates.position = Math.max( + 0, + Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, + ); + } if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { updates.duration = Math.max( 0.001, diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index 675788b4d1..4ce6fec55b 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -583,6 +583,22 @@ function resolveTargetSelector( return null; } +function hasUnresolvedArrayPart( + node: any, + ancestors: any[], + scope: ScopeBindings, + bindings: TargetBindings, +): boolean { + return ( + node?.type === "ArrayExpression" && + node.elements.some( + (el: any) => + !resolveTargetSelector(el, ancestors, scope, bindings) || + hasUnresolvedArrayPart(el, ancestors, scope, bindings), + ) + ); +} + /** * Classify an otherwise-unresolved tween target that is a plain object literal * (`tl.to({}, …)`) or a proxy object (`tl.to(s, {onUpdate})`). Returns a @@ -864,6 +880,7 @@ export interface TweenCallInfo { ancestors: any[]; method: GsapMethod; selector: string; + selectorPartial?: boolean; varsArg: any; fromArg?: any; positionArg?: any; @@ -929,6 +946,9 @@ function findAllTweenCalls( ? (resolveTargetSelector(args[0], nodeAncestors, scope, targetBindings) ?? "__unresolved__") : "__unresolved__"; + const partial = hasUnresolvedArrayPart(args[0], nodeAncestors, scope, targetBindings) + ? { selectorPartial: true } + : {}; if (method === "fromTo" && args.length >= 3) { results.push({ @@ -936,6 +956,7 @@ function findAllTweenCalls( ancestors: nodeAncestors, method: "fromTo", selector: selectorValue, + ...partial, fromArg: args[1], varsArg: args[2], positionArg: args[3], @@ -946,6 +967,7 @@ function findAllTweenCalls( ancestors: nodeAncestors, method: method as GsapMethod, selector: selectorValue, + ...partial, varsArg: args[1], positionArg: args[2], ...(isGlobalSet ? { global: true } : {}), @@ -1403,6 +1425,7 @@ function tweenCallToAnimation( if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; if (durationUnresolved) anim.durationUnresolved = true; if (selector === "__unresolved__") anim.hasUnresolvedSelector = true; + if (call.selectorPartial) anim.hasPartialSelector = true; if (provenance) anim.provenance = provenance; return anim; } diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index 227b188f97..d2651f8d6d 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -70,6 +70,7 @@ export interface GsapAnimation { hasUnresolvedKeyframes?: boolean; /** True when the tween's target selector couldn't be statically resolved (dynamic). */ hasUnresolvedSelector?: boolean; + hasPartialSelector?: boolean; /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ resolvedStart?: number; /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index ea174e15b1..f2407c33c0 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -7,8 +7,7 @@ * This is the safety net for porting WS-3 ops one at a time: each ported op * gets a fixture row here proving it matches the battle-tested original. * - * The server switches between writers via STUDIO_SDK_CUTOVER_ENABLED (WS-3.F). - * Recast remains the default; acorn runs only when the flag is enabled. + * The server picks the writer from HYPERFRAMES_GSAP_WRITER; acorn is the default. */ import { describe, expect, it } from "vitest"; import { parseHTML } from "linkedom"; @@ -1987,14 +1986,20 @@ describe("shift/scalePositionsInScript carry the clip's inner tweens", () => { const script = `const tl = gsap.timeline({ paused: true }); tl.from("#scene", { opacity: 0, duration: 1 }, 1); tl.from("#scene h1", { y: 20, duration: 1 }, 1.5); +tl.to("#child", { rotation: 90, duration: 1 }); tl.to("#child", { x: 10, duration: 1 }, 2); tl.to(["#child", "#scene h1"], { opacity: 0.5, duration: 1 }, 3); tl.to(".inner", { scale: 2, duration: 1 }, 3.5); tl.to("#sibling", { x: 5, duration: 1 }, 2); tl.to(".title", { color: "red", duration: 1 }, 2.5); -tl.to("#deep", { y: 5, duration: 0.5 }, 2);`; +tl.to("#deep", { y: 5, duration: 0.5 }, 2); +tl.to(["#scene h1", window.logo], { x: 1, duration: 1 }, 2);`; const timings = (out: string) => - parseGsapScriptAcorn(out).animations.map((a) => [a.targetSelector, a.position, a.duration]); + parseGsapScriptAcorn(out).animations.map((a) => [ + a.targetSelector, + a.implicitPosition ? "chained" : a.position, + a.duration, + ]); const writers = [ ["acorn", shiftAcorn, scaleAcorn], ["recast", shiftRecast, scaleRecast], @@ -2005,12 +2010,14 @@ tl.to("#deep", { y: 5, duration: 0.5 }, 2);`; expect(timings(shift(script, "#scene", 2, document))).toEqual([ ["#scene", 3, 1], ["#scene h1", 3.5, 1], + ["#child", "chained", 1], ["#child", 4, 1], ["#child, #scene h1", 5, 1], [".inner", 5.5, 1], ["#sibling", 2, 1], [".title", 2.5, 1], ["#deep", 2, 0.5], + ["#scene h1", 2, 1], ]); }); @@ -2018,12 +2025,14 @@ tl.to("#deep", { y: 5, duration: 0.5 }, 2);`; expect(timings(scale(script, "#scene", 1, 4, 1, 8, document))).toEqual([ ["#scene", 1, 2], ["#scene h1", 2, 2], + ["#child", "chained", 2], ["#child", 3, 2], ["#child, #scene h1", 5, 2], [".inner", 6, 2], ["#sibling", 2, 1], [".title", 2.5, 1], ["#deep", 2, 0.5], + ["#scene h1", 2, 1], ]); }); } diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index c7f979b2e9..47132960fe 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -32,7 +32,9 @@ import { } from "./gsapObjectArrayTiming.js"; import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js"; import * as acornWalk from "acorn-walk"; -import { clipTweenMatcher } from "./clipTweens.js"; +import { clipTweenMatcher, hasExplicitTime } from "./clipTweens.js"; + +export { clipTweenMatcher, hasExplicitTime }; // acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / // gsapInline.ts rather than re-deriving the full ESTree union for every access. @@ -462,8 +464,7 @@ export function shiftPositionsInScript( const ms = new MagicString(script); let changed = false; for (const entry of parsed.located) { - if (!carries(entry.animation.targetSelector)) continue; - if (typeof entry.animation.position !== "number") continue; + if (!carries(entry.animation) || !hasExplicitTime(entry.animation)) continue; const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); overwritePosition(ms, entry.call, newPos); changed = true; @@ -494,13 +495,14 @@ export function scalePositionsInScript( const ms = new MagicString(script); let changed = false; for (const entry of parsed.located) { - if (!carries(entry.animation.targetSelector)) continue; - if (typeof entry.animation.position !== "number") continue; - const newPos = Math.max( - 0, - Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, - ); - overwritePosition(ms, entry.call, newPos); + if (!carries(entry.animation) || typeof entry.animation.position !== "number") continue; + if (hasExplicitTime(entry.animation)) { + const newPos = Math.max( + 0, + Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, + ); + overwritePosition(ms, entry.call, newPos); + } if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000); upsertProp(ms, entry.call.varsArg, "duration", newDur); diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 5083ae1c2b..0f04ed61e6 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -1081,6 +1081,26 @@ describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => { expect(script).not.toMatch(/tl\.to\("#box",[^)]*\}, \d/); }); + it("moving a scene carries its children's tweens, but not chained, partly known or outside ones", () => { + const parsed = + parseMutable(`
+

Hi

+
+ +
`); + applyOp(parsed, { type: "setTiming", target: "hf-scene", start: 3 }); + const script = getScript(parsed); + expect(script).toContain('tl.from("#scene h1", { y: 20, duration: 1 }, 3);'); + expect(script).toContain('tl.to("#scene h1", { x: 5, duration: 1 });'); + expect(script).toContain('tl.to(["#scene h1", window.logo], { x: 1, duration: 1 }, 2);'); + expect(script).toContain('tl.to("#side", { x: 5, duration: 1 }, 1);'); + }); + it("canonicalizes a clip carrying both authored duration and derived end", () => { const parsed = timingDoc( `data-start="1" data-duration="2" data-end="3"`, diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 8498ba9445..77dc886ef4 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -77,6 +77,8 @@ import { updateArcSegmentInScript, removeArcPathFromScript, unrollDynamicAnimations, + clipTweenMatcher, + hasExplicitTime, } from "@hyperframes/core/gsap-writer-acorn"; import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; import { @@ -541,12 +543,11 @@ function handleSetTiming( // Sync GSAP tween positions: the GSAP script is the source of truth at play time — // the timeline rebuilds from it on every seek. Without this, DOM attribute edits // have zero playback effect; the script's position/duration silently overrides them. - // Match against BOTH the element's data-hf-id (the canonical form) AND its DOM - // id: the Studio GSAP panel / ensureElementAddressable author tweens as - // `#domId`, which selectorMatchesId(hfId) never matched — so moving/resizing - // those clips left their tweens unsynced. - const matchHfId = el.getAttribute("data-hf-id") ?? id; - const matchDomId = el.getAttribute("id"); + const domId = el.getAttribute("id"); + const carries = clipTweenMatcher( + domId ? `#${domId}` : `[data-hf-id="${el.getAttribute("data-hf-id") ?? id}"]`, + parsed.document, + ); if (parsedGsap && currentScript) { // A missing data-start means an implicit start of 0 (matching the server // shiftGsapPositions path); a malformed attr parses to NaN. Sanitize to a @@ -566,10 +567,7 @@ function handleSetTiming( : 1; const remapStart = startChanged && newStart !== null ? newStart : oldStartNum; for (const { id: animId, animation } of parsedGsap.located) { - const matches = - selectorMatchesId(animation.targetSelector, matchHfId) || - (matchDomId !== null && selectorMatchesId(animation.targetSelector, matchDomId)); - if (!matches) continue; + if (!carries(animation)) continue; // Skip tweens whose position is a label or relative string ("+=0.5", // "<", ">"): relative positions already track their neighbours, and a // string position can't be safely shifted by the clip delta here. @@ -581,7 +579,7 @@ function handleSetTiming( // explicit position arg → parsed as implicitPosition): the writer would // APPEND a position arg, collapsing the stagger onto one point. Duration // still scales below. - if ((startChanged || durChanged) && animation.implicitPosition !== true) { + if ((startChanged || durChanged) && hasExplicitTime(animation)) { const shifted = remapStart + (animation.position - oldStartNum) * ratio; updates.position = Math.max(0, Math.round(shifted * 1000) / 1000); } diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 8b223cb52e..bb018acb12 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -2513,7 +2513,9 @@ tl.to("#b", { duration: 1, x: 200 }, 2); it("moving or stretching a clip retimes the tweens inside it and leaves outside ones alone", async () => { const comp = ``; @@ -2545,6 +2548,7 @@ tl.to("#b", { duration: 1, x: 200 }, 2); expect(moved).toContain('tl.from("#card h1", { y: 20, duration: 1 }, 3);'); expect(moved).toContain('tl.to("#line", { x: 10, duration: 1 }, 3.5);'); expect(moved).toContain('tl.to("#side", { x: 5, duration: 1 }, 1);'); + expect(moved).toContain('tl.to("#dot", { x: 1, duration: 1 }, 1.5);'); const stretched = await mutate({ type: "scale-positions", @@ -2557,6 +2561,7 @@ tl.to("#b", { duration: 1, x: 200 }, 2); expect(stretched).toContain('tl.from("#card h1", { y: 20, duration: 2 }, 3);'); expect(stretched).toContain('tl.to("#line", { x: 10, duration: 2 }, 4);'); expect(stretched).toContain('tl.to("#side", { x: 5, duration: 1 }, 1);'); + expect(stretched).toContain('tl.to("#dot", { x: 1, duration: 1 }, 1.5);'); }); it("rejects a shift-positions-batch with a missing/non-array `shifts` field (400)", async () => { From 409e2139f5eceee025dc3684a7c95c5975d54b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 09:03:16 -0400 Subject: [PATCH 3/9] fix(parsers): moving a clip leaves chained outside tweens and template aliases in place --- packages/parsers/src/clipTweens.ts | 36 ++++++++++++++++--- packages/parsers/src/gsapParser.ts | 9 ++++- .../parsers/src/gsapWriter.parity.test.ts | 18 +++++++++- packages/parsers/src/gsapWriterAcorn.ts | 16 +++++++-- packages/sdk/src/engine/model.ts | 2 +- packages/sdk/src/engine/mutate.gsap.test.ts | 18 ++++++++++ packages/sdk/src/engine/mutate.ts | 20 +++++++++-- packages/studio-server/src/routes/files.ts | 3 +- 8 files changed, 109 insertions(+), 13 deletions(-) diff --git a/packages/parsers/src/clipTweens.ts b/packages/parsers/src/clipTweens.ts index c42be6b59c..196b0a3de6 100644 --- a/packages/parsers/src/clipTweens.ts +++ b/packages/parsers/src/clipTweens.ts @@ -2,17 +2,20 @@ import type { GsapAnimation } from "./gsapSerialize.js"; type TweenTarget = Pick; type TweenTime = Pick; +type Located = { animation: TweenTarget & TweenTime & Pick }; -// Tweens a timeline move or retime of a clip carries: its own selector, plus (given a DOM) a fully known target +// Tweens a timeline move or retime of a clip carries: an own selector, plus (given a DOM) a fully known target // set that is the clip or sits inside it with no nearer `data-start` clip. A tween also aiming outside stays put. export function clipTweenMatcher( - clipSelector: string, + clipSelectors: string | readonly string[], root?: ParentNode, ): (tween: TweenTarget) => boolean { - const clips = root ? queryAll(root, clipSelector) : []; + const own = typeof clipSelectors === "string" ? [clipSelectors] : clipSelectors; + const clips = root ? own.flatMap((selector) => queryAll(root, selector)) : []; return ({ targetSelector, hasPartialSelector }) => { - if (targetSelector === clipSelector) return true; - if (!root || clips.length === 0 || hasPartialSelector) return false; + if (hasPartialSelector) return false; + if (own.includes(targetSelector)) return true; + if (!root || clips.length === 0) return false; const targets = queryAll(root, targetSelector); return ( targets.length > 0 && @@ -24,6 +27,11 @@ export function clipTweenMatcher( }; } +/** Where a GSAP script's clips live: linkedom keeps a `