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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/parsers/src/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2924,6 +2924,7 @@ describe("base gsap.set (off-timeline global hold)", () => {
});
expect(script).toContain('gsap.set("#box"');
expect(script).not.toContain('tl.set("#box"');
expect(script.indexOf('gsap.set("#box"')).toBeLessThan(script.indexOf("gsap.timeline"));
});

it("updates a global set in place, keeping it gsap.set", () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1543,7 +1543,13 @@ export function addAnimationToScript(
const id = `anim-${Date.now()}`;
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
const newStatement = parseScript(statementCode).program.body[0];
insertAfterAnchor(parsed, newStatement);
if (animation.method === "set" && animation.global) {
const timeline = findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (timeline) timeline.insertBefore(newStatement);
else insertAfterAnchor(parsed, newStatement);
} else {
insertAfterAnchor(parsed, newStatement);
}
return { script: recast.print(parsed.ast).code, id };
}

Expand Down
81 changes: 81 additions & 0 deletions packages/parsers/src/gsapWriterAcorn.motionPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
addMotionPathPointInScript as addAcorn,
addMotionPathToScript as addPathAcorn,
removeMotionPathPointInScript as removeAcorn,
syncPositionHoldsBeforeKeyframes as syncAcorn,
updateMotionPathPointInScript as updateAcorn,
} from "./gsapWriterAcorn.js";
import {
addMotionPathPointInScript as addRecast,
addMotionPathToScript as addPathRecast,
parseGsapScript,
removeMotionPathPointInScript as removeRecast,
syncPositionHoldsBeforeKeyframes as syncRecast,
updateMotionPathPointInScript as updateRecast,
} from "./gsapParser.js";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";

const SCRIPT = `// keep-this-comment
const untouched = { spacing: "verbatim" };
const tl = gsap.timeline({ paused: true });
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: -50 }, { x: 200, y: 0 }], curviness: 1.2 }, duration: 1 }, 2);
window.__timelines = { main: tl };`;

function idOf(script: string): string {
const id = parseGsapScript(script).animations.find((animation) => animation.arcPath)?.id;
if (!id) throw new Error("motion path fixture did not parse");
return id;
}

function motionModel(script: string) {
return parseGsapScriptAcorn(script).animations.map((animation) => ({
targetSelector: animation.targetSelector,
method: animation.method,
position: animation.position,
duration: animation.duration,
ease: animation.ease,
keyframes: animation.keyframes,
arcPath: animation.arcPath,
}));
}

function expectParity(acorn: string, recast: string): void {
expect(motionModel(acorn)).toEqual(motionModel(recast));
expect(acorn).toContain("// keep-this-comment");
expect(acorn).toContain('const untouched = { spacing: "verbatim" };');
}

describe("Acorn motion-path writer parity", () => {
it("moves an anchor while preserving path semantics and untouched source", () => {
const id = idOf(SCRIPT);
expectParity(
updateAcorn(SCRIPT, id, 1, { x: 120, y: -80 }),
updateRecast(SCRIPT, id, 1, { x: 120, y: -80 }),
);
});

it("adds and removes anchors with the same segment behavior as Recast", () => {
const id = idOf(SCRIPT);
expectParity(
addAcorn(SCRIPT, id, 1, { x: 50, y: -20 }),
addRecast(SCRIPT, id, 1, { x: 50, y: -20 }),
);
expectParity(removeAcorn(SCRIPT, id, 1), removeRecast(SCRIPT, id, 1));
});

it("authors a new path with the same parsed model", () => {
const base = "const tl = gsap.timeline({ paused: true });\nwindow.__timelines = { main: tl };";
const acorn = addPathAcorn(base, "#hero", 1.5, 2, { x: 300, y: -100 }).script;
const recast = addPathRecast(base, "#hero", 1.5, 2, { x: 300, y: -100 }).script;
expect(motionModel(acorn)).toEqual(motionModel(recast));
});

it("synchronizes delayed position holds without Recast and remains idempotent", () => {
const acorn = syncAcorn(SCRIPT);
const recast = syncRecast(SCRIPT);
expect(motionModel(acorn)).toEqual(motionModel(recast));
expect(acorn).toContain('data: "hf-hold"');
expect(syncAcorn(acorn)).toBe(acorn);
});
});
162 changes: 162 additions & 0 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2074,6 +2074,105 @@ export function updateArcSegmentInScript(
return ms.toString();
}

