diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 015362395..5976e4b66 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -77,7 +77,10 @@ export function classifyTweenPropertyGroup( ): PropertyGroupName | undefined { const groups = new Set(); for (const key of Object.keys(properties)) { - if (key === "transformOrigin") continue; + // transformOrigin is a modifier; `_auto` is Studio's internal endpoint marker; + // `data` is GSAP-reserved (carries the Studio hold-set tag). None is an animated + // property, so none should affect the group. + if (key === "transformOrigin" || key === "_auto" || key === "data") continue; const g = classifyPropertyGroup(key); groups.add(g); } diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 7f56fd0a7..2e927b2dd 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -14,11 +14,16 @@ import { addKeyframeToScript, removeKeyframeFromScript, updateKeyframeInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, convertToKeyframesInScript, removeAllKeyframesFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, splitIntoPropertyGroups, + syncPositionHoldsBeforeKeyframes, shiftPositionsInScript, scalePositionsInScript, } from "./gsapParser.js"; @@ -483,6 +488,12 @@ describe("property group classification", () => { ); }); + it("ignores the internal `_auto` endpoint marker when classifying", () => { + // Regression: the `_auto: 1` sentinel on auto-generated endpoint keyframes must + // not pull a position tween into a mixed group, or drag-intercept can't resolve it. + expect(classifyTweenPropertyGroup({ x: 100, y: 50, _auto: 1 })).toBe("position"); + }); + it("returns undefined for mixed-group tweens", () => { expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); @@ -1560,6 +1571,98 @@ describe("keyframe mutations", () => { expect(kfs[1].properties.x).toBe(999); }); + // ── backfillDefaults: editing one keyframe must not move the others ────── + // UX invariant (CapCut/AE): keyframes are independent. Introducing a property + // to one keyframe (e.g. `y` on an x-only tween) must backfill the other + // keyframes at the element's base value — otherwise GSAP holds the new prop's + // value across keyframes that omit it, dragging them to the same position. + const X_ONLY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#puck", { keyframes: { "0%": { x: 0 }, "100%": { x: -260 } }, duration: 2.2 }, 1.2); + `; + + it("addKeyframeToScript — WITHOUT backfill, the other keyframe omits the new prop (GSAP would hold it)", () => { + const id = getAnimId(X_ONLY_SCRIPT); + const updated = addKeyframeToScript(X_ONLY_SCRIPT, id, 0, { x: 240, y: 780 }); + const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes; + const kf100 = kfs.find((k) => k.percentage === 100)!; + expect(kf100.properties.x).toBe(-260); + expect("y" in kf100.properties).toBe(false); // <- the bug surface + }); + + it("addKeyframeToScript — WITH backfill, the new prop is added to the other keyframe at base (it stays put)", () => { + const id = getAnimId(X_ONLY_SCRIPT); + const updated = addKeyframeToScript(X_ONLY_SCRIPT, id, 0, { x: 240, y: 780 }, undefined, { + x: 0, + y: 0, + }); + const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes; + const kf0 = kfs.find((k) => k.percentage === 0)!; + const kf100 = kfs.find((k) => k.percentage === 100)!; + // edited keyframe holds the drag + expect(kf0.properties).toMatchObject({ x: 240, y: 780 }); + // other keyframe keeps its own x and gets y at base (0) — not 780 + expect(kf100.properties.x).toBe(-260); + expect(kf100.properties.y).toBe(0); + }); + + // ── syncPositionHoldsBeforeKeyframes (hold before first keyframe) ──────── + // UX invariant (every NLE): before the first keyframe, the element holds that + // keyframe's value — it must NOT snap to its CSS base then jump when the tween + // starts. Implemented as a tagged `tl.set(...,0)` kept in sync with the tween. + describe("syncPositionHoldsBeforeKeyframes", () => { + const posTweenAt = (start: number) => + `const tl = gsap.timeline({ paused: true });\n` + + `tl.to("#p", { keyframes: { "0%": { x: -1500, y: 700 }, "100%": { x: -260, y: 0 } }, duration: 2.2 }, ${start});`; + + it("inserts a hold set holding the first keyframe's position at t=0", () => { + const out = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const anims = parseGsapScript(out).animations; + const hold = anims.find((a) => a.method === "set"); + expect(hold).toBeDefined(); + expect(hold!.position).toBe(0); + expect(hold!.properties).toMatchObject({ x: -1500, y: 700 }); + }); + + it("is idempotent (re-running does not stack holds)", () => { + const once = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + expect(syncPositionHoldsBeforeKeyframes(once)).toBe(once); + expect((once.match(/hf-hold/g) ?? []).length).toBe(1); + }); + + it("re-syncs the hold value when the first keyframe changes", () => { + const out1 = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const moved = updateKeyframeInScript( + out1, + parseGsapScript(out1).animations.find((a) => a.keyframes)!.id, + 0, + { x: 99, y: 88 }, + ); + const out2 = syncPositionHoldsBeforeKeyframes(moved); + const hold = parseGsapScript(out2).animations.find((a) => a.method === "set"); + expect(hold!.properties).toMatchObject({ x: 99, y: 88 }); + expect((out2.match(/hf-hold/g) ?? []).length).toBe(1); // still just one + }); + + it("adds no hold for a tween that already starts at t=0", () => { + expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold"); + }); + + it("adds no hold for an opacity-only keyframed tween (position-scoped)", () => { + const opacity = + `const tl = gsap.timeline({ paused: true });\n` + + `tl.to("#b", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 1 }, 2);`; + expect(syncPositionHoldsBeforeKeyframes(opacity)).not.toContain("hf-hold"); + }); + + it("removes an orphaned hold when its tween is gone", () => { + const withHold = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const tweenId = parseGsapScript(withHold).animations.find((a) => a.keyframes)!.id; + const deleted = removeAnimationFromScript(withHold, tweenId); + expect(syncPositionHoldsBeforeKeyframes(deleted)).not.toContain("hf-hold"); + }); + }); + // ── _auto endpoint updates ──────────────────────────────────────────── const AUTO_SCRIPT = ` @@ -1681,6 +1784,204 @@ describe("keyframe mutations", () => { expect(kf100.properties.y).toBe(50); }); + // 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). + const ARRAY_KF_SCRIPT = + "const tl = gsap.timeline();\n" + + 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);'; + + it("updateKeyframeInScript — array-form: drags node 2 (pct 33.3) by index", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 33.3, { x: 503, y: 642 }); + expect(updated).not.toBe(ARRAY_KF_SCRIPT); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect([kf[1]!.properties.x, kf[1]!.properties.y]).toEqual([503, 642]); + expect([kf[0]!.properties.x, kf[0]!.properties.y]).toEqual([0, 0]); + expect([kf[2]!.properties.x, kf[2]!.properties.y]).toEqual([1040, 0]); + }); + + it("addKeyframeToScript — array-form: normalizes to object form + inserts 50%", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = addKeyframeToScript(ARRAY_KF_SCRIPT, id, 50, { x: 780, y: 60 }); + expect(updated).not.toBe(ARRAY_KF_SCRIPT); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect(kf.length).toBe(5); + const at50 = kf.find((k) => Math.abs(k.percentage - 50) < 1)!; + expect([at50.properties.x, at50.properties.y]).toEqual([780, 60]); + }); + + it("removeKeyframeFromScript — array-form: drops node 3 (pct 66.7)", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = removeKeyframeFromScript(ARRAY_KF_SCRIPT, id, 66.7); + expect(updated).not.toBe(ARRAY_KF_SCRIPT); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect(kf.length).toBe(3); + }); + + it("updateKeyframeInScript — stale position-id resolves to the nearest same-selector tween", () => { + // Tween authored at 1.0s → id "#el-to-1000-position". A client that cached the + // pre-reposition id "#el-to-1200-position" (a gesture/convert moved it) must + // still resolve, instead of no-op'ing. + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#el", { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 50, y: 50 } }, duration: 2 }, 1);'; + const updated = updateKeyframeInScript(script, "#el-to-1200-position", 100, { x: 77, y: 88 }); + expect(updated).not.toBe(script); + const at100 = parseGsapScript(updated).animations[0].keyframes!.keyframes.find( + (k) => k.percentage === 100, + )!; + expect([at100.properties.x, at100.properties.y]).toEqual([77, 88]); + }); + + // ── updateMotionPathPointInScript ─────────────────────────────────────── + + const MOTION_PATH_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { + motionPath: { + path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}], + curviness: 1.5 + }, + duration: 2 + }, 0); + `; + + it("updateMotionPathPointInScript — moves one waypoint, preserves the rest and curviness", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = updateMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1, { x: 250, y: -140 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + const wp = anim.keyframes!.keyframes; + expect(wp.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [250, -140], + [400, 50], + ]); + expect(anim.arcPath!.segments[0].curviness).toBe(1.5); + expect(anim.arcPath!.segments[1].curviness).toBe(1.5); + }); + + it("updateMotionPathPointInScript — out-of-range index leaves the script unchanged", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + expect(updateMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 9, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("updateMotionPathPointInScript — unknown animation id leaves the script unchanged", () => { + expect(updateMotionPathPointInScript(MOTION_PATH_SCRIPT, "nope", 0, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("updateMotionPathPointInScript — moves a cubic anchor, keeps control points", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { + motionPath: { + path: [ + {x: 0, y: 0}, + {x: 50, y: -80}, {x: 150, y: -120}, + {x: 200, y: -100} + ], + type: "cubic" + }, + duration: 2 + }, 0); + `; + const id = getAnimId(script); + const updated = updateMotionPathPointInScript(script, id, 1, { x: 220, y: -130 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + // anchor 1 moved; the segment's control points are untouched. + expect(anim.keyframes!.keyframes[1].properties).toMatchObject({ x: 220, y: -130 }); + expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 }); + expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 }); + }); + + // ── add/removeMotionPathPointInScript ─────────────────────────────────── + + it("addMotionPathPointInScript — inserts a waypoint between anchors, keeps curviness", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1, { x: 100, y: -50 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [100, -50], + [200, -100], + [400, 50], + ]); + // 4 anchors → 3 segments, all curviness 1.5 + expect(anim.arcPath!.segments).toHaveLength(3); + expect(anim.arcPath!.segments.every((s) => s.curviness === 1.5)).toBe(true); + }); + + it("addMotionPathPointInScript — refuses an index at the ends or out of range", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + expect(addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 0, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + expect(addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 3, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("removeMotionPathPointInScript — drops a waypoint, preserves the rest", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = removeMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [400, 50], + ]); + expect(anim.arcPath!.segments).toHaveLength(1); + }); + + it("removeMotionPathPointInScript — refuses to drop below two anchors", () => { + const two = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { motionPath: { path: [{x: 0, y: 0}, {x: 400, y: 50}], curviness: 1 }, duration: 2 }, 0); + `; + const id = getAnimId(two); + expect(removeMotionPathPointInScript(two, id, 0)).toBe(two); + }); + + it("add/removeMotionPathPointInScript — leave cubic paths untouched", () => { + const cubic = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { motionPath: { path: [{x:0,y:0},{x:50,y:-80},{x:150,y:-120},{x:200,y:-100}], type: "cubic" }, duration: 2 }, 0); + `; + const id = getAnimId(cubic); + expect(addMotionPathPointInScript(cubic, id, 1, { x: 1, y: 1 })).toBe(cubic); + expect(removeMotionPathPointInScript(cubic, id, 1)).toBe(cubic); + }); + + // ── addMotionPathToScript ─────────────────────────────────────────────── + + it("addMotionPathToScript — authors a new 2-anchor motionPath tween", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.from("#title", { opacity: 0, duration: 0.5 }, 0); + `; + const { script: updated, id } = addMotionPathToScript(script, "#el", 2.0, 1.5, { + x: 300, + y: -100, + }); + expect(id).not.toBe(""); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations.find((a) => a.targetSelector === "#el")!; + expect(anim).toBeDefined(); + expect(anim.arcPath!.enabled).toBe(true); + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [300, -100], + ]); + expect(anim.duration).toBe(1.5); + }); + // ── convertToKeyframesInScript ────────────────────────────────────────── it("convertToKeyframesInScript — converts flat to() tween", () => { @@ -1984,6 +2285,19 @@ describe("splitAnimationsInScript", () => { expect(forNew[0]!.position).toBe(opts.splitTime); }); + it("does not pin the clone to from-values for a completed .from() before the split", () => { + // A .from() that finished before the split leaves the element at its natural + // state. Carrying its from-values (opacity:0) into the clone's `set` made the + // clone invisible. The clone should get NO inherited set for those props. + const script = `${baseScript}\ntl.from("#el1", { y: 70, opacity: 0, duration: 0.9 }, 0.4);`; + const result = split(script); + const parsed = parseGsapScript(result); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + const inheritedSet = forNew.find((a) => a.method === "set"); + expect(inheritedSet).toBeUndefined(); + expect(result).not.toContain("#el1-split"); + }); + it("retargets animation entirely in second half to new element", () => { const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);`; const selectors = parseSplitAndAssert(script, (s) => split(s), 1); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 0f383bc67..4069545b0 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1563,6 +1563,67 @@ function insertInheritedStateSet( return recast.print(parsed.ast).code; } +/** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved + * config key (attached to the tween, never applied to the target), so it carries + * the tag without triggering GSAP's "Invalid property" warning. */ +export const STUDIO_HOLD_MARKER = "hf-hold"; + +/** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween. + * The Studio filters these out so they never appear as user keyframes/diamonds. */ +export function isStudioHoldSet(anim: GsapAnimation): boolean { + return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER; +} + +/** + * Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed + * tween that starts after t=0, so the element holds its first keyframe's position + * BEFORE the tween plays instead of snapping to its CSS base (the universal NLE + * "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"` + * so this pass owns it: every call wipes the prior holds and recomputes from the + * current keyframes, keeping them in sync as keyframes are added/moved/deleted. + * + * Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale + * keep their authored pre-tween behavior. A tween already starting at 0 needs no + * hold (no gap before it). + */ +export function syncPositionHoldsBeforeKeyframes(script: string): string { + let parsed: ParsedGsap; + try { + parsed = parseGsapScript(script); + } catch { + return script; + } + // 1. Drop every hold this pass previously emitted, so we recompute fresh. + let result = script; + const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id); + for (const id of staleHoldIds) result = removeAnimationFromScript(result, id); + + // 2. Re-add a hold for each position-keyframed tween that starts after t=0. + let reparsed: ParsedGsap; + try { + reparsed = parseGsapScript(result); + } catch { + return result; + } + for (const anim of reparsed.animations) { + if (!anim.keyframes) continue; + const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); + if (!(start > 0.001)) continue; + const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0]; + if (!firstKf) continue; + const posProps: Record = {}; + for (const [k, v] of Object.entries(firstKf.properties)) { + if (classifyPropertyGroup(k) === "position" && typeof v === "number") posProps[k] = v; + } + if (Object.keys(posProps).length === 0) continue; + result = insertInheritedStateSet(result, anim.targetSelector, 0, { + ...posProps, + data: STUDIO_HOLD_MARKER, + }); + } + return result; +} + // ── Split Animation Functions ───────────────────────────────────────────── export interface SplitAnimationsOptions { @@ -1640,8 +1701,16 @@ export function splitAnimationsInScript( } if (animEnd <= opts.splitTime) { - for (const [k, v] of Object.entries(anim.properties)) { - inheritedProps[k] = v; + // A completed .from() reverts the element to its natural state, so its + // recorded properties are the HIDDEN start (e.g. opacity:0), not the + // resting state — clearing them keeps the clone at its natural value + // instead of pinning it to the from-values (which made it invisible). + if (anim.method === "from") { + for (const k of Object.keys(anim.properties)) delete inheritedProps[k]; + } else { + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } } continue; } @@ -1787,6 +1856,14 @@ function locateAnimation( return target ? { parsed, target } : null; } +// Animation ids encode the tween's timeline position in ms +// (`#puck-a-to-1200-position`). A gesture/convert can re-emit a tween at a +// different position, changing its id — so a client that cached the old id (its +// selectedGsapAnimations hasn't refreshed) edits a now-nonexistent id and the op +// no-ops. Parse `{selector}-{method}-{posMs}-{group}` so we can fall back to the +// same selector+method+group tween nearest the requested position. +const ANIM_ID_RE = /^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$/; + function locateAnimationWithFallback( script: string, animationId: string, @@ -1794,8 +1871,34 @@ function locateAnimationWithFallback( const loc = locateAnimation(script, animationId); if (loc) return loc; const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - if (convertedId === animationId) return null; - return locateAnimation(script, convertedId); + if (convertedId !== animationId) { + const converted = locateAnimation(script, convertedId); + if (converted) return converted; + } + // Position-drift fallback: match by stable identity (selector+method+group), + // disambiguating by the position closest to the one the caller asked for. + const want = ANIM_ID_RE.exec(animationId); + if (!want) return null; + const [, sel, method, wantPosStr, group] = want; + const wantPos = Number(wantPosStr); + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return null; + } + let best: ParsedGsapAst["located"][number] | null = null; + let bestDist = Number.POSITIVE_INFINITY; + for (const l of parsed.located) { + const m = ANIM_ID_RE.exec(l.id); + if (!m || m[1] !== sel || m[2] !== method || m[4] !== group) continue; + const dist = Math.abs(Number(m[3]) - wantPos); + if (dist < bestDist) { + best = l; + bestDist = dist; + } + } + return best ? { parsed, target: best } : null; } /** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */ @@ -1804,6 +1907,33 @@ function findKeyframesObjectNode(varsArg: AstNode): AstNode | null { return node?.type === "ObjectExpression" ? node : null; } +/** + * Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object + * form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node + * (or null if not array-form). GSAP distributes an array evenly, so this is + * runtime-identical — but it gives the percentage-keyed write ops something to + * target. Needed before INSERTING a keyframe at an arbitrary percentage, which an + * even array can't host. + */ +function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null { + if (varsArg?.type !== "ObjectExpression") return null; + const prop = (varsArg.properties ?? []).find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes", + ); + if (!prop || prop.value?.type !== "ArrayExpression") return null; + const els: AstNode[] = (prop.value.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = els.length; + if (n === 0) return null; + const entries = els.map((el: AstNode, i: number) => { + const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; + return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`; + }); + prop.value = parseExpr(`{ ${entries.join(", ")} }`); + return prop.value; +} + /** Filter percentage-keyed properties from a keyframes ObjectExpression. */ function filterPercentageProps(kfNode: AstNode): AstNode[] { return kfNode.properties.filter((p: AstNode) => { @@ -1856,6 +1986,11 @@ export function addKeyframeToScript( if (!loc) return script; let kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + // Array-form keyframes can't host an arbitrary new percentage — normalize to + // object form in place first. (convertToKeyframesInScript below only converts + // FLAT tweens; it early-returns when keyframes already exist.) + if (!kfNode) kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg); + if (!kfNode) { script = convertToKeyframesInScript(script, animationId); loc = locateAnimationWithFallback(script, animationId); @@ -1967,6 +2102,43 @@ export function removeKeyframeFromScript( animationId: string, percentage: number, ): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The object-form path below can't see them + // (findKeyframesObjectNode only matches ObjectExpression), so removing from an + // array-form tween silently no-op'd. Resolve the element by its implicit + // percentage and splice it; collapse to a flat tween when fewer than two remain. + const arrLoc = locateAnimationWithFallback(script, animationId); + // findPropertyNode here returns the property's VALUE node directly. + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {}; + collapseKeyframesToFlat(arrLoc.target.call.varsArg, record); + } else { + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements.splice(realIdx, 1); + } + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; @@ -1999,6 +2171,36 @@ export function updateKeyframeInScript( properties: Record, ease?: string, ): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The percentage-keyed object path below can't + // match them (findKeyframesObjectNode only matches ObjectExpression), so dragging + // a motion-path node on an array-authored tween silently no-op'd. Resolve the + // element by its implicit percentage and replace it in place. Mirrors the array + // branch in removeKeyframeFromScript. + const arrLoc = locateAnimationWithFallback(script, animationId); + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease); + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; @@ -2346,6 +2548,173 @@ export function updateArcSegmentInScript( return recast.print(loc.parsed.ast).code; } +/** + * Move a single motionPath waypoint (anchor) to a new position. The waypoint + * list is normalized to anchors for both straight and cubic paths, so + * `pointIndex` matches the node order the studio overlay renders; cubic control + * points are preserved. No-op when the animation/arc is missing or the index is + * out of range. + */ +export function updateMotionPathPointInScript( + script: string, + animationId: string, + pointIndex: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (!anim.arcPath?.enabled) return script; + + const waypoints = extractArcWaypoints(anim); + if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2) return script; + + const nextWaypoints = waypoints.map((wp, i) => + i === pointIndex ? { x: point.x, y: point.y } : wp, + ); + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: nextWaypoints, + segments: anim.arcPath.segments, + autoRotate: anim.arcPath.autoRotate, + }); + + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) { + existingProp.value = parseExpr(motionPathCode); + } + + return recast.print(loc.parsed.ast).code; +} + +/** True when any segment carries explicit cubic control points. Add/remove are + * restricted to curviness (non-cubic) paths — synthesizing control points for + * an inserted cubic anchor is out of scope. */ +function hasCubicSegments(segments: ArcPathSegment[]): boolean { + return segments.some((s) => s.cp1 != null || s.cp2 != null); +} + +function writeMotionPathValue( + loc: NonNullable>, + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], + autoRotate: boolean | number, +): string { + const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate }); + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) existingProp.value = parseExpr(motionPathCode); + return recast.print(loc.parsed.ast).code; +} + +/** + * Insert a waypoint at `index` (between existing anchors), splitting the segment + * it lands on so the new neighbor inherits its curviness. Non-cubic paths only. + * No-op for missing animation/arc, out-of-range index, or cubic paths. + */ +export function addMotionPathPointInScript( + script: string, + animationId: string, + index: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + // Insert strictly between two anchors: index 1..length-1. + if (index < 1 || index > waypoints.length - 1) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 0, { x: point.x, y: point.y }); + const splitCurviness = segments[index - 1]?.curviness ?? 1; + segments.splice(index - 1, 0, { curviness: splitCurviness }); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Remove the waypoint at `index`. Refuses to drop below two anchors (a path + * can't have fewer). Non-cubic paths only. No-op for missing animation/arc, + * out-of-range index, cubic paths, or a 2-point path. + */ +export function removeMotionPathPointInScript( + script: string, + animationId: string, + index: number, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 1); + // Drop the segment on the side that still exists (last anchor → preceding segment). + segments.splice(Math.min(index, segments.length - 1), 1); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Author a fresh 2-anchor motionPath tween on a target element: a straight line + * from the element's home (0,0) to `point`, gentle ease, ready for waypoint + * editing. Mirrors `addAnimationWithKeyframesToScript`. + */ +export function addMotionPathToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + point: { x: number; y: number }, + ease = "power1.inOut", +): { script: string; id: string } { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); + return { script, id: "" }; + } + if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + return { script, id: "" }; + } + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: [ + { x: 0, y: 0 }, + { x: point.x, y: point.y }, + ], + segments: [{ curviness: 1 }], + autoRotate: false, + }); + const selector = JSON.stringify(targetSelector); + const varEntries = [ + `motionPath: ${motionPathCode}`, + `duration: ${valueToCode(duration)}`, + `ease: ${JSON.stringify(ease)}`, + ]; + const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`; + const newStatement = parseScript(stmtCode).program.body[0]; + insertAfterAnchor(parsed, newStatement); + + const result = recast.print(parsed.ast).code; + const reParsed = parseGsapAst(result); + const newId = reParsed.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + export function removeArcPathFromScript(script: string, animationId: string): string { return setArcPathInScript(script, animationId, { enabled: false, diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index 839b1079b..0c0b16fa6 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -250,6 +250,41 @@ describe("T6c — keyframe write ops", () => { expect(result).toContain("}, 0.2)"); }); + it("updateKeyframeInScript edits ARRAY-form keyframes by percentage→index (the #shuttle case)", () => { + // Array-form keyframes carry no explicit percentages; GSAP distributes 4 of + // them evenly → 0 / 33.3 / 66.7 / 100. Dragging the 2nd motion-path node + // (pct 33.3) must rewrite array index 1 — not no-op (regression: array form + // bailed the ObjectExpression check, so the drag committed nothing). + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);'; + const result = updateKeyframeInScript(script, "#shuttle-to-5200-position", 33.3, { + x: 503, + y: 642, + }); + expect(result).not.toBe(script); // actually changed (not a no-op) + expect(result).toContain("x: 503"); + expect(result).toContain("y: 642"); + expect(result).not.toContain("x: 520"); // index 1 replaced + // Sibling array entries untouched. + expect(result).toContain("{ x: 0, y: 0 }"); + expect(result).toContain("{ x: 1040, y: 0 }"); + expect(result).toContain("{ x: 1480, y: 160 }"); + }); + + it("addKeyframeToScript — ARRAY-form normalizes to object form + inserts 50%", () => { + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);'; + const result = addKeyframeToScript(script, "#shuttle-to-5200-position", 50, { x: 780, y: 60 }); + expect(result).not.toBe(script); // not a no-op + expect(result).toContain('"50%"'); // converted to percentage-object form + expect(result).toContain("x: 780"); + // Original even-distribution stops preserved as percentage keys. + expect(result).toContain('"0%"'); + expect(result).toContain('"100%"'); + }); + it("addKeyframeToScript inserts new percentage in sorted order", () => { const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 }); expect(result).toContain('"25%"'); diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 2a478bb0a..7ee5537be 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -157,6 +157,57 @@ describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => { }); }); +// Array-form keyframes (`keyframes: [{x,y}, …]`, no explicit %) used to no-op on +// removal in BOTH writers — the object-form path couldn't see the array, so the +// keyframe survived while downstream hold-sync stranded an `hf-hold`. +describe("removeKeyframeFromScript: array-form keyframes (recast + acorn parity)", () => { + const arrayScript = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#p", { + keyframes: [ { x: 0, y: 0 }, { x: -180, y: -60 }, { x: -320, y: 40 }, { x: -460, y: -20 } ], + duration: 3.4, + ease: "power1.inOut" + }, 1.0); + `; + + it("removes the matched element (implicit %) — both writers, parity", () => { + const id = acornId(arrayScript); + expect(parseGsapScript(arrayScript).animations[0]!.id).toBe(id); + + const recastOut = removeKeyframeRecast(arrayScript, id, 67); + const acornOut = removeKeyframeAcorn(arrayScript, id, 67); + + expect(recastOut).not.toBe(arrayScript); + expect(acornOut).not.toBe(arrayScript); + + const recShape = shapeOf(recastOut); + expect(recShape.keyframes?.keyframes.length).toBe(3); + // the 67% element { x: -320, y: 40 } is the one removed + expect(JSON.stringify(recShape.keyframes)).not.toContain("-320"); + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + + it("collapses to a flat tween when fewer than two remain — both writers, parity", () => { + const twoScript = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#p", { keyframes: [ { x: 0, y: 0 }, { x: 100, y: 50 } ], duration: 1 }, 0); + `; + const id = acornId(twoScript); + const recastOut = removeKeyframeRecast(twoScript, id, 100); + const acornOut = removeKeyframeAcorn(twoScript, id, 100); + + expect(shapeOf(recastOut).keyframes).toBeUndefined(); + expect(shapeOf(acornOut).keyframes).toBeUndefined(); + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + + it("no-op when the percentage matches no element", () => { + const id = acornId(arrayScript); + expect(removeKeyframeAcorn(arrayScript, id, 12)).toBe(arrayScript); + expect(removeKeyframeRecast(arrayScript, id, 12)).toBe(arrayScript); + }); +}); + const CONVERT_FIXTURES: Array<{ name: string; script: string; diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index cfd459cc7..0760d77d6 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -744,7 +744,18 @@ export function updateKeyframeInScript( if (!target) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + if (!kfPropNode) return script; + + // Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages + // — GSAP distributes them evenly, and the runtime read assigns even percentages + // (0, 100/(n-1), …). Map the percentage back to an array index and overwrite that + // element in place (preserving the array form). Without this the function bailed + // on the ObjectExpression check, so dragging a motion-path node on an array-form + // tween committed nothing (server no-op). + if (kfPropNode.value?.type === "ArrayExpression") { + return updateArrayKeyframeByPct(script, kfPropNode.value, percentage, properties, ease); + } + if (kfPropNode.value?.type !== "ObjectExpression") return script; const match = findKfPropByPct(kfPropNode.value, percentage); if (!match) return script; @@ -756,6 +767,33 @@ export function updateKeyframeInScript( return ms.toString(); } +// ponytail: even-spacing index map; if array keyframes ever carry per-element +// `duration`, switch to matching the closest cumulative position. +function updateArrayKeyframeByPct( + script: string, + arrayNode: Node, + percentage: number, + properties: Record, + ease?: string, +): string { + const elements = ((arrayNode.elements ?? []) as Array).filter( + (el): el is Node => !!el && el.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + const idx = n > 1 ? Math.round((percentage / 100) * (n - 1)) : 0; + const el = elements[Math.max(0, Math.min(n - 1, idx))]; + if (!el) return script; + const merged: Record = { + ...valueNodeToRecord(el, script), + ...properties, + }; + if (ease) merged.ease = ease; + const ms = new MagicString(script); + ms.overwrite(el.start, el.end, recordToCode(merged)); + return ms.toString(); +} + /** * Build the final property record for the keyframe at `percentage`. If a * keyframe already exists there, MERGE the new props over the existing record @@ -826,6 +864,26 @@ function locateWithKeyframes( } /** Locate a tween's keyframes object, converting a flat tween first if absent. */ +// Array-form keyframes (`keyframes: [{x,y}, …]`) → even-percentage object form +// (`{ "0%": {…}, "33.3%": {…}, … }`). Inserting a keyframe needs percentage keys, +// which an even array can't host. Runtime-identical; mirrors the recast path. +function convertArrayKeyframesToObject(script: string, target: Node): string { + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ArrayExpression") return script; + const els = ((kfPropNode.value.elements ?? []) as Array).filter( + (el): el is Node => !!el && el.type === "ObjectExpression", + ); + const n = els.length; + if (n === 0) return script; + const entries = els.map((el, i) => { + const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; + return `${JSON.stringify(`${pct}%`)}: ${script.slice(el.start, el.end)}`; + }); + const ms = new MagicString(script); + ms.overwrite(kfPropNode.value.start, kfPropNode.value.end, `{ ${entries.join(", ")} }`); + return ms.toString(); +} + function ensureKeyframesNode( script: string, animationId: string, @@ -833,10 +891,19 @@ function ensureKeyframesNode( const direct = locateWithKeyframes(script, animationId); if (direct) return direct; - // No static keyframes object — convert the flat tween, then re-locate. const parsed = parseGsapScriptAcornForWrite(script); const target = parsed?.located.find((l) => l.id === animationId); if (!target) return null; + + // Array-form keyframes → normalize to object form, then re-locate. + const kfProp = findPropertyNode(target.call.varsArg, "keyframes"); + if (kfProp?.value?.type === "ArrayExpression") { + const normalized = convertArrayKeyframesToObject(script, target); + if (normalized !== script) return locateWithKeyframes(normalized, animationId); + return null; + } + + // No static keyframes object — convert the flat tween, then re-locate. const converted = convertFlatTweenToKeyframes(script, target); if (converted === script) return null; return locateWithKeyframes(converted, animationId); @@ -963,6 +1030,53 @@ function collapseKeyframesToFlat( ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`); } +/** Implicit tween-relative percentage of array-form keyframe index `i` of `n` + * (GSAP distributes array keyframes evenly: 0%, 1/(n-1), …, 100%). */ +function arrayKeyframePct(i: number, n: number): number { + return n > 1 ? (i / (n - 1)) * 100 : 0; +} + +// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages — +// GSAP distributes them evenly. removeKeyframeFromScript only handled the +// object-form (`keyframes: { "50%": {…} }`), so removing from an array-form tween +// was a silent no-op (and the downstream hold-sync then stranded an `hf-hold`). +// Resolve the element by its implicit percentage and splice it out; collapse to a +// flat tween when fewer than two remain (parity with the object-form path). +function removeArrayKeyframe( + ms: MagicString, + varsArg: Node, + arrNode: Node, + script: string, + percentage: number, +): boolean { + const elements: Node[] = (arrNode.elements ?? []).filter( + (e: Node | null): e is Node => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return false; + + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const dist = Math.abs(arrayKeyframePct(i, n) - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return false; + + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? valueNodeToRecord(sole, script) : {}; + collapseKeyframesToFlat(ms, varsArg, script, record); + return true; + } + removeProp(ms, elements[matchIdx], elements); + return true; +} + export function removeKeyframeFromScript( script: string, animationId: string, @@ -974,7 +1088,16 @@ export function removeKeyframeFromScript( if (!target) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + if (!kfPropNode) return script; + + if (kfPropNode.value?.type === "ArrayExpression") { + const ms = new MagicString(script); + return removeArrayKeyframe(ms, target.call.varsArg, kfPropNode.value, script, percentage) + ? ms.toString() + : script; + } + + if (kfPropNode.value?.type !== "ObjectExpression") return script; const kfNode = kfPropNode.value; const match = findKfPropByPct(kfNode, percentage);