diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index af649ed614..89a70b5247 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -978,106 +978,6 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); - // gsap_studio_edit_blocked - it("warns when script registers timeline AND has GSAP tweens targeting #id selectors", async () => { - const html = ` - -
-
Hello
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("warning"); - expect(finding?.message).toContain('"#headline"'); - }); - - it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => { - const html = ` - -
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeDefined(); - expect(finding?.message).toContain('".box"'); - }); - - it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => { - const html = ` - -
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeUndefined(); - }); - - it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => { - const html = ` - -
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeUndefined(); - }); - - it("lists all unique targeted selectors in the warning message", async () => { - const html = ` - -
-
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeDefined(); - expect(finding?.message).toContain('"#title"'); - expect(finding?.message).toContain('"#sub"'); - }); - it("scene_layer_missing_visibility_kill: fires when multi-scene exit lacks hard kill", async () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 4e2d67b743..257d01af69 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -27,7 +27,6 @@ import { truncateSnippet, stripJsComments, WINDOW_TIMELINE_ASSIGN_PATTERN, - TIMELINE_REGISTRY_ASSIGN_PATTERN, } from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -855,39 +854,4 @@ export const gsapRules: LintRule[] = [ } return findings; }, - - // gsap_studio_edit_blocked - // When a script both registers a timeline on window.__timelines AND contains - // GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted - // check returns true for those elements and silently skips saving drag/resize - // position changes back to source HTML. - ({ scripts }) => { - const findings: HyperframeLintFinding[] = []; - const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g; - - for (const script of scripts) { - const content = stripJsComments(script.content); - if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue; - - const targets = new Set(); - let match: RegExpExecArray | null; - const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g"); - while ((match = re.exec(content)) !== null) { - if (match[1]) targets.add(match[1]); - } - if (targets.size === 0) continue; - - const selList = [...targets].map((s) => `"${s}"`).join(", "); - findings.push({ - code: "gsap_studio_edit_blocked", - severity: "warning", - message: `GSAP tweens target ${selList} in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.`, - fixHint: - "The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " + - "For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " + - "If GSAP must own these elements' positions, avoid drag-editing them in Studio.", - }); - } - return findings; - }, ]; diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 47eac2c40d..3d785c6426 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -2002,6 +2002,31 @@ describe("keyframe mutations", () => { expect(updated).toBe("const x = 1;"); }); + it("addMotionPathToScript + hold-sync — holds (0,0) at t=0 when authored past t=0", () => { + // A motionPath authored at position > 0 parses with a first keyframe of (0,0). + // Without a pre-tween hold the element would snap to its CSS home at frame 0 and + // jump when the tween starts — this is why `add-motion-path` is hold-synced. + const script = `const tl = gsap.timeline({ paused: true });`; + const { script: withPath } = addMotionPathToScript(script, "#el", 2.0, 1.5, { + x: 300, + y: -100, + }); + const synced = syncPositionHoldsBeforeKeyframes(withPath); + const hold = parseGsapScript(synced).animations.find((a) => a.method === "set"); + expect(hold).toBeDefined(); + expect(hold!.position).toBe(0); + expect(hold!.properties).toMatchObject({ x: 0, y: 0 }); + }); + + it("addMotionPathToScript + hold-sync — adds no hold when authored at t=0", () => { + const script = `const tl = gsap.timeline({ paused: true });`; + const { script: withPath } = addMotionPathToScript(script, "#el", 0, 1.5, { + x: 300, + y: -100, + }); + expect(syncPositionHoldsBeforeKeyframes(withPath)).not.toContain("hf-hold"); + }); + // ── convertToKeyframesInScript ────────────────────────────────────────── it("convertToKeyframesInScript — converts flat to() tween", () => { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bdc0e2c3a2..f3962140c5 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -410,23 +410,6 @@ export function initSandboxRuntimeModular(): void { return resolveStartForElement(element, fallback); }; - const findTimedClipAncestor = ( - element: HTMLElement, - rootComp: HTMLElement | null, - ): HTMLElement | null => { - let node = element.parentElement; - while (node) { - // rootComp may be null when no composition is mounted; the walk still - // terminates via `while (node)` — node === null is never true here. - if (node === rootComp) break; - if (node.hasAttribute("data-start")) { - return node; - } - node = node.parentElement; - } - return null; - }; - const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { @@ -1073,6 +1056,21 @@ export function initSandboxRuntimeModular(): void { const dur = String(rootDuration > 0 ? rootDuration : 1); const seen = new Set(); + // Only an AUTHORED clip (data-start already in the source, captured before + // we stamp anything) should suppress stamping its descendants. An animated + // scene container we auto-stamp below (e.g. an opacity-crossfaded scene) + // must NOT suppress its own animated children — otherwise those children + // never become timeline clips and that scene can't inline-expand. + const authoredTimed = new Set(document.querySelectorAll("[data-start]")); + const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => { + let node = element.parentElement; + while (node && node !== rootComp) { + if (authoredTimed.has(node)) return true; + node = node.parentElement; + } + return false; + }; + // Stamp GSAP-targeted elements if (state.capturedTimeline.getChildren) { try { @@ -1082,7 +1080,7 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(target, rootComp)) continue; + if (hasAuthoredTimedAncestor(target)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1102,7 +1100,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(el, rootComp)) continue; + if (hasAuthoredTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); @@ -1439,6 +1437,46 @@ export function initSandboxRuntimeModular(): void { }; // fallow-ignore-next-line complexity + // Whether a timed clip participates in normal flow (static/relative/sticky). + // In-flow clips must leave the flow when hidden — `visibility:hidden` reserves + // their layout box, so a split sibling would stack below the active half + // instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper, + // and avoids disturbing absolute media playback). Computed once per element. + let timedClipInFlow = new WeakMap(); + const isTimedClipInFlow = (el: HTMLElement): boolean => { + const cached = timedClipInFlow.get(el); + if (cached !== undefined) return cached; + const pos = window.getComputedStyle(el).position; + const inFlow = pos === "static" || pos === "relative" || pos === "sticky"; + timedClipInFlow.set(el, inFlow); + return inFlow; + }; + + // `display:none` is only safe on a LEAF timed clip (no nested timed clips). On a + // container it removes the whole subtree, hiding descendants that are still inside + // their OWN visibility window — e.g. an in-flow composition root whose window + // clamps to the timeline end would black out a child video that should still + // show. `visibility:hidden` doesn't have this problem (a child can override it + // with `visibility:visible`), so containers keep that and only leaves leave-flow. + let timedClipIsLeaf = new WeakMap(); + const isTimedClipLeaf = (el: HTMLElement): boolean => { + const cached = timedClipIsLeaf.get(el); + if (cached !== undefined) return cached; + const leaf = el.querySelector("[data-start]") === null; + timedClipIsLeaf.set(el, leaf); + return leaf; + }; + + // Both caches key on live DOM facts that change when the timed-element set + // changes: leaf status flips when a clip gains/loses a nested `[data-start]` + // descendant (sub-composition load/unload, studio insert/delete), and a swapped + // element can reuse an identity whose in-flow status differs. WeakMap has no + // `clear()`, so drop both maps wholesale — re-derived lazily on next access. + const invalidateTimedClipCaches = () => { + timedClipInFlow = new WeakMap(); + timedClipIsLeaf = new WeakMap(); + }; + const syncMediaForCurrentState = () => { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); @@ -1544,6 +1582,11 @@ export function initSandboxRuntimeModular(): void { if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); } + if (isVisibleNow) { + if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display"); + } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) { + rawNode.style.display = "none"; + } } }; @@ -1605,6 +1648,10 @@ export function initSandboxRuntimeModular(): void { window.__clipManifest = payload; const currentSignature = computeClipTreeSignature(); + if (clipTreeSignature !== currentSignature) { + // The timed-element set changed — leaf/in-flow caches may be stale. + invalidateTimedClipCaches(); + } if (!window.__clipTree || clipTreeSignature !== currentSignature) { const runtimeWindow = window as Window & { __timelines?: Record; diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 06bb7155e4..a0f3dedcb1 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -508,6 +508,28 @@ describe("splitElementInHtml", () => { expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false); }); + it("splits a GSAP element with no authored timing using fallback timing", () => { + // #title has no data-start/data-duration (GSAP-driven); the store supplies the range. + const gsapSource = `

