mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
* refactor(core): retire recast/babel, route all GSAP mutations to acorn (WS-E/3.F) - Delete gsapParser.ts (2595-line recast-based parser/writer) - Delete gsapParser.test.ts, gsapParser.stress.test.ts, gsapParser.test-helpers.ts - Add gsapParserExports.ts: re-export umbrella for gsap-parser subpath - Move SplitAnimationsOptions/SplitAnimationsResult to gsapSerialize.ts - executeGsapMutation: async->sync, static acorn imports replace loadGsapParser() - Fix 3 function name mismatches in files.ts switch cases - generators/hyperframes.ts: imports from gsapSerialize (blocker resolved) - gsapWriterAcorn.ts: SplitAnimationsOptions from gsapSerialize - Parity tests: recast oracle removed; acorn-only regression (14 pass) - Remove recast and @babel/parser from core/package.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): harden mutation handlers + widen variable API (code-review) Self-contained review fixes for the SDK-hotspot stack (#1569–#1573). The dispatch path (_dispatch → applyOp) never runs validateOp, so the new WS-D/WS-3.C guards were advisory-only; re-enforce them in the handlers. - addElement: null-guard the resolved parent (no more `as Element` masking a null → crash on unknown parent id); reject <script> and multi-root fragments via parseInsertableFragment instead of inserting raw markup / silently dropping extra roots. - addWithKeyframes / replaceWithKeyframes: bail on empty keyframes (no degenerate `keyframes: {}` tween) and when the animationId resolves to nothing (no silent degrade-to-add leaving a duplicate tween). - isObjectVariableValue: exclude arrays so an array override value can't be misclassified as a font/image object and written into the variable model. - Composition.setVariableValue: widen the public interface signature to `… | FontValue | ImageValue` to match the impl + EditOp (B2 object-valued variables were unreachable via the typed API). - mutate.gsap.test.ts: import addKeyframeToScript from gsap-writer-acorn — the gsap-parser subpath no longer re-exports write fns after recast retire, so the test threw at runtime (red suite). - Dedup: export EXCLUDED_TAGS from hfIds.ts and drop the verbatim HF_EXCLUDED_TAGS copy in mutate.ts. Adds guard regression tests. SDK 340/340, core hfIds 13/13, build green, fallow --gate new-only clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): variable-model dedup + undo/scoped-parent correctness; test honesty (code-review) Second batch of review fixes for the SDK-hotspot stack. - Variable model (#7, #13): extract readVariableDefault/writeVariableDefault into a shared engine/variableModel.ts used by both mutate.ts (forward) and apply-patches.ts (replay), so the model shape can't diverge. Add clearVariableDefault and make a `variable` remove patch DELETE the decl's `default` key — the exact inverse of a first-set on a default-less variable. Previously undo of such a set no-op'd and stranded the value. - addElement scoped parent (#8): record the caller's id verbatim (scoped "hf-host/hf-leaf" path or composition id) as the patch parentId instead of the bare data-hf-id, so redo/replay re-resolves the SAME parent via resolveScoped rather than the canonical top-level dup (or document.body). - resolveTimings honesty (#5): correct the header + test that claimed a live "preview == render" parity — neither path consumes the resolver yet (anchor inputs are Pacific/backend-deferred). It's a pure-function property, not a current guarantee. - GSAP writer parity (#12): the recast oracle was deleted in WS-3.F, leaving the WS-3.C keyframe ops comparing acorn output to itself. Pin them as golden inline snapshots and drop the now-dead recast scaffolding (replaceWithKfRecast, removeAnimRecast alias). Remaining pre-WS-3.C parity blocks noted as follow-up. Adds regression tests (undo of default-less variable; scoped-parent redo). SDK 342/342, core timingResolver+parity green, build + fallow --gate new-only clean. Not changed (need design / out of scope): #9 pre-#1569 persisted-override CSS replay (moot for unreleased data; proper fix is render-time CSS derivation), #11 replaceWithKeyframes stale positional id (mitigated by the missing-id no-op guard + type doc; full fix needs non-positional ids). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): replay CSS-prop derivation for legacy var overrides; stale-id selector guard (code-review) Final review-fix batch — the two items deferred from the prior pass. - #9 legacy variable-override CSS: applyOverrideSet now derives the `--{id}` CSS custom prop from any scalar `var.{id}` override on replay (and removes it for a null override). Sets written before the model/CSS split carried only `var.{id}`; without this, replaying them updated the JSON model but left `var(--{id})` bindings rendering the schema default. Replay-path only — the undo path (applyOne) is untouched, so #1569's separate-patch undo correctness is preserved. Object (font/image) values are never CSS, so they are skipped. - #11 stale positional id: replaceWithKeyframes now requires the located animation to still target the caller's `targetSelector`. Position-derived ids re-point after structural edits; a stale id resolving to a DIFFERENT element's tween previously got silently replaced. It now bails (no-op) unless the id still points at the expected selector. Adds regression tests (legacy var.{id}-only override restores CSS; object override writes no CSS; stale-id-wrong-selector replace is a no-op). SDK 345/345, build + fallow --gate new-only clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F) Product decision pivot: acorn no longer replaces recast as the GSAP writer. Recast remains the default server writer; acorn runs only when STUDIO_SDK_CUTOVER_ENABLED=true (or =1) is set server-side — the same env flag name as the client Vite var, so a single switch flips both sides. Changes: - Restore gsapParser.ts (recast writer) + test/stress/helper files deleted by 3F - Restore @babel/parser + recast deps in packages/core/package.json - Add isAcornGsapWriterEnabled() + loadGsapParser() to files.ts (lines 59-82) - Split executeGsapMutation into async dispatcher + executeGsapMutationRecast (recast, async via loadGsapParser) + executeGsapMutationAcorn (acorn, sync) - Dispatcher defaults to recast; acorn branch taken only when flag is on - Restore gsapWriter.parity.test.ts, gsapWriterParity.acorn.test.ts, and gsapWriterParity.corpus.test.ts to true recast-vs-acorn differential suites (not acorn-vs-itself) - Exempt gsapParser.ts in .fallowrc.jsonc health.ignore + ignoreExports (pre-existing complexity + barrel re-exports consumed outside diff scope) - Add fallow-ignore-file code-duplication to files.ts (intentional parallel switch bodies for two writers) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2203 lines
84 KiB
TypeScript
2203 lines
84 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
/**
|
|
* Browser-safe GSAP write path — magic-string offset-splice.
|
|
*
|
|
* T6c: edits GSAP scripts by overwriting/removing byte ranges in the original
|
|
* source. Every byte outside the edited span is preserved verbatim — no
|
|
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
|
|
*/
|
|
import MagicString from "magic-string";
|
|
import type {
|
|
GsapAnimation,
|
|
GsapPercentageKeyframe,
|
|
ArcPathConfig,
|
|
ArcPathSegment,
|
|
} from "./gsapSerialize.js";
|
|
import {
|
|
resolveConversionProps,
|
|
extractArcWaypoints,
|
|
buildMotionPathObjectCode,
|
|
} from "./gsapSerialize.js";
|
|
import {
|
|
parseGsapScriptAcornForWrite,
|
|
type ParsedGsapAcornForWrite,
|
|
type TweenCallInfo,
|
|
} from "./gsapParserAcorn.js";
|
|
import { classifyPropertyGroup } from "./gsapConstants.js";
|
|
import type { PropertyGroupName } from "./gsapConstants.js";
|
|
import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js";
|
|
import * as acornWalk from "acorn-walk";
|
|
|
|
// acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts /
|
|
// gsapInline.ts rather than re-deriving the full ESTree union for every access.
|
|
type Node = any;
|
|
|
|
// ── Code generation helpers ──────────────────────────────────────────────────
|
|
|
|
// Local serializer for the tween-statement path, which may carry boolean/object
|
|
// extras (stagger config). serializeValue stringifies objects to "[object
|
|
// Object]", so keep this richer JSON fallback for that path. Keyframe values are
|
|
// always number|string and use the shared serializeValue (recast parity).
|
|
function valueToCode(value: unknown): string {
|
|
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
|
|
if (typeof value === "string") return JSON.stringify(value);
|
|
if (typeof value === "number") return Number.isNaN(value) ? "0" : String(value);
|
|
if (typeof value === "boolean") return String(value);
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function safeKey(key: string): string {
|
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
|
|
const selector = JSON.stringify(anim.targetSelector);
|
|
const props: Record<string, number | string> = { ...anim.properties };
|
|
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
|
|
if (anim.ease) props.ease = anim.ease;
|
|
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
if (anim.extras) {
|
|
for (const [k, v] of Object.entries(anim.extras)) {
|
|
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
|
}
|
|
}
|
|
const objCode = `{ ${entries.join(", ")} }`;
|
|
const posCode = valueToCode(
|
|
typeof anim.position === "number" ? anim.position : (anim.position ?? 0),
|
|
);
|
|
if (anim.method === "fromTo") {
|
|
const fromEntries = Object.entries(anim.fromProperties ?? {}).map(
|
|
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
|
|
);
|
|
return `${timelineVar}.fromTo(${selector}, { ${fromEntries.join(", ")} }, ${objCode}, ${posCode});`;
|
|
}
|
|
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
|
|
}
|
|
|
|
// ── AST node helpers ─────────────────────────────────────────────────────────
|
|
|
|
function isObjectProperty(prop: Node): boolean {
|
|
return prop?.type === "ObjectProperty" || prop?.type === "Property";
|
|
}
|
|
|
|
function propKeyName(prop: Node): string | undefined {
|
|
return prop?.key?.name ?? prop?.key?.value;
|
|
}
|
|
|
|
function findPropertyNode(varsArgNode: Node, key: string): Node | undefined {
|
|
if (varsArgNode?.type !== "ObjectExpression") return undefined;
|
|
for (const prop of varsArgNode.properties ?? []) {
|
|
if (!isObjectProperty(prop)) continue;
|
|
if (propKeyName(prop) === key) return prop;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** The `keyframes` property's ObjectExpression value, or null when not a keyframe tween. */
|
|
function keyframesObjectNode(varsNode: Node): Node | null {
|
|
const kfProp = findPropertyNode(varsNode, "keyframes");
|
|
return kfProp?.value?.type === "ObjectExpression" ? kfProp.value : null;
|
|
}
|
|
|
|
function findEnclosingExpressionStatement(ancestors: Node[]): Node | null {
|
|
for (let i = ancestors.length - 2; i >= 0; i--) {
|
|
if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */
|
|
function findTimelineDeclarationStatement(ast: Node, timelineVar: string): Node | null {
|
|
let found: Node = null;
|
|
acornWalk.simple(ast, {
|
|
// fallow-ignore-next-line complexity
|
|
VariableDeclaration(node: Node) {
|
|
if (found) return;
|
|
for (const decl of node.declarations ?? []) {
|
|
if (
|
|
decl.id?.name === timelineVar &&
|
|
decl.init?.type === "CallExpression" &&
|
|
decl.init.callee?.type === "MemberExpression" &&
|
|
decl.init.callee.object?.name === "gsap" &&
|
|
decl.init.callee.property?.name === "timeline"
|
|
) {
|
|
found = node;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
return found;
|
|
}
|
|
|
|
// ── Property splice helpers ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Remove a property from a properties array, handling its comma.
|
|
* `editableProps` must be the isObjectProperty-filtered subset in source order.
|
|
*/
|
|
function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): void {
|
|
const idx = editableProps.indexOf(propNode);
|
|
if (idx === -1) return;
|
|
if (editableProps.length === 1) {
|
|
ms.remove(propNode.start, propNode.end);
|
|
} else if (idx === 0) {
|
|
// First prop: remove from its start to next prop start (drops trailing ", ")
|
|
ms.remove(editableProps[0].start, editableProps[1].start);
|
|
} else {
|
|
// Non-first: remove from prev prop end to this prop end (drops leading ", ")
|
|
ms.remove(editableProps[idx - 1].end, propNode.end);
|
|
}
|
|
}
|
|
|
|
/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */
|
|
function buildVarsObjectCode(record: Record<string, number | string>): string {
|
|
const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
|
|
}
|
|
|
|
/** Overwrite a tween call's vars ObjectExpression with freshly-built source. */
|
|
function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string): void {
|
|
if (!call.varsArg) return;
|
|
ms.overwrite(call.varsArg.start, call.varsArg.end, objCode);
|
|
}
|
|
|
|
/**
|
|
* Update a property value if it exists, or append a new key: val before the
|
|
* closing `}`. Call with the full ObjectExpression node.
|
|
*/
|
|
function upsertProp(ms: MagicString, objNode: Node, key: string, value: unknown): void {
|
|
if (objNode?.type !== "ObjectExpression") return;
|
|
const existing = findPropertyNode(objNode, key);
|
|
if (existing) {
|
|
ms.overwrite(existing.value.start, existing.value.end, valueToCode(value));
|
|
} else {
|
|
const sep = objNode.properties.length > 0 ? ", " : "";
|
|
ms.appendLeft(objNode.end - 1, `${sep}${safeKey(key)}: ${valueToCode(value)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vars keys that are NOT editable transform/style props: builtins
|
|
* (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…).
|
|
* The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS,
|
|
* so both writers classify vars keys identically. (Distinct from the keyframe-
|
|
* conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease`
|
|
* because that path re-emits ease separately.)
|
|
*/
|
|
const NON_EDITABLE_PROP_KEYS = new Set([
|
|
"duration",
|
|
"ease",
|
|
"delay",
|
|
"onComplete",
|
|
"onStart",
|
|
"onUpdate",
|
|
"onRepeat",
|
|
"stagger",
|
|
"yoyo",
|
|
"repeat",
|
|
"repeatDelay",
|
|
"snap",
|
|
"overwrite",
|
|
"immediateRender",
|
|
]);
|
|
|
|
/**
|
|
* Editable transform/style key test: anything NOT a builtin, dropped callback, or
|
|
* extras key. Mirrors recast's isEditablePropertyKey so both writers classify
|
|
* vars keys identically.
|
|
*/
|
|
function isEditableVarKey(key: string): boolean {
|
|
return !NON_EDITABLE_PROP_KEYS.has(key);
|
|
}
|
|
|
|
/**
|
|
* Collect verbatim `key: value` entries to PRESERVE from a vars/keyframe
|
|
* ObjectExpression: every property whose key `drop` does not reject, sliced from
|
|
* source — except keys present in `overrides`, whose value is replaced. Returns
|
|
* the entries plus the set of keys it kept, so callers can append new keys.
|
|
*/
|
|
function preservedEntries(
|
|
objNode: Node,
|
|
source: string,
|
|
drop: (key: string) => boolean,
|
|
overrides: Record<string, unknown>,
|
|
): { entries: string[]; keys: Set<string> } {
|
|
const entries: string[] = [];
|
|
const keys = new Set<string>();
|
|
for (const prop of objNode.properties ?? []) {
|
|
if (!isObjectProperty(prop)) continue;
|
|
const key = propKeyName(prop);
|
|
if (typeof key !== "string" || drop(key)) continue;
|
|
keys.add(key);
|
|
const code =
|
|
key in overrides
|
|
? valueToCode(overrides[key])
|
|
: source.slice(prop.value.start, prop.value.end);
|
|
entries.push(`${safeKey(key)}: ${code}`);
|
|
}
|
|
return { entries, keys };
|
|
}
|
|
|
|
/**
|
|
* Replace the editable-property keys on a vars ObjectExpression with exactly
|
|
* `newProps`, leaving non-editable keys (duration/ease/stagger/callbacks/…)
|
|
* untouched unless overridden in `nonEditableOverrides`. Mirrors recast's
|
|
* reconcileEditableProperties: editable keys absent from `newProps` are DROPPED,
|
|
* not merged. Rebuilt in a single ms.overwrite so the splice can never overlap a
|
|
* sibling edit — non-editable updates that also target this node (duration/ease/
|
|
* extras) are folded into the same rebuild rather than spliced separately.
|
|
*/
|
|
function reconcileEditableProps(
|
|
ms: MagicString,
|
|
objNode: Node,
|
|
source: string,
|
|
newProps: Record<string, number | string>,
|
|
nonEditableOverrides?: Record<string, unknown>,
|
|
): void {
|
|
if (objNode?.type !== "ObjectExpression") return;
|
|
const overrides = nonEditableOverrides ?? {};
|
|
const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides);
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`);
|
|
}
|
|
for (const [key, value] of Object.entries(newProps)) {
|
|
entries.push(`${safeKey(key)}: ${valueToCode(value)}`);
|
|
}
|
|
ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`);
|
|
}
|
|
|
|
// ── Insertion helpers ─────────────────────────────────────────────────────────
|
|
|
|
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
|
|
function isTimelineRooted(node: Node, timelineVar: string): boolean {
|
|
if (node?.type === "Identifier") return node.name === timelineVar;
|
|
if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Find the byte offset after which to insert a new statement (tween or label).
|
|
* Returns null when no timeline declaration exists in the script — callers must
|
|
* not emit `tl.xxx()` calls in that case as `tl` would be undefined at render.
|
|
*/
|
|
function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
|
|
const lastLocated = parsed.located[parsed.located.length - 1];
|
|
if (lastLocated) {
|
|
const lastCall = lastLocated.call;
|
|
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
|
|
return exprStmt?.end ?? lastCall.node.end;
|
|
}
|
|
if (!parsed.hasTimeline) return null;
|
|
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
|
|
return tlDecl?.end ?? (parsed.ast.end as number);
|
|
}
|
|
|
|
// ── Public write API ─────────────────────────────────────────────────────────
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function updateAnimationInScript(
|
|
script: string,
|
|
animationId: string,
|
|
updates: Partial<GsapAnimation>,
|
|
): string {
|
|
if (!Object.keys(updates).length) return script;
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
const { call }: { call: TweenCallInfo } = target;
|
|
|
|
// When `properties` is present we REPLACE the editable set (recast parity:
|
|
// editable keys absent from the update are dropped). Fold any concurrent
|
|
// non-editable updates (duration/ease/extras) into the single varsArg rebuild
|
|
// so their splices can't overlap the rebuild's overwrite of the whole node.
|
|
if (updates.properties) {
|
|
const overrides: Record<string, unknown> = {};
|
|
if (updates.duration !== undefined) overrides.duration = updates.duration;
|
|
if (updates.ease !== undefined) overrides.ease = updates.ease;
|
|
if (updates.extras) Object.assign(overrides, updates.extras);
|
|
reconcileEditableProps(ms, call.varsArg, script, updates.properties, overrides);
|
|
} else {
|
|
if (updates.duration !== undefined) {
|
|
upsertProp(ms, call.varsArg, "duration", updates.duration);
|
|
}
|
|
if (updates.ease !== undefined) {
|
|
// For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe),
|
|
// not a top-level ease. Writing top-level ease would leave the per-keyframe
|
|
// easing unchanged — the user's edit would silently do nothing.
|
|
const kfNode = keyframesObjectNode(call.varsArg);
|
|
if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease);
|
|
else upsertProp(ms, call.varsArg, "ease", updates.ease);
|
|
}
|
|
if (updates.extras) {
|
|
for (const [key, value] of Object.entries(updates.extras)) {
|
|
upsertProp(ms, call.varsArg, key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
|
|
// fromTo's from-vars carry only editable props — REPLACE them too (recast
|
|
// parity). fromArg is a distinct node from varsArg, so this rebuild never
|
|
// overlaps the varsArg edits above.
|
|
reconcileEditableProps(ms, call.fromArg, script, updates.fromProperties);
|
|
}
|
|
|
|
if (updates.position !== undefined) {
|
|
overwritePosition(ms, call, updates.position);
|
|
}
|
|
|
|
return ms.toString();
|
|
}
|
|
|
|
/**
|
|
* Overwrite a tween call's numeric position argument (the positionArg the parser
|
|
* located: 3rd arg for fromTo, else 2nd), or append one when the call has no
|
|
* explicit position. Shared by updateAnimationInScript and the
|
|
* shift/scalePositionsInScript timeline ops.
|
|
*/
|
|
function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void {
|
|
if (call.positionArg) {
|
|
ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position));
|
|
} else {
|
|
ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shift every tween targeting `targetSelector` 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).
|
|
*/
|
|
export function shiftPositionsInScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
delta: number,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const ms = new MagicString(script);
|
|
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((entry.animation.position + delta) * 1000) / 1000);
|
|
overwritePosition(ms, entry.call, newPos);
|
|
changed = true;
|
|
}
|
|
return changed ? ms.toString() : script;
|
|
}
|
|
|
|
/**
|
|
* Linearly remap every tween targeting `targetSelector` 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).
|
|
*/
|
|
export function scalePositionsInScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
oldStart: number,
|
|
oldDuration: number,
|
|
newStart: number,
|
|
newDuration: number,
|
|
): string {
|
|
if (oldDuration <= 0 || newDuration <= 0) return script;
|
|
const ratio = newDuration / oldDuration;
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const ms = new MagicString(script);
|
|
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,
|
|
);
|
|
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);
|
|
}
|
|
changed = true;
|
|
}
|
|
return changed ? ms.toString() : script;
|
|
}
|
|
|
|
export function addAnimationToScript(
|
|
script: string,
|
|
animation: Omit<GsapAnimation, "id">,
|
|
): { script: string; id: string } {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return { script, id: "" };
|
|
|
|
const insertionPoint = findInsertionPoint(parsed);
|
|
if (insertionPoint === null) return { script, id: "" };
|
|
|
|
const ms = new MagicString(script);
|
|
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
|
|
ms.appendLeft(insertionPoint, "\n" + statementCode);
|
|
|
|
const result = ms.toString();
|
|
const reParsed = parseGsapScriptAcornForWrite(result);
|
|
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
|
|
return { script: result, id: newId };
|
|
}
|
|
|
|
export function removeAnimationFromScript(script: string, animationId: string): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
const N = target.call.node;
|
|
const exprStmt = findEnclosingExpressionStatement(target.call.ancestors);
|
|
|
|
if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) {
|
|
// Standalone `tl.method(...)` — remove the whole ExpressionStatement
|
|
const end =
|
|
exprStmt.end < script.length && script[exprStmt.end] === "\n"
|
|
? exprStmt.end + 1
|
|
: exprStmt.end;
|
|
ms.remove(exprStmt.start, end);
|
|
} else {
|
|
// Chain link — splice out `.method(args)` from N.callee.object.end to N.end
|
|
ms.remove(N.callee.object.end, N.end);
|
|
}
|
|
|
|
return ms.toString();
|
|
}
|
|
|
|
// ── Flat-tween → keyframes conversion ──────────────────────────────────────────
|
|
//
|
|
// Mirror recast's convertToKeyframesInScript: when the first keyframe op lands
|
|
// on a flat to()/from()/fromTo() tween, rewrite its vars object to
|
|
// `{ keyframes: { "0%": {from}, "100%": {to} }, <preserved non-editable keys>,
|
|
// ease: "none"? }` and convert from()/fromTo() to to(). We rebuild the whole
|
|
// vars ObjectExpression in one ms.overwrite (single-edit-per-node), so the next
|
|
// keyframe-add re-parses cleanly.
|
|
|
|
// Identity value for an editable transform/style prop (recast's CSS_IDENTITY).
|
|
const CSS_IDENTITY: Record<string, number> = {
|
|
opacity: 1,
|
|
autoAlpha: 1,
|
|
scale: 1,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
};
|
|
|
|
function cssIdentityValue(prop: string): number {
|
|
return CSS_IDENTITY[prop] ?? 0;
|
|
}
|
|
|
|
// Keys NOT in the editable set — preserved verbatim on the converted vars object
|
|
// (matches the parser's classification: builtin/dropped/extras keys).
|
|
const NON_EDITABLE_VAR_KEYS = new Set([
|
|
"duration",
|
|
"delay",
|
|
"onComplete",
|
|
"onStart",
|
|
"onUpdate",
|
|
"onRepeat",
|
|
"stagger",
|
|
"yoyo",
|
|
"repeat",
|
|
"repeatDelay",
|
|
"snap",
|
|
"overwrite",
|
|
"immediateRender",
|
|
]);
|
|
|
|
/** The CSS-identity counterpart of a props record (numbers → identity value). */
|
|
function identityProps(
|
|
properties: Record<string, number | string>,
|
|
): Record<string, number | string> {
|
|
const identity: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(properties)) {
|
|
if (v != null) identity[k] = typeof v === "number" ? cssIdentityValue(k) : v;
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
/** Resolve the 0%/100% endpoint records for a tween being converted. */
|
|
function conversionEndpoints(animation: GsapAnimation): {
|
|
fromProps: Record<string, number | string>;
|
|
toProps: Record<string, number | string>;
|
|
} {
|
|
if (animation.method === "from") {
|
|
return { fromProps: { ...animation.properties }, toProps: identityProps(animation.properties) };
|
|
}
|
|
if (animation.method === "fromTo") {
|
|
return {
|
|
fromProps: { ...(animation.fromProperties ?? {}) },
|
|
toProps: { ...animation.properties },
|
|
};
|
|
}
|
|
// to(): 0% is the CSS identity state, 100% is the authored props.
|
|
return { fromProps: identityProps(animation.properties), toProps: { ...animation.properties } };
|
|
}
|
|
|
|
/** Collect preserved (non-editable) `key: value` entries from the original vars node. */
|
|
function preservedVarsEntries(varsNode: Node, source: string): string[] {
|
|
const entries: string[] = [];
|
|
if (varsNode?.type !== "ObjectExpression") return entries;
|
|
for (const prop of varsNode.properties ?? []) {
|
|
if (!isObjectProperty(prop)) continue;
|
|
const key = propKeyName(prop);
|
|
if (typeof key !== "string" || !NON_EDITABLE_VAR_KEYS.has(key)) continue;
|
|
entries.push(`${safeKey(key)}: ${source.slice(prop.value.start, prop.value.end)}`);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** Build the rebuilt vars-object code for a converted flat tween. */
|
|
function buildConvertedVarsCode(animation: GsapAnimation, varsNode: Node, source: string): string {
|
|
const { fromProps, toProps } = conversionEndpoints(animation);
|
|
const easeEach = animation.ease;
|
|
const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : "";
|
|
const kfCode = `{ "0%": ${recordToCode(fromProps)}, "100%": ${recordToCode(toProps)}${easeEachEntry} }`;
|
|
const entries = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)];
|
|
if (easeEach) entries.push(`ease: "none"`);
|
|
return `{ ${entries.join(", ")} }`;
|
|
}
|
|
|
|
/** Rename a from()/fromTo() call to to(), dropping fromTo's leading from-vars arg. */
|
|
function convertMethodToTo(
|
|
ms: MagicString,
|
|
animation: GsapAnimation,
|
|
call: Node,
|
|
varsNode: Node,
|
|
): void {
|
|
if (animation.method !== "from" && animation.method !== "fromTo") return;
|
|
const calleeProp = call.node.callee?.property;
|
|
if (calleeProp) ms.overwrite(calleeProp.start, calleeProp.end, "to");
|
|
// Remove the from-vars arg and its trailing separator up to the to-vars arg.
|
|
if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start);
|
|
}
|
|
|
|
function convertFlatTweenToKeyframes(script: string, target: Node): string {
|
|
const animation: GsapAnimation = target.animation;
|
|
if (animation.keyframes || animation.method === "set") return script;
|
|
const call = target.call;
|
|
const varsNode = call.varsArg;
|
|
if (varsNode?.type !== "ObjectExpression") return script;
|
|
|
|
const ms = new MagicString(script);
|
|
ms.overwrite(varsNode.start, varsNode.end, buildConvertedVarsCode(animation, varsNode, script));
|
|
convertMethodToTo(ms, animation, call, varsNode);
|
|
return ms.toString();
|
|
}
|
|
|
|
// ── Keyframe write ops ────────────────────────────────────────────────────────
|
|
//
|
|
// Design: mirror the recast writer's rebuild-the-node model. The recast writer
|
|
// mutates AST nodes in place and re-prints, so it never has an offset-overlap
|
|
// problem. Here we instead compute the FINAL property record for every keyframe
|
|
// value node that must change (the target merge, `_auto` endpoint sync, and
|
|
// backfilled siblings) against the ORIGINAL parsed AST, then emit exactly ONE
|
|
// `ms.overwrite(valueNode.start, valueNode.end, code)` per changed node (and a
|
|
// single insert for a brand-new key). No node is ever both overwritten and
|
|
// appended into, so the splices can never overlap.
|
|
|
|
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
|
|
|
|
// Matches recast's PCT_TOLERANCE: percentages within 2 of an existing key are
|
|
// treated as the same keyframe (merge), not a new insert.
|
|
const PCT_TOLERANCE = 2;
|
|
|
|
function percentageFromKey(key: string): number {
|
|
const m = PERCENTAGE_KEY_RE.exec(key);
|
|
return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN;
|
|
}
|
|
|
|
/** Serialize a final keyframe property record (number|string values) to code. */
|
|
function recordToCode(record: Record<string, number | string>): string {
|
|
const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
return `{ ${entries.join(", ")} }`;
|
|
}
|
|
|
|
/** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */
|
|
function percentagePropsOf(kfNode: Node): Node[] {
|
|
return (kfNode.properties ?? []).filter((p: Node) => {
|
|
if (!isObjectProperty(p)) return false;
|
|
const key = propKeyName(p);
|
|
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
|
|
});
|
|
}
|
|
|
|
const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral"]);
|
|
|
|
/** Read one value node: a number/string literal, a negative number, or raw source. */
|
|
// fallow-ignore-next-line complexity
|
|
function readValueNode(v: Node, source: string): number | string {
|
|
if (
|
|
LITERAL_NODE_TYPES.has(v?.type) &&
|
|
(typeof v.value === "number" || typeof v.value === "string")
|
|
) {
|
|
return v.value;
|
|
}
|
|
if (
|
|
v?.type === "UnaryExpression" &&
|
|
v.operator === "-" &&
|
|
typeof v.argument?.value === "number"
|
|
) {
|
|
return -v.argument.value;
|
|
}
|
|
return `__raw:${source.slice(v.start, v.end)}`;
|
|
}
|
|
|
|
/**
|
|
* Read a keyframe value ObjectExpression into a record, mirroring the parser's
|
|
* `objectExpressionToRecord`: literals resolve to their value; anything else is
|
|
* preserved as `__raw:<source>` so serializeValue round-trips it verbatim.
|
|
* Keyframe values are literals in practice, so the raw fallback is rarely hit.
|
|
*/
|
|
function valueNodeToRecord(valueNode: Node, source: string): Record<string, number | string> {
|
|
const record: Record<string, number | string> = {};
|
|
if (valueNode?.type !== "ObjectExpression") return record;
|
|
for (const prop of valueNode.properties ?? []) {
|
|
if (!isObjectProperty(prop)) continue;
|
|
const key = propKeyName(prop);
|
|
if (typeof key !== "string") continue;
|
|
record[key] = readValueNode(prop.value, source);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
/** True when a keyframe value record carries the synthetic `_auto` marker. */
|
|
function recordHasAuto(record: Record<string, number | string>): boolean {
|
|
return "_auto" in record;
|
|
}
|
|
|
|
/**
|
|
* Compute `_auto` endpoint overwrites: when the new keyframe is the immediate
|
|
* neighbor of an `_auto` 0% or 100% endpoint, that endpoint is rewritten to
|
|
* `{ ...newProps, _auto: 1 }`. Only fires for interior keyframes. Returns the
|
|
* percentage→overwrite map so the caller can fold these into the per-node final
|
|
* records (never a separate splice).
|
|
*/
|
|
function autoEndpointOverwrites(
|
|
kfNode: Node,
|
|
source: string,
|
|
percentage: number,
|
|
properties: Record<string, number | string>,
|
|
): Map<any, Record<string, number | string>> {
|
|
const result = new Map<any, Record<string, number | string>>();
|
|
if (percentage <= 0 || percentage >= 100) return result;
|
|
const pctProps = percentagePropsOf(kfNode);
|
|
const allPcts = pctProps
|
|
.map((p: Node) => percentageFromKey(propKeyName(p) ?? ""))
|
|
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
|
|
.sort((a: number, b: number) => a - b);
|
|
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
|
|
const rightNeighbor = allPcts.find((p: number) => p > percentage);
|
|
for (const endPct of [0, 100]) {
|
|
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
|
|
if (!isNeighbor) continue;
|
|
const endProp = pctProps.find((p: Node) => percentageFromKey(propKeyName(p) ?? "") === endPct);
|
|
if (!endProp) continue;
|
|
const rec = valueNodeToRecord(endProp.value, source);
|
|
if (!recordHasAuto(rec)) continue;
|
|
result.set(endProp, { ...properties, _auto: 1 });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: number } | null {
|
|
// Match the CLOSEST keyframe within tolerance, not the first one within range.
|
|
// Keyframes at e.g. 0/49/50/100 are all valid (the SDK dedups to a unique
|
|
// match at TOLERANCE=0.001 upstream); picking the first-within-PCT_TOLERANCE=2
|
|
// would hit 49% when the caller meant 50%. Tie-break on the earliest index so
|
|
// the choice stays deterministic.
|
|
const props = kfNode.properties ?? [];
|
|
let best: { prop: Node; idx: number } | null = null;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
for (let i = 0; i < props.length; i++) {
|
|
const prop = props[i];
|
|
if (!isObjectProperty(prop)) continue;
|
|
const key = propKeyName(prop);
|
|
if (typeof key !== "string") continue;
|
|
const dist = Math.abs(percentageFromKey(key) - percentage);
|
|
if (dist <= PCT_TOLERANCE && dist < bestDist) {
|
|
best = { prop, idx: i };
|
|
bestDist = dist;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function updateKeyframeInScript(
|
|
script: string,
|
|
animationId: string,
|
|
percentage: number,
|
|
properties: Record<string, number | string>,
|
|
ease?: string,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
|
|
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
|
|
|
|
const match = findKfPropByPct(kfPropNode.value, percentage);
|
|
if (!match) return script;
|
|
|
|
const record: Record<string, number | string> = { ...properties };
|
|
if (ease) record.ease = ease;
|
|
const ms = new MagicString(script);
|
|
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
|
|
return ms.toString();
|
|
}
|
|
|
|
/**
|
|
* Build the final property record for the keyframe at `percentage`. If a
|
|
* keyframe already exists there, MERGE the new props over the existing record
|
|
* (preserve untouched props, preserve `_auto`, preserve the existing per-keyframe
|
|
* ease when the op omits one); otherwise it's just the new props.
|
|
*/
|
|
function buildTargetRecord(
|
|
existing: { prop: Node; idx: number } | null,
|
|
source: string,
|
|
properties: Record<string, number | string>,
|
|
ease: string | undefined,
|
|
): Record<string, number | string> {
|
|
if (!existing || existing.prop.value?.type !== "ObjectExpression") {
|
|
const record: Record<string, number | string> = { ...properties };
|
|
if (ease) record.ease = ease;
|
|
return record;
|
|
}
|
|
const existingRecord = valueNodeToRecord(existing.prop.value, source);
|
|
const existingEase = typeof existingRecord.ease === "string" ? existingRecord.ease : undefined;
|
|
const merged: Record<string, number | string> = { ...existingRecord };
|
|
for (const [k, v] of Object.entries(properties)) merged[k] = v;
|
|
const finalEase = ease ?? existingEase;
|
|
if (finalEase) merged.ease = finalEase;
|
|
else delete merged.ease;
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Compute the backfilled final record for one sibling keyframe: append any of
|
|
* `newPropKeys` it's missing, using the backfill default. Returns null when
|
|
* nothing changes (so the caller emits no overwrite for it).
|
|
*/
|
|
function backfilledSiblingRecord(
|
|
valueNode: Node,
|
|
source: string,
|
|
newPropKeys: string[],
|
|
backfillDefaults: Record<string, number | string>,
|
|
): Record<string, number | string> | null {
|
|
if (valueNode?.type !== "ObjectExpression") return null;
|
|
const record = valueNodeToRecord(valueNode, source);
|
|
let changed = false;
|
|
for (const pk of newPropKeys) {
|
|
const defaultVal = backfillDefaults[pk];
|
|
if (pk in record || defaultVal == null) continue;
|
|
record[pk] = defaultVal;
|
|
changed = true;
|
|
}
|
|
return changed ? record : null;
|
|
}
|
|
|
|
/** A located tween whose varsArg has a static keyframes ObjectExpression, or null. */
|
|
function locateWithKeyframes(
|
|
script: string,
|
|
animationId: string,
|
|
): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return null;
|
|
// Converting from()/fromTo() to to() rewrites the content-derived id; match
|
|
// recast's locateAnimationWithFallback by remapping the method segment.
|
|
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
|
const target =
|
|
parsed.located.find((l) => l.id === animationId) ??
|
|
parsed.located.find((l) => l.id === convertedId);
|
|
if (!target) return null;
|
|
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
|
|
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return null;
|
|
return { script, parsed, target, kfNode: kfPropNode.value };
|
|
}
|
|
|
|
/** Locate a tween's keyframes object, converting a flat tween first if absent. */
|
|
function ensureKeyframesNode(
|
|
script: string,
|
|
animationId: string,
|
|
): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null {
|
|
const direct = locateWithKeyframes(script, animationId);
|
|
if (direct) return direct;
|
|
|
|
// No static keyframes object — convert the flat tween, then re-locate.
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
const target = parsed?.located.find((l) => l.id === animationId);
|
|
if (!target) return null;
|
|
const converted = convertFlatTweenToKeyframes(script, target);
|
|
if (converted === script) return null;
|
|
return locateWithKeyframes(converted, animationId);
|
|
}
|
|
|
|
/**
|
|
* Compute the sibling keyframe nodes that need a backfilled prop, excluding the
|
|
* target keyframe and any node already being overwritten as an `_auto` endpoint.
|
|
*/
|
|
function collectBackfillOverwrites(
|
|
kfNode: Node,
|
|
src: string,
|
|
properties: Record<string, number | string>,
|
|
backfillDefaults: Record<string, number | string> | undefined,
|
|
skip: { existingProp: Node; endpoints: Map<any, unknown> },
|
|
): Map<any, Record<string, number | string>> {
|
|
const result = new Map<any, Record<string, number | string>>();
|
|
if (!backfillDefaults) return result;
|
|
const newPropKeys = Object.keys(properties);
|
|
for (const prop of percentagePropsOf(kfNode)) {
|
|
if (prop === skip.existingProp || skip.endpoints.has(prop)) continue;
|
|
const rec = backfilledSiblingRecord(prop.value, src, newPropKeys, backfillDefaults);
|
|
if (rec) result.set(prop, rec);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function addKeyframeToScript(
|
|
script: string,
|
|
animationId: string,
|
|
percentage: number,
|
|
properties: Record<string, number | string>,
|
|
ease?: string,
|
|
backfillDefaults?: Record<string, number | string>,
|
|
): string {
|
|
const located = ensureKeyframesNode(script, animationId);
|
|
if (!located) return script;
|
|
const { script: src, kfNode } = located;
|
|
|
|
const existing = findKfPropByPct(kfNode, percentage);
|
|
|
|
// Final record for the target keyframe (merge if it already exists).
|
|
const targetRecord = buildTargetRecord(existing, src, properties, ease);
|
|
// `_auto` endpoint syncs fire only on new inserts; a merge landing ON an
|
|
// endpoint already preserves `_auto` via buildTargetRecord.
|
|
const endpointOverwrites = existing
|
|
? new Map<any, Record<string, number | string>>()
|
|
: autoEndpointOverwrites(kfNode, src, percentage, properties);
|
|
// Backfilled siblings (each node changes at most once).
|
|
const backfillOverwrites = collectBackfillOverwrites(kfNode, src, properties, backfillDefaults, {
|
|
existingProp: existing?.prop,
|
|
endpoints: endpointOverwrites,
|
|
});
|
|
|
|
// Emit exactly one overwrite per changed node, plus one insert for a new key.
|
|
const ms = new MagicString(src);
|
|
if (existing) {
|
|
// Merge into the existing keyframe at this percentage, preserving sibling
|
|
// properties — overwrite only the given keys. (A whole-value overwrite here
|
|
// would silently drop other properties already keyframed at this percent.)
|
|
if (existing.prop.value?.type === "ObjectExpression") {
|
|
for (const [k, v] of Object.entries(properties)) {
|
|
upsertProp(ms, existing.prop.value, k, v);
|
|
}
|
|
if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease);
|
|
} else {
|
|
ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord));
|
|
}
|
|
} else {
|
|
insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord));
|
|
}
|
|
for (const [prop, rec] of [...endpointOverwrites, ...backfillOverwrites]) {
|
|
ms.overwrite(prop.value.start, prop.value.end, recordToCode(rec));
|
|
}
|
|
|
|
return ms.toString();
|
|
}
|
|
|
|
/** Insert a brand-new `"pct%": {...}` property in sorted order. */
|
|
function insertNewKeyframe(
|
|
ms: MagicString,
|
|
kfNode: Node,
|
|
percentage: number,
|
|
pctKey: string,
|
|
valueCode: string,
|
|
): void {
|
|
const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p));
|
|
let insertBeforeProp: Node = null;
|
|
for (const prop of allProps) {
|
|
const key = propKeyName(prop);
|
|
if (typeof key === "string" && percentageFromKey(key) > percentage) {
|
|
insertBeforeProp = prop;
|
|
break;
|
|
}
|
|
}
|
|
if (insertBeforeProp) {
|
|
ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `);
|
|
} else {
|
|
const sep = allProps.length > 0 ? ", " : "";
|
|
ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebuild a vars ObjectExpression that has just dropped below two keyframes,
|
|
* collapsing `keyframes: {…}` back to a flat tween. Mirrors recast's
|
|
* collapseKeyframesToFlat: drop the `keyframes` + `easeEach` keys, preserve every
|
|
* other vars key verbatim, and splice the remaining keyframe's properties (minus
|
|
* its per-keyframe `ease`) in as flat vars keys. Single ms.overwrite of the whole
|
|
* vars node so the splice can't overlap the keyframe removal.
|
|
*/
|
|
function collapseKeyframesToFlat(
|
|
ms: MagicString,
|
|
varsNode: Node,
|
|
source: string,
|
|
remainingRecord: Record<string, number | string>,
|
|
): void {
|
|
if (varsNode?.type !== "ObjectExpression") return;
|
|
const dropKeyframeKeys = (key: string) => key === "keyframes" || key === "easeEach";
|
|
const { entries } = preservedEntries(varsNode, source, dropKeyframeKeys, {});
|
|
for (const [k, v] of Object.entries(remainingRecord)) {
|
|
if (k !== "ease") entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
|
}
|
|
ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`);
|
|
}
|
|
|
|
export function removeKeyframeFromScript(
|
|
script: string,
|
|
animationId: string,
|
|
percentage: number,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
|
|
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
|
|
const kfNode = kfPropNode.value;
|
|
|
|
const match = findKfPropByPct(kfNode, percentage);
|
|
if (!match) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
|
|
// If removing this keyframe leaves fewer than two, collapse the keyframes
|
|
// object back to a flat tween (recast parity) instead of leaving a lone
|
|
// keyframe. We rebuild the whole vars node, so we never also splice the kf
|
|
// node — the two edits would overlap.
|
|
const remaining = percentagePropsOf(kfNode).filter((p) => p !== match.prop);
|
|
if (remaining.length < 2) {
|
|
const sole = remaining[0];
|
|
const record = sole ? valueNodeToRecord(sole.value, script) : {};
|
|
collapseKeyframesToFlat(ms, target.call.varsArg, script, record);
|
|
return ms.toString();
|
|
}
|
|
|
|
const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p));
|
|
removeProp(ms, match.prop, allProps);
|
|
return ms.toString();
|
|
}
|
|
|
|
export function removePropertyFromAnimation(
|
|
script: string,
|
|
animationId: string,
|
|
property: string,
|
|
from = false,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
const { call } = target;
|
|
const objNode = from ? (call.method === "fromTo" ? call.fromArg : null) : call.varsArg;
|
|
if (!objNode) return script;
|
|
const propNode = findPropertyNode(objNode, property);
|
|
if (!propNode) return script;
|
|
const allProps = (objNode.properties ?? []).filter((p: Node) => isObjectProperty(p));
|
|
const ms = new MagicString(script);
|
|
removeProp(ms, propNode, allProps);
|
|
return ms.toString();
|
|
}
|
|
|
|
/**
|
|
* Remove all keyframes from a tween, collapsing to a flat tween with one
|
|
* keyframe's properties: the first for `from()`, the last otherwise (the
|
|
* destination = the visible resting state).
|
|
*/
|
|
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
const kfs = target.animation.keyframes?.keyframes;
|
|
if (!kfs || kfs.length === 0) return script;
|
|
|
|
const sorted = [...kfs].sort((a, b) => a.percentage - b.percentage);
|
|
const collapse = target.call.method === "from" ? sorted[0] : sorted[sorted.length - 1];
|
|
if (!collapse) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
overwriteVarsArg(
|
|
ms,
|
|
target.call,
|
|
buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)),
|
|
);
|
|
return ms.toString();
|
|
}
|
|
|
|
// Flat vars for a tween collapsing its keyframes onto one stop: existing
|
|
// top-level props, then the collapse keyframe's props (skip per-keyframe
|
|
// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission.
|
|
function buildCollapsedFlatVars(
|
|
animation: GsapAnimation,
|
|
collapse: { properties: Record<string, number | string> },
|
|
): Record<string, number | string> {
|
|
const flat: Record<string, number | string> = { ...animation.properties };
|
|
for (const [k, v] of Object.entries(collapse.properties)) {
|
|
if (k !== "ease") flat[k] = v;
|
|
}
|
|
if (animation.duration !== undefined) flat.duration = animation.duration;
|
|
if (animation.ease) flat.ease = animation.ease;
|
|
for (const [k, v] of Object.entries(animation.extras ?? {})) {
|
|
if (typeof v === "number" || typeof v === "string") flat[k] = v;
|
|
}
|
|
return flat;
|
|
}
|
|
|
|
/** Build the full replacement vars object for a tween being converted to keyframes. */
|
|
function buildKeyframesVarsCode(
|
|
animation: GsapAnimation,
|
|
fromProps: Record<string, number | string>,
|
|
toProps: Record<string, number | string>,
|
|
varsNode: Node,
|
|
source: string,
|
|
): string {
|
|
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : "";
|
|
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`;
|
|
// Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…)
|
|
// verbatim from source — rebuilding from the animation object alone dropped
|
|
// `delay` (not a GsapAnimation field), shifting the tween's start time.
|
|
const parts: string[] = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)];
|
|
if (animation.ease) parts.push(`ease: "none"`);
|
|
return `{ ${parts.join(", ")} }`;
|
|
}
|
|
|
|
/**
|
|
* Convert a flat tween (to/from/fromTo) to percentage-keyframes format.
|
|
* `resolvedFromValues` supplies the current DOM state: overrides the 0% endpoint
|
|
* for `to()`, the 100% endpoint for `from()`, or merges into toProps for `fromTo()`.
|
|
*/
|
|
export function convertToKeyframesFromScript(
|
|
script: string,
|
|
animationId: string,
|
|
resolvedFromValues?: Record<string, number | string>,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
const { animation, call } = target;
|
|
if (animation.keyframes || call.method === "set") return script;
|
|
|
|
const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues);
|
|
const ms = new MagicString(script);
|
|
|
|
if (call.method === "from" || call.method === "fromTo") {
|
|
ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to");
|
|
}
|
|
if (call.method === "fromTo" && call.fromArg) {
|
|
ms.remove(call.fromArg.start, call.varsArg.start);
|
|
}
|
|
overwriteVarsArg(
|
|
ms,
|
|
call,
|
|
buildKeyframesVarsCode(animation, fromProps, toProps, call.varsArg, script),
|
|
);
|
|
|
|
return ms.toString();
|
|
}
|
|
|
|
// ── Keyframe-object code builder ─────────────────────────────────────────────
|
|
|
|
/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */
|
|
function buildKeyframeObjectCode(
|
|
keyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
auto?: boolean;
|
|
}>,
|
|
easeEach?: string,
|
|
): string {
|
|
const entries = keyframes.map((kf) => {
|
|
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
|
|
if (kf.auto) props.push(`_auto: 1`);
|
|
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
|
|
});
|
|
if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
|
|
return `{ ${entries.join(", ")} }`;
|
|
}
|
|
|
|
// ── Materialize keyframes ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Replace a dynamic or static keyframes expression with a fully-resolved
|
|
* percentage-keyframes object. Called when a user first edits a dynamically-
|
|
* generated keyframe in the studio so it becomes statically editable.
|
|
*/
|
|
export function materializeKeyframesFromScript(
|
|
script: string,
|
|
animationId: string,
|
|
keyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
}>,
|
|
easeEach?: string,
|
|
resolvedSelector?: string,
|
|
): string {
|
|
// An empty keyframe list has no materialized form — rebuilding vars with an
|
|
// empty keyframes object would empty the animation. No-op instead.
|
|
if (keyframes.length === 0) return script;
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const { call } = target;
|
|
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
|
|
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
|
|
const ms = new MagicString(script);
|
|
|
|
if (resolvedSelector) {
|
|
const selectorArg = call.node.arguments[0];
|
|
if (selectorArg)
|
|
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector));
|
|
}
|
|
|
|
const kfProp = findPropertyNode(call.varsArg, "keyframes");
|
|
if (kfProp) {
|
|
ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode);
|
|
} else if (call.varsArg?.type === "ObjectExpression") {
|
|
const vars = call.varsArg;
|
|
if (vars.properties.length > 0) {
|
|
ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `);
|
|
} else {
|
|
ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`);
|
|
}
|
|
}
|
|
|
|
const eachProp = findPropertyNode(call.varsArg, "easeEach");
|
|
if (eachProp) {
|
|
const allProps = (call.varsArg.properties ?? []).filter((p: Node) => isObjectProperty(p));
|
|
removeProp(ms, eachProp, allProps);
|
|
}
|
|
|
|
return ms.toString();
|
|
}
|
|
|
|
// ── Add animation with keyframes ──────────────────────────────────────────────
|
|
|
|
/** Insert a new keyframed `to()` call and return the new animation ID. */
|
|
export function addAnimationWithKeyframesToScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
position: number,
|
|
duration: number,
|
|
keyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
auto?: boolean;
|
|
}>,
|
|
ease?: string,
|
|
): { script: string; id: string } {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return { script, id: "" };
|
|
const insertionPoint = findInsertionPoint(parsed);
|
|
if (insertionPoint === null) return { script, id: "" };
|
|
|
|
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
|
|
const kfObjCode = buildKeyframeObjectCode(sorted);
|
|
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
|
|
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
|
|
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
|
|
|
|
const ms = new MagicString(script);
|
|
ms.appendLeft(insertionPoint, "\n" + stmtCode);
|
|
|
|
const result = ms.toString();
|
|
const reParsed = parseGsapScriptAcornForWrite(result);
|
|
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
|
|
return { script: result, id: newId };
|
|
}
|
|
|
|
// ── Split into property groups ────────────────────────────────────────────────
|
|
|
|
function collectPropertyKeys(anim: GsapAnimation): Set<string> {
|
|
const keys = new Set<string>();
|
|
if (anim.keyframes) {
|
|
for (const kf of anim.keyframes.keyframes) {
|
|
for (const k of Object.keys(kf.properties)) keys.add(k);
|
|
}
|
|
} else {
|
|
for (const k of Object.keys(anim.properties)) keys.add(k);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
function partitionPropertyGroups(keys: Set<string>): Map<PropertyGroupName, string[]> {
|
|
const groups = new Map<PropertyGroupName, string[]>();
|
|
for (const key of keys) {
|
|
if (key === "transformOrigin") continue;
|
|
const group = classifyPropertyGroup(key);
|
|
let arr = groups.get(group);
|
|
if (!arr) {
|
|
arr = [];
|
|
groups.set(group, arr);
|
|
}
|
|
arr.push(key);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function assignTransformOrigin(groupProps: Map<PropertyGroupName, string[]>): void {
|
|
let largestGroup: PropertyGroupName | undefined;
|
|
let largestCount = 0;
|
|
for (const [group, props] of groupProps) {
|
|
if (props.length > largestCount) {
|
|
largestCount = props.length;
|
|
largestGroup = group;
|
|
}
|
|
}
|
|
const largest = largestGroup ? groupProps.get(largestGroup) : undefined;
|
|
if (largest) largest.push("transformOrigin");
|
|
}
|
|
|
|
function filterGroupKeyframes(
|
|
kfs: GsapPercentageKeyframe[],
|
|
propSet: Set<string>,
|
|
): Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }> {
|
|
const result: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
}> = [];
|
|
for (const kf of kfs) {
|
|
const filtered: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(kf.properties)) {
|
|
if (propSet.has(k)) filtered[k] = v;
|
|
}
|
|
if (Object.keys(filtered).length > 0) {
|
|
result.push({
|
|
percentage: kf.percentage,
|
|
properties: filtered,
|
|
...(kf.ease ? { ease: kf.ease } : {}),
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function filterGroupProperties(
|
|
properties: Record<string, number | string>,
|
|
propSet: Set<string>,
|
|
): Record<string, number | string> {
|
|
const result: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(properties)) {
|
|
if (propSet.has(k)) result[k] = v;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function addGroupAnimToScript(
|
|
script: string,
|
|
anim: GsapAnimation,
|
|
propSet: Set<string>,
|
|
): { script: string; id: string } {
|
|
if (anim.keyframes) {
|
|
const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet);
|
|
if (groupKeyframes.length === 0) return { script, id: "" };
|
|
const pos = typeof anim.position === "number" ? anim.position : 0;
|
|
return addAnimationWithKeyframesToScript(
|
|
script,
|
|
anim.targetSelector,
|
|
pos,
|
|
anim.duration ?? 0.5,
|
|
groupKeyframes,
|
|
anim.keyframes.easeEach ?? anim.ease,
|
|
);
|
|
}
|
|
const groupProperties = filterGroupProperties(anim.properties, propSet);
|
|
if (Object.keys(groupProperties).length === 0) return { script, id: "" };
|
|
const fromProperties =
|
|
anim.method === "fromTo" && anim.fromProperties
|
|
? filterGroupProperties(anim.fromProperties, propSet)
|
|
: undefined;
|
|
return addAnimationToScript(script, {
|
|
targetSelector: anim.targetSelector,
|
|
method: anim.method,
|
|
position: anim.position,
|
|
duration: anim.duration,
|
|
ease: anim.ease,
|
|
properties: groupProperties,
|
|
fromProperties,
|
|
extras: anim.extras,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Split a mixed-property tween into one tween per property group (position,
|
|
* scale, visual, etc.) so each group can be edited independently.
|
|
* Returns the updated script and the IDs of the newly-created tweens.
|
|
*/
|
|
export function splitIntoPropertyGroupsFromScript(
|
|
script: string,
|
|
animationId: string,
|
|
): { script: string; ids: string[] } {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return { script, ids: [animationId] };
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return { script, ids: [animationId] };
|
|
const { animation } = target;
|
|
|
|
const allPropKeys = collectPropertyKeys(animation);
|
|
const groupProps = partitionPropertyGroups(allPropKeys);
|
|
if (groupProps.size <= 1) return { script, ids: [animationId] };
|
|
if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps);
|
|
|
|
let result = removeAnimationFromScript(script, animationId);
|
|
for (const [, props] of groupProps) {
|
|
const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props));
|
|
if (id) result = next;
|
|
}
|
|
|
|
const reParsed = parseGsapScriptAcornForWrite(result);
|
|
const newIds = (reParsed?.located ?? [])
|
|
.filter((l) => l.animation.targetSelector === animation.targetSelector)
|
|
.map((l) => l.id);
|
|
return { script: result, ids: newIds };
|
|
}
|
|
|
|
// ── Label write ops ───────────────────────────────────────────────────────────
|
|
|
|
/** True when `expr` is `tl.<method>(…)` rooted at the timeline var. */
|
|
function isTimelineMethodCall(expr: Node, timelineVar: string, method: string): boolean {
|
|
return (
|
|
expr?.type === "CallExpression" &&
|
|
expr.callee?.type === "MemberExpression" &&
|
|
isTimelineRooted(expr.callee.object, timelineVar) &&
|
|
expr.callee.property?.name === method
|
|
);
|
|
}
|
|
|
|
/** True when `expr` is `tl.addLabel("<name>", …)` rooted at the timeline var. */
|
|
function isAddLabelCall(expr: Node, timelineVar: string, name: string): boolean {
|
|
const firstArg = expr?.arguments?.[0];
|
|
return (
|
|
isTimelineMethodCall(expr, timelineVar, "addLabel") &&
|
|
firstArg?.type === "Literal" &&
|
|
firstArg.value === name
|
|
);
|
|
}
|
|
|
|
/** Every `tl.addLabel("<name>", …)` ExpressionStatement in the script. */
|
|
function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): Node[] {
|
|
const targets: Node[] = [];
|
|
acornWalk.simple(parsed.ast, {
|
|
ExpressionStatement(node: Node) {
|
|
if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node);
|
|
},
|
|
});
|
|
return targets;
|
|
}
|
|
|
|
export function addLabelToScript(script: string, name: string, position: number): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
|
|
// If the label already exists, MOVE it (overwrite its position) rather than
|
|
// appending a duplicate. Two same-named addLabel statements make removeLabel
|
|
// over-remove — it deletes every match, including a pre-existing label the
|
|
// user never touched.
|
|
const existing = findLabelStatements(parsed, name)[0];
|
|
if (existing) {
|
|
const ms = new MagicString(script);
|
|
const posArg = existing.expression.arguments?.[1];
|
|
if (posArg) ms.overwrite(posArg.start, posArg.end, valueToCode(position));
|
|
else ms.appendLeft(existing.expression.end - 1, `, ${valueToCode(position)}`);
|
|
return ms.toString();
|
|
}
|
|
|
|
const insertionPoint = findInsertionPoint(parsed);
|
|
if (insertionPoint === null) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
const labelCode = `${parsed.timelineVar}.addLabel(${JSON.stringify(name)}, ${valueToCode(position)});`;
|
|
ms.appendLeft(insertionPoint, "\n" + labelCode);
|
|
return ms.toString();
|
|
}
|
|
|
|
export function removeLabelFromScript(script: string, name: string): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
|
|
const targets = findLabelStatements(parsed, name);
|
|
if (!targets.length) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
for (const target of targets) {
|
|
const end =
|
|
target.end < script.length && script[target.end] === "\n" ? target.end + 1 : target.end;
|
|
ms.remove(target.start, end);
|
|
}
|
|
return ms.toString();
|
|
}
|
|
|
|
// ── Arc path helpers ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Remove a set of properties from an ObjectExpression in a single pass.
|
|
* Groups consecutive marked props into blocks to avoid overlapping remove ranges.
|
|
*/
|
|
function removePropsByKey(ms: MagicString, objNode: Node, keys: Set<string>): void {
|
|
if (objNode?.type !== "ObjectExpression") return;
|
|
const allProps = (objNode.properties ?? []).filter(isObjectProperty);
|
|
const marked = allProps.map((p: Node) => keys.has(propKeyName(p) ?? ""));
|
|
let i = 0;
|
|
while (i < allProps.length) {
|
|
if (!marked[i]) {
|
|
i++;
|
|
continue;
|
|
}
|
|
const blockStart = i;
|
|
while (i < allProps.length && marked[i]) i++;
|
|
ms.remove(...blockRemoveRange(allProps, blockStart, i));
|
|
}
|
|
}
|
|
|
|
function blockRemoveRange(
|
|
allProps: Node[],
|
|
blockStart: number,
|
|
blockEnd: number,
|
|
): [number, number] {
|
|
if (blockStart === 0 && blockEnd === allProps.length)
|
|
return [allProps[0].start, allProps[allProps.length - 1].end];
|
|
if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start];
|
|
return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end];
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function readLastWaypointXY(mpVal: Node): { x: number | null; y: number | null } {
|
|
if (mpVal?.type !== "ObjectExpression") return { x: null, y: null };
|
|
const pathProp = findPropertyNode(mpVal, "path");
|
|
if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null };
|
|
const elems: Node[] = pathProp.value.elements ?? [];
|
|
const last = elems[elems.length - 1];
|
|
if (last?.type !== "ObjectExpression") return { x: null, y: null };
|
|
return {
|
|
x: readNumericLiteralNode(findPropertyNode(last, "x")?.value),
|
|
y: readNumericLiteralNode(findPropertyNode(last, "y")?.value),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read a numeric value node — a plain numeric literal or a unary-minus negative
|
|
* literal (e.g. `-120`). Returns null for anything non-numeric. Without the
|
|
* UnaryExpression branch, negative waypoint coords (parsed as a UnaryExpression
|
|
* with no `.value`) would be lost when disabling an arc path.
|
|
*/
|
|
function readNumericLiteralNode(v: Node): number | null {
|
|
if (LITERAL_NODE_TYPES.has(v?.type) && typeof v.value === "number") return v.value;
|
|
if (
|
|
v?.type === "UnaryExpression" &&
|
|
v.operator === "-" &&
|
|
typeof v.argument?.value === "number"
|
|
) {
|
|
return -v.argument.value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean {
|
|
const mpProp = findPropertyNode(call.varsArg, "motionPath");
|
|
if (!mpProp) return false;
|
|
const { x, y } = readLastWaypointXY(mpProp.value);
|
|
if (x === null && y === null) {
|
|
const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty);
|
|
removeProp(ms, mpProp, allProps);
|
|
return true;
|
|
}
|
|
// Overwrite the entire motionPath property with the recovered x/y pair — avoids
|
|
// the appendLeft+remove range-boundary issue in MagicString.
|
|
const parts: string[] = [];
|
|
if (x !== null) parts.push(`x: ${x}`);
|
|
if (y !== null) parts.push(`y: ${y}`);
|
|
ms.overwrite(mpProp.start, mpProp.end, parts.join(", "));
|
|
return true;
|
|
}
|
|
|
|
function stripXYFromKeyframes(ms: MagicString, kfPropNode: Node): void {
|
|
if (kfPropNode?.value?.type !== "ObjectExpression") return;
|
|
const xyKeys = new Set(["x", "y"]);
|
|
for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) {
|
|
const k = propKeyName(pctProp);
|
|
if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") {
|
|
removePropsByKey(ms, pctProp.value, xyKeys);
|
|
}
|
|
}
|
|
}
|
|
|
|
function enableArcPath(
|
|
ms: MagicString,
|
|
call: TweenCallInfo,
|
|
animation: GsapAnimation,
|
|
config: ArcPathConfig,
|
|
): boolean {
|
|
const waypoints = extractArcWaypoints(animation);
|
|
if (waypoints.length < 2) return false;
|
|
const segments: ArcPathSegment[] =
|
|
config.segments.length === waypoints.length - 1
|
|
? config.segments
|
|
: Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 }));
|
|
const motionPathCode = buildMotionPathObjectCode({
|
|
waypoints,
|
|
segments,
|
|
autoRotate: config.autoRotate,
|
|
});
|
|
const vars = call.varsArg;
|
|
if (vars?.type !== "ObjectExpression") return false;
|
|
// Insert motionPath right after the opening `{` (appendRight at start+1) so the
|
|
// insertion point can never coincide with the end boundary of the x/y removal
|
|
// range. upsertProp would appendLeft at `end - 1`, which collides with a
|
|
// remove-range that ends at the same offset when x/y are the only props —
|
|
// MagicString then discards the append and the output loses everything.
|
|
const editable = (vars.properties ?? []).filter(isObjectProperty);
|
|
const survivesRemoval = editable.some((p: Node) => {
|
|
const k = propKeyName(p);
|
|
return k !== "x" && k !== "y";
|
|
});
|
|
const sep = survivesRemoval ? ", " : "";
|
|
ms.appendRight(vars.start + 1, ` motionPath: ${motionPathCode}${sep}`);
|
|
stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes"));
|
|
removePropsByKey(ms, call.varsArg, new Set(["x", "y"]));
|
|
return true;
|
|
}
|
|
|
|
export function setArcPathInScript(
|
|
script: string,
|
|
animationId: string,
|
|
config: ArcPathConfig,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
const ms = new MagicString(script);
|
|
const handled = config.enabled
|
|
? enableArcPath(ms, target.call, target.animation, config)
|
|
: disableArcPath(ms, target.call);
|
|
return handled ? ms.toString() : script;
|
|
}
|
|
|
|
export function updateArcSegmentInScript(
|
|
script: string,
|
|
animationId: string,
|
|
segmentIndex: number,
|
|
update: Partial<ArcPathSegment>,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const { call, animation } = target;
|
|
if (!animation.arcPath?.enabled) return script;
|
|
|
|
const segments = [...animation.arcPath.segments];
|
|
const existingSeg = segments[segmentIndex];
|
|
if (segmentIndex < 0 || segmentIndex >= segments.length || !existingSeg) return script;
|
|
|
|
segments[segmentIndex] = { ...existingSeg, ...update };
|
|
|
|
const waypoints = extractArcWaypoints(animation);
|
|
if (waypoints.length < 2) return script;
|
|
|
|
const motionPathCode = buildMotionPathObjectCode({
|
|
waypoints,
|
|
segments,
|
|
autoRotate: animation.arcPath.autoRotate,
|
|
});
|
|
|
|
const mpProp = findPropertyNode(call.varsArg, "motionPath");
|
|
if (!mpProp) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode);
|
|
return ms.toString();
|
|
}
|
|
|
|
export function removeArcPathFromScript(script: string, animationId: string): string {
|
|
return setArcPathInScript(script, animationId, {
|
|
enabled: false,
|
|
autoRotate: false,
|
|
segments: [],
|
|
});
|
|
}
|
|
|
|
// ── splitAnimationsInScript helpers ──────────────────────────────────────────
|
|
|
|
/** Overwrite the selector (first arg) of a tween call. */
|
|
function updateAnimationSelectorInScript(
|
|
script: string,
|
|
animationId: string,
|
|
newSelector: string,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
const selectorArg = target.call.node.arguments?.[0];
|
|
if (!selectorArg) return script;
|
|
const ms = new MagicString(script);
|
|
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector));
|
|
return ms.toString();
|
|
}
|
|
|
|
/**
|
|
* Insert a `tl.set()` call immediately after the timeline declaration
|
|
* (before existing tweens) to establish inherited state on a new element.
|
|
*/
|
|
function insertInheritedStateSetInScript(
|
|
script: string,
|
|
selector: string,
|
|
position: number,
|
|
properties: Record<string, number | string>,
|
|
): string {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const props = Object.entries(properties)
|
|
.map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`)
|
|
.join(", ");
|
|
const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
|
|
const ms = new MagicString(script);
|
|
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
|
|
const firstLocated = parsed.located[0];
|
|
if (tlDecl) {
|
|
ms.appendLeft(tlDecl.end, "\n" + code);
|
|
} else if (firstLocated) {
|
|
const firstCall = firstLocated.call;
|
|
const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors);
|
|
const insertAt = exprStmt?.start ?? firstCall.node.start;
|
|
ms.prependLeft(insertAt, code + "\n");
|
|
} else {
|
|
ms.append("\n" + code);
|
|
}
|
|
return ms.toString();
|
|
}
|
|
|
|
/**
|
|
* Compute, in forward (timeline) order, the inherited-props baseline available
|
|
* BEFORE each matching tween, plus the final cumulative state at the split point.
|
|
* A tween contributes to later baselines when it ends at/before the split (full
|
|
* props or last keyframe), spans the split via keyframes (kfs at/before split),
|
|
* or spans the split as a flat tween (its interpolated midpoint). Decoupled from
|
|
* the reverse write loop so the spanning-tween midpoint reads earlier tweens.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
function computeForwardBaselines(
|
|
matching: GsapAnimation[],
|
|
splitTime: number,
|
|
): { before: Array<Record<string, number | string>>; final: Record<string, number | string> } {
|
|
const before: Array<Record<string, number | string>> = [];
|
|
const acc: Record<string, number | string> = {};
|
|
for (const anim of matching) {
|
|
before.push({ ...acc });
|
|
const pos = typeof anim.position === "number" ? anim.position : 0;
|
|
const dur = anim.duration ?? 0;
|
|
const animEnd = pos + dur;
|
|
|
|
if (anim.keyframes) {
|
|
const kfs = anim.keyframes.keyframes;
|
|
if (pos >= splitTime) {
|
|
// Moves wholly to the new element — contributes nothing to the baseline.
|
|
} else if (animEnd > splitTime) {
|
|
for (const kf of kfs) {
|
|
const kfTime = pos + (kf.percentage / 100) * dur;
|
|
if (kfTime <= splitTime) {
|
|
for (const [k, v] of Object.entries(kf.properties)) acc[k] = v;
|
|
}
|
|
}
|
|
} else {
|
|
const lastKf = kfs[kfs.length - 1];
|
|
if (lastKf) {
|
|
for (const [k, v] of Object.entries(lastKf.properties)) acc[k] = v;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (animEnd <= splitTime) {
|
|
for (const [k, v] of Object.entries(anim.properties)) acc[k] = v;
|
|
continue;
|
|
}
|
|
|
|
if (pos >= splitTime) continue;
|
|
|
|
// Flat tween spanning the split — its midpoint becomes the inherited value.
|
|
const progress = dur > 0 ? (splitTime - pos) / dur : 0;
|
|
const fromSource = anim.fromProperties ?? acc;
|
|
for (const [k, v] of Object.entries(anim.properties)) {
|
|
if (typeof v !== "number") {
|
|
acc[k] = v;
|
|
continue;
|
|
}
|
|
const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0;
|
|
acc[k] = fromVal + (v - fromVal) * progress;
|
|
}
|
|
}
|
|
return { before, final: { ...acc } };
|
|
}
|
|
|
|
// Split one tween that straddles the split point: trim the original to the
|
|
// first half (interpolated midpoint as its new end) and add a fromTo for the
|
|
// second half on the new element. `fromSource` is the forward baseline.
|
|
function buildSpanningSplit(
|
|
result: string,
|
|
anim: GsapAnimation,
|
|
pos: number,
|
|
dur: number,
|
|
fromSource: Record<string, number | string>,
|
|
ctx: { splitTime: number; newSelector: string; newElementStart: number },
|
|
): string {
|
|
const progress = dur > 0 ? (ctx.splitTime - pos) / dur : 0;
|
|
const midProps: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(anim.properties)) {
|
|
if (typeof v !== "number") {
|
|
midProps[k] = v;
|
|
continue;
|
|
}
|
|
const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0;
|
|
midProps[k] = fromVal + (v - fromVal) * progress;
|
|
}
|
|
const trimmed = updateAnimationInScript(result, anim.id, {
|
|
duration: ctx.splitTime - pos,
|
|
properties: midProps,
|
|
});
|
|
return addAnimationToScript(trimmed, {
|
|
targetSelector: ctx.newSelector,
|
|
method: "fromTo",
|
|
position: ctx.newElementStart,
|
|
duration: pos + dur - ctx.splitTime,
|
|
properties: { ...anim.properties },
|
|
fromProperties: { ...midProps },
|
|
ease: anim.ease,
|
|
extras: anim.extras,
|
|
}).script;
|
|
}
|
|
|
|
type SplitCtx = {
|
|
splitTime: number;
|
|
originalSelector: string;
|
|
newSelector: string;
|
|
newElementStart: number;
|
|
};
|
|
|
|
// Decide what one matching tween does at the split point: move to the new
|
|
// element (wholly after), stay (wholly before / keyframes before), get skipped
|
|
// (keyframes spanning), or get interpolated in half (spanning). Returns the
|
|
// updated script; pushes any skip reason into `skippedSelectors`.
|
|
function applyTweenSplit(
|
|
result: string,
|
|
anim: GsapAnimation,
|
|
baselineBefore: Record<string, number | string>,
|
|
ctx: SplitCtx,
|
|
skippedSelectors: string[],
|
|
): string {
|
|
const pos = typeof anim.position === "number" ? anim.position : 0;
|
|
const dur = anim.duration ?? 0;
|
|
const animEnd = pos + dur;
|
|
|
|
if (anim.keyframes) {
|
|
if (pos >= ctx.splitTime)
|
|
return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector);
|
|
if (animEnd > ctx.splitTime) {
|
|
skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`);
|
|
}
|
|
// Inherited-state for kf tweens is handled by computeForwardBaselines.
|
|
return result;
|
|
}
|
|
// Wholly before the split — kept on the original element.
|
|
if (animEnd <= ctx.splitTime) return result;
|
|
// Wholly after — move to the new element.
|
|
if (pos >= ctx.splitTime)
|
|
return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector);
|
|
// Spans the split — interpolate the midpoint from the FORWARD baseline.
|
|
const fromSource = anim.fromProperties ?? baselineBefore;
|
|
return buildSpanningSplit(result, anim, pos, dur, fromSource, ctx);
|
|
}
|
|
|
|
export function splitAnimationsInScript(
|
|
script: string,
|
|
opts: SplitAnimationsOptions,
|
|
): SplitAnimationsResult {
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return { script, skippedSelectors: [] };
|
|
|
|
const originalSelector = `#${opts.originalId}`;
|
|
const newSelector = `#${opts.newId}`;
|
|
|
|
const animations = parsed.located.map((l) => l.animation);
|
|
const skippedSelectors: string[] = [];
|
|
|
|
for (const a of animations) {
|
|
if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
|
|
skippedSelectors.push(a.targetSelector);
|
|
}
|
|
}
|
|
|
|
const matching = animations.filter((a) => a.targetSelector === originalSelector);
|
|
if (matching.length === 0) return { script, skippedSelectors };
|
|
|
|
let result = script;
|
|
const newElementStart = opts.splitTime;
|
|
|
|
// Forward pre-pass: compute the inherited-props baseline available BEFORE each
|
|
// matching tween, in source/timeline order. The write loop below runs in
|
|
// REVERSE (so updateAnimationSelectorInScript's selector edits can't shift the
|
|
// count-based IDs of not-yet-processed tweens), but the spanning-tween midpoint
|
|
// interpolation needs the baseline from EARLIER tweens — which a reverse
|
|
// accumulator hasn't seen yet. Decoupling the two fixes the wrong midpoint.
|
|
const { before: baselineBefore, final: finalInheritedProps } = computeForwardBaselines(
|
|
matching,
|
|
opts.splitTime,
|
|
);
|
|
|
|
// Reverse iteration: updateAnimationSelectorInScript mutates selectors which
|
|
// can shift count-based ID suffixes for later animations.
|
|
const ctx = { splitTime: opts.splitTime, originalSelector, newSelector, newElementStart };
|
|
for (let i = matching.length - 1; i >= 0; i--) {
|
|
const anim = matching[i];
|
|
if (!anim) continue;
|
|
result = applyTweenSplit(result, anim, baselineBefore[i] ?? {}, ctx, skippedSelectors);
|
|
}
|
|
|
|
if (Object.keys(finalInheritedProps).length > 0) {
|
|
result = insertInheritedStateSetInScript(
|
|
result,
|
|
newSelector,
|
|
newElementStart,
|
|
finalInheritedProps,
|
|
);
|
|
}
|
|
|
|
return { script: result, skippedSelectors };
|
|
}
|
|
|
|
// ── Unroll dynamic animations ────────────────────────────────────────────────
|
|
|
|
function isLoopNode(node: Node): boolean {
|
|
const t = node?.type;
|
|
return (
|
|
t === "ForStatement" ||
|
|
t === "ForInStatement" ||
|
|
t === "ForOfStatement" ||
|
|
t === "WhileStatement"
|
|
);
|
|
}
|
|
|
|
function isForEachStatement(node: Node): boolean {
|
|
return (
|
|
node?.type === "ExpressionStatement" &&
|
|
node.expression?.type === "CallExpression" &&
|
|
node.expression.callee?.property?.name === "forEach"
|
|
);
|
|
}
|
|
|
|
/** The nearest enclosing loop / forEach AST node (not just its byte range). */
|
|
function findEnclosingLoopNode(ancestors: Node[]): Node | null {
|
|
for (let i = ancestors.length - 2; i >= 0; i--) {
|
|
const node = ancestors[i];
|
|
if (isLoopNode(node) || isForEachStatement(node)) return node;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Statements making up a loop's body block, or null when not a simple block. */
|
|
function loopBodyStatements(loopNode: Node): Node[] | null {
|
|
let body: Node;
|
|
if (loopNode?.type === "ExpressionStatement") {
|
|
// forEach(cb): body is the callback's block.
|
|
const cb = loopNode.expression?.arguments?.[0];
|
|
body = cb?.body;
|
|
} else {
|
|
body = loopNode?.body;
|
|
}
|
|
if (body?.type !== "BlockStatement") return null;
|
|
return (body.body ?? []).filter((s: Node) => s?.type === "ExpressionStatement");
|
|
}
|
|
|
|
/** The loop's index identifier name (`for (let i …)`), used for per-iteration substitution. */
|
|
function loopIndexVarName(loopNode: Node): string | null {
|
|
if (loopNode?.type === "ForStatement") {
|
|
const decl = loopNode.init?.declarations?.[0];
|
|
return typeof decl?.id?.name === "string" ? decl.id.name : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Rewrite one body statement's source for iteration `idx`: replace USES of the
|
|
* loop index variable (AST Identifier nodes) with the literal index. AST-based,
|
|
* not a text regex, so the index name appearing inside a string literal (e.g. a
|
|
* selector ".row-i") or as a non-computed member/key (`obj.i`, `{ i: … }`) is
|
|
* left untouched — only real references to the variable are substituted.
|
|
*/
|
|
// An identifier in "binding position" is a name, not a value reference: a
|
|
// non-computed member property (`obj.i`) or object-literal key (`{ i: … }`).
|
|
// Those must NOT be substituted with the iteration index.
|
|
function isIndexBindingPosition(node: Node, parent: Node): boolean {
|
|
if (parent?.type === "MemberExpression") return parent.property === node && !parent.computed;
|
|
if (parent?.type === "Property" || parent?.type === "ObjectProperty") {
|
|
return parent.key === node && !parent.computed;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function substituteLoopIndex(stmt: Node, indexVar: string, idx: number, script: string): string {
|
|
const base = stmt.start as number;
|
|
const src = script.slice(base, stmt.end as number);
|
|
const ranges: Array<[number, number]> = [];
|
|
acornWalk.ancestor(stmt, {
|
|
Identifier(node: Node, _state: unknown, ancestors: Node[]) {
|
|
if (node.name !== indexVar) return;
|
|
if (isIndexBindingPosition(node, ancestors[ancestors.length - 2])) return;
|
|
ranges.push([(node.start as number) - base, (node.end as number) - base]);
|
|
},
|
|
});
|
|
if (ranges.length === 0) return src;
|
|
ranges.sort((a, b) => b[0] - a[0]);
|
|
let out = src;
|
|
for (const [s, e] of ranges) out = out.slice(0, s) + String(idx) + out.slice(e);
|
|
return out;
|
|
}
|
|
|
|
function buildUnrollReplacement(
|
|
timelineVar: string,
|
|
animation: GsapAnimation,
|
|
elements: Array<{
|
|
selector: string;
|
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
easeEach?: string;
|
|
}>,
|
|
): string {
|
|
const duration = typeof animation.duration === "number" ? animation.duration : 8;
|
|
const ease = typeof animation.ease === "string" ? animation.ease : "none";
|
|
const pos = animation.position ?? 0;
|
|
const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos);
|
|
const calls = elements.map((el) => {
|
|
const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage);
|
|
const kfCode = buildKeyframeObjectCode(sorted, el.easeEach);
|
|
return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`;
|
|
});
|
|
return calls.join("\n ");
|
|
}
|
|
|
|
export type UnrollElement = {
|
|
selector: string;
|
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
easeEach?: string;
|
|
};
|
|
|
|
/** Build one element's unrolled `tl.to(...)` call from the target animation. */
|
|
function buildUnrollCallForElement(
|
|
timelineVar: string,
|
|
animation: GsapAnimation,
|
|
el: UnrollElement,
|
|
): string {
|
|
const duration = typeof animation.duration === "number" ? animation.duration : 8;
|
|
const ease = typeof animation.ease === "string" ? animation.ease : "none";
|
|
const pos = animation.position ?? 0;
|
|
const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos);
|
|
const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage);
|
|
const kfCode = buildKeyframeObjectCode(sorted, el.easeEach);
|
|
return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`;
|
|
}
|
|
|
|
/** Sentinel: the unroll cannot safely reproduce the loop body — caller no-ops. */
|
|
const REFUSE_UNROLL = Symbol("refuse-unroll");
|
|
|
|
/** Every statement in a loop's body block (unfiltered), or [] when not a block. */
|
|
function loopBodyRawStatements(loopNode: Node): Node[] {
|
|
const body =
|
|
loopNode?.type === "ExpressionStatement"
|
|
? loopNode.expression?.arguments?.[0]?.body
|
|
: loopNode?.body;
|
|
return body?.type === "BlockStatement" ? (body.body ?? []) : [];
|
|
}
|
|
|
|
/** A node that re-binds `indexVar`: a re-declaration or a function param. */
|
|
function rebindsIndex(node: Node, indexVar: string): boolean {
|
|
if (node.type === "VariableDeclarator") return node.id?.name === indexVar;
|
|
if (
|
|
node.type === "FunctionExpression" ||
|
|
node.type === "FunctionDeclaration" ||
|
|
node.type === "ArrowFunctionExpression"
|
|
) {
|
|
return (node.params ?? []).some((p: Node) => p?.name === indexVar);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Object shorthand `{ i }` — substituting the value would yield invalid `{ 0 }`. */
|
|
function isShorthandIndexUse(node: Node, indexVar: string): boolean {
|
|
return (
|
|
(node.type === "Property" || node.type === "ObjectProperty") &&
|
|
node.shorthand === true &&
|
|
propKeyName(node) === indexVar
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A sibling statement can't be safely index-substituted when it re-binds the
|
|
* loop index (shadowing — a nested `for (let i …)`, a callback param `i`) or
|
|
* uses it in object shorthand (`{ i }`, which would splice to the invalid
|
|
* `{ 0 }`). substituteLoopIndex has no scope analysis, so in these cases it
|
|
* would emit broken or wrong code — the unroll must refuse instead.
|
|
*/
|
|
function hasUnsafeLoopIndexUse(stmt: Node, indexVar: string): boolean {
|
|
let unsafe = false;
|
|
acornWalk.full(stmt, (node: Node) => {
|
|
if (!unsafe && (isShorthandIndexUse(node, indexVar) || rebindsIndex(node, indexVar))) {
|
|
unsafe = true;
|
|
}
|
|
});
|
|
return unsafe;
|
|
}
|
|
|
|
/** How to handle the loop body's non-target siblings when unrolling. */
|
|
function unrollSiblingStrategy(
|
|
loopNode: Node,
|
|
targetStmt: Node,
|
|
stmts: Node[],
|
|
indexVar: string | null,
|
|
): "blanket" | "refuse" | "preserve" {
|
|
const siblings = stmts.filter((s) => s !== targetStmt);
|
|
// A sibling the filtered statement list doesn't model (non-ExpressionStatement)
|
|
// would be silently lost by either path — refuse if any exists.
|
|
const hasUnmodeledSibling = loopBodyRawStatements(loopNode).some(
|
|
(s) => s !== targetStmt && !stmts.includes(s),
|
|
);
|
|
if (siblings.length === 0 && !hasUnmodeledSibling) return "blanket";
|
|
if (hasUnmodeledSibling || !indexVar) return "refuse";
|
|
return siblings.some((s) => hasUnsafeLoopIndexUse(s, indexVar)) ? "refuse" : "preserve";
|
|
}
|
|
|
|
/** Emit the per-iteration unrolled lines (target → static tl.to, siblings → index-substituted). */
|
|
function emitUnrolledLines(
|
|
stmts: Node[],
|
|
targetStmt: Node,
|
|
elements: UnrollElement[],
|
|
timelineVar: string,
|
|
animation: GsapAnimation,
|
|
indexVar: string,
|
|
script: string,
|
|
): string {
|
|
const lines: string[] = [];
|
|
for (let idx = 0; idx < elements.length; idx++) {
|
|
const el = elements[idx];
|
|
if (!el) continue;
|
|
for (const stmt of stmts) {
|
|
lines.push(
|
|
stmt === targetStmt
|
|
? buildUnrollCallForElement(timelineVar, animation, el)
|
|
: substituteLoopIndex(stmt, indexVar, idx, script),
|
|
);
|
|
}
|
|
}
|
|
return lines.join("\n ");
|
|
}
|
|
|
|
/**
|
|
* Unroll the loop body, preserving every statement that is NOT the target tween.
|
|
* For each iteration, emit each non-target statement with the loop index
|
|
* substituted (e.g. `tl.set(items[i], …)` → `tl.set(items[0], …)`), and replace
|
|
* the target tween statement with that element's static `tl.to()` call.
|
|
*
|
|
* Returns null when a blanket overwrite is lossless (no sibling statements), and
|
|
* REFUSE_UNROLL when siblings exist but can't be safely reproduced — a non-`for`
|
|
* loop (no numeric index to splice), a statement we don't model, or an unsafe
|
|
* index use (shadowing / shorthand). Refusing no-ops the unroll, which is safe:
|
|
* the dynamic loop keeps rendering correctly, just un-flattened.
|
|
*/
|
|
function buildLoopUnrollPreserving(
|
|
script: string,
|
|
timelineVar: string,
|
|
animation: GsapAnimation,
|
|
elements: UnrollElement[],
|
|
loopNode: Node,
|
|
targetStmt: Node,
|
|
): string | null | typeof REFUSE_UNROLL {
|
|
const stmts = loopBodyStatements(loopNode);
|
|
if (!stmts || !stmts.includes(targetStmt)) return null;
|
|
const indexVar = loopIndexVarName(loopNode);
|
|
const strategy = unrollSiblingStrategy(loopNode, targetStmt, stmts, indexVar);
|
|
if (strategy === "blanket") return null;
|
|
if (strategy === "refuse" || !indexVar) return REFUSE_UNROLL;
|
|
return emitUnrolledLines(stmts, targetStmt, elements, timelineVar, animation, indexVar, script);
|
|
}
|
|
|
|
/**
|
|
* Replace a dynamic loop that generates multiple tween calls with individual
|
|
* static `tl.to()` calls — one per element. Finds the loop containing the
|
|
* animation and replaces the loop with unrolled static calls, preserving every
|
|
* non-target statement in the loop body per iteration.
|
|
*/
|
|
export function unrollDynamicAnimations(
|
|
script: string,
|
|
animationId: string,
|
|
elements: UnrollElement[],
|
|
): string {
|
|
// An empty element list has no unrolled form — replacing the loop/statement
|
|
// with zero calls would silently delete the animation. No-op instead.
|
|
if (elements.length === 0) return script;
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
if (!parsed) return script;
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
const loopNode = findEnclosingLoopNode(target.call.ancestors);
|
|
if (loopNode) {
|
|
const targetStmt = findEnclosingExpressionStatement(target.call.ancestors);
|
|
const preserving = targetStmt
|
|
? buildLoopUnrollPreserving(
|
|
script,
|
|
parsed.timelineVar,
|
|
target.animation,
|
|
elements,
|
|
loopNode,
|
|
targetStmt,
|
|
)
|
|
: null;
|
|
// Siblings exist but can't be safely reproduced — leave the loop untouched
|
|
// rather than drop or corrupt them. The op no-ops (before === after).
|
|
if (preserving === REFUSE_UNROLL) return script;
|
|
// Fall back to the simple whole-body replacement when the body isn't a plain
|
|
// block of statements we can preserve.
|
|
const replacement =
|
|
preserving ?? buildUnrollReplacement(parsed.timelineVar, target.animation, elements);
|
|
ms.overwrite(loopNode.start as number, loopNode.end as number, replacement);
|
|
} else {
|
|
const stmt = findEnclosingExpressionStatement(target.call.ancestors);
|
|
if (!stmt) return script;
|
|
const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements);
|
|
ms.overwrite(stmt.start as number, stmt.end as number, replacement);
|
|
}
|
|
return ms.toString();
|
|
}
|