diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 3da54f199c..4ca25d7878 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -12,11 +12,15 @@ import { parseGsapScript, removeAllKeyframesFromScript as removeAllRecast, convertToKeyframesInScript as convertRecast, + materializeKeyframesInScript as materializeRecast, + splitIntoPropertyGroups as splitGroupsRecast, } from "./gsapParser.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js"; import { removeAllKeyframesFromScript as removeAllAcorn, convertToKeyframesFromScript as convertAcorn, + materializeKeyframesFromScript as materializeAcorn, + splitIntoPropertyGroupsFromScript as splitGroupsAcorn, } from "./gsapWriterAcorn.js"; function acornId(script: string): string { @@ -176,3 +180,124 @@ describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => { expect(convertAcorn(script, id)).toBe(script); }); }); + +// ── materializeKeyframes parity ─────────────────────────────────────────────── + +const MATERIALIZE_KFS = [ + { percentage: 0, properties: { x: 0, opacity: 1 } }, + { percentage: 50, properties: { x: 150, opacity: 0.5 } }, + { percentage: 100, properties: { x: 300, opacity: 0 } }, +]; + +const MATERIALIZE_FIXTURES: Array<{ + name: string; + script: string; + kfs: typeof MATERIALIZE_KFS; + easeEach?: string; +}> = [ + { + name: "flat tween — adds keyframes property", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 300, duration: 2 }, 0); + `, + kfs: MATERIALIZE_KFS, + }, + { + name: "with easeEach", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { opacity: 0, duration: 1 }, 0); + `, + kfs: [ + { percentage: 0, properties: { opacity: 1 } }, + { percentage: 100, properties: { opacity: 0 } }, + ], + easeEach: "power2.inOut", + }, +]; + +describe("parity: materializeKeyframesFromScript (recast vs acorn)", () => { + for (const { name, script, kfs, easeEach } of MATERIALIZE_FIXTURES) { + it(name, () => { + const id = acornId(script); + const recastOut = materializeRecast(script, id, kfs, easeEach); + const acornOut = materializeAcorn(script, id, kfs, easeEach); + const recastShape = shapeOf(recastOut); + const acornShape = shapeOf(acornOut); + expect(acornShape.keyframes).toBeDefined(); + expect(acornShape).toEqual(recastShape); + }); + } + + it("no-op when id not found", () => { + const script = MATERIALIZE_FIXTURES[0]!.script; + expect(materializeAcorn(script, "nope", MATERIALIZE_KFS)).toBe(script); + }); +}); + +// ── splitIntoPropertyGroups parity ──────────────────────────────────────────── + +function shapesOf(script: string) { + return parseGsapScript(script).animations.map((a) => ({ + method: a.method, + properties: a.properties, + keyframes: a.keyframes, + duration: a.duration, + ease: a.ease, + selector: a.targetSelector, + propertyGroup: a.propertyGroup, + })); +} + +const SPLIT_FIXTURES: Array<{ name: string; script: string }> = [ + { + name: "flat mixed tween — splits into position + visual groups", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, y: 50, opacity: 0.5, duration: 1 }, 0); + `, + }, + { + name: "keyframed mixed tween — splits per group", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { x: 0, opacity: 1 }, "100%": { x: 200, opacity: 0 } }, duration: 1 }, 0); + `, + }, +]; + +describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => { + for (const { name, script } of SPLIT_FIXTURES) { + it(name, () => { + const id = acornId(script); + const { script: recastOut } = splitGroupsRecast(script, id); + const { script: acornOut } = splitGroupsAcorn(script, id); + const recastShapes = shapesOf(recastOut); + const acornShapes = shapesOf(acornOut); + expect(acornShapes).toHaveLength(recastShapes.length); + expect(acornShapes.length).toBeGreaterThan(1); + // Each produced group should match its counterpart by propertyGroup + const sortByGroup = (arr: typeof recastShapes) => + arr.slice().sort((a, b) => (a.propertyGroup ?? "").localeCompare(b.propertyGroup ?? "")); + expect(sortByGroup(acornShapes)).toEqual(sortByGroup(recastShapes)); + }); + } + + it("no-op when id not found", () => { + const script = SPLIT_FIXTURES[0]!.script; + const { script: out, ids } = splitGroupsAcorn(script, "nope"); + expect(out).toBe(script); + expect(ids).toEqual(["nope"]); + }); + + it("no-op when single-group tween", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { x: 100, y: 50, duration: 1 }, 0); + `; + const id = acornId(script); + const { script: out } = splitGroupsAcorn(script, id); + expect(out).toBe(script); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 03567a6dc6..d7d2e3ff3f 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -7,13 +7,15 @@ * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. */ import MagicString from "magic-string"; -import type { GsapAnimation } from "./gsapSerialize.js"; +import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js"; import { resolveConversionProps } from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite, type TweenCallInfo, } from "./gsapParserAcorn.js"; +import { classifyPropertyGroup } from "./gsapConstants.js"; +import type { PropertyGroupName } from "./gsapConstants.js"; import * as acornWalk from "acorn-walk"; // ── Code generation helpers ────────────────────────────────────────────────── @@ -997,6 +999,262 @@ export function convertToKeyframesFromScript( return ms.toString(); } +// ── Keyframe-object code builder ───────────────────────────────────────────── + +/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */ +function buildKeyframeObjectCode( + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, +): string { + const entries = keyframes.map((kf) => { + const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`); + return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`; + }); + if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`); + return `{ ${entries.join(", ")} }`; +} + +// ── Materialize keyframes ──────────────────────────────────────────────────── + +/** + * Replace a dynamic or static keyframes expression with a fully-resolved + * percentage-keyframes object. Called when a user first edits a dynamically- + * generated keyframe in the studio so it becomes statically editable. + */ +export function materializeKeyframesFromScript( + script: string, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const { call } = target; + const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); + const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); + const ms = new MagicString(script); + + if (resolvedSelector) { + const selectorArg = call.node.arguments[0]; + if (selectorArg) + ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector)); + } + + const kfProp = findPropertyNode(call.varsArg, "keyframes"); + if (kfProp) { + ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode); + } else if (call.varsArg?.type === "ObjectExpression") { + const vars = call.varsArg; + if (vars.properties.length > 0) { + ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `); + } else { + ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`); + } + } + + const eachProp = findPropertyNode(call.varsArg, "easeEach"); + if (eachProp) { + const allProps = (call.varsArg.properties ?? []).filter((p: any) => isObjectProperty(p)); + removeProp(ms, eachProp, allProps); + } + + return ms.toString(); +} + +// ── Add animation with keyframes ────────────────────────────────────────────── + +/** Insert a new keyframed `to()` call and return the new animation ID. */ +export function addAnimationWithKeyframesToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + ease?: string, +): { script: string; id: string } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, id: "" }; + const insertionPoint = findInsertionPoint(parsed); + if (insertionPoint === null) return { script, id: "" }; + + const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); + const kfObjCode = buildKeyframeObjectCode(sorted); + 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)});`; + + const ms = new MagicString(script); + ms.appendLeft(insertionPoint, "\n" + stmtCode); + + const result = ms.toString(); + const reParsed = parseGsapScriptAcornForWrite(result); + const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + +// ── Split into property groups ──────────────────────────────────────────────── + +function collectPropertyKeys(anim: GsapAnimation): Set { + const keys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) keys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) keys.add(k); + } + return keys; +} + +function partitionPropertyGroups(keys: Set): Map { + const groups = new Map(); + for (const key of keys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groups.get(group); + if (!arr) { + arr = []; + groups.set(group, arr); + } + arr.push(key); + } + return groups; +} + +function assignTransformOrigin(groupProps: Map): void { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + if (largestGroup) groupProps.get(largestGroup)!.push("transformOrigin"); +} + +function filterGroupKeyframes( + kfs: GsapPercentageKeyframe[], + propSet: Set, +): Array<{ percentage: number; properties: Record; ease?: string }> { + const result: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> = []; + for (const kf of kfs) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + if (Object.keys(filtered).length > 0) { + result.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + } + return result; +} + +function filterGroupProperties( + properties: Record, + propSet: Set, +): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(properties)) { + if (propSet.has(k)) result[k] = v; + } + return result; +} + +function addGroupAnimToScript( + script: string, + anim: GsapAnimation, + propSet: Set, +): { script: string; id: string } { + if (anim.keyframes) { + const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet); + if (groupKeyframes.length === 0) return { script, id: "" }; + const pos = typeof anim.position === "number" ? anim.position : 0; + return addAnimationWithKeyframesToScript( + script, + anim.targetSelector, + pos, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + } + const groupProperties = filterGroupProperties(anim.properties, propSet); + if (Object.keys(groupProperties).length === 0) return { script, id: "" }; + const fromProperties = + anim.method === "fromTo" && anim.fromProperties + ? filterGroupProperties(anim.fromProperties, propSet) + : undefined; + return addAnimationToScript(script, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); +} + +/** + * Split a mixed-property tween into one tween per property group (position, + * scale, visual, etc.) so each group can be edited independently. + * Returns the updated script and the IDs of the newly-created tweens. + */ +export function splitIntoPropertyGroupsFromScript( + script: string, + animationId: string, +): { script: string; ids: string[] } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, ids: [animationId] }; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return { script, ids: [animationId] }; + const { animation } = target; + + const allPropKeys = collectPropertyKeys(animation); + const groupProps = partitionPropertyGroups(allPropKeys); + if (groupProps.size <= 1) return { script, ids: [animationId] }; + if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps); + + let result = removeAnimationFromScript(script, animationId); + for (const [, props] of groupProps) { + const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props)); + if (id) result = next; + } + + const reParsed = parseGsapScriptAcornForWrite(result); + const newIds = (reParsed?.located ?? []) + .filter((l) => l.animation.targetSelector === animation.targetSelector) + .map((l) => l.id); + return { script: result, ids: newIds }; +} + // ── Label write ops ─────────────────────────────────────────────────────────── export function addLabelToScript(script: string, name: string, position: number): string { diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 1af854d98e..064709b73b 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -583,6 +583,89 @@ window.__timelines["t"] = tl;`; }); }); +// ─── materializeKeyframes ───────────────────────────────────────────────────── + +describe("materializeKeyframes", () => { + it("adds keyframes property to flat tween", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: TWEEN_ANIM_ID, + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("keyframes"); + expect(newScript).toContain('"0%"'); + expect(newScript).toContain('"100%"'); + }); + + it("injects easeEach into keyframes object", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: TWEEN_ANIM_ID, + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + easeEach: "power2.out", + }); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("easeEach"); + expect(newScript).toContain("power2.out"); + }); + + it("no-op when animation id not found", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: "nope", + keyframes: [{ percentage: 0, properties: { opacity: 0 } }], + }); + expect(result.forward).toHaveLength(0); + }); +}); + +// ─── splitIntoPropertyGroups ────────────────────────────────────────────────── + +describe("splitIntoPropertyGroups", () => { + it("splits mixed tween into multiple group tweens", () => { + const script = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const parsed = fresh(script); + // mixed tween has no propertyGroup → no group suffix in id + const animId = `[data-hf-id="hf-box"]-to-0`; + const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + // x is position group, opacity is visual group — expect 2 tweens + const toCount = (newScript.match(/\.to\(/g) ?? []).length; + expect(toCount).toBe(2); + }); + + it("no-op when animation id not found", () => { + const parsed = fresh(); + const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" }); + expect(result.forward).toHaveLength(0); + }); + + it("no-op when tween has only one property group", () => { + // x + y = same "position" group → nothing to split + const script = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const parsed = fresh(script); + const animId = `[data-hf-id="hf-box"]-to-0-position`; + const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId }); + expect(result.forward).toHaveLength(0); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 23eb6ccbc8..c820749bda 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -52,6 +52,8 @@ import { removeKeyframeFromScript, removeAllKeyframesFromScript, convertToKeyframesFromScript, + materializeKeyframesFromScript, + splitIntoPropertyGroupsFromScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, @@ -167,6 +169,16 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult return handleRemoveAllKeyframes(parsed, op.animationId); case "convertToKeyframes": return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues); + case "materializeKeyframes": + return handleMaterializeKeyframes( + parsed, + op.animationId, + op.keyframes, + op.easeEach, + op.resolvedSelector, + ); + case "splitIntoPropertyGroups": + return handleSplitIntoPropertyGroups(parsed, op.animationId); default: return undefined; } @@ -774,6 +786,43 @@ function handleConvertToKeyframes( return gsapScriptChange(script, newScript); } +function handleMaterializeKeyframes( + parsed: ParsedDocument, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = materializeKeyframesFromScript( + script, + animationId, + keyframes, + easeEach, + resolvedSelector, + ); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + +function handleSplitIntoPropertyGroups( + parsed: ParsedDocument, + animationId: string, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; @@ -993,6 +1042,8 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeGsapTween": case "removeAllKeyframes": case "convertToKeyframes": + case "materializeKeyframes": + case "splitIntoPropertyGroups": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 439edc160c..c71cb8138c 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -112,6 +112,18 @@ export type EditOp = resolvedFromValues?: Record; } | { type: "deleteAllForSelector"; selector: string } + | { + type: "materializeKeyframes"; + animationId: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + easeEach?: string; + resolvedSelector?: string; + } + | { type: "splitIntoPropertyGroups"; animationId: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string };