function hasCubicSegments(segments: ArcPathSegment[]): boolean {
return segments.some((segment) => segment.cp1 != null || segment.cp2 != null);
}

function writeMotionPathValue(
script: string,
target: ParsedGsapAcornForWrite["located"][number],
waypoints: Array<{ x: number; y: number }>,
segments: ArcPathSegment[],
autoRotate: boolean | number,
): string {
const motionPath = findPropertyNode(target.call.varsArg, "motionPath");
if (!motionPath) return script;
const code = buildMotionPathObjectCode({ waypoints, segments, autoRotate });
const ms = new MagicString(script);
ms.overwrite(motionPath.value.start, motionPath.value.end, code);
return ms.toString();
}

export function updateMotionPathPointInScript(
script: string,
animationId: string,
pointIndex: number,
point: { x: number; y: number },
): string {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((entry) => entry.id === animationId);
if (!target?.animation.arcPath?.enabled) return script;
const waypoints = extractArcWaypoints(target.animation);
if (waypoints.length < 2 || pointIndex < 0 || pointIndex >= waypoints.length) return script;
const next = waypoints.map((waypoint, index) => (index === pointIndex ? { ...point } : waypoint));
return writeMotionPathValue(
script,
target,
next,
target.animation.arcPath.segments,
target.animation.arcPath.autoRotate,
);
}

export function addMotionPathPointInScript(
script: string,
animationId: string,
index: number,
point: { x: number; y: number },
): string {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((entry) => entry.id === animationId);
const arc = target?.animation.arcPath;
if (!target || !arc?.enabled || hasCubicSegments(arc.segments)) return script;
const waypoints = extractArcWaypoints(target.animation);
if (index < 1 || index > waypoints.length - 1) return script;
const segments = [...arc.segments];
waypoints.splice(index, 0, { ...point });
segments.splice(index - 1, 0, { curviness: segments[index - 1]?.curviness ?? 1 });
return writeMotionPathValue(script, target, waypoints, segments, arc.autoRotate);
}

export function removeMotionPathPointInScript(
script: string,
animationId: string,
index: number,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((entry) => entry.id === animationId);
const arc = target?.animation.arcPath;
if (!target || !arc?.enabled || hasCubicSegments(arc.segments)) return script;
const waypoints = extractArcWaypoints(target.animation);
if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script;
const segments = [...arc.segments];
waypoints.splice(index, 1);
segments.splice(Math.min(index, segments.length - 1), 1);
return writeMotionPathValue(script, target, waypoints, segments, arc.autoRotate);
}

export function addMotionPathToScript(
script: string,
targetSelector: string,
position: number,
duration: number,
point: { x: number; y: number },
ease = "power1.inOut",
): { script: string; id: string | null } {
const motionPath = buildMotionPathObjectCode({
waypoints: [{ x: 0, y: 0 }, { ...point }],
segments: [{ curviness: 1 }],
autoRotate: false,
});
const result = addAnimationToScript(script, {
targetSelector,
method: "to",
position,
duration,
ease,
properties: { motionPath: `__raw:${motionPath}` },
});
return { script: result.script, id: result.id || null };
}

