diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 3d785c642..5ddc5889a 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -1792,6 +1792,19 @@ describe("keyframe mutations", () => { expect(kf100.properties.y).toBe(50); }); + it("updateKeyframeInScript — ease-only update preserves existing properties", () => { + // Per-keyframe ease editing passes empty properties + an ease. The existing + // property bag must survive (don't wipe x/opacity when only the ease changes). + const id = getAnimId(KF_SCRIPT); + const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, {}, "power2.inOut"); + const kf100 = parseGsapScript(updated).animations[0].keyframes!.keyframes.find( + (k) => k.percentage === 100, + )!; + expect(kf100.ease).toBe("power2.inOut"); + expect(kf100.properties.x).toBe(200); + expect(kf100.properties.opacity).toBe(1); + }); + // Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP // distributes them evenly. The motion-path overlay drags/adds by percentage, // which used to no-op on array-authored tweens (#puck-b / #shuttle). diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index a2f104712..07f93798b 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void { } } -function applyUpdatesToCall(call: TweenCallInfo, updates: Partial): void { +function applyUpdatesToCall( + call: TweenCallInfo, + updates: Partial & { easeEach?: string }, +): void { if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties); if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { reconcileEditableProperties(call.fromArg, updates.fromProperties); } if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration); - if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); + if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach); + else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); if (updates.position !== undefined) { const posIdx = call.method === "fromTo" ? 3 : 2; call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position)); @@ -1282,10 +1286,13 @@ function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void { function buildTweenStatementCode(timelineVar: string, anim: Omit): string { const selector = JSON.stringify(anim.targetSelector); const props: Record = { ...anim.properties }; - // `set` is instantaneous — GSAP ignores duration on it, so don't emit one. if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + // immediateRender forces GSAP to apply the set when added to the timeline, + // not on the first seek — without it, tl.set at position 0 on a paused + // timeline is invisible until the playhead moves past 0. + if (anim.method === "set") entries.push("immediateRender: true"); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); @@ -1308,7 +1315,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit, + updates: Partial & { easeEach?: string }, ): string { let parsed: ParsedGsapAst; try { @@ -1437,6 +1444,7 @@ export function addAnimationWithKeyframesToScript( auto?: boolean; }>, ease?: string, + easeEach?: string, ): { script: string; id: string } { let parsed: ParsedGsapAst; try { @@ -1450,7 +1458,7 @@ export function addAnimationWithKeyframesToScript( } const selector = JSON.stringify(targetSelector); - const kfCode = buildKeyframeObjectCode(keyframes); + const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined); const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`]; if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`); const posCode = valueToCode(position); @@ -2216,6 +2224,27 @@ export function updateKeyframeInScript( const match = findKeyframePropByPct(kfNode, percentage); if (!match) return script; + if (Object.keys(properties).length === 0 && ease) { + // Ease-only update: preserve existing properties, just add/replace ease + const existing = match.prop.value; + if (existing?.type === "ObjectExpression") { + const props = (existing.properties ?? []) as AstNode[]; + const easeIdx = props.findIndex( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease", + ); + const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0]; + if (easeIdx >= 0) { + props[easeIdx] = easeNode; + } else { + props.push(easeNode); + } + return recast.print(loc.parsed.ast).code; + } + // Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there + // is no property bag to merge the ease into. Rebuilding from empty + // `properties` would wipe the primitive — leave the keyframe untouched. + return script; + } match.prop.value = buildKeyframeValueNode(properties, ease); return recast.print(loc.parsed.ast).code; } diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 568bc255a..c4ef2998a 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null { export function updateAnimationInScript( script: string, animationId: string, - updates: Partial, + updates: Partial & { easeEach?: string }, ): string { if (!Object.keys(updates).length) return script; const parsed = parseGsapScriptAcornForWrite(script); @@ -324,13 +324,11 @@ export function updateAnimationInScript( if (updates.duration !== undefined) { upsertProp(ms, call.varsArg, "duration", updates.duration); } - if (updates.ease !== undefined) { - // For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe), - // not a top-level ease. Writing top-level ease would leave the per-keyframe - // easing unchanged — the user's edit would silently do nothing. + const easeValue = updates.easeEach ?? updates.ease; + if (easeValue !== undefined) { const kfNode = keyframesObjectNode(call.varsArg); - if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease); - else upsertProp(ms, call.varsArg, "ease", updates.ease); + if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue); + else upsertProp(ms, call.varsArg, "ease", easeValue); } if (updates.extras) { for (const [key, value] of Object.entries(updates.extras)) { @@ -1338,6 +1336,7 @@ export function addAnimationWithKeyframesToScript( auto?: boolean; }>, ease?: string, + easeEach?: string, ): { script: string; id: string } { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return { script, id: "" }; @@ -1345,7 +1344,7 @@ export function addAnimationWithKeyframesToScript( if (insertionPoint === null) return { script, id: "" }; const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); - const kfObjCode = buildKeyframeObjectCode(sorted); + const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`]; if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`); const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d7db8c778..5ed3dea2d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -72,6 +72,46 @@ export function initSandboxRuntimeModular(): void { } window.__timelines = window.__timelines || {}; + + // Resolve the root composition element with the same priority the rest of + // the runtime uses (explicit `data-root` marker first, then the topmost + // non-nested composition, then first in DOM order). Defined here so the + // array-normalization + data-start defaults below pick the same root the + // closure-based `resolveRootCompositionElement` does on multi-comp pages. + const findRootCompositionEl = (): HTMLElement | null => { + const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]'); + if (explicitRoot instanceof HTMLElement) return explicitRoot; + const nodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[]; + return ( + nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? + nodes[0] ?? + null + ); + }; + + // Agents often write `window.__timelines = [tl]` (array) instead of the + // keyed-by-composition-id object the runtime expects. Normalize at init so + // the rest of the pipeline can assume a Record. + if (Array.isArray(window.__timelines)) { + const arr = window.__timelines as unknown[]; + const rootId = findRootCompositionEl()?.getAttribute("data-composition-id") ?? "root"; + const normalized: Record = {}; + if (arr.length === 1) { + normalized[rootId] = arr[0]; + } else { + for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i]; + } + (window as Record).__timelines = normalized; + } + + // Agents sometimes omit data-start on the root composition element. The + // runtime skips timed-visibility for elements without it, making clips + // invisible and timelines non-seekable. Default to 0 for the root. + const rootComp = findRootCompositionEl(); + if (rootComp && !rootComp.hasAttribute("data-start")) { + rootComp.setAttribute("data-start", "0"); + } + const registerRuntimeCleanup = (callback: () => void) => { runtimeCleanupCallbacks.push(callback); }; @@ -218,23 +258,7 @@ export function initSandboxRuntimeModular(): void { return `${parsed}px`; }; - const resolveRootCompositionElement = (): HTMLElement | null => { - // 1. Explicit root marker takes priority - const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]'); - if (explicitRoot instanceof HTMLElement) { - return explicitRoot; - } - // 3. Topmost composition element (not nested inside another) - const compositionNodes = Array.from( - document.querySelectorAll("[data-composition-id]"), - ) as HTMLElement[]; - if (compositionNodes.length === 0) return null; - return ( - compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? - compositionNodes[0] ?? - null - ); - }; + const resolveRootCompositionElement = (): HTMLElement | null => findRootCompositionEl(); const applyCompositionSizing = () => { const rootEl = resolveRootCompositionElement(); @@ -1003,16 +1027,38 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.timeScale(state.playbackRate); } const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); + if (boundDuration <= 0) { + // No resolvable duration (e.g. a set()-only timeline, or one whose + // duration isn't known yet). Kick GSAP off the creation position so the + // set() renders. For a finite-but-zero timeline progress(1) === progress(0); + // for an infinite-repeat timeline this lands on the first iteration's end + // frame, which is the best we can do without a known cycle length. + if (typeof state.capturedTimeline.progress === "function") { + state.capturedTimeline.progress(1, true); + state.capturedTimeline.progress(0, false); + state.capturedTimeline.pause(); + } + } if (boundDuration > 0) { try { clock.setDuration(boundDuration); } catch { // clock not yet initialized — duration will be set during TransportClock setup } - state.capturedTimeline.pause(); - const seekTime = Math.max(0, state.currentTime || 0); + if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP won't render tl.set() at position 0 when the paused timeline + // starts there — play/pause/seek/totalTime are all no-ops at the + // creation position. Force the set to render by cycling progress past + // 0 (when the timeline implements it), then seek to the prior playhead + // (state.currentTime) so a rebind after a user scrub or soft-reload + // restore doesn't snap back to 0. + if (typeof state.capturedTimeline.progress === "function") { + state.capturedTimeline.progress(0.0001, true); + } + const seekTime = Math.max(0, state.currentTime || 0); state.capturedTimeline.totalTime(seekTime, false); + state.capturedTimeline.pause(); } // GSAP bakes the CSS `translate` into style.transform on seek. diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 9cfe694f0..c37058c93 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -443,6 +443,14 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v member.element.removeAttribute("data-hf-drag-initial-offset-y"); member.element.removeAttribute("data-hf-drag-gsap-base-x"); member.element.removeAttribute("data-hf-drag-gsap-base-y"); + // Clear the draft's `translate: none` so the soft reload starts clean — + // otherwise button-less pointermoves after the reload compute deltas + // from a stale base and fling the element off-screen (#1673). + // Do NOT clearProps:"transform" — that nukes the committed GSAP position + // and causes a visual snap-back before the soft reload re-applies it. + if (member.element.style.getPropertyValue("translate") === "none") { + member.element.style.removeProperty("translate"); + } resumeGsapTimelines(member.element); } } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index e0f2c6131..54b3ecbd1 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -239,7 +239,9 @@ export async function tryGsapDragIntercept( // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in // place (idempotent), else add a new one. This also covers the stale-cache // phantom — committing a set is correct because the element genuinely has no live motion. - if (!hasNonHoldTweenForElement(iframe, selector)) { + const hasNonHold = hasNonHoldTweenForElement(iframe, selector); + + if (!hasNonHold) { const existingSet = posAnim && posAnim.method === "set" && posAnim.targetSelector === selector ? posAnim @@ -251,7 +253,9 @@ export async function tryGsapDragIntercept( return true; } - if (!posAnim) return false; + if (!posAnim) { + return false; + } // Verify the anim ID is still valid in the current file. The React-state // `animations` list can lag behind the file after a prior mutation changed