Files
hyperframes/packages/studio/src/utils/gsapSoftReload.ts
T
Miguel Ángel f0b499b582 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.
2026-06-10 18:53:06 -04:00

127 lines
4.1 KiB
TypeScript

type IframeWindow = Window & {
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
__player?: { getTime?: () => number; seek?: (t: number) => void };
__hfForceTimelineRebind?: () => void;
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
__hfStudioManualEditsApply?: () => void;
gsap?: {
timeline?: (...args: unknown[]) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
};
MotionPathPlugin?: unknown;
};
function isGsapScript(text: string): boolean {
return (
text.includes("gsap.timeline") ||
text.includes("__timelines") ||
text.includes(".to(") ||
text.includes(".set(")
);
}
function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
const results: HTMLScriptElement[] = [];
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
for (const script of scripts) {
if (isGsapScript(script.textContent || "")) results.push(script);
}
return results;
}
/**
* Replace the GSAP script in the live iframe without reloading. This preserves
* the WebGL context and shader transition cache.
*
* Scoped to root-document GSAP scripts only — scripts inside `<template>`
* elements (sub-compositions) are not visible to `querySelectorAll` and will
* fall back to a full iframe reload.
*
* Returns false (triggering a full reload fallback) when:
* - The iframe or GSAP runtime isn't available
* - Multiple GSAP scripts are found (ambiguous which to replace)
* - No matching GSAP script element exists in the live DOM
*/
export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: string): boolean {
if (!iframe || !scriptText) return false;
const win = iframe.contentWindow as IframeWindow | null;
const doc = iframe.contentDocument;
if (!win || !doc) return false;
if (!win.gsap || !win.__hfForceTimelineRebind) return false;
const gsapScripts = findGsapScriptElements(doc);
if (gsapScripts.length !== 1) return false;
const oldScriptEl = gsapScripts[0]!;
const currentTime = win.__player?.getTime?.() ?? 0;
const doReload = () => {
const timelines = win.__timelines;
if (timelines) {
for (const key of Object.keys(timelines)) {
try {
const tl = timelines[key] as {
kill?: () => void;
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
};
const allTargets: Element[] = [];
if (tl?.getChildren) {
try {
for (const child of tl.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) {
allTargets.push(t);
delete (t as unknown as Record<string, unknown>)._gsap;
}
}
}
} catch {}
}
tl?.kill?.();
} catch {}
delete timelines[key];
}
}
oldScriptEl.remove();
const executeScript = () => {
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
win.gsap.registerPlugin(win.MotionPathPlugin);
}
const s = doc.createElement("script");
s.textContent = `(function(){${scriptText}\n})();`;
doc.body.appendChild(s);
win.__hfForceTimelineRebind?.();
win.__player?.seek?.(currentTime);
win.__hfStudioManualEditsApply?.();
};
// Load MotionPathPlugin on demand if the script uses motionPath.
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
const pluginScript = doc.createElement("script");
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
pluginScript.onload = () => executeScript();
pluginScript.onerror = () => executeScript();
doc.head.appendChild(pluginScript);
return;
}
executeScript();
};
try {
if (win.__hfSuppressSceneMutations) {
win.__hfSuppressSceneMutations(doReload);
} else {
doReload();
}
return true;
} catch {
return false;
}
}