Skip to content
Merged
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
112 changes: 112 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
splitIntoPropertyGroups,
shiftPositionsInScript,
scalePositionsInScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
Expand Down Expand Up @@ -2275,3 +2277,113 @@ describe("splitIntoPropertyGroups", () => {
}
});
});

describe("shiftPositionsInScript", () => {
it("shifts all numeric positions for the target selector", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
const result = shiftPositionsInScript(script, "#hero", 3);
const parsed = parseGsapScript(result);
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
expect(hero[0].position).toBe(3);
expect(hero[1].position).toBe(5.5);
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
expect(bg!.position).toBe(1);
});

it("clamps negative-going positions to zero", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 0.3);
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
const result = shiftPositionsInScript(script, "#el", -1.0);
const parsed = parseGsapScript(result);
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
expect(anims[0].position).toBe(0);
expect(anims[1].position).toBe(0.5);
});

it("returns the original script when delta is zero", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);`;
expect(shiftPositionsInScript(script, "#el", 0)).toBe(script);
});

it("does not collide when two tweens have adjacent positions", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
const result = shiftPositionsInScript(script, "#burst", 0.5);
const parsed = parseGsapScript(result);
const burst = parsed.animations.filter((a) => a.targetSelector === "#burst");
expect(burst[0].position).toBe(1.5);
expect(burst[1].position).toBe(2);
});

it("skips string positions", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
const result = shiftPositionsInScript(script, "#el", 1);
const parsed = parseGsapScript(result);
expect(parsed.animations[0].position).toBe(3);
expect(parsed.animations[1].position).toBe("+=0.5");
});
});

describe("scalePositionsInScript", () => {
it("scales positions and durations proportionally for the target selector", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
const result = scalePositionsInScript(script, "#hero", 0, 3, 0, 2);
const parsed = parseGsapScript(result);
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
expect(hero[0].position).toBe(0);
expect(hero[0].duration).toBeCloseTo(0.667, 2);
expect(hero[1].position).toBeCloseTo(1.667, 2);
expect(hero[1].duration).toBeCloseTo(0.333, 2);
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
expect(bg!.position).toBe(1);
expect(bg!.duration).toBe(1);
});

it("handles start-edge resize (new start + shorter duration)", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.from("#el", { opacity: 0, duration: 1 }, 0);
tl.to("#el", { y: 50, duration: 0.5 }, 2.5);`;
const result = scalePositionsInScript(script, "#el", 0, 3, 1, 2);
const parsed = parseGsapScript(result);
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
expect(anims[0].position).toBe(1);
expect(anims[0].duration).toBeCloseTo(0.667, 2);
expect(anims[1].position).toBeCloseTo(2.667, 2);
expect(anims[1].duration).toBeCloseTo(0.333, 2);
});

it("clamps negative-going positions to zero", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);`;
const result = scalePositionsInScript(script, "#el", 2, 1, 0, 0.5);
const parsed = parseGsapScript(result);
expect(parsed.animations[0].position).toBe(0);
});

it("returns the original script when old and new timing are identical", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);`;
expect(scalePositionsInScript(script, "#el", 0, 3, 0, 3)).toBe(script);
});

it("skips string positions", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
const result = scalePositionsInScript(script, "#el", 0, 3, 0, 2);
const parsed = parseGsapScript(result);
expect(parsed.animations[0].position).toBeCloseTo(1.333, 2);
expect(parsed.animations[1].position).toBe("+=0.5");
});
});
61 changes: 61 additions & 0 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,67 @@ export function updateAnimationInScript(
return recast.print(parsed.ast).code;
}

