Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/parsers/src/clipTweens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { GsapAnimation } from "./gsapSerialize.js";

type TweenTarget = Pick<GsapAnimation, "targetSelector" | "hasPartialSelector">;
type TweenTime = Pick<GsapAnimation, "position" | "implicitPosition">;

// 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(
clipSelectors: string | readonly string[],
root?: ParentNode,
): (tween: TweenTarget) => boolean {
const own = typeof clipSelectors === "string" ? [clipSelectors] : clipSelectors;
const clips = root ? own.flatMap((selector) => queryAll(root, selector)) : [];
return ({ targetSelector, hasPartialSelector }) => {
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 &&
targets.every((target) => {
const owner = target.closest("[data-start]");
return clips.includes(target) || (owner !== null && clips.includes(owner));
})
);
};
}

/** Where a GSAP script's clips live: linkedom keeps a `<template>`'s children under it, out of document queries. */
export function clipQueryRoot(script: Element): ParentNode {
return script.closest("template") ?? script.ownerDocument;
}

/** A written position; an implicit one follows the tween before it and must stay unwritten. */
export function hasExplicitTime<T extends TweenTime>(
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));
} catch {
// Pseudo-selectors such as a proxy or dwell label never match the DOM.
return [];
}
}
47 changes: 38 additions & 9 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
} from "./gsapConstants";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
import { clipTweenMatcher, hasExplicitTime } from "./clipTweens";
import {
findObjectArrayKeyframeIndex,
getCompatibleObjectArrayKeyframeTiming,
Expand Down Expand Up @@ -402,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
Expand Down Expand Up @@ -585,6 +603,7 @@ interface TweenCallInfo {
node: AstNode;
method: GsapMethod;
selector: string;
selectorPartial?: boolean;
varsArg: AstNode;
fromArg?: AstNode;
positionArg?: AstNode;
Expand Down Expand Up @@ -649,13 +668,17 @@ 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({
path,
node,
method: "fromTo",
selector: selectorValue,
...partial,
fromArg: args[1],
varsArg: args[2],
positionArg: args[3],
Expand All @@ -666,6 +689,7 @@ function findAllTweenCalls(
node,
method: method as GsapMethod,
selector: selectorValue,
...partial,
varsArg: args[1],
positionArg: args[2],
...(isGlobalSet ? { global: true } : {}),
Expand Down Expand Up @@ -1134,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;
}

Expand Down Expand Up @@ -1543,7 +1568,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);
Expand All @@ -1553,8 +1580,7 @@ export function shiftPositionsInScript(
}
let changed = false;
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== 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;
Expand All @@ -1569,9 +1595,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);
Expand All @@ -1581,13 +1609,14 @@ export function scalePositionsInScript(
}
let changed = false;
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== 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<GsapAnimation> = { position: newPos };
if (!carries(entry.animation) || typeof entry.animation.position !== "number") continue;
const updates: Partial<GsapAnimation> = {};
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,
Expand Down
35 changes: 35 additions & 0 deletions packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -864,6 +880,7 @@ export interface TweenCallInfo {
ancestors: any[];
method: GsapMethod;
selector: string;
selectorPartial?: boolean;
varsArg: any;
fromArg?: any;
positionArg?: any;
Expand Down Expand Up @@ -929,13 +946,17 @@ 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({
node,
ancestors: nodeAncestors,
method: "fromTo",
selector: selectorValue,
...partial,
fromArg: args[1],
varsArg: args[2],
positionArg: args[3],
Expand All @@ -946,6 +967,7 @@ function findAllTweenCalls(
ancestors: nodeAncestors,
method: method as GsapMethod,
selector: selectorValue,
...partial,
varsArg: args[1],
positionArg: args[2],
...(isGlobalSet ? { global: true } : {}),
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1903,6 +1926,18 @@ const parseMemo = new Map<string, ParsedGsap>();
* Browser-safe equivalent of `parseGsapScript` (gsapParser.ts).
* Uses acorn + acorn-walk instead of recast + @babel/parser.
*/
/** The script that holds a file's timeline: the first to declare one, else the first with tween calls. */
export function findTimelineScript<T extends { textContent: string | null }>(
scripts: readonly T[],
): T | null {
const text = (script: T) => script.textContent ?? "";
return (
scripts.find((script) => text(script).includes("gsap.timeline")) ??
scripts.find((script) => /\.(set|to)\(/.test(text(script))) ??
null
);
}

export function parseGsapScriptAcorn(script: string): ParsedGsap {
const parsed = parseMemo.get(script) ?? parseGsapScriptAcornUncached(script);
parseMemo.delete(script);
Expand Down
1 change: 1 addition & 0 deletions packages/parsers/src/gsapSerialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
94 changes: 92 additions & 2 deletions packages/parsers/src/gsapWriter.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
* 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";
import {
parseGsapScript,
removeAllKeyframesFromScript as removeAllRecast,
Expand Down Expand Up @@ -1973,3 +1973,93 @@ 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(`<html><body>
<div id="scene" data-start="1" data-duration="4">
<h1 class="title">Hi</h1>
<p id="child" class="inner">Kid</p>
<div id="nested" data-start="2" data-duration="1"><span id="deep">x</span></div>
</div>
<div id="sibling" class="title">Out</div>
</body></html>`);
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("#sibling", { y: 5, duration: 0.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);
tl.to(["#scene h1", window.logo], { x: 1, duration: 1 }, 2);
tl.to(["#scene", window.logo], { x: 2, duration: 1 }, 2);`;
const timings = (out: string) =>
parseGsapScriptAcorn(out).animations.map((a) => [
a.targetSelector,
a.implicitPosition ? "chained" : a.position,
a.duration,
]);
const writers = [
["acorn", shiftAcorn, scaleAcorn],
["recast", shiftRecast, scaleRecast],
] as const;

const bare = parseHTML(
`<html><body><div id="bare" class="card"><p>x</p></div></body></html>`,
).document;
const bareScript = `const tl = gsap.timeline({ paused: true });
tl.to(".card", { x: 1, duration: 1 }, 1);`;

for (const [name, shift, scale] of writers) {
it(`${name}: a tween whose target is the clip moves with it, even without data-start`, () => {
expect(timings(shift(bareScript, "#bare", 1, bare))).toEqual([[".card", 2, 1]]);
});

it(`${name}: known limit, an outside tween chained after moved content moves with it`, () => {
const sibling = (out: string) => parseGsapScriptAcorn(out).animations[3]!;
expect(sibling(script)).toMatchObject({ targetSelector: "#sibling", resolvedStart: 3.5 });
expect(sibling(shift(script, "#scene", 2, document))).toMatchObject({
targetSelector: "#sibling",
implicitPosition: true,
resolvedStart: 5.5,
});
});

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", "chained", 1],
["#sibling", "chained", 0.5],
["#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],
["#scene", 2, 1],
]);
});

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", "chained", 2],
["#sibling", "chained", 0.5],
["#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],
["#scene", 2, 1],
]);
});
}
});
Loading
Loading