mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user