export function shiftPositionsInScript(
script: string,
targetSelector: string,
delta: number,
): string {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch (e) {
console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e);
return 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);
applyUpdatesToCall(entry.call, { position: newPos });
changed = true;
}
return changed ? recast.print(parsed.ast).code : script;
}

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;
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch (e) {
console.warn("[gsap-parser] scalePositionsInScript parse failed:", e);
return 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,
);
const updates: Partial<GsapAnimation> = { position: newPos };
if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
updates.duration = Math.max(
0.001,
Math.round(entry.animation.duration * ratio * 1000) / 1000,
);
}
applyUpdatesToCall(entry.call, updates);
changed = true;
}
return changed ? recast.print(parsed.ast).code : script;
}

function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
let parsed: ParsedGsapAst;
try {
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,19 @@ type GsapMutationRequest =
| {
type: "delete-all-for-selector";
targetSelector: string;
}
| {
type: "shift-positions";
targetSelector: string;
delta: number;
}
| {
type: "scale-positions";
targetSelector: string;
oldStart: number;
oldDuration: number;
newStart: number;
newDuration: number;
};

// ── GSAP mutation executor ──────────────────────────────────────────────────
Expand Down Expand Up @@ -715,6 +728,35 @@ async function executeGsapMutation(
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
return result.script;
}
case "shift-positions": {
const { targetSelector, delta } = body;
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
const { shiftPositionsInScript } = parser;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if (
!targetSelector ||
!Number.isFinite(oldStart) ||
!Number.isFinite(oldDuration) ||
!Number.isFinite(newStart) ||
!Number.isFinite(newDuration) ||
oldDuration <= 0 ||
newDuration <= 0
)
return block.scriptText;
if (oldStart === newStart && oldDuration === newDuration) return block.scriptText;
const { scalePositionsInScript } = parser;
return scalePositionsInScript(
block.scriptText,
targetSelector,
oldStart,
oldDuration,
newStart,
newDuration,
);
}
default:
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}
Expand Down
7 changes: 4 additions & 3 deletions packages/studio/src/components/editor/GestureTrailOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface GestureTrailOverlayProps {
sampleCount?: number;
trail?: Array<{ x: number; y: number }>;
simplifiedPoints?: Map<number, Record<string, number>>;
canvasRect: { left: number; top: number; width: number; height: number };
canvasRect: { left: number; top: number; width: number; height: number } | null;
compositionSize?: { width: number; height: number };
mode: "recording" | "preview";
accentColor?: string;
Expand All @@ -23,6 +23,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
accentColor = "#3CE6AC",
}: GestureTrailOverlayProps) {
const trailPoints = useMemo(() => {
if (!canvasRect) return "";
if (trail && trail.length > 1) {
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
}
Expand All @@ -32,7 +33,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
.map((s) => `${s.properties.x},${s.properties.y}`)
.join(" ");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
}, [samples, trail, sampleCount, canvasRect?.left, canvasRect?.top]);

const simplifiedPath = useMemo(() => {
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
Expand All @@ -58,7 +59,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
return pts.sort((a, b) => a.pct - b.pct);
}, [simplifiedPoints]);

if (samples.length < 2 && !simplifiedPoints) return null;
if (!canvasRect || (samples.length < 2 && !simplifiedPoints)) return null;

return (
<svg
Expand Down
61 changes: 61 additions & 0 deletions packages/studio/src/hooks/timelineEditingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,66 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
return data.content;
}

/**
* Shift all GSAP animation positions targeting a given element by a time delta.
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
*/
export async function shiftGsapPositions(
projectId: string,
filePath: string,
elementId: string,
delta: number,
): Promise<void> {
if (delta === 0 || !elementId) return;
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions",
targetSelector: `#${elementId}`,
delta,
}),
},
);
Comment on lines +158 to +169
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
}
}

export async function scaleGsapPositions(
projectId: string,
filePath: string,
elementId: string,
oldStart: number,
oldDuration: number,
newStart: number,
newDuration: number,
): Promise<void> {
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
if (oldStart === newStart && oldDuration === newDuration) return;
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "scale-positions",
targetSelector: `#${elementId}`,
oldStart,
oldDuration,
newStart,
newDuration,
}),
},
);
Comment on lines +187 to +201
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
}
}

// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
Loading
Loading