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
+42 -28
View File
@@ -10,6 +10,7 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import {
@@ -59,31 +60,40 @@ function readGsapPositionFromIframe(
// ── Animation matching ─────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null {
// Prefer animations that already have x/y
for (const anim of animations) {
if (anim.keyframes) {
const hasPos = anim.keyframes.keyframes.some(
(kf) => "x" in kf.properties || "y" in kf.properties,
);
if (hasPos) return anim;
}
const props = anim.properties;
const fromProps = anim.fromProperties;
if (anim.method === "fromTo") {
if ("x" in props || "y" in props || (fromProps && ("x" in fromProps || "y" in fromProps))) {
return anim;
}
} else if ("x" in props || "y" in props) {
return anim;
}
function animHasPosition(anim: GsapAnimation): boolean {
if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
return true;
if (anim.method === "fromTo") {
const from = anim.fromProperties;
return (
"x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
);
}
// Fall back to any keyframed animation — drag will add x/y to it
for (const anim of animations) {
if (anim.keyframes) return anim;
}
// Fall back to any animation — will be converted to keyframes
return animations[0] ?? null;
return "x" in anim.properties || "y" in anim.properties;
}
function findGsapPositionAnimation(
animations: GsapAnimation[],
selector?: string,
): GsapAnimation | null {
if (animations.length === 0) return null;
const currentTime = usePlayerStore.getState().currentTime;
const scored = animations
.filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
.map((a) => {
let score = 0;
if (animHasPosition(a)) score += 10;
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
const pos = typeof a.position === "number" ? a.position : 0;
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
return { anim: a, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.anim ?? animations[0];
}
// ── Selector resolution ────────────────────────────────────────────────────
@@ -114,15 +124,19 @@ export async function tryGsapDragIntercept(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
let posAnim = findGsapPositionAnimation(animations);
const selector = selectorForSelection(selection);
if (!selector) return false;
let posAnim = findGsapPositionAnimation(animations, selector);
if (!posAnim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
posAnim = findGsapPositionAnimation(fresh);
posAnim = findGsapPositionAnimation(fresh, selector);
}
if (!posAnim) return false;
const selector = selectorForSelection(selection);
if (!selector) return false;
// Keyframe writes at 0%/100% when outside the tween range. Acceptable
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
const gsapPos = readGsapPositionFromIframe(iframe, selector);
if (!gsapPos) return false;