fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)

- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
  stripStudioEdits flag on the delete mutation type so they only fire on
  user-initiated deletes, not on internal delete-then-recreate drags.

- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
  elements with CSS opacity:0 stay visible after collapsing keyframes.

- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
  precision for visual properties (opacity, scale, rotation) instead of
  Math.round which corrupted mid-fade values to 0.

- Guard VISUAL_BASELINE against cross-tween contamination by querying
  __timelines for properties animated by other tweens on the same element.

- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
  containing opacity, guard against relative values (+=/-=/*=), and add
  Number.isFinite check.

- Fix falsy-zero doubling in drag commit: replace || fallback with
  Number.isFinite so a base GSAP position of 0 is correctly preserved.

- Fix gesture recording sign inversion: remove pointerElementOffset
  subtraction from dx/dy formula and instead apply it once to basePosition
  so the element center tracks the pointer.

- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).

- Strip all diagnostic logs from production code.
This commit is contained in:
Miguel Ángel
2026-06-10 18:53:06 -04:00
committed by GitHub
parent 3a72aa528d
commit f0b499b582
38 changed files with 1641 additions and 821 deletions
@@ -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", () => {
+27 -18
View File
@@ -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);
}
}
@@ -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;
}