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
55 changes: 55 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,7 @@ import {
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
splitIntoPropertyGroups,
shiftPositionsInScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
Expand Down Expand Up @@ -2275,3 +2276,57 @@ 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 (Via's race case)", () => {
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");
});
});
23 changes: 23 additions & 0 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,29 @@ 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;
}

function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
let parsed: ParsedGsapAst;
try {
Expand Down
11 changes: 11 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,11 @@ type GsapMutationRequest =
| {
type: "delete-all-for-selector";
targetSelector: string;
}
| {
type: "shift-positions";
targetSelector: string;
delta: number;
};

// ── GSAP mutation executor ──────────────────────────────────────────────────
Expand Down Expand Up @@ -715,6 +720,12 @@ async function executeGsapMutation(
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
return result.script;
}
case "shift-positions": {
const { targetSelector, delta } = body;
if (!targetSelector || typeof delta !== "number" || delta === 0) return block.scriptText;
const { shiftPositionsInScript } = parser;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
default:
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}
Expand Down
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 @@ -22,6 +22,8 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
mode,
accentColor = "#3CE6AC",
}: GestureTrailOverlayProps) {
if (!canvasRect) return null;

const trailPoints = useMemo(() => {
if (trail && trail.length > 1) {
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
Expand Down
29 changes: 29 additions & 0 deletions packages/studio/src/hooks/timelineEditingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,34 @@
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,
}),
},
);

Check warning

Code scanning / CodeQL

Client-side request forgery Medium

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
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");
}
}

// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
19 changes: 13 additions & 6 deletions packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
readFileContent,
applyPatchByTarget,
formatTimelineAttributeNumber,
shiftGsapPositions,
} from "./timelineEditingHelpers";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";

Expand Down Expand Up @@ -122,6 +123,8 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-track-index", String(updates.track)],
]);
const delta = updates.start - element.start;
const filePath = element.sourceFile || activeCompPath || "index.html";
return enqueueEdit(element, "Move timeline clip", (original, target) => {
let patched = applyPatchByTarget(original, target, {
type: "attribute",
Expand All @@ -133,9 +136,16 @@ export function useTimelineEditing({
property: "track-index",
value: String(updates.track),
});
}).then(() => {
const pid = projectIdRef.current;
if (delta !== 0 && element.domId && pid) {
return shiftGsapPositions(pid, filePath, element.domId, delta)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
}
});
},
[previewIframeRef, enqueueEdit],
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
);

const handleTimelineElementResize = useCallback(
Expand All @@ -147,9 +157,6 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-duration", formatTimelineAttributeNumber(updates.duration)],
];
// A start-edge trim advances the media-start offset (skips into the
// source). Patch it live too — otherwise the iframe keeps the old offset
// and the clip only repositions instead of trimming the audio.
if (updates.playbackStart != null) {
const liveAttr =
element.playbackStartAttr === "playback-start"
Expand Down Expand Up @@ -178,9 +185,9 @@ export function useTimelineEditing({
});
}
return patched;
});
}).then(() => reloadPreview());
},
[previewIframeRef, enqueueEdit],
[previewIframeRef, enqueueEdit, reloadPreview],
);

const handleTimelineElementDelete = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
const half = diamondSize / 2;
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
const sorted = keyframesData.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const sorted = keyframesData.keyframes
.filter((kf) => kf.percentage >= 0 && kf.percentage <= 100)
.sort((a, b) => a.percentage - b.percentage);
const baseColor = isSelected ? accentColor : "#a3a3a3";
const baseOpacity = isSelected ? 0.4 : 0.25;

Expand Down
Loading