diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 291c515f6..28301a653 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -475,3 +475,80 @@ export function resolveConversionProps( : { ...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 entries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`]; + for (let i = 0; i < segments.length; i++) { + const nextWp = waypoints[i + 1]!; + entries.push(...cubicControlPoints(segments[i]!, waypoints[i]!, 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); + if (segments.some((s) => s.cp1 && s.cp2) && 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} }`; +} diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 579a00f17..e8f8bed99 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -24,13 +24,20 @@ import { materializeKeyframesFromScript as materializeAcorn, splitIntoPropertyGroupsFromScript as splitGroupsAcorn, splitAnimationsInScript as splitAnimsAcorn, + setArcPathInScript as setArcAcorn, + updateArcSegmentInScript as updateArcSegmentAcorn, + removeArcPathFromScript as removeArcAcorn, } from "./gsapWriterAcorn.js"; - function acornId(script: string): string { const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; return parsed.located[0]!.id; } +function arcShapeOf(script: string) { + const anim = parseGsapScript(script).animations[0]!; + return { arcPath: anim.arcPath, properties: anim.properties }; +} + /** Reparse a written script and return the first animation's editable shape. */ function shapeOf(script: string) { const anim = parseGsapScript(script).animations[0]!; @@ -390,3 +397,66 @@ describe("parity: splitAnimationsInScript (recast vs acorn)", () => { expect(splitAnimsAcorn(script, opts).script).toBe(script); }); }); + +// ─── arc path parity ────────────────────────────────────────────────────────── + +const ARC_FLAT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, y: 50, duration: 2 }, 0); +`; +const ARC_CFG = { + enabled: true as const, + autoRotate: false as const, + segments: [{ curviness: 1 }], +}; +const DISABLE_CFG = { + enabled: false as const, + autoRotate: false as const, + segments: [] as never[], +}; + +function arcFixture() { + const id = acornId(ARC_FLAT_SCRIPT); + const enabled = setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG); + return { id, enabled }; +} + +describe("setArcPathInScript: acorn output correctness", () => { + it("enable: arcPath.enabled=true, segments preserved", () => { + const id = acornId(ARC_FLAT_SCRIPT); + const shape = arcShapeOf(setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG)); + expect(shape.arcPath?.enabled).toBe(true); + expect(shape.arcPath?.segments).toHaveLength(1); + }); + + it("disable: arcPath=undefined, x/y restored", () => { + const { id, enabled } = arcFixture(); + const shape = arcShapeOf(setArcAcorn(enabled, id, DISABLE_CFG)); + expect(shape.arcPath).toBeUndefined(); + expect(typeof shape.properties.x).toBe("number"); + }); + + it("no-op when animation not found", () => { + expect(setArcAcorn(ARC_FLAT_SCRIPT, "nope", ARC_CFG)).toBe(ARC_FLAT_SCRIPT); + }); +}); + +describe("updateArcSegmentInScript: acorn output correctness", () => { + it("curviness update reflected in parsed shape", () => { + const { id, enabled } = arcFixture(); + const shape = arcShapeOf(updateArcSegmentAcorn(enabled, id, 0, { curviness: 2 })); + expect(shape.arcPath?.segments[0]?.curviness).toBe(2); + }); + + it("no-op when index out of range", () => { + const { id, enabled } = arcFixture(); + expect(updateArcSegmentAcorn(enabled, id, 99, { curviness: 2 })).toBe(enabled); + }); +}); + +describe("removeArcPathFromScript: acorn output correctness", () => { + it("arcPath=undefined after removal", () => { + const { id, enabled } = arcFixture(); + expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined(); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 7aa82fa59..7ec9bd975 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -7,8 +7,17 @@ * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. */ import MagicString from "magic-string"; -import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js"; -import { resolveConversionProps } from "./gsapSerialize.js"; +import type { + GsapAnimation, + GsapPercentageKeyframe, + ArcPathConfig, + ArcPathSegment, +} from "./gsapSerialize.js"; +import { + resolveConversionProps, + extractArcWaypoints, + buildMotionPathObjectCode, +} from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite, @@ -1304,6 +1313,160 @@ export function removeLabelFromScript(script: string, name: string): string { 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: any, keys: Set): void { + if (objNode?.type !== "ObjectExpression") return; + const allProps = (objNode.properties ?? []).filter(isObjectProperty); + const marked = allProps.map((p: any) => 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: any[], 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: any): { 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: any[] = pathProp.value.elements ?? []; + const last = elems[elems.length - 1]; + if (last?.type !== "ObjectExpression") return { x: null, y: null }; + const xRaw = findPropertyNode(last, "x")?.value?.value; + const yRaw = findPropertyNode(last, "y")?.value?.value; + return { x: typeof xRaw === "number" ? xRaw : null, y: typeof yRaw === "number" ? yRaw : 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: any): 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, + }); + upsertProp(ms, call.varsArg, "motionPath", `__raw:${motionPathCode}`); + 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, +): 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]; + if (segmentIndex < 0 || segmentIndex >= segments.length) return script; + + segments[segmentIndex] = { ...segments[segmentIndex]!, ...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. */ diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index ac6ec501b..8fc18d73a 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -32,17 +32,6 @@ function fresh(script = GSAP_SCRIPT) { return parseMutable(makeHtml(script)); } -// A sub-composition host: data-hf-id="hf-host" (its own leaf id) AND -// data-composition-id="sub-1" (the id studio passes when targeting the root). -function freshSubComp(script = GSAP_SCRIPT) { - return parseMutable( - `
-
- -
`.trim(), - ); -} - function getScript(parsed: ReturnType): string { const doc = serializeDocument(parsed); const m = /