import { useCallback, useEffect, useRef } from "react"; import type { Composition } from "@hyperframes/sdk"; import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { sdkGsapTweenPersist, sdkGsapRemovePropertyPersist, type CutoverDeps, } from "../utils/sdkCutover"; import { extractGsapScriptText } from "../utils/gsapSoftReload"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; const DEBOUNCE_MS = 150; /** * The SDK `setGsapTween` 'set' path REPLACES a tween's editable property set * (engine `handleSetGsapTween` → `updateAnimationInScript`), so sending only the * single edited key would silently drop the tween's other animated props. Mirror * the legacy server path (`{ ...anim.properties, [property]: val }`): read the * tween's CURRENT properties from the in-memory SDK doc and merge the one edit in, * so REPLACE semantics preserve siblings. Returns the single-key map unchanged * when the tween/script can't be found (best-effort; before===after then falls * back to the server path). */ export function mergeTweenProperties( sdkSession: Composition, animationId: string, edited: Record, kind: "to" | "from", ): Record { try { const script = extractGsapScriptText(sdkSession.serialize()); if (!script) return { ...edited }; const anim = parseGsapScriptAcorn(script).animations.find((a) => a.id === animationId); if (!anim) return { ...edited }; const existing = kind === "from" ? (anim.fromProperties ?? {}) : anim.properties; return { ...existing, ...edited }; } catch { return { ...edited }; } } interface SdkPropertyDeps { sdkSession?: Composition | null; sdkDeps?: CutoverDeps | null; activeCompPath?: string | null; } export function useGsapPropertyDebounce( commitMutationSafely: SafeGsapCommitMutation, sdk?: SdkPropertyDeps, ) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; animationId: string; property: string; value: number | string; } | null>(null); const debounceTimerRef = useRef | null>(null); // The caller passes `sdk` as a fresh object literal every render. Keying any // callback's deps on it (esp. flushPendingPropertyEdit, whose identity drives // the unmount-flush cleanup effect) re-fires the cleanup on EVERY parent // re-render — so a playhead tick mid-slider-drag would flush + record an undo // entry per render. Hold the latest value in a ref instead so every callback // reads current deps without re-subscribing on identity churn. const sdkRef = useRef(sdk); sdkRef.current = sdk; const flushPendingPropertyEdit = useCallback(async () => { const pending = pendingPropertyEditRef.current; if (!pending) return; pendingPropertyEditRef.current = null; const { selection, animationId, property, value } = pending; const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"), }, }, sdkSession, sdkDeps, { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, ); if (handled) return; } commitMutationSafely( selection, { type: "update-property", animationId, property, value }, { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}`, softReload: true, }, ); }, [commitMutationSafely]); const updateGsapProperty = useCallback( ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { pendingPropertyEditRef.current = { selection, animationId, property, value }; if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); debounceTimerRef.current = setTimeout(() => { void flushPendingPropertyEdit(); }, DEBOUNCE_MS); }, [flushPendingPropertyEdit], ); useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); void flushPendingPropertyEdit(); }; }, [flushPendingPropertyEdit]); // fallow-ignore-next-line complexity const addGsapProperty = useCallback( // fallow-ignore-next-line complexity async (selection: DomEditSelection, animationId: string, property: string) => { let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; const el = selection.element; if (property === "width" || property === "height") { const rect = el.getBoundingClientRect(); defaultValue = Math.round(property === "width" ? rect.width : rect.height); } else if (property === "opacity" || property === "autoAlpha") { const cs = el.ownerDocument.defaultView?.getComputedStyle(el); // Use `|| 1` only as a non-finite fallback, not a falsy fallback: an // element currently at opacity 0 must seed 0, not 1. const current = cs ? Number.parseFloat(cs.opacity) : Number.NaN; defaultValue = Number.isFinite(current) ? current : 1; } const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { properties: mergeTweenProperties( sdkSession, animationId, { [property]: defaultValue }, "to", ), }, }, sdkSession, sdkDeps, { label: `Add GSAP ${property}` }, ); if (handled) return; } commitMutationSafely( selection, { type: "add-property", animationId, property, defaultValue }, { label: `Add GSAP ${property}` }, ); }, [commitMutationSafely], ); const removeProperty = useCallback( async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => { const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapRemovePropertyPersist( targetPath, animationId, property, from, sdkSession, sdkDeps, { label: `Remove GSAP ${from ? `from-${property}` : property}` }, ); if (handled) return; } if (from) { commitMutationSafely( selection, { type: "remove-from-property", animationId, property }, { label: `Remove GSAP from-${property}`, }, ); } else { commitMutationSafely( selection, { type: "remove-property", animationId, property }, { label: `Remove GSAP ${property}`, }, ); } }, [commitMutationSafely], ); const removeGsapProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => removeProperty(selection, animationId, property, false), [removeProperty], ); const updateGsapFromProperty = useCallback( async ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { fromProperties: mergeTweenProperties( sdkSession, animationId, { [property]: value }, "from", ), }, }, sdkSession, sdkDeps, { label: `Edit GSAP from-${property}`, coalesceKey: `gsap:${animationId}:from:${property}`, }, ); if (handled) return; } commitMutationSafely( selection, { type: "update-from-property", animationId, property, value }, { label: `Edit GSAP from-${property}`, coalesceKey: `gsap:${animationId}:from:${property}`, }, ); }, [commitMutationSafely], ); const addGsapFromProperty = useCallback( async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { fromProperties: mergeTweenProperties( sdkSession, animationId, { [property]: defaultValue }, "from", ), }, }, sdkSession, sdkDeps, { label: `Add GSAP from-${property}` }, ); if (handled) return; } commitMutationSafely( selection, { type: "add-from-property", animationId, property, defaultValue }, { label: `Add GSAP from-${property}` }, ); }, [commitMutationSafely], ); const removeGsapFromProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => removeProperty(selection, animationId, property, true), [removeProperty], ); return { updateGsapProperty, addGsapProperty, removeGsapProperty, updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, }; }