/** * Recast-free GSAP helpers: serialization, keyframe<->animation conversion, * validation, and shared types. * * This module MUST NOT import recast / @babel/parser. It is part of the * isomorphic core layer that the barrel and browser code depend on. AST * parsing of GSAP source lives in the Node-only `./gsapParser` module. */ import type { Keyframe, KeyframeProperties, ValidationResult } from "./types.js"; import type { PropertyGroupName } from "./gsapConstants"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; /** How a tween was constructed in source — drives display classification and editability. */ export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic"; /** * Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit * directly; `helper`/`loop` tweens are expanded from a reused construct (unroll * to edit); `runtime-dynamic` tweens come from live introspection (override to * edit). Absent provenance is treated as `literal`. */ export interface GsapProvenance { kind: GsapProvenanceKind; /** Helper function name (kind === "helper"). */ fn?: string; /** 1-based ordinal of the originating call site / loop construct in source order. */ callSite?: number; /** 0-based iteration index (kind === "loop"). */ iteration?: number; /** Source offset [start, end] of the originating call/loop, when known. */ sourceRange?: [number, number]; } /** How a tween's keyframes can be edited, derived from its provenance. */ export type KeyframeEditability = "direct" | "unroll" | "source"; /** * Map provenance to an editing strategy: * - `direct` — literal tween, maps 1:1 to source; edit in place. * - `unroll` — helper/loop expansion; unroll to literal tweens, then edit. * - `source` — runtime-dynamic value; not statically editable, edit the code. */ export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability { if (!provenance || provenance.kind === "literal") return "direct"; if (provenance.kind === "runtime-dynamic") return "source"; return "unroll"; } export interface GsapAnimation { id: string; targetSelector: string; method: GsapMethod; position: number | string; properties: Record; fromProperties?: Record; duration?: number; ease?: string; /** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */ extras?: Record; /** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */ keyframes?: GsapKeyframesData; /** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */ arcPath?: ArcPathConfig; /** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */ hasUnresolvedKeyframes?: boolean; /** True when the tween's target selector couldn't be statically resolved (dynamic). */ hasUnresolvedSelector?: boolean; /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ resolvedStart?: number; /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ implicitPosition?: boolean; /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). * Undefined for legacy mixed tweens that bundle multiple groups. */ propertyGroup?: PropertyGroupName; /** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the * timeline) rather than `tl.set(...)`. Carries no timeline position and shows no * keyframe marker — used to persist a static value (e.g. a 3D transform) without * introducing a 0% keyframe. */ global?: boolean; /** How this tween was constructed in source. Absent ⇒ literal. */ provenance?: GsapProvenance; } export interface GsapPercentageKeyframe { percentage: number; properties: Record; ease?: string; } export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array"; export interface GsapKeyframesData { format: GsapKeyframeFormat; keyframes: GsapPercentageKeyframe[]; ease?: string; easeEach?: string; } export interface ArcPathSegment { curviness: number; cp1?: { x: number; y: number }; cp2?: { x: number; y: number }; } export interface ArcPathConfig { enabled: boolean; autoRotate: boolean | number; segments: ArcPathSegment[]; } export interface MotionPathShape { arcPath: ArcPathConfig; waypoints: Array<{ x: number; y: number }>; } /** * Build arcPath segments + waypoints from resolved path coordinates. Shared by * the AST parser (coords from literal nodes) and the runtime scanner (coords * from a live `vars.motionPath`), so both produce identical arc config. */ export function buildArcPath( coords: Array<{ x: number; y: number }>, curviness: number, autoRotate: boolean | number, isCubic: boolean, ): MotionPathShape | undefined { const first = coords[0]; if (coords.length < 2 || !first) return undefined; const segments: ArcPathSegment[] = []; let waypoints: Array<{ x: number; y: number }>; if (isCubic && coords.length >= 4) { // coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]. waypoints = [first]; for (let i = 1; i + 2 < coords.length; i += 3) { const cp1 = coords[i]; const cp2 = coords[i + 1]; const anchor = coords[i + 2]; if (!cp1 || !cp2 || !anchor) continue; waypoints.push(anchor); segments.push({ curviness, cp1, cp2 }); } } else { waypoints = coords; for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness }); } return { arcPath: { enabled: true, autoRotate, segments }, waypoints }; } export interface ParsedGsap { animations: GsapAnimation[]; timelineVar: string; preamble: string; postamble: string; multipleTimelines?: boolean; unsupportedTimelinePattern?: boolean; } export { SUPPORTED_PROPS, SUPPORTED_EASES } from "./gsapConstants"; // ── Split-animation types (used by gsapWriterAcorn) ───────────────────────── export interface SplitAnimationsOptions { originalId: string; newId: string; splitTime: number; elementStart: number; elementDuration: number; } export interface SplitAnimationsResult { script: string; /** Non-ID-selector animations that the engine cannot safely retarget. */ skippedSelectors: string[]; } // ── Serialization ─────────────────────────────────────────────────────────── export function serializeGsapAnimations( animations: GsapAnimation[], timelineVar = "tl", options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, ): string { const sorted = [...animations].sort((a, b) => { const aNum = a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER); const bNum = b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER); return aNum - bNum; }); // fallow-ignore-next-line complexity const lines = sorted.map((anim) => { const selector = `"${anim.targetSelector}"`; const props: Record = { ...anim.properties }; if (anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; let propsStr = serializeObject(props); if (anim.extras && Object.keys(anim.extras).length > 0) { const extrasStr = serializeExtras(anim.extras); if (Object.keys(props).length === 0) { propsStr = `{ ${extrasStr} }`; } else { // Insert extras before the closing brace propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`; } } const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; switch (anim.method) { case "set": // A global set is a base `gsap.set` — off the timeline, no position arg. return anim.global ? ` gsap.set(${selector}, ${propsStr});` : ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; case "to": return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; case "from": return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`; case "fromTo": { const fromStr = serializeObject(anim.fromProperties || {}); return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`; } } }); let mediaSync = ""; if (options?.includeMediaSync) { mediaSync = ` ${timelineVar}.eventCallback("onUpdate", function() { const time = ${timelineVar}.time(); document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) { const start = parseFloat(media.dataset.start); const end = parseFloat(media.dataset.end) || Infinity; const mediaTime = time - start; if (time >= start && time < end) { if (Math.abs(media.currentTime - mediaTime) > 0.1) { media.currentTime = mediaTime; } if (media.paused && !${timelineVar}.paused()) { media.play().catch(function() {}); } } else if (!media.paused) { media.pause(); } }); });`; } const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`; const postamble = options?.postamble ? `\n ${options.postamble}` : ""; return ` ${preamble} ${lines.join("\n")}${mediaSync}${postamble} `; } export function serializeValue(value: unknown): string { if (typeof value === "string" && value.startsWith("__raw:")) { return value.slice(6); } if (typeof value === "string") return JSON.stringify(value); return String(value); } export function safeJsKey(key: string): string { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); } function serializeObject(obj: Record): string { const entries = Object.entries(obj).map(([key, value]) => { return `${safeJsKey(key)}: ${serializeValue(value)}`; }); return `{ ${entries.join(", ")} }`; } function serializeExtras(extras: Record): string { return Object.entries(extras) .map(([key, value]) => { return `${safeJsKey(key)}: ${serializeValue(value)}`; }) .join(", "); } // ── Element filtering ───────────────────────────────────────────────────────── /** * Filter animations to those targeting `#` (id-only match). For the * studio panel's id-OR-selector matching, see `getAnimationsForElement` in * `useGsapTweenCache.ts` — distinct on purpose, hence the distinct name. */ export function getAnimationsForElementId( animations: GsapAnimation[], elementId: string, ): GsapAnimation[] { const selector = `#${elementId}`; return animations.filter((a) => a.targetSelector === selector); } // ── Validation (regex-based, no AST needed) ───────────────────────────────── const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [ { pattern: /\.call\s*\(/, message: "call() method not allowed" }, { pattern: /\.add\s*\(/, message: "add() method not allowed" }, { pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" }, { pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" }, { pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" }, { pattern: /onComplete\s*:/, message: "onComplete callback not allowed" }, { pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" }, { pattern: /onStart\s*:/, message: "onStart callback not allowed" }, { pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" }, { pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" }, { pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" }, { pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" }, { pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" }, { pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" }, { pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" }, { pattern: /setInterval\s*\(/, message: "setInterval not allowed" }, { pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" }, ]; export function validateCompositionGsap(script: string): ValidationResult { const errors: string[] = []; const warnings: string[] = []; for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) { if (pattern.test(script)) errors.push(message); } if (/yoyo\s*:\s*true/.test(script)) { warnings.push("yoyo animations may behave unexpectedly when scrubbing"); } if (/stagger\s*:/.test(script)) { warnings.push("stagger animations may not serialize correctly"); } return { valid: errors.length === 0, errors, warnings }; } // ── Keyframe Conversion Helpers ───────────────────────────────────────────── export function keyframesToGsapAnimations( elementId: string, keyframes: Keyframe[], elementStartTime: number, base?: { x?: number; y?: number; scale?: number }, ): GsapAnimation[] { const sorted = [...keyframes].sort((a, b) => a.time - b.time); const animations: GsapAnimation[] = []; const baseX = base?.x ?? 0; const baseY = base?.y ?? 0; const baseScale = base?.scale ?? 1; // fallow-ignore-next-line complexity sorted.forEach((kf, i) => { const absoluteTime = elementStartTime + kf.time; const isFirst = i === 0; const prevKf = i > 0 ? sorted[i - 1] : null; const duration = prevKf ? kf.time - prevKf.time : undefined; const position = prevKf ? elementStartTime + prevKf.time : absoluteTime; const properties: Record = {}; for (const [key, value] of Object.entries(kf.properties)) { if (typeof value !== "number") continue; if (key === "x") properties.x = baseX + value; else if (key === "y") properties.y = baseY + value; else if (key === "scale") properties.scale = baseScale * value; else properties[key] = value; } animations.push({ id: `${elementId}-kf-${kf.id}`, targetSelector: `#${elementId}`, method: isFirst ? "set" : "to", position, properties, duration: isFirst ? undefined : duration, ease: kf.ease, }); }); return animations; } export function gsapAnimationsToKeyframes( animations: GsapAnimation[], elementStartTime: number, options?: { baseX?: number; baseY?: number; baseScale?: number; clampTimeToZero?: boolean; skipBaseSet?: boolean; }, ): Keyframe[] { const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"]; const baseX = options?.baseX ?? 0; const baseY = options?.baseY ?? 0; const baseScale = options?.baseScale ?? 1; const clampTimeToZero = options?.clampTimeToZero ?? true; const skipBaseSet = options?.skipBaseSet ?? false; const baseTimeEpsilon = 0.001; const baseValueEpsilon = 0.00001; return ( animations .filter( (a): a is GsapAnimation & { position: number } => validMethods.includes(a.method) && typeof a.position === "number", ) // fallow-ignore-next-line complexity .map((a) => { const relativeTimeRaw = a.position - elementStartTime; const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw; const properties: Partial = {}; for (const [key, value] of Object.entries(a.properties)) { if (typeof value !== "number") continue; if (key === "x") properties.x = value - baseX; else if (key === "y") properties.y = value - baseY; else if (key === "scale") { properties.scale = baseScale !== 0 ? value / baseScale : value; } else { (properties as Record)[key] = value; } } if ( skipBaseSet && a.method === "set" && time < baseTimeEpsilon && Object.values(properties).every( (v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon, ) ) { return null; } return { id: a.id.replace(/^.*-kf-/, ""), time, properties: properties as KeyframeProperties, ease: a.ease, }; }) .filter((kf): kf is NonNullable => kf !== null) ); } // ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ──── /** * CSS identity values for properties whose "rest" state isn't 0 — used to * synthesize the missing endpoint when converting a flat tween to keyframes. */ const CSS_IDENTITY: Record = { opacity: 1, autoAlpha: 1, scale: 1, scaleX: 1, scaleY: 1, }; function cssIdentityValue(prop: string): number { return CSS_IDENTITY[prop] ?? 0; } /** Build the identity-endpoint map for a flat tween's properties. */ function buildIdentityMap(props: Record): Record { const identity: Record = {}; for (const [key, val] of Object.entries(props)) { if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val; } return identity; } /** * Resolve the 0% (from) and 100% (to) property maps for a tween being * converted to percentage keyframes. * * @param resolvedFromValues — Despite the "from" in the name (historical), these * are runtime-captured DOM values that override the conversion endpoint: * - For to(): overrides fromProps (the 0% state / where the element is now). * - For from(): overrides toProps (the 100% state / where the element rests). * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). */ export function resolveConversionProps( anim: GsapAnimation, resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { if (anim.method === "set") { // A static hold becomes a keyframed `to` whose 0% and 100% both start at the // set's value — the visual is unchanged until the user edits a keyframe to // animate it. (The caller flips the call from `set` to `to` + adds a duration.) return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } }; } if (anim.method === "to") { const identity = buildIdentityMap(anim.properties); const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; return { fromProps, toProps: { ...anim.properties } }; } if (anim.method === "from") { const identity = buildIdentityMap(anim.properties); const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; return { fromProps: { ...anim.properties }, toProps }; } // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), // anim.properties = toVars (100% state). resolvedFromValues contains the // current DOM position from a drag — it represents the NEW destination, so // it merges into toProps (the 100% endpoint the user is editing), NOT into // fromProps. This is intentional and not inverted. const toProps = resolvedFromValues ? { ...anim.properties, ...resolvedFromValues } : { ...anim.properties }; return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; } // ── Arc path serialization helpers (shared by recast + acorn writers) ───────── function numericXY(props: Record): { x: number; y: number } | null { const vx = props.x; const vy = props.y; return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null; } export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { const keyframeWps = (anim.keyframes?.keyframes ?? []) .map((kf) => numericXY(kf.properties)) .filter((pt): pt is { x: number; y: number } => pt !== null); if (keyframeWps.length >= 2) return keyframeWps; const propX = anim.properties.x; const propY = anim.properties.y; if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps; const destX = typeof propX === "number" ? propX : 0; const destY = typeof propY === "number" ? propY : 0; return [ { x: 0, y: 0 }, { x: destX, y: destY }, ]; } function autoRotateSuffix(autoRotate: boolean | number): string { if (autoRotate === true) return ", autoRotate: true"; if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`; return ""; } function cubicControlPoints( seg: ArcPathSegment, wp: { x: number; y: number }, nextWp: { x: number; y: number }, ): string[] { if (seg.cp1 && seg.cp2) { return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`]; } const dx = nextWp.x - wp.x; const dy = nextWp.y - wp.y; const c = seg.curviness ?? 1; return [ `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, ]; } function buildCubicPathEntries( waypoints: Array<{ x: number; y: number }>, segments: ArcPathSegment[], ): string[] { const first = waypoints[0]; if (!first) return []; const entries = [`{x: ${first.x}, y: ${first.y}}`]; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; const wp = waypoints[i]; const nextWp = waypoints[i + 1]; if (!seg || !wp || !nextWp) continue; entries.push(...cubicControlPoints(seg, wp, nextWp)); entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); } return entries; } export function buildMotionPathObjectCode(config: { waypoints: Array<{ x: number; y: number }>; segments: ArcPathSegment[]; autoRotate: boolean | number; }): string { const { waypoints, segments, autoRotate } = config; const arSuffix = autoRotateSuffix(autoRotate); // GSAP's simple `path` array supports only ONE scalar `curviness` for the whole // path, so per-segment curviness can only be expressed in the cubic form (each // segment's curviness baked into its control points). Emit cubic when segments // carry explicit control points OR when their curviness values differ — the // simple branch would otherwise serialize only segments[0].curviness and drop // every other segment's curve. const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2); const curvinessVaries = segments.some( (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), ); if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) { const pathStr = buildCubicPathEntries(waypoints, segments).join(", "); return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`; } const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); const curviness = segments[0]?.curviness ?? 1; const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : ""; return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`; }