feat(sdk): stage 6 — arc path ops (setArcPath, updateArcSegment, removeArcPath) (#1500)

Port arc path trio from recast to browser-safe acorn+MagicString writer.
Add SDK op types and mutate.ts handlers for setArcPath / updateArcSegment /
removeArcPath. Decompose buildMotionPathObjectCode into small sub-functions
in gsapSerialize.ts to stay within fallow complexity thresholds. Tests verify
acorn output re-parses to correct arcPath shape.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:53:02 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent a746db6017
commit 1612d18fdf
7 changed files with 510 additions and 110 deletions
@@ -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<string, number | string>): { 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} }`;
}
@@ -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();
});
});
+165 -2
View File
@@ -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<string>): 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<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];
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. */