diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index b4c484bfb..478fbdbba 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -419,11 +419,8 @@ function findAllTweenCalls( this.traverse(path); return; } - const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings); - if (!selectorValue) { - this.traverse(path); - return; - } + const selectorValue = + resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__"; if (method === "fromTo") { results.push({ @@ -697,6 +694,7 @@ function tweenCallToAnimation( const properties: Record = {}; const extras: Record = {}; let keyframesData: GsapKeyframesData | undefined; + let hasUnresolvedKeyframes = false; for (const [key, val] of Object.entries(vars)) { if (BUILTIN_VAR_KEYS.has(key)) continue; @@ -705,6 +703,7 @@ function tweenCallToAnimation( if (key === "keyframes") { const kfNode = findPropertyNode(call.varsArg, "keyframes"); keyframesData = parseKeyframesNode(kfNode, scope); + if (!keyframesData && kfNode) hasUnresolvedKeyframes = true; continue; } @@ -763,6 +762,8 @@ function tweenCallToAnimation( }; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; + if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; + if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; return anim; } @@ -1174,6 +1175,7 @@ export function addKeyframeToScript( percentage: number, properties: Record, ease?: string, + backfillDefaults?: Record, ): string { const loc = locateAnimation(script, animationId); if (!loc) return script; @@ -1189,25 +1191,48 @@ export function addKeyframeToScript( ); if (existingIdx !== -1) { kfNode.properties[existingIdx].value = newValueNode; - return recast.print(loc.parsed.ast).code; + } else { + // Build the new property node with a quoted percentage key + const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; + newProp.value = newValueNode; + + // Insert in sorted order by percentage + let insertIdx = kfNode.properties.length; + for (let i = 0; i < kfNode.properties.length; i++) { + const key = isObjectProperty(kfNode.properties[i]) + ? propKeyName(kfNode.properties[i]) + : undefined; + if (typeof key === "string" && percentageFromKey(key) > percentage) { + insertIdx = i; + break; + } + } + kfNode.properties.splice(insertIdx, 0, newProp); } - // Build the new property node with a quoted percentage key - const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; - newProp.value = newValueNode; - - // Insert in sorted order by percentage - let insertIdx = kfNode.properties.length; - for (let i = 0; i < kfNode.properties.length; i++) { - const key = isObjectProperty(kfNode.properties[i]) - ? propKeyName(kfNode.properties[i]) - : undefined; - if (typeof key === "string" && percentageFromKey(key) > percentage) { - insertIdx = i; - break; + // Backfill: when the new keyframe introduces properties absent from other + // keyframes, add default values so GSAP can interpolate them. + if (backfillDefaults) { + const newPropKeys = Object.keys(properties); + const pctProps = filterPercentageProps(kfNode); + for (const prop of pctProps) { + const key = propKeyName(prop); + if (key === pctKey) continue; + const valObj = prop.value; + if (!valObj || valObj.type !== "ObjectExpression") continue; + const existingKeys = new Set( + valObj.properties.filter((p: any) => isObjectProperty(p)).map((p: any) => propKeyName(p)), + ); + for (const pk of newPropKeys) { + if (existingKeys.has(pk)) continue; + const defaultVal = backfillDefaults[pk]; + if (defaultVal == null) continue; + const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0]; + valObj.properties.push(fillProp); + } } } - kfNode.properties.splice(insertIdx, 0, newProp); + return recast.print(loc.parsed.ast).code; } @@ -1329,10 +1354,12 @@ function insertKeyframesProp( varsArg: any, fromProps: Record, toProps: Record, + easeEach?: string, ): void { const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} } }`; + const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; const kfProp = parseExpr(`{ keyframes: {} }`).properties[0]; kfProp.value = parseExpr(kfCode); if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp); @@ -1359,10 +1386,9 @@ export function convertToKeyframesInScript( const originalEase = anim.ease; stripEditableAndEase(varsArg); - insertKeyframesProp(varsArg, fromProps, toProps); + insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined); if (originalEase) { - setVarsKey(varsArg, "easeEach", originalEase); setVarsKey(varsArg, "ease", "none"); } @@ -1400,3 +1426,158 @@ export function removeAllKeyframesFromScript(script: string, animationId: string return recast.print(loc.parsed.ast).code; } + +/** + * Replace a dynamic `keyframes: ` with a static percentage-keyframes object. + * Called when the user first edits a dynamically-generated keyframe in the studio. + */ +export function materializeKeyframesInScript( + script: string, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + + // Replace dynamic selector with resolved static string + if (resolvedSelector && loc.target.call.node.arguments[0]) { + loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector)); + } + + const entries: string[] = []; + const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); + for (const kf of sorted) { + const propEntries = Object.entries(kf.properties).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`); + entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`); + } + if (easeEach) { + entries.push(`easeEach: ${JSON.stringify(easeEach)}`); + } + + const kfObjCode = `{ ${entries.join(", ")} }`; + const kfParent = varsArg.properties.find( + (p: any) => isObjectProperty(p) && propKeyName(p) === "keyframes", + ); + if (kfParent) { + kfParent.value = parseExpr(kfObjCode); + } else { + const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0]; + varsArg.properties.unshift(kfProp); + } + + removeVarsKey(varsArg, "easeEach"); + + return recast.print(loc.parsed.ast).code; +} + +/** + * Replace a dynamic loop that generates multiple tween calls with individual + * static `tl.to()` calls — one per element. Finds the loop containing the + * animation and replaces the entire loop body with unrolled static calls. + */ +export function unrollDynamicAnimations( + script: string, + animationId: string, + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + + // Read duration and ease from the original tween vars + const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope); + const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope); + const duration = typeof durationVal === "number" ? durationVal : 8; + const ease = typeof easeVal === "string" ? easeVal : "none"; + const posArg = loc.target.call.positionArg; + const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0; + const posCode = + typeof position === "number" + ? String(position) + : typeof position === "string" + ? JSON.stringify(position) + : "0"; + + // Find the enclosing loop (for/forEach) by walking up the AST path + let loopNode: any = null; + let current = loc.target.call.path; + while (current) { + const node = current.node ?? current.value; + if ( + node?.type === "ForStatement" || + node?.type === "ForInStatement" || + node?.type === "ForOfStatement" || + node?.type === "WhileStatement" + ) { + loopNode = node; + break; + } + if ( + node?.type === "ExpressionStatement" && + node.expression?.type === "CallExpression" && + node.expression.callee?.property?.name === "forEach" + ) { + loopNode = node; + break; + } + current = current.parent ?? current.parentPath; + } + + // Build replacement code: individual tl.to() calls for each element + const calls: string[] = []; + for (const el of elements) { + const kfEntries: string[] = []; + const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage); + for (const kf of sorted) { + const propEntries = Object.entries(kf.properties).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`); + } + if (el.easeEach) { + kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`); + } + calls.push( + `tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, + ); + } + + const replacement = calls.join("\n "); + + if (loopNode) { + // Replace the entire loop with the unrolled calls + const start = loopNode.start ?? loopNode.range?.[0]; + const end = loopNode.end ?? loopNode.range?.[1]; + if (typeof start === "number" && typeof end === "number") { + return script.slice(0, start) + replacement + script.slice(end); + } + } + + // Fallback: replace just the tween call's enclosing expression statement + const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node; + if (stmtNode?.type === "ExpressionStatement") { + const start = stmtNode.start ?? stmtNode.range?.[0]; + const end = stmtNode.end ?? stmtNode.range?.[1]; + if (typeof start === "number" && typeof end === "number") { + return script.slice(0, start) + replacement + script.slice(end); + } + } + + return script; +} diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 0e482be5d..364c586da 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -23,6 +23,10 @@ export interface GsapAnimation { extras?: Record; /** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */ keyframes?: GsapKeyframesData; + /** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */ + hasUnresolvedKeyframes?: boolean; + /** True when the tween's target selector couldn't be statically resolved (dynamic). */ + hasUnresolvedSelector?: boolean; } export interface GsapPercentageKeyframe { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 8e409a322..fddd4321d 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -22,6 +22,7 @@ import { removeElementFromHtml, patchElementInHtml, probeElementInSource, + splitElementInHtml, type PatchOperation, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; @@ -316,6 +317,39 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { ); }); + api.post("/projects/:id/file-mutations/split-element/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "split-element"); + if ("error" in ctx) return ctx.error; + + const parsed = await parseMutationBody<{ + target?: { id?: string; selector?: string; selectorIndex?: number }; + splitTime?: number; + newId?: string; + }>(c); + if ("error" in parsed) return parsed.error; + if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) { + return c.json({ error: "target, splitTime, and newId required" }, 400); + } + + let originalContent: string; + try { + originalContent = readFileSync(ctx.absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const result = splitElementInHtml( + originalContent, + parsed.target, + parsed.body.splitTime, + parsed.body.newId, + ); + if (!result.matched) { + return c.json({ ok: false, changed: false, content: originalContent }); + } + writeFileSync(ctx.absPath, result.html, "utf-8"); + return c.json({ ok: true, changed: true, content: result.html, newId: result.newId }); + }); + api.post("/projects/:id/file-mutations/patch-element/*", async (c) => { const ctx = await resolveFileMutationContext(c, adapter, "patch-element"); if ("error" in ctx) return ctx.error; @@ -586,6 +620,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { percentage: number; properties: Record; ease?: string; + backfillDefaults?: Record; } | { type: "remove-keyframe"; animationId: string; percentage: number } | { @@ -600,7 +635,23 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { animationId: string; resolvedFromValues?: Record; } - | { type: "remove-all-keyframes"; animationId: string }; + | { type: "remove-all-keyframes"; animationId: string } + | { + type: "materialize-keyframes"; + animationId: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + easeEach?: string; + resolvedSelector?: string; + allElements?: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>; + }; api.post("/projects/:id/gsap-mutations/*", async (c) => { const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, { @@ -735,6 +786,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { body.percentage, body.properties, body.ease, + body.backfillDefaults, ); break; } @@ -768,6 +820,21 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId); break; } + case "materialize-keyframes": { + const { materializeKeyframesInScript, unrollDynamicAnimations } = await loadGsapParser(); + if (body.allElements && body.allElements.length > 0) { + newScript = unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements); + } else { + newScript = materializeKeyframesInScript( + block.scriptText, + body.animationId, + body.keyframes, + body.easeEach, + body.resolvedSelector, + ); + } + break; + } default: return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400); } diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index d280d0733..1535eb80a 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -3,7 +3,7 @@ import { getTimelineZoomPercent, } from "../player/components/timelineZoom"; import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; -import { usePlayerStore } from "../player"; +import { usePlayerStore, type TimelineElement } from "../player"; import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; @@ -85,6 +85,7 @@ interface DomEditSessionSlice { handleGsapRemoveKeyframe: (animId: string, pct: number) => void; handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void; handleGsapConvertToKeyframes: (animId: string) => void; + handleGsapMaterializeKeyframes: (animId: string) => Promise; handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; previewIframeRef?: React.RefObject; } @@ -92,6 +93,7 @@ interface DomEditSessionSlice { interface TimelineToolbarProps { toggleTimelineVisibility: () => void; domEditSession?: DomEditSessionSlice; + onSplitElement?: (element: TimelineElement, splitTime: number) => void; } // fallow-ignore-next-line complexity @@ -119,9 +121,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { // fallow-ignore-next-line complexity const onToggle = sel - ? () => { + ? async () => { const t = usePlayerStore.getState().currentTime; if (kfAnim?.keyframes) { + if (kfAnim.hasUnresolvedKeyframes) { + await session.handleGsapMaterializeKeyframes(kfAnim.id); + } const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; const pct = @@ -161,6 +166,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { export function TimelineToolbar({ toggleTimelineVisibility, domEditSession, + onSplitElement, }: TimelineToolbarProps) { const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); @@ -212,6 +218,38 @@ export function TimelineToolbar({ )} + {onSplitElement && ( + + + + )}
diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 39b7183da..c556718f6 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -68,6 +68,7 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, @@ -135,6 +136,7 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, @@ -196,6 +198,7 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 3a967e5e6..13c2ca665 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -12,6 +12,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { clearStudioPathOffset } from "../components/editor/manualEdits"; import { usePlayerStore } from "../player/store/playerStore"; +import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -99,6 +100,52 @@ function computeCurrentPercentage(selection: DomEditSelection): number { : 0; } +// ── Dynamic keyframe materialization ────────────────────────────────────── + +async function materializeIfDynamic( + anim: GsapAnimation, + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + selection: DomEditSelection, +): Promise { + if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return; + + if (anim.hasUnresolvedSelector) { + // Unroll: read ALL elements' keyframes from runtime and replace the loop + const allScanned = scanAllRuntimeKeyframes(iframe); + if (allScanned.size === 0) return; + const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ + selector: `#${id}`, + keyframes: data.keyframes, + easeEach: data.easeEach, + })); + await commitMutation( + selection, + { + type: "materialize-keyframes", + animationId: anim.id, + keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [], + allElements, + }, + { label: "Unroll dynamic animations", skipReload: true }, + ); + return `${anim.targetSelector}-to-0`; + } + + const runtime = readRuntimeKeyframes(iframe, anim.targetSelector); + if (!runtime || runtime.keyframes.length === 0) return; + await commitMutation( + selection, + { + type: "materialize-keyframes", + animationId: anim.id, + keyframes: runtime.keyframes, + easeEach: runtime.easeEach, + }, + { label: "Materialize dynamic keyframes", skipReload: true }, + ); +} + // ── High-level intercept ─────────────────────────────────────────────────── export interface GsapDragCommitCallbacks { @@ -192,10 +239,12 @@ async function commitGsapPositionFromDrag( const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { + const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); + const effectiveAnim = newId ? { ...anim, id: newId } : anim; const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); await commitKeyframedPosition( selection, - anim, + effectiveAnim, { ...runtimeProps, x: newX, y: newY }, callbacks, clearOffset, @@ -334,6 +383,24 @@ async function commitFromToPosition( // ── Runtime property reader ─────────────────────────────────────────────── +function readGsapProperty( + iframe: HTMLIFrameElement | null, + selector: string | null, + prop: string, +): number | null { + if (!iframe?.contentWindow || !selector) return null; + try { + const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + if (!gsap?.getProperty) return null; + const el = iframe.contentDocument?.querySelector(selector); + if (!el) return null; + const val = Number(gsap.getProperty(el, prop)); + return Number.isFinite(val) ? Math.round(val) : null; + } catch { + return null; + } +} + function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, @@ -396,7 +463,10 @@ export async function tryGsapResizeIntercept( const pct = computeCurrentPercentage(selection); - if (!anim.keyframes) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { await commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, @@ -406,6 +476,17 @@ export async function tryGsapResizeIntercept( const selector = selectorForSelection(selection); const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + + const backfillDefaults: Record = { ...runtimeProps }; + if (!("width" in runtimeProps)) { + const cssW = readGsapProperty(iframe, selector, "width"); + backfillDefaults.width = cssW ?? Math.round(size.width); + } + if (!("height" in runtimeProps)) { + const cssH = readGsapProperty(iframe, selector, "height"); + backfillDefaults.height = cssH ?? Math.round(size.height); + } + const properties = { ...runtimeProps, width: Math.round(size.width), @@ -419,6 +500,7 @@ export async function tryGsapResizeIntercept( animationId: anim.id, percentage: pct, properties, + backfillDefaults, }, { label: `Resize (keyframe ${pct}%)`, softReload: true }, ); @@ -466,7 +548,10 @@ export async function tryGsapRotationIntercept( const pct = computeCurrentPercentage(selection); const newRotation = Math.round(gsapRotation + angle); - if (!anim.keyframes) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { await commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, @@ -475,6 +560,12 @@ export async function tryGsapRotationIntercept( } const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + + const backfillDefaults: Record = { ...runtimeProps }; + if (!("rotation" in runtimeProps)) { + backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0; + } + const properties = { ...runtimeProps, rotation: newRotation }; await commitMutation( @@ -484,8 +575,11 @@ export async function tryGsapRotationIntercept( animationId: anim.id, percentage: pct, properties, + backfillDefaults, }, { label: `Rotate (keyframe ${pct}%)`, softReload: true }, ); return true; } + +export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts new file mode 100644 index 000000000..4aa976f59 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -0,0 +1,170 @@ +/** + * Read GSAP keyframe data from the live runtime in the preview iframe. + * Used to discover dynamic keyframes that the AST parser can't resolve + * (loops, variables, computed selectors). + */ + +interface RuntimeTween { + targets?: () => Element[]; + vars?: Record; + duration?: () => number; + startTime?: () => number; +} + +interface RuntimeTimeline { + getChildren?: (deep: boolean) => RuntimeTween[]; + duration?: () => number; +} + +export function readRuntimeKeyframes( + iframe: HTMLIFrameElement | null, + selector: string, + compositionId?: string, +): { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; +} | null { + if (!iframe?.contentWindow) return null; + + let timelines: Record | undefined; + try { + timelines = ( + iframe.contentWindow as unknown as { __timelines?: Record } + ).__timelines; + } catch { + return null; + } + if (!timelines) return null; + + const tlId = compositionId || Object.keys(timelines)[0]; + if (!tlId) return null; + const timeline = timelines[tlId]; + if (!timeline?.getChildren) return null; + + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return null; + } + if (!doc) return null; + + const targetEl = doc.querySelector(selector); + if (!targetEl) return null; + + for (const tween of timeline.getChildren(true)) { + if (!tween.targets || !tween.vars) continue; + let matches = false; + for (const t of tween.targets()) { + if (t === targetEl || (targetEl.id && t.id === targetEl.id)) { + matches = true; + break; + } + } + if (!matches) continue; + + const vars = tween.vars; + if (!vars.keyframes || typeof vars.keyframes !== "object") continue; + + const kfObj = vars.keyframes as Record; + const result: Array<{ percentage: number; properties: Record }> = []; + let easeEach: string | undefined; + + for (const [key, val] of Object.entries(kfObj)) { + if (key === "easeEach") { + if (typeof val === "string") easeEach = val; + continue; + } + const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); + if (!pctMatch || !val || typeof val !== "object") continue; + const percentage = parseFloat(pctMatch[1]); + const properties: Record = {}; + for (const [pk, pv] of Object.entries(val as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) { + result.push({ percentage, properties }); + } + } + + if (result.length > 0) { + result.sort((a, b) => a.percentage - b.percentage); + return { keyframes: result, easeEach }; + } + } + return null; +} + +// fallow-ignore-next-line complexity +export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map< + string, + { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + } +> { + const result = new Map< + string, + { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + } + >(); + if (!iframe?.contentWindow) return result; + + let timelines: Record | undefined; + try { + timelines = ( + iframe.contentWindow as unknown as { __timelines?: Record } + ).__timelines; + } catch { + return result; + } + if (!timelines) return result; + + for (const timeline of Object.values(timelines)) { + if (!timeline?.getChildren) continue; + for (const tween of timeline.getChildren(true)) { + if (!tween.targets || !tween.vars) continue; + const vars = tween.vars; + if (!vars.keyframes || typeof vars.keyframes !== "object") continue; + + const kfObj = vars.keyframes as Record; + const keyframes: Array<{ percentage: number; properties: Record }> = + []; + let easeEach: string | undefined; + + for (const [key, val] of Object.entries(kfObj)) { + if (key === "easeEach") { + if (typeof val === "string") easeEach = val; + continue; + } + const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); + if (!pctMatch || !val || typeof val !== "object") continue; + const percentage = parseFloat(pctMatch[1]); + const properties: Record = {}; + for (const [pk, pv] of Object.entries(val as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) { + keyframes.push({ percentage, properties }); + } + } + + if (keyframes.length === 0) continue; + keyframes.sort((a, b) => a.percentage - b.percentage); + + for (const target of tween.targets()) { + const id = (target as HTMLElement).id; + if (id && !result.has(id)) { + result.set(id, { keyframes, easeEach }); + } + } + } + } + return result; +} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 5edaef835..92aed8b00 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -27,6 +27,7 @@ import { tryGsapDragIntercept, tryGsapResizeIntercept, tryGsapRotationIntercept, + readRuntimeKeyframes, } from "./gsapRuntimeBridge"; // ── Types ── @@ -215,6 +216,7 @@ export function useDomEditSession({ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, gsapSourceFile, gsapCacheVersion, + previewIframeRef, ); const { @@ -228,6 +230,7 @@ export function useDomEditSession({ ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, gsapCacheVersion, + previewIframeRef, ); const { @@ -491,6 +494,49 @@ export function useDomEditSession({ [domEditSelection, convertToKeyframes], ); + const handleGsapMaterializeKeyframes = useCallback( + async (animId: string) => { + if (!domEditSelection || !gsapCommitMutation) return; + const anim = selectedGsapAnimations.find((a) => a.id === animId); + if (!anim || (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) || !anim.keyframes) + return; + if (anim.hasUnresolvedSelector) { + const { scanAllRuntimeKeyframes } = await import("./gsapRuntimeKeyframes"); + const allScanned = scanAllRuntimeKeyframes(previewIframeRef.current); + if (allScanned.size === 0) return; + const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ + selector: `#${id}`, + keyframes: data.keyframes, + easeEach: data.easeEach, + })); + await gsapCommitMutation( + domEditSelection, + { + type: "materialize-keyframes", + animationId: animId, + keyframes: allScanned.get(domEditSelection.id ?? "")?.keyframes ?? [], + allElements, + }, + { label: "Unroll dynamic animations", skipReload: true }, + ); + return; + } + const runtime = readRuntimeKeyframes(previewIframeRef.current, anim.targetSelector); + if (!runtime || runtime.keyframes.length === 0) return; + await gsapCommitMutation( + domEditSelection, + { + type: "materialize-keyframes", + animationId: animId, + keyframes: runtime.keyframes, + easeEach: runtime.easeEach, + }, + { label: "Materialize dynamic keyframes", skipReload: true }, + ); + }, + [domEditSelection, selectedGsapAnimations, gsapCommitMutation, previewIframeRef], + ); + const handleGsapRemoveAllKeyframes = useCallback( (animId: string) => { if (!domEditSelection) return; @@ -656,6 +702,7 @@ export function useDomEditSession({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache: bumpGsapCache, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 75484e307..4c5908f4a 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -164,6 +164,17 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (result.parsed?.animations) { + const { setKeyframeCache } = usePlayerStore.getState(); + for (const anim of result.parsed.animations) { + if (!anim.keyframes) continue; + const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; + if (!id) continue; + setKeyframeCache(`${targetPath}#${id}`, anim.keyframes); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); + } + } + if (options.skipReload) return; options.beforeReload?.(); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 70fafa9a4..b2c2d5280 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; +import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; function extractIdFromSelector(selector: string): string | null { const match = selector.match(/^#([\w-]+)/); @@ -53,6 +54,7 @@ export function useGsapAnimationsForElement( sourceFile: string, target: GsapElementTarget | null, version: number, + iframeRef?: React.RefObject, ): { animations: GsapAnimation[]; multipleTimelines: boolean; @@ -94,9 +96,23 @@ export function useGsapAnimationsForElement( }; }, [projectId, sourceFile, version]); + // Retry fetch if we have a target but no animations — handles cold-load race + // where the initial fetch runs before the drilled-down sourceFile is resolved + useEffect(() => { + if (!projectId || !target || allAnimations.length > 0) return; + const timer = setTimeout(() => { + fetchParsedAnimations(projectId, sourceFile).then((parsed) => { + if (parsed && parsed.animations.length > 0) { + setAllAnimations(parsed.animations); + } + }); + }, 800); + return () => clearTimeout(timer); + }, [projectId, sourceFile, target, allAnimations.length]); + const targetId = target?.id ?? null; const targetSelector = target?.selector ?? null; - const animations = useMemo( + const rawAnimations = useMemo( () => targetId || targetSelector ? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector }) @@ -104,6 +120,66 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); + const animations = useMemo(() => { + const iframe = iframeRef?.current; + let result = rawAnimations; + + // Enrich animations with unresolved keyframes from runtime + if (iframe) { + result = result.map((anim) => { + if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim; + const runtime = readRuntimeKeyframes(iframe, anim.targetSelector); + if (!runtime) return anim; + return { + ...anim, + keyframes: { + format: "percentage" as const, + keyframes: runtime.keyframes, + ...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}), + }, + }; + }); + } + + // Match unresolved-selector animations from the parser to runtime tweens + // targeting this element. This handles fully dynamic code (loop with variable selector). + if (iframe && targetId && result.length === 0) { + const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector); + if (unresolvedAnims.length > 0) { + const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`); + if (runtimeData) { + const scanned = scanAllRuntimeKeyframes(iframe); + const runtimeEntry = scanned.get(targetId); + if (runtimeEntry) { + // Find which unresolved animation index matches this element + // by correlating parser order with runtime tween order + const runtimeIds = Array.from(scanned.keys()); + const runtimeIndex = runtimeIds.indexOf(targetId); + const matchedAnim = + runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length + ? unresolvedAnims[runtimeIndex] + : unresolvedAnims[0]; + if (matchedAnim) { + result = [ + { + ...matchedAnim, + targetSelector: `#${targetId}`, + keyframes: { + format: "percentage" as const, + keyframes: runtimeEntry.keyframes, + ...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}), + }, + }, + ]; + } + } + } + } + } + + return result; + }, [rawAnimations, allAnimations, iframeRef, targetId]); + // Populate keyframe cache for the selected element. // Key format must match timeline element keys: "sourceFile#domId". const elementId = target?.id ?? null; @@ -132,28 +208,72 @@ export function usePopulateKeyframeCacheForFile( projectId: string | null, sourceFile: string, version: number, + iframeRef?: React.RefObject, ): void { const lastFetchKeyRef = useRef(""); + const runtimeScanDoneRef = useRef(""); + useEffect(() => { const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; + runtimeScanDoneRef.current = ""; if (!projectId) return; - let cancelled = false; - fetchParsedAnimations(projectId, sourceFile).then((parsed) => { - if (cancelled || !parsed) return; + const sf = sourceFile; + fetchParsedAnimations(projectId, sf).then((parsed) => { + if (!parsed) return; const { setKeyframeCache } = usePlayerStore.getState(); for (const anim of parsed.animations) { - if (!anim.keyframes) continue; const id = extractIdFromSelector(anim.targetSelector); - if (id) setKeyframeCache(`${sourceFile}#${id}`, anim.keyframes); + if (!id || !anim.keyframes) continue; + setKeyframeCache(`${sf}#${id}`, anim.keyframes); + if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); } + runtimeScanDoneRef.current = fetchKey; }); + }, [projectId, sourceFile, version]); - return () => { - cancelled = true; + // Separate effect for runtime keyframe discovery — polls until the iframe + // has loaded GSAP timelines, independent of the AST fetch lifecycle. + useEffect(() => { + if (!projectId) return; + const sf = sourceFile; + + let attempts = 0; + const maxAttempts = 10; + + const tryRuntimeScan = () => { + if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true; + const iframe = iframeRef?.current; + if (!iframe) return false; + const scanned = scanAllRuntimeKeyframes(iframe); + if (scanned.size === 0) return false; + const { setKeyframeCache, keyframeCache } = usePlayerStore.getState(); + for (const [id, data] of scanned) { + const cacheKey = `${sf}#${id}`; + const fallbackKey = `index.html#${id}`; + if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue; + const entry = { + format: "percentage" as const, + keyframes: data.keyframes, + ...(data.easeEach ? { easeEach: data.easeEach } : {}), + }; + setKeyframeCache(cacheKey, entry); + if (sf !== "index.html") setKeyframeCache(fallbackKey, entry); + } + runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`; + return true; }; - }, [projectId, sourceFile, version]); + + if (tryRuntimeScan()) return; + + const interval = setInterval(() => { + attempts++; + if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval); + }, 500); + + return () => clearInterval(interval); + }, [projectId, sourceFile, version, iframeRef]); }