Hi

`; + const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", { + start: 0, + duration: 6, + }); + expect(result.matched).toBe(true); + // original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined) + const original = result.html.match(/]*\bid="title"[^>]*>/)![0]; + expect(original).toContain('data-start="0"'); + expect(original).toContain('data-duration="2"'); + const clone = result.html.match(/]*\bid="title-split"[^>]*>/)![0]; + expect(clone).toContain('data-start="2"'); + expect(clone).toContain('data-duration="4"'); + }); + + it("still rejects a no-timing element when no fallback timing is given", () => { + const gsapSource = `

Hi

`; + expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false); + }); + it("adjusts media playback-start for the second half", () => { const mediaSource = source.replace( 'id="box" class="clip" data-start="1" data-duration="6"', diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index ddbf1c1036..727cd326a2 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -292,12 +292,23 @@ export function splitElementInHtml( target: SourceMutationTarget, splitTime: number, newId: string, + fallbackTiming?: { start: number; duration: number }, ): SplitElementResult { const { document, wrappedFragment } = parseSourceDocument(source); const el = findTargetElement(document, target); if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; - const { start, duration, usesDataEnd } = resolveElementTiming(el); + const timing = resolveElementTiming(el); + const { usesDataEnd } = timing; + let { start, duration } = timing; + // GSAP-animated elements carry their timing in the script, not in data-* attrs, + // so the source has no authored duration. Fall back to the store's (GSAP-derived) + // range — the runtime windows visibility off data-start/data-duration regardless + // of class, so stamping both halves below makes each half show only in its window. + if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) { + start = fallbackTiming.start; + duration = fallbackTiming.duration; + } if (duration <= 0 || splitTime <= start || splitTime >= start + duration) { return { html: source, matched: false, newId: null }; } @@ -316,6 +327,9 @@ export function splitElementInHtml( const clone = el.cloneNode(true) as HTMLElement; clone.setAttribute("id", newId); clone.removeAttribute("data-hf-id"); + // Descendants carry their own data-hf-id; leaving them duplicates the id of + // every nested node (e.g. an inner ), so strip them on the clone too. + for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id"); clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); setElementDuration(clone, splitTime, secondDuration, usesDataEnd); @@ -344,7 +358,9 @@ export function splitElementInHtml( duplicateCssRulesForId(document, originalId, newId); } - // Trim the original element's duration + // Trim the original element's duration. A GSAP element had no data-start; stamp + // it so the runtime windows the first half (visibility selects on [data-start]). + el.setAttribute("data-start", String(Math.round(start * 1000) / 1000)); setElementDuration(el, start, firstDuration, usesDataEnd); // Insert clone after original diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/core/src/studio-api/routes/files.test.ts index 302831dba5..c65238a089 100644 --- a/packages/core/src/studio-api/routes/files.test.ts +++ b/packages/core/src/studio-api/routes/files.test.ts @@ -503,6 +503,46 @@ const tl = gsap.timeline(); expect(body.error).toContain("fromProperties"); }); + // A rotation-only keyframe set must strip the legacy studio rotation channel just + // as a position keyframe set strips the offset channel — otherwise --hf-studio-rotation + // double-applies on top of the new GSAP rotation tween. + it("replace-with-keyframes strips studio rotation edits for a rotation-only keyframe set", async () => { + const projectDir = createProjectDir(); + const ROT_COMP = ` +
+ +`; + writeHtml(projectDir, "rot.html", ROT_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "rot.html"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/rot.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: "#box", + position: 0, + duration: 1, + keyframes: [ + { percentage: 0, properties: { rotation: 0 } }, + { percentage: 100, properties: { rotation: 90 } }, + ], + }), + }); + const result = (await res.json()) as { ok: boolean; after: string }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.after).not.toContain("--hf-studio-rotation"); + expect(result.after).not.toContain("data-hf-studio-rotation"); + }); + it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => { const projectDir = createProjectDir(); writeComp(projectDir, "scene.html", TEMPLATE_COMP); diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 0ce977592d..b6b288b540 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -28,6 +28,7 @@ import { type UnsafeMutationValue, } from "../helpers/finiteMutation.js"; import type { GsapAnimation } from "../../parsers/gsapSerialize.js"; +import { classifyPropertyGroup } from "../../parsers/gsapConstants.js"; import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js"; import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js"; import { @@ -315,21 +316,49 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe let stripped = 0; try { for (const el of document.querySelectorAll(selector)) { - if (!el.getAttribute("data-hf-studio-path-offset")) continue; if (!isHTMLElement(el)) continue; const htmlEl = el; - const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate"); - htmlEl.style.removeProperty("--hf-studio-offset-x"); - htmlEl.style.removeProperty("--hf-studio-offset-y"); - if (originalTranslate) { - htmlEl.style.setProperty("translate", originalTranslate); - } else { - htmlEl.style.removeProperty("translate"); + let touched = false; + // Manual path offset (--hf-studio-offset / translate) — a GSAP position tween + // now owns position, so the stale offset channel must go. + if (el.getAttribute("data-hf-studio-path-offset")) { + const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate"); + htmlEl.style.removeProperty("--hf-studio-offset-x"); + htmlEl.style.removeProperty("--hf-studio-offset-y"); + if (originalTranslate) { + htmlEl.style.setProperty("translate", originalTranslate); + } else { + htmlEl.style.removeProperty("translate"); + } + el.removeAttribute("data-hf-studio-path-offset"); + el.removeAttribute("data-hf-studio-original-translate"); + el.removeAttribute("data-hf-studio-original-inline-translate"); + touched = true; + } + // Manual rotation (--hf-studio-rotation / rotate) — likewise, a GSAP rotation + // set/tween now owns rotation, so clear the legacy CSS-var channel. + if (el.getAttribute("data-hf-studio-rotation")) { + const originalRotate = el.getAttribute("data-hf-studio-original-inline-rotate"); + const originalOrigin = el.getAttribute("data-hf-studio-original-rotation-transform-origin"); + htmlEl.style.removeProperty("--hf-studio-rotation"); + if (originalRotate) { + htmlEl.style.setProperty("rotate", originalRotate); + } else { + htmlEl.style.removeProperty("rotate"); + } + if (originalOrigin) { + htmlEl.style.setProperty("transform-origin", originalOrigin); + } else { + htmlEl.style.removeProperty("transform-origin"); + } + el.removeAttribute("data-hf-studio-rotation"); + el.removeAttribute("data-hf-studio-rotation-draft"); + el.removeAttribute("data-hf-studio-original-rotate"); + el.removeAttribute("data-hf-studio-original-inline-rotate"); + el.removeAttribute("data-hf-studio-original-rotation-transform-origin"); + touched = true; } - el.removeAttribute("data-hf-studio-path-offset"); - el.removeAttribute("data-hf-studio-original-translate"); - el.removeAttribute("data-hf-studio-original-inline-translate"); - stripped++; + if (touched) stripped++; } } catch { // Invalid selector — skip silently. @@ -337,6 +366,30 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe return stripped; } +// A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP +// position tween both drive translate — keeping both stacks the offsets (a gesture or +// drag recorded over a stale offset plays shoved off-position). When a committed tween +// writes a position property, the tween owns position, so the stale offset must go. +function keyframesWritePosition( + keyframes: Array<{ properties: Record }>, +): boolean { + return keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), + ); +} + +// A studio rotation edit (--hf-studio-rotation / data-hf-studio-rotation) and a GSAP +// rotation tween both drive rotate — keeping both stacks them. When a committed keyframe +// set writes a rotation property, the tween owns rotation, so the stale CSS-var channel +// must go (the position twin of this is `keyframesWritePosition`). +function keyframesWriteRotation( + keyframes: Array<{ properties: Record }>, +): boolean { + return keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "rotation"), + ); +} + function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined { if (!kfs) return undefined; for (let i = kfs.keyframes.length - 1; i >= 0; i--) { @@ -468,6 +521,24 @@ type GsapMutationRequest = cp1?: { x: number; y: number }; cp2?: { x: number; y: number }; } + | { + type: "update-motion-path-point"; + animationId: string; + pointIndex: number; + x: number; + y: number; + } + | { type: "add-motion-path-point"; animationId: string; index: number; x: number; y: number } + | { type: "remove-motion-path-point"; animationId: string; index: number } + | { + type: "add-motion-path"; + targetSelector: string; + position: number; + duration: number; + x: number; + y: number; + ease?: string; + } | { type: "remove-arc-path"; animationId: string } | { type: "add-with-keyframes"; @@ -535,6 +606,41 @@ type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +// Mutations that can change a position tween's first keyframe (value/existence/timing) +// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. +// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts +// on every tween that has keyframes whose first percentage carries a position prop and +// whose start is > 0. So any mutation that creates such a tween, retargets it, or moves +// its start across the t=0 boundary must trigger a re-sync. +const HOLD_SYNC_MUTATION_TYPES = new Set([ + "add-keyframe", + "update-keyframe", + "remove-keyframe", + "remove-all-keyframes", + "add-with-keyframes", + "replace-with-keyframes", + "convert-to-keyframes", + "materialize-keyframes", + "update-motion-path-point", + "add-motion-path-point", + "remove-motion-path-point", + // Authors a fresh motionPath tween whose parsed first keyframe is (0,0); if it lands + // at position > 0 the element snaps home at t=0 without a pre-tween hold-`set`. + "add-motion-path", + // Can move a tween's `position` (start) across the t=0 boundary, which flips whether a + // keyframed position tween needs a hold (started at 0 → moved later, or vice versa). + "update-meta", + // Time-shift / time-scale tweens, which can move a keyframed position tween's start + // across t=0, flipping hold need; stale holds are not repositioned by these ops. + "shift-positions", + "scale-positions", + // Retargets keyframed position tweens to a cloned element's selector; the old hold is + // keyed to the prior selector, so holds must be rebuilt for the new target. + "split-animations", + "delete", + "delete-all-for-selector", +]); + async function executeGsapMutation( body: GsapMutationRequest, block: NonNullable>, @@ -824,6 +930,10 @@ async function executeGsapMutationRecast( unrollDynamicAnimations, setArcPathInScript, updateArcSegmentInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, @@ -877,6 +987,17 @@ async function executeGsapMutationRecast( if (body.fromProperties && body.method !== "fromTo") { return respond({ error: "fromProperties is only valid for method=fromTo" }, 400); } + // A new position/rotation animation owns that channel — strip the matching + // legacy studio CSS var (--hf-studio-offset / --hf-studio-rotation) so it can't + // double with the tween, matching add-with-keyframes/replace-with-keyframes. + if ( + Object.keys(body.properties).some((k) => { + const group = classifyPropertyGroup(k); + return group === "position" || group === "rotation"; + }) + ) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationToScript(block.scriptText, { targetSelector: body.targetSelector, method: body.method, @@ -987,10 +1108,39 @@ async function executeGsapMutationRecast( ...(body.cp2 ? { cp2: body.cp2 } : {}), }); } + case "update-motion-path-point": { + return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, { + x: body.x, + y: body.y, + }); + } + case "add-motion-path-point": { + return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, { + x: body.x, + y: body.y, + }); + } + case "remove-motion-path-point": { + return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index); + } + case "add-motion-path": { + const result = addMotionPathToScript( + block.scriptText, + body.targetSelector, + body.position, + body.duration, + { x: body.x, y: body.y }, + body.ease, + ); + return result.script; + } case "remove-arc-path": { return removeArcPathFromScript(block.scriptText, body.animationId); } case "add-with-keyframes": { + if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationWithKeyframesToScript( block.scriptText, body.targetSelector, @@ -1002,6 +1152,9 @@ async function executeGsapMutationRecast( return result.script; } case "replace-with-keyframes": { + if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const script = removeAnimationFromScript(block.scriptText, body.animationId); const added = addAnimationWithKeyframesToScript( script, @@ -1277,11 +1430,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { target?: { id?: string; selector?: string; selectorIndex?: number }; splitTime?: number; newId?: string; + elementStart?: number; + elementDuration?: number; }>(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); } + const fallbackTiming = + typeof parsed.body.elementStart === "number" && + typeof parsed.body.elementDuration === "number" + ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } + : undefined; let originalContent: string; try { @@ -1294,6 +1454,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { parsed.target, parsed.body.splitTime, parsed.body.newId, + fallbackTiming, ); if (!result.matched) { return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); @@ -1537,7 +1698,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const result = await executeGsapMutation(body, block, respond); if (result instanceof Response) return result; - const newScript = typeof result === "string" ? result : result.script; + let newScript = typeof result === "string" ? result : result.script; + // Keep the "hold before first keyframe" sets in sync after any mutation that can + // change a position tween's first keyframe or its existence. Without it, an + // element snaps to its CSS base before the tween starts instead of holding its + // first keyframe (the universal NLE behavior). + if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) { + const parser = await loadGsapParser(); + newScript = parser.syncPositionHoldsBeforeKeyframes(newScript); + } const changed = newScript !== block.scriptText; const newHtml = changed ? block.replaceScript(newScript) : html; let backupPath: string | null = null;