export function removeArcPathFromScript(script: string, animationId: string): string {
return setArcPathInScript(script, animationId, {
enabled: false,
Expand Down Expand Up @@ -2133,6 +2232,69 @@ function insertInheritedStateSetInScript(
return ms.toString();
}

const STUDIO_HOLD_MARKER = "hf-hold";

function isStudioHoldSet(animation: GsapAnimation): boolean {
return animation.method === "set" && animation.properties.data === STUDIO_HOLD_MARKER;
}

function removeStudioHoldSets(script: string, parsed: ParsedGsapAcornForWrite): string {
const staleHolds = parsed.located.filter((entry) => isStudioHoldSet(entry.animation));
if (staleHolds.length === 0) return script;
const ms = new MagicString(script);
for (const hold of staleHolds) removeCallFromMagicString(ms, hold.call, script);
return ms.toString();
}

function animationStart(animation: GsapAnimation): number {
if (animation.resolvedStart !== undefined) return animation.resolvedStart;
return typeof animation.position === "number" ? animation.position : 0;
}

function positionProperties(
properties: Record<string, number | string>,
): Record<string, number | string> {
const position: Record<string, number | string> = {};
for (const [property, value] of Object.entries(properties)) {
if (classifyPropertyGroup(property) === "position" && typeof value === "number") {
position[property] = value;
}
}
return position;
}

function positionHoldForAnimation(
animation: GsapAnimation,
): Record<string, number | string> | null {
if (!animation.keyframes) return null;
if (!(animationStart(animation) > 0.001)) return null;
const first = [...animation.keyframes.keyframes].sort(
(left, right) => left.percentage - right.percentage,
)[0];
if (!first) return null;
const position = positionProperties(first.properties);
return Object.keys(position).length > 0 ? position : null;
}

/** Acorn-native, byte-preserving hold synchronization used after mutations. */
export function syncPositionHoldsBeforeKeyframes(script: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
let result = removeStudioHoldSets(script, parsed);
const current = parseGsapScriptAcornForWrite(result);
if (!current) return result;
for (const entry of current.located) {
const animation = entry.animation;
const position = positionHoldForAnimation(animation);
if (!position) continue;
result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, {
...position,
data: STUDIO_HOLD_MARKER,
});
}
return result;
}

/**
* Compute, in forward (timeline) order, the inherited-props baseline available
* BEFORE each matching tween, plus the final cumulative state at the split point.
Expand Down
2 changes: 2 additions & 0 deletions packages/studio-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:acorn-writer": "bun scripts/run-acorn-writer-tests.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"report:gsap-writers": "bun src/routes/gsapMutationCapabilities.report.ts",
"prepublishOnly": "echo skip"
},
"dependencies": {
Expand Down
19 changes: 19 additions & 0 deletions packages/studio-server/scripts/run-acorn-writer-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { fileURLToPath } from "node:url";

const child = Bun.spawn(
[
"bunx",
"vitest",
"run",
"src/routes/files.test.ts",
"src/routes/gsapMutationCapabilities.test.ts",
],
{
cwd: fileURLToPath(new URL("..", import.meta.url)),
env: { ...process.env, HYPERFRAMES_GSAP_WRITER: "acorn" },
stdout: "inherit",
stderr: "inherit",
},
);

process.exitCode = await child.exited;
30 changes: 30 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Pro
}

describe("registerFileRoutes", () => {
it("returns a clean 400 for an invalid GSAP writer flag", async () => {
const previous = process.env.HYPERFRAMES_GSAP_WRITER;
process.env.HYPERFRAMES_GSAP_WRITER = "true";
try {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<div id="box"></div><script>const tl = gsap.timeline(); tl.to("#box", { x: 10 });</script>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request(
"http://localhost/projects/demo/gsap-mutations/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "shift-positions", targetSelector: "#box", delta: 1 }),
},
);
const payload = (await response.json()) as { error?: string };

expect(response.status).toBe(400);
expect(payload.error).toContain("expected recast or acorn");
} finally {
if (previous === undefined) delete process.env.HYPERFRAMES_GSAP_WRITER;
else process.env.HYPERFRAMES_GSAP_WRITER = previous;
}
});

it("returns empty content for missing files when caller marks the read optional", async () => {
const projectDir = createProjectDir();
const app = new Hono();
Expand Down
Loading
Loading