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
29 changes: 29 additions & 0 deletions packages/core/src/lint/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,4 +867,33 @@ export const gsapRules: LintRule<LintContext>[] = [
}
return findings;
},

// gsap_group_selector_keyframes
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const selector = match[1]!;
const count = selector.split(",").length;
const contextStart = Math.max(0, match.index - 20);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(content.slice(contextStart, contextEnd)),
});
}
}
return findings;
},
];
89 changes: 89 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,95 @@ describe("keyframe mutations", () => {
expect(kfs[1].properties.x).toBe(999);
});

// ── _auto endpoint updates ────────────────────────────────────────────

const AUTO_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0, y: 0, _auto: 1 }, "100%": { x: 200, y: 100, _auto: 1 } },
duration: 2
}, 0);
`;

const AUTO_5KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: {
"0%": { x: 0, y: 0, _auto: 1 },
"25%": { x: 50, y: 25 },
"50%": { x: 100, y: 50 },
"75%": { x: 150, y: 75 },
"100%": { x: 200, y: 100, _auto: 1 }
},
duration: 2
}, 0);
`;

it("addKeyframe adjacent to auto 100% — updates 100%", () => {
const id = getAnimId(AUTO_SCRIPT);
const updated = addKeyframeToScript(AUTO_SCRIPT, id, 50, { x: 300, y: 200 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(300);
expect(kf100.properties.y).toBe(200);
});

it("addKeyframe adjacent to auto 0% — updates 0%", () => {
const id = getAnimId(AUTO_SCRIPT);
const updated = addKeyframeToScript(AUTO_SCRIPT, id, 50, { x: 300, y: 200 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf0 = kfs.find((k) => k.percentage === 0)!;
expect(kf0.properties.x).toBe(300);
expect(kf0.properties.y).toBe(200);
});

it("addKeyframe NOT adjacent to auto 100% — leaves 100% untouched", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 74, { x: 999, y: 888 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(200);
expect(kf100.properties.y).toBe(100);
});

it("addKeyframe NOT adjacent to auto 0% — leaves 0% untouched", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 30, { x: 999, y: 888 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf0 = kfs.find((k) => k.percentage === 0)!;
expect(kf0.properties.x).toBe(0);
expect(kf0.properties.y).toBe(0);
});

it("addKeyframe at 88% in 5-keyframe set — updates adjacent 100% only", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 88, { x: 500, y: 400 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
const kf0 = kfs.find((k) => k.percentage === 0)!;
expect(kf100.properties.x).toBe(500);
expect(kf0.properties.x).toBe(0);
});

it("addKeyframe at 12% in 5-keyframe set — updates adjacent 0% only", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 12, { x: 500, y: 400 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf0 = kfs.find((k) => k.percentage === 0)!;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf0.properties.x).toBe(500);
expect(kf100.properties.x).toBe(200);
});

it("non-auto 100% is never modified", () => {
const id = getAnimId(KF_SCRIPT);
const updated = addKeyframeToScript(KF_SCRIPT, id, 50, { x: 999 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(200);
expect(kf100.properties.opacity).toBe(1);
});

// ── removeKeyframeFromScript ────────────────────────────────────────────

it("removeKeyframeFromScript — removes one keyframe", () => {
Expand Down
45 changes: 27 additions & 18 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1404,21 +1404,30 @@ export function addKeyframeToScript(
kfNode.properties.splice(insertIdx, 0, newProp);
}

// Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never
// explicitly edited by the user), update it to match the new keyframe's
// values so the element holds its final position instead of snapping back.
// Once the user drags at 100%, `_auto` is gone and we stop touching it.
if (percentage < 100 && percentage !== 0) {
// Auto-update adjacent endpoints: only update an `_auto` 0% or 100%
// keyframe when the new keyframe is directly next to it (no other keyframe
// between them). This prevents a keyframe at 74% from clobbering 100% when
// 75% already exists, and a keyframe at 30% from clobbering 0% when 25%
// already exists.
if (percentage > 0 && percentage < 100) {
const pctProps = filterPercentageProps(kfNode);
const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100);
if (hundredProp?.value?.type === "ObjectExpression") {
const hasAuto = hundredProp.value.properties.some(
const allPcts = pctProps
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
.sort((a: number, b: number) => a - b);
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
const rightNeighbor = allPcts.find((p: number) => p > percentage);
for (const endPct of [0, 100]) {
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
if (!isNeighbor) continue;
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue;
const hasAuto = endProp.value.properties.some(
(p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
);
if (hasAuto) {
const updatedProps = { ...properties, _auto: 1 as number | string };
hundredProp.value = buildKeyframeValueNode(updatedProps, undefined);
}
if (!hasAuto) continue;
const updatedProps = { ...properties, _auto: 1 as number | string };
endProp.value = buildKeyframeValueNode(updatedProps, undefined);
}
}

Expand Down Expand Up @@ -1623,18 +1632,18 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;

// Collect all percentage keyframe entries, sorted
const kfEntries = filterPercentageProps(kfNode)
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;

const lastRecord = objectExpressionToRecord(
kfEntries[kfEntries.length - 1]!.prop.value,
loc.parsed.scope,
);
collapseKeyframesToFlat(loc.target.call.varsArg, lastRecord);
// For to()/set(): collapse to last keyframe (the destination = visible state).
// For from(): collapse to first keyframe (the starting state).
const method = loc.target.call.method;
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
collapseKeyframesToFlat(loc.target.call.varsArg, record);

return recast.print(loc.parsed.ast).code;
}
Expand Down
Loading
Loading