/** * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit. */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser"; import { getIframeGsap, queryIframeElement } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; export function readGsapProperty( iframe: HTMLIFrameElement | null, selector: string | null, prop: string, ): number | null { if (!selector) return null; const gsap = getIframeGsap(iframe); if (!gsap) return null; const el = queryIframeElement(iframe, selector); if (!el) return null; try { const val = Number(gsap.getProperty(el, prop)); if (!Number.isFinite(val)) return null; return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } catch { return null; } } export const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); const GSAP_CONFIG_KEYS = new Set([ "duration", "ease", "delay", "stagger", "id", "onComplete", "onUpdate", "onStart", "onRepeat", "repeat", "yoyo", "repeatDelay", "paused", "immediateRender", "lazy", "overwrite", "keyframes", "parent", ]); export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, anim: GsapAnimation, group?: PropertyGroupName, ): Record { const result: Record = {}; if (!iframe) return result; const gsap = getIframeGsap(iframe); if (!gsap) return result; const el = queryIframeElement(iframe, selector); if (!el) return result; let doc: Document | null = null; try { doc = iframe?.contentDocument ?? null; } catch { /* cross-origin guard — doc stays null */ } const propKeys = new Set(); if (anim.keyframes) { for (const kf of anim.keyframes.keyframes) { for (const p of Object.keys(kf.properties)) { if (typeof kf.properties[p] === "number") propKeys.add(p); } } } else { for (const p of Object.keys(anim.properties)) propKeys.add(p); } // When a group filter is specified, only keep properties belonging to that group. if (group) { for (const p of propKeys) { if (classifyPropertyGroup(p) !== group) propKeys.delete(p); } } for (const prop of propKeys) { const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val)) { result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } } const otherTweenProps = new Set(); try { const win = iframe.contentWindow as unknown as { __timelines?: Record }; const timelines = win.__timelines; if (timelines) { for (const tl of Object.values(timelines)) { const tlObj = tl as { getChildren?: ( deep: boolean, ) => Array<{ targets?: () => Element[]; vars?: Record }>; }; if (!tlObj?.getChildren) continue; for (const child of tlObj.getChildren(true)) { if (typeof child.targets !== "function") continue; const targets = child.targets(); if (!targets.includes(el)) continue; const vars = child.vars; if (!vars) continue; for (const k of Object.keys(vars)) { if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k); } } } } } catch {} for (const p of propKeys) otherTweenProps.delete(p); // Tier 1: Transform + visual properties with universal CSS defaults. // Safe to compare against hardcoded values — these are always 0 or 1 // regardless of the element's stylesheet. const UNIVERSAL_BASELINE: Record = { opacity: 1, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1, rotation: 0, rotationX: 0, rotationY: 0, skewX: 0, skewY: 0, z: 0, xPercent: 0, yPercent: 0, transformPerspective: 0, blur: 0, brightness: 1, contrast: 1, saturate: 1, hueRotate: 0, grayscale: 0, sepia: 0, invert: 0, }; // Collect all properties that ANY tween on this element explicitly targets. // Only capture baseline values for these — GSAP reports non-default values // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes. const allTweenedProps = new Set([...propKeys, ...otherTweenProps]); for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) { if (prop in result) continue; if (!allTweenedProps.has(prop)) continue; const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { result[prop] = roundTo3(val); } } // Tier 2: Element-dependent properties — their "default" depends on the // stylesheet, so we compare GSAP's runtime value against the element's // computed CSS value. Only capture if GSAP has actively changed it. const COMPUTED_BASELINE = [ "borderRadius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius", "letterSpacing", "wordSpacing", "lineHeight", "fontSize", "outlineOffset", "outlineWidth", "strokeDashoffset", "strokeWidth", "backgroundPositionX", "backgroundPositionY", ]; let computedStyle: CSSStyleDeclaration | null = null; try { computedStyle = doc?.defaultView?.getComputedStyle(el) ?? null; } catch {} for (const prop of COMPUTED_BASELINE) { if (prop in result) continue; if (otherTweenProps.has(prop)) continue; const gsapVal = Number(gsap.getProperty(el, prop)); if (!Number.isFinite(gsapVal)) continue; let cssVal = NaN; if (computedStyle) { const raw = computedStyle.getPropertyValue( prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`), ); cssVal = parseFloat(raw); } if (Number.isFinite(cssVal) && Math.round(gsapVal * 1000) === Math.round(cssVal * 1000)) continue; result[prop] = roundTo3(gsapVal); } return result; }