import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3 } from "../utils/rounding"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import { type GsapDragCommitCallbacks, computeCurrentPercentage, parkPlayheadOnKeyframe, materializeIfDynamic, } from "./gsapDragCommit"; async function extendTweenAndAddKeyframe( selection: DomEditSelection, anim: GsapAnimation, properties: Record, targetTime: number, tweenStart: number, tweenDuration: number, callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, backfillDefaults?: Record, ): Promise { const tweenEnd = tweenStart + tweenDuration; const newStart = Math.min(targetTime, tweenStart); const newEnd = Math.max(targetTime, tweenEnd); const newDuration = Math.max(0.01, newEnd - newStart); const existingKfs = anim.keyframes?.keyframes ?? []; const remappedKfs: Array<{ percentage: number; properties: Record }> = []; for (const kf of existingKfs) { const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; const props: Record = { ...kf.properties }; for (const k of Object.keys(properties)) { if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k]; } remappedKfs.push({ percentage: newPct, properties: props }); } const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; remappedKfs.push({ percentage: targetPct, properties }); remappedKfs.sort((a, b) => a.percentage - b.percentage); await callbacks.commitMutation( selection, { type: "replace-with-keyframes", animationId: anim.id, targetSelector: anim.targetSelector, position: roundTo3(newStart), duration: roundTo3(newDuration), keyframes: remappedKfs, }, { label: `Move layer (extended keyframe)`, softReload: true, beforeReload }, ); } // fallow-ignore-next-line complexity async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, backfillDefaults?: Record, ): Promise { const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); const computedPct = computeCurrentPercentage(selection, anim); const pct = activeKeyframePct ?? computedPct; await callbacks.commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, properties, ...(backfillDefaults ? { backfillDefaults } : {}), }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); if (activeKeyframePct != null) { setActiveKeyframePct(null); parkPlayheadOnKeyframe(anim, pct); } } interface DragRuntimeGsap { getProperty: (target: Element, key: string) => unknown; set: (target: Element, vars: Record) => void; } interface DragRuntimeTimeline { seek: (time: number) => void; } interface DragRuntime { gsapLib: DragRuntimeGsap; el: Element; mainTl: DragRuntimeTimeline; } function resolveDragRuntime( iframe: HTMLIFrameElement | null | undefined, selector: string | undefined, ): DragRuntime | null { if (!iframe || !selector) return null; const win = iframe.contentWindow as | (Window & { gsap?: Partial; __timelines?: Record>; }) | null; const gsap = win?.gsap; if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null; let el: Element | null = null; try { el = iframe.contentDocument?.querySelector(selector) ?? null; } catch { return null; } if (!el) return null; const timelines = win?.__timelines; const mainTl = timelines ? Object.values(timelines)[0] : undefined; if (typeof mainTl?.seek !== "function") return null; return { gsapLib: gsap as DragRuntimeGsap, el, mainTl: mainTl as DragRuntimeTimeline, }; } // fallow-ignore-next-line complexity async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, iframe?: HTMLIFrameElement | null, selector?: string, backfillDefaults?: Record, ): Promise { const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(anim); const td = resolveTweenDuration(anim); const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); const outsideRange = activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); const resolvedFromValues: Record = {}; const runtime = resolveDragRuntime(iframe, selector); if (runtime && ts !== null) { const { gsapLib, el, mainTl } = runtime; const draggedValues: Record = {}; for (const key of Object.keys(properties)) { const v = Number(gsapLib.getProperty(el, key)); if (Number.isFinite(v)) draggedValues[key] = v; } try { gsapLib.set(el, { clearProps: Object.keys(properties).join(",") }); mainTl.seek(ts); for (const key of Object.keys(properties)) { const v = Number(gsapLib.getProperty(el, key)); if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v); } mainTl.seek(ct); } catch { for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key]; } finally { if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues); } } if (outsideRange && ts !== null) { const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id, ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}), }, { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, ); const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : []; const converted = fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim; const convertedStart = resolveTweenStart(converted) ?? ts; const convertedDur = resolveTweenDuration(converted) || td; await extendTweenAndAddKeyframe( selection, converted, properties, ct, convertedStart, convertedDur, callbacks, beforeReload, ); return; } const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id, ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}), }, { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, ); const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); const editedSelected = activeKeyframePct != null; if (editedSelected) setActiveKeyframePct(null); await callbacks.commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, properties, ...(backfillDefaults ? { backfillDefaults } : {}), }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey }, ); if (editedSelected) parkPlayheadOnKeyframe(anim, pct); } // fallow-ignore-next-line complexity export async function commitGsapPositionFromDrag( selection: DomEditSelection, anim: GsapAnimation, studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, iframe: HTMLIFrameElement | null, selector: string, callbacks: GsapDragCommitCallbacks, ): Promise { const el = selection.element; const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition( el, studioOffset, gsapPos, ); const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; const restoreOffset = () => { el.style.setProperty("--hf-studio-offset-x", `${origX}px`); el.style.setProperty("--hf-studio-offset-y", `${origY}px`); el.removeAttribute("data-hf-drag-initial-offset-x"); el.removeAttribute("data-hf-drag-initial-offset-y"); }; const backfillDefaults: Record = { x: baseGsapX, y: baseGsapY }; const ct = usePlayerStore.getState().currentTime; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; const dragProps: Record = { x: newX, y: newY }; const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; if (outsideRange && !hasSelectedKeyframe) { await extendTweenAndAddKeyframe( selection, effectiveAnim, dragProps, ct, ts, td, callbacks, restoreOffset, backfillDefaults, ); } else { await commitKeyframedPosition( selection, effectiveAnim, dragProps, callbacks, restoreOffset, backfillDefaults, ); } } else if (anim.method === "from" || anim.method === "fromTo") { const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(anim); const td = resolveTweenDuration(anim); const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; const outsideRange = !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); const dragProps: Record = { x: newX, y: newY }; if (outsideRange && ts !== null) { await callbacks.commitMutation( selection, { type: "split-into-property-groups", animationId: anim.id }, { label: "Split from() for drag", skipReload: true }, ); const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : []; const existingPosAnim = allAnims.find( (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector, ); if (existingPosAnim?.keyframes) { const posTs = resolveTweenStart(existingPosAnim); const posTd = resolveTweenDuration(existingPosAnim); if (posTs !== null) { await extendTweenAndAddKeyframe( selection, existingPosAnim, { x: newX, y: newY }, ct, posTs, posTd, callbacks, restoreOffset, backfillDefaults, ); return; } } const newStart = Math.min(ct, ts); const newEnd = Math.max(ct, ts + td); const newDuration = Math.max(0.01, newEnd - newStart); const dragBefore = ct < ts; const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10; const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10; const keyframes: Array<{ percentage: number; properties: Record }> = []; if (dragBefore) { keyframes.push({ percentage: 0, properties: { x: newX, y: newY } }); if (origStartPct > 0.5 && origStartPct < 99.5) { keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } }); } keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } }); } else { keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } }); if (origEndPct > 0.5 && origEndPct < 99.5) { keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } }); } keyframes.push({ percentage: 100, properties: { x: newX, y: newY } }); } keyframes.sort((a, b) => a.percentage - b.percentage); const baseKf = { targetSelector: anim.targetSelector, position: roundTo3(newStart), duration: roundTo3(newDuration), keyframes, }; await callbacks.commitMutation( selection, existingPosAnim ? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf } : { type: "add-with-keyframes", ...baseKf }, { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset }, ); } else { const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id, }, { label: "Convert from() for drag", skipReload: true, coalesceKey }, ); const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); if (activeKeyframePct != null) setActiveKeyframePct(null); await callbacks.commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, properties: dragProps, ...(backfillDefaults ? { backfillDefaults } : {}), }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload: restoreOffset, coalesceKey, }, ); } } else { await commitFlatViaKeyframes( selection, anim, { x: newX, y: newY }, callbacks, restoreOffset, iframe, selector, backfillDefaults, ); } }