diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e0e9498de..0f383bc67 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -20,6 +20,7 @@ import { type ParsedGsap, serializeValue as valueToCode, safeJsKey as safeKey, + resolveConversionProps, } from "./gsapSerialize"; export type { @@ -2009,62 +2010,6 @@ export function updateKeyframeInScript( return recast.print(loc.parsed.ast).code; } -/** Resolve from/to property maps for a tween being converted to keyframes. */ -const CSS_IDENTITY: Record = { - opacity: 1, - autoAlpha: 1, - scale: 1, - scaleX: 1, - scaleY: 1, -}; - -function cssIdentityValue(prop: string): number { - return CSS_IDENTITY[prop] ?? 0; -} - -/** - * Resolve the 0% (from) and 100% (to) property maps for a tween being - * converted to percentage keyframes. - * - * @param resolvedFromValues — Despite the "from" in the name (historical), these - * are runtime-captured DOM values that override the conversion endpoint: - * - For to(): overrides fromProps (the 0% state / where the element is now). - * - For from(): overrides toProps (the 100% state / where the element rests). - * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). - */ -function resolveConversionProps( - anim: GsapAnimation, - resolvedFromValues?: Record, -): { fromProps: Record; toProps: Record } { - if (anim.method === "to") { - const identityFrom: Record = {}; - for (const [key, val] of Object.entries(anim.properties)) { - if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; - } - const fromProps = resolvedFromValues - ? { ...identityFrom, ...resolvedFromValues } - : identityFrom; - return { fromProps, toProps: { ...anim.properties } }; - } - if (anim.method === "from") { - const identityTo: Record = {}; - for (const [key, val] of Object.entries(anim.properties)) { - if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; - } - const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo; - return { fromProps: { ...anim.properties }, toProps }; - } - // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), - // anim.properties = toVars (100% state). resolvedFromValues contains the - // current DOM position from a drag — it represents the NEW destination, so - // it merges into toProps (the 100% endpoint the user is editing), NOT into - // fromProps. This is intentional and not inverted. - const toProps = resolvedFromValues - ? { ...anim.properties, ...resolvedFromValues } - : { ...anim.properties }; - return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; -} - /** Strip editable properties and ease/keyframes keys from a varsArg. */ function stripEditableAndEase(varsArg: AstNode): void { // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it — @@ -2225,9 +2170,16 @@ function buildMotionPathObjectCode(config: { }): string { const { waypoints, segments, autoRotate } = config; const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2); + // The simple `path` array supports only one scalar curviness for the whole + // path, so per-segment curviness must use the cubic form (curviness baked into + // each segment's control points). Without this, the simple branch serializes + // only segments[0].curviness and silently drops every other segment's curve. + const curvinessVaries = segments.some( + (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), + ); let pathEntries: string[]; - if (hasExplicitControlPoints && waypoints.length >= 2) { + if ((hasExplicitControlPoints || curvinessVaries) && waypoints.length >= 2) { // type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...] pathEntries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`]; for (let i = 0; i < segments.length; i++) { diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 9f3ea1f04..0ab7386a0 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -120,15 +120,20 @@ export function buildArcPath( autoRotate: boolean | number, isCubic: boolean, ): MotionPathShape | undefined { - if (coords.length < 2) return undefined; + const first = coords[0]; + if (coords.length < 2 || !first) return undefined; const segments: ArcPathSegment[] = []; let waypoints: Array<{ x: number; y: number }>; if (isCubic && coords.length >= 4) { // coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]. - waypoints = [coords[0]!]; + waypoints = [first]; for (let i = 1; i + 2 < coords.length; i += 3) { - waypoints.push(coords[i + 2]!); - segments.push({ curviness, cp1: coords[i]!, cp2: coords[i + 1]! }); + const cp1 = coords[i]; + const cp2 = coords[i + 1]; + const anchor = coords[i + 2]; + if (!cp1 || !cp2 || !anchor) continue; + waypoints.push(anchor); + segments.push({ curviness, cp1, cp2 }); } } else { waypoints = coords; @@ -413,3 +418,157 @@ export function gsapAnimationsToKeyframes( .filter((kf): kf is NonNullable => kf !== null) ); } + +// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ──── + +/** + * CSS identity values for properties whose "rest" state isn't 0 — used to + * synthesize the missing endpoint when converting a flat tween to keyframes. + */ +const CSS_IDENTITY: Record = { + opacity: 1, + autoAlpha: 1, + scale: 1, + scaleX: 1, + scaleY: 1, +}; + +function cssIdentityValue(prop: string): number { + return CSS_IDENTITY[prop] ?? 0; +} + +/** Build the identity-endpoint map for a flat tween's properties. */ +function buildIdentityMap(props: Record): Record { + const identity: Record = {}; + for (const [key, val] of Object.entries(props)) { + if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val; + } + return identity; +} + +/** + * Resolve the 0% (from) and 100% (to) property maps for a tween being + * converted to percentage keyframes. + * + * @param resolvedFromValues — Despite the "from" in the name (historical), these + * are runtime-captured DOM values that override the conversion endpoint: + * - For to(): overrides fromProps (the 0% state / where the element is now). + * - For from(): overrides toProps (the 100% state / where the element rests). + * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). + */ +export function resolveConversionProps( + anim: GsapAnimation, + resolvedFromValues?: Record, +): { fromProps: Record; toProps: Record } { + if (anim.method === "to") { + const identity = buildIdentityMap(anim.properties); + const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; + return { fromProps, toProps: { ...anim.properties } }; + } + if (anim.method === "from") { + const identity = buildIdentityMap(anim.properties); + const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; + return { fromProps: { ...anim.properties }, toProps }; + } + // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), + // anim.properties = toVars (100% state). resolvedFromValues contains the + // current DOM position from a drag — it represents the NEW destination, so + // it merges into toProps (the 100% endpoint the user is editing), NOT into + // fromProps. This is intentional and not inverted. + const toProps = resolvedFromValues + ? { ...anim.properties, ...resolvedFromValues } + : { ...anim.properties }; + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; +} + +// ── Arc path serialization helpers (shared by recast + acorn writers) ───────── + +function numericXY(props: Record): { x: number; y: number } | null { + const vx = props.x; + const vy = props.y; + return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null; +} + +export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { + const keyframeWps = (anim.keyframes?.keyframes ?? []) + .map((kf) => numericXY(kf.properties)) + .filter((pt): pt is { x: number; y: number } => pt !== null); + if (keyframeWps.length >= 2) return keyframeWps; + const propX = anim.properties.x; + const propY = anim.properties.y; + if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps; + const destX = typeof propX === "number" ? propX : 0; + const destY = typeof propY === "number" ? propY : 0; + return [ + { x: 0, y: 0 }, + { x: destX, y: destY }, + ]; +} + +function autoRotateSuffix(autoRotate: boolean | number): string { + if (autoRotate === true) return ", autoRotate: true"; + if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`; + return ""; +} + +function cubicControlPoints( + seg: ArcPathSegment, + wp: { x: number; y: number }, + nextWp: { x: number; y: number }, +): string[] { + if (seg.cp1 && seg.cp2) { + return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`]; + } + const dx = nextWp.x - wp.x; + const dy = nextWp.y - wp.y; + const c = seg.curviness ?? 1; + return [ + `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, + `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, + ]; +} + +function buildCubicPathEntries( + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], +): string[] { + const first = waypoints[0]; + if (!first) return []; + const entries = [`{x: ${first.x}, y: ${first.y}}`]; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const wp = waypoints[i]; + const nextWp = waypoints[i + 1]; + if (!seg || !wp || !nextWp) continue; + entries.push(...cubicControlPoints(seg, wp, nextWp)); + entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); + } + return entries; +} + +export function buildMotionPathObjectCode(config: { + waypoints: Array<{ x: number; y: number }>; + segments: ArcPathSegment[]; + autoRotate: boolean | number; +}): string { + const { waypoints, segments, autoRotate } = config; + const arSuffix = autoRotateSuffix(autoRotate); + // GSAP's simple `path` array supports only ONE scalar `curviness` for the whole + // path, so per-segment curviness can only be expressed in the cubic form (each + // segment's curviness baked into its control points). Emit cubic when segments + // carry explicit control points OR when their curviness values differ — the + // simple branch would otherwise serialize only segments[0].curviness and drop + // every other segment's curve. + const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2); + const curvinessVaries = segments.some( + (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), + ); + if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) { + const pathStr = buildCubicPathEntries(waypoints, segments).join(", "); + return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`; + } + const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); + const curviness = segments[0]?.curviness ?? 1; + const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : ""; + return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`; +} diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index d015b92a8..839b1079b 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -269,6 +269,14 @@ describe("T6c — keyframe write ops", () => { expect((result.match(/"50%"/g) ?? []).length).toBe(1); }); + it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => { + // 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity. + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 }); + expect(result).toContain("opacity: 0.7"); + expect(result).toContain("x: 100"); + expect((result.match(/"50%"/g) ?? []).length).toBe(1); + }); + it("removeKeyframeFromScript removes the target percentage", () => { // Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c) const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50); diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts new file mode 100644 index 000000000..2a478bb0a --- /dev/null +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -0,0 +1,991 @@ +/** + * Parity harness — recast writer (gsapParser.ts) vs acorn writer + * (gsapWriterAcorn.ts). Both must produce scripts that REPARSE to the same + * animation model. Byte-equality is not expected (recast pretty-prints, acorn + * splices), so parity is asserted on the parsed GsapAnimation, not raw text. + * + * This is the safety net for porting WS-3 ops one at a time: each ported op + * gets a fixture row here proving it matches the battle-tested original. + */ +import { describe, expect, it } from "vitest"; +import { + parseGsapScript, + removeAllKeyframesFromScript as removeAllRecast, + convertToKeyframesInScript as convertRecast, + materializeKeyframesInScript as materializeRecast, + splitIntoPropertyGroups as splitGroupsRecast, + splitAnimationsInScript as splitAnimsRecast, + setArcPathInScript as setArcRecast, + updateArcSegmentInScript as updateArcSegmentRecast, + removeArcPathFromScript as removeArcRecast, + unrollDynamicAnimations as unrollRecast, + addKeyframeToScript as addKeyframeRecast, + removeKeyframeFromScript as removeKeyframeRecast, + addAnimationWithKeyframesToScript as addWithKfRecast, + shiftPositionsInScript as shiftRecast, + scalePositionsInScript as scaleRecast, + type SplitAnimationsOptions, +} from "./gsapParser.js"; +import { + parseGsapScriptAcorn, + parseGsapScriptAcornForWrite, + type ParsedGsapAcornForWrite, +} from "./gsapParserAcorn.js"; +import { + removeAllKeyframesFromScript as removeAllAcorn, + convertToKeyframesFromScript as convertAcorn, + materializeKeyframesFromScript as materializeAcorn, + splitIntoPropertyGroupsFromScript as splitGroupsAcorn, + splitAnimationsInScript as splitAnimsAcorn, + setArcPathInScript as setArcAcorn, + updateArcSegmentInScript as updateArcSegmentAcorn, + removeArcPathFromScript as removeArcAcorn, + unrollDynamicAnimations as unrollAcorn, + addKeyframeToScript as addKeyframeAcorn, + removeKeyframeFromScript as removeKeyframeAcorn, + addAnimationWithKeyframesToScript as addWithKfAcorn, + shiftPositionsInScript as shiftAcorn, + scalePositionsInScript as scaleAcorn, +} from "./gsapWriterAcorn.js"; +function acornId(script: string): string { + const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; + return parsed.located[0]!.id; +} + +/** + * True recast-vs-acorn differential: parse a written script with the acorn + * parser and strip per-parse metadata, leaving only the AUTHORED animation + * shape. Both writers must produce scripts that reparse to the same model + * (raw text differs — recast pretty-prints, acorn splices in place). + */ +function modelOf(script: string) { + return parseGsapScriptAcorn(script).animations.map((a) => { + // Drop per-parse metadata; compare AUTHORED shape only. + const { + id: _id, + resolvedStart: _resolvedStart, + implicitPosition: _implicitPosition, + propertyGroup: _propertyGroup, + provenance: _provenance, + ...rest + } = a; + return rest; + }); +} + +function arcShapeOf(script: string) { + const anim = parseGsapScript(script).animations[0]!; + return { arcPath: anim.arcPath, properties: anim.properties }; +} + +/** Reparse a written script and return the first animation's editable shape. */ +function shapeOf(script: string) { + const anim = parseGsapScript(script).animations[0]!; + return { + method: anim.method, + properties: anim.properties, + keyframes: anim.keyframes, + duration: anim.duration, + ease: anim.ease, + }; +} + +const REMOVE_ALL_FIXTURES: Array<{ name: string; script: string }> = [ + { + name: "to() — collapses to last keyframe", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } }, + duration: 2 + }, 0); + `, + }, + { + name: "to() — single keyframe + ease", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { + keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, + duration: 1, + ease: "none" + }, 0.5); + `, + }, + { + name: "to() — easeEach dropped on collapse", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#card", { + keyframes: { "0%": { y: 0 }, "100%": { y: -40 }, easeEach: "power2.inOut" }, + duration: 1.5 + }, 0); + `, + }, +]; + +describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => { + for (const { name, script } of REMOVE_ALL_FIXTURES) { + it(name, () => { + const id = acornId(script); + // Sanity: recast and acorn agree on the id for this tween. + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + + const recastOut = removeAllRecast(script, id); + const acornOut = removeAllAcorn(script, id); + + const recastShape = shapeOf(recastOut); + const acornShape = shapeOf(acornOut); + + expect(acornShape.keyframes).toBeUndefined(); + expect(acornShape).toEqual(recastShape); + }); + } + + it("no-op when id not found", () => { + const script = REMOVE_ALL_FIXTURES[0]!.script; + expect(removeAllAcorn(script, "nonexistent-id")).toBe(script); + }); + + it("no-op when tween has no keyframes", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#flat", { x: 100, duration: 1 }, 0); + `; + const id = acornId(script); + expect(removeAllAcorn(script, id)).toBe(script); + }); +}); + +const CONVERT_FIXTURES: Array<{ + name: string; + script: string; + resolvedFromValues?: Record; +}> = [ + { + name: "to() — builds 0%/100% keyframes with identity from", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 200, opacity: 0.5, duration: 1.5 }, 0); + `, + }, + { + name: "to() — with ease becomes easeEach + ease: none", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { x: 100, duration: 1, ease: "power2.out" }, 0); + `, + }, + { + name: "from() — method renamed to to()", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.from("#card", { y: -50, opacity: 0, duration: 0.8 }, 0); + `, + }, + { + name: "fromTo() — method renamed, fromArg removed", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.fromTo("#text", { x: 0 }, { x: 300, duration: 2 }, 0); + `, + }, + { + name: "to() — with resolvedFromValues overrides 0%", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { x: 100, duration: 1 }, 0); + `, + resolvedFromValues: { x: 42 }, + }, +]; + +describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => { + for (const { name, script, resolvedFromValues } of CONVERT_FIXTURES) { + it(name, () => { + const id = acornId(script); + const recastOut = convertRecast(script, id, resolvedFromValues); + const acornOut = convertAcorn(script, id, resolvedFromValues); + + const recastShape = shapeOf(recastOut); + const acornShape = shapeOf(acornOut); + + expect(acornShape.keyframes).toBeDefined(); + expect(acornShape.method).toBe("to"); + expect(acornShape).toEqual(recastShape); + }); + } + + it("no-op when id not found", () => { + const script = CONVERT_FIXTURES[0]!.script; + expect(convertAcorn(script, "nonexistent-id")).toBe(script); + }); + + it("no-op when tween already has keyframes", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0); + `; + const id = acornId(script); + 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); + }); +}); + +// ── splitAnimationsInScript parity ──────────────────────────────────────────── + +function animShapesOf(script: string) { + return parseGsapScript(script).animations.map((a) => ({ + method: a.method, + selector: a.targetSelector, + properties: a.properties, + fromProperties: a.fromProperties, + duration: a.duration, + position: a.position, + })); +} + +const SPLIT_ANIM_CASES: Array<{ name: string; script: string; opts: SplitAnimationsOptions }> = [ + { + name: "all tweens before split — retargets none", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, duration: 1 }, 0); + `, + opts: { + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + }, + { + name: "tween entirely after split — retargeted to newId", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { opacity: 0, duration: 0.5 }, 3); + `, + opts: { + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + }, + { + name: "tween spanning split — truncated first half + fromTo second half", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 200, duration: 4 }, 0); + `, + opts: { + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + }, +]; + +describe("parity: splitAnimationsInScript (recast vs acorn)", () => { + for (const { name, script, opts } of SPLIT_ANIM_CASES) { + it(name, () => { + const { script: recastOut } = splitAnimsRecast(script, opts); + const { script: acornOut } = splitAnimsAcorn(script, opts); + const sortByPos = (arr: ReturnType) => + arr.slice().sort((a, b) => { + const pa = typeof a.position === "number" ? a.position : 0; + const pb = typeof b.position === "number" ? b.position : 0; + return pa - pb || (a.selector ?? "").localeCompare(b.selector ?? ""); + }); + expect(sortByPos(animShapesOf(acornOut))).toEqual(sortByPos(animShapesOf(recastOut))); + }); + } + + it("no-op when originalId not found in script", () => { + const script = SPLIT_ANIM_CASES[0]!.script; + const opts: SplitAnimationsOptions = { + originalId: "nonexistent", + newId: "x", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }; + expect(splitAnimsAcorn(script, opts).script).toBe(script); + }); +}); + +// ─── arc path parity ────────────────────────────────────────────────────────── + +const ARC_FLAT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, y: 50, duration: 2 }, 0); +`; +const ARC_CFG = { + enabled: true as const, + autoRotate: false as const, + segments: [{ curviness: 1 }], +}; +const DISABLE_CFG = { + enabled: false as const, + autoRotate: false as const, + segments: [] as never[], +}; + +function arcFixture() { + const id = acornId(ARC_FLAT_SCRIPT); + const enabled = setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG); + return { id, enabled }; +} + +// Multi-waypoint fixture: keyframes drive >2 path waypoints and >1 segment, and +// autoRotate is on — exercises the multi-segment branch of buildMotionPathObjectCode. +const ARC_KEYFRAME_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 50, y: 100 }, "100%": { x: 200, y: 0 } }, + duration: 2 + }, 0); +`; +const ARC_KEYFRAME_CFG = { + enabled: true as const, + autoRotate: true as const, + segments: [{ curviness: 1.5 }, { curviness: 0.5 }], +}; + +describe("setArcPathInScript: acorn output correctness", () => { + it("enable: arcPath.enabled=true, segments preserved", () => { + const id = acornId(ARC_FLAT_SCRIPT); + const shape = arcShapeOf(setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG)); + expect(shape.arcPath?.enabled).toBe(true); + expect(shape.arcPath?.segments).toHaveLength(1); + }); + + it("disable: arcPath=undefined, x/y restored", () => { + const { id, enabled } = arcFixture(); + const shape = arcShapeOf(setArcAcorn(enabled, id, DISABLE_CFG)); + expect(shape.arcPath).toBeUndefined(); + expect(typeof shape.properties.x).toBe("number"); + }); + + it("no-op when animation not found", () => { + expect(setArcAcorn(ARC_FLAT_SCRIPT, "nope", ARC_CFG)).toBe(ARC_FLAT_SCRIPT); + }); +}); + +describe("updateArcSegmentInScript: acorn output correctness", () => { + it("curviness update reflected in parsed shape", () => { + const { id, enabled } = arcFixture(); + const shape = arcShapeOf(updateArcSegmentAcorn(enabled, id, 0, { curviness: 2 })); + expect(shape.arcPath?.segments[0]?.curviness).toBe(2); + }); + + it("no-op when index out of range", () => { + const { id, enabled } = arcFixture(); + expect(updateArcSegmentAcorn(enabled, id, 99, { curviness: 2 })).toBe(enabled); + }); +}); + +describe("removeArcPathFromScript: acorn output correctness", () => { + it("arcPath=undefined after removal", () => { + const { id, enabled } = arcFixture(); + expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined(); + }); +}); + +// ─── unrollDynamicAnimations correctness ────────────────────────────────────── + +const UNROLL_LOOP_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + const items = ["#a", "#b"]; + for (let i = 0; i < items.length; i++) { + tl.to(items[i], { opacity: 1, duration: 1 }, 0); + } +`; + +const UNROLL_FOREACH_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + ["#a", "#b"].forEach(function(sel) { + tl.to(sel, { opacity: 1, duration: 2 }, 1); + }); +`; + +const UNROLL_ELEMENTS = [ + { + selector: "#hero", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + { + selector: "#sub", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, +]; + +function unrollId(script: string): string { + const p = acornId(script); + return p; +} + +describe("unrollDynamicAnimations: acorn output correctness", () => { + it("for-loop: loop replaced with individual tl.to() calls", () => { + const id = unrollId(UNROLL_LOOP_SCRIPT); + const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS); + expect(out).not.toBe(UNROLL_LOOP_SCRIPT); + expect(out).toContain('tl.to("#hero"'); + expect(out).toContain('tl.to("#sub"'); + expect(out).not.toContain("for ("); + }); + + it("forEach: loop replaced with individual tl.to() calls", () => { + const id = unrollId(UNROLL_FOREACH_SCRIPT); + const out = unrollAcorn(UNROLL_FOREACH_SCRIPT, id, UNROLL_ELEMENTS); + expect(out).toContain('tl.to("#hero"'); + expect(out).not.toContain("forEach"); + }); + + it("preserves duration and position from original tween", () => { + const id = unrollId(UNROLL_LOOP_SCRIPT); + const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS); + expect(out).toContain("duration: 1"); + expect(out).toContain("}, 0)"); + }); + + it("no-op when animationId not found", () => { + expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); + }); +}); + +// ── True recast-vs-acorn differential for the arc trio ────────────────────── +// For each representative input, apply the op via BOTH the recast writer +// (gsapParser.ts) and the acorn writer (gsapWriterAcorn.ts), then assert the +// reparsed authored model is identical. This is the WS-3.F parity safety net: +// acorn cannot drop or mis-serialize a path/segment/restored-xy that recast keeps. +describe("parity: arc path trio (recast vs acorn)", () => { + for (const { name, script, cfg } of [ + { name: "flat x/y — single segment", script: ARC_FLAT_SCRIPT, cfg: ARC_CFG }, + { + name: "keyframes — multi-segment + autoRotate", + script: ARC_KEYFRAME_SCRIPT, + cfg: ARC_KEYFRAME_CFG, + }, + ]) { + describe(name, () => { + it("setArcPath enable: models match", () => { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + expect(modelOf(setArcAcorn(script, id, cfg))).toEqual( + modelOf(setArcRecast(script, id, cfg)), + ); + }); + + it("updateArcSegment: models match", () => { + const recastEnabled = setArcRecast(script, acornId(script), cfg); + const acornEnabled = setArcAcorn(script, acornId(script), cfg); + const idx = cfg.segments.length - 1; + expect( + modelOf( + updateArcSegmentAcorn(acornEnabled, acornId(acornEnabled), idx, { curviness: 3 }), + ), + ).toEqual( + modelOf( + updateArcSegmentRecast(recastEnabled, acornId(recastEnabled), idx, { curviness: 3 }), + ), + ); + }); + + it("removeArcPath: models match (x/y restored, motionPath gone)", () => { + const recastEnabled = setArcRecast(script, acornId(script), cfg); + const acornEnabled = setArcAcorn(script, acornId(script), cfg); + expect(modelOf(removeArcAcorn(acornEnabled, acornId(acornEnabled)))).toEqual( + modelOf(removeArcRecast(recastEnabled, acornId(recastEnabled))), + ); + }); + }); + } +}); + +// ── forEach with explicit ease + nonzero position — the acorn writer reads +// duration/ease/position from the parsed animation model, recast reads them +// straight from the original tween's AST var/position args. ──────────────────── +const UNROLL_FOREACH_EASE_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + ["#a", "#b"].forEach(function(sel) { + tl.to(sel, { opacity: 1, duration: 2, ease: "power2.out" }, 1); + }); +`; + +// Dynamic tween NOT inside a loop — both writers fall back to replacing the +// enclosing expression statement. String (label) position, no duration/ease, +// so the default duration: 8 / ease: "none" path is exercised on both sides. +const UNROLL_FALLBACK_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#dyn", { opacity: 1 }, "intro"); +`; + +const UNROLL_ELEMENTS_EASE = [ + { + selector: "#hero", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + easeEach: "power1.in", + }, + { + selector: "#sub", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, +]; + +// True recast-vs-acorn differential: apply unroll via BOTH writers, then assert +// the reparsed (authored-shape) models are identical. Covers a for-loop, a +// forEach with explicit ease + nonzero position, and a non-loop (fallback) +// tween with a label position + defaulted duration/ease. +const UNROLL_PARITY_CASES: Array<{ + name: string; + script: string; + elements: typeof UNROLL_ELEMENTS; +}> = [ + { name: "for-loop", script: UNROLL_LOOP_SCRIPT, elements: UNROLL_ELEMENTS }, + { name: "forEach", script: UNROLL_FOREACH_SCRIPT, elements: UNROLL_ELEMENTS }, + { + name: "forEach with ease + nonzero position", + script: UNROLL_FOREACH_EASE_SCRIPT, + elements: UNROLL_ELEMENTS_EASE, + }, + { + name: "non-loop fallback — label position, defaulted duration/ease", + script: UNROLL_FALLBACK_SCRIPT, + elements: UNROLL_ELEMENTS_EASE, + }, +]; + +describe("parity: unrollDynamicAnimations (recast vs acorn)", () => { + for (const { name, script, elements } of UNROLL_PARITY_CASES) { + it(name, () => { + const id = unrollId(script); + // Sanity: recast and acorn agree on the id for the dynamic tween. + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + + const recastOut = unrollRecast(script, id, elements); + const acornOut = unrollAcorn(script, id, elements); + + // Both writers must actually unroll (not no-op). + expect(recastOut).not.toBe(script); + expect(acornOut).not.toBe(script); + + // Reparsed authored models must be identical. + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + } + + it("no-op parity when animationId not found", () => { + expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); + expect(unrollRecast(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); + }); +}); + +// ── addKeyframeToScript parity (recast vs acorn) ──────────────────────────── +// PR #1470 routes Studio's GSAP keyframe-add through the acorn writer. Each +// case applies the op via BOTH writers and asserts the parsed authored models +// are equal — closing the parity gap the keyframe-add fix enforces (the acorn +// whole-value overwrite branch must emit recordToCode, not a stale valueCode). + +// Two distinct percentages so adding/merging exercises the insert + merge paths. +const KF_ADD_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +// `_auto`-marked endpoints exercise the adjacent-endpoint sync branch. +const KF_ADD_AUTO_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "100%": { opacity: 1, _auto: 1 } }, duration: 0.5 }, 0.2); +`; + +// Flat tween — adding a keyframe runs the convert-to-keyframes path first. +const KF_ADD_FLAT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2); +`; + +// A percentage entry whose value is NOT an object literal — exercises the +// whole-value overwrite branch (the acorn path here once referenced an +// undefined `valueCode`; recast emits the new value node). +const KF_ADD_NON_OBJECT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": 0.7, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +describe("parity: addKeyframeToScript (recast vs acorn)", () => { + function expectParity( + script: string, + percentage: number, + properties: Record, + ease?: string, + backfillDefaults?: Record, + ) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + const acorn = addKeyframeAcorn(script, id, percentage, properties, ease, backfillDefaults); + const recast = addKeyframeRecast(script, id, percentage, properties, ease, backfillDefaults); + expect(modelOf(acorn)).toEqual(modelOf(recast)); + } + + it("inserts a new percentage in sorted order", () => { + expectParity(KF_ADD_SCRIPT, 25, { opacity: 0.3 }); + }); + + it("replaces the value when the percentage already exists", () => { + expectParity(KF_ADD_SCRIPT, 100, { opacity: 0.99 }); + }); + + it("merges a new property into an existing keyframe, preserving siblings", () => { + expectParity(KF_ADD_SCRIPT, 100, { x: 100 }); + }); + + it("carries an ease onto the new keyframe", () => { + expectParity(KF_ADD_SCRIPT, 30, { opacity: 0.4 }, "power2.out"); + }); + + it("backfills a new property across sibling keyframes", () => { + expectParity(KF_ADD_SCRIPT, 25, { x: 50 }, undefined, { x: 0 }); + }); + + it("syncs an adjacent _auto 0% endpoint", () => { + expectParity(KF_ADD_AUTO_SCRIPT, 10, { opacity: 0.2 }); + }); + + it("syncs an adjacent _auto 100% endpoint", () => { + expectParity(KF_ADD_AUTO_SCRIPT, 90, { opacity: 0.8 }); + }); + + it("converts a flat tween to keyframes before inserting", () => { + expectParity(KF_ADD_FLAT_SCRIPT, 50, { opacity: 0.5 }); + }); + + it("overwrites a non-object keyframe value with the new properties", () => { + expectParity(KF_ADD_NON_OBJECT_SCRIPT, 50, { opacity: 0.5 }); + }); + + it("no-op on unknown id agrees between writers", () => { + expect(addKeyframeAcorn(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT); + expect(addKeyframeRecast(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT); + }); +}); + +// ── removeKeyframeFromScript parity (recast vs acorn) ─────────────────────── +// When removal drops a keyframes block below two stops it must collapse back to +// a flat tween (recast via collapseKeyframesToFlat). The acorn writer must +// mirror this — folding the survivor (incl. `_auto`), dropping per-keyframe +// `ease` and the sibling `easeEach` — or the SDK/server paths diverge. + +// Three plain keyframes — removing the interior one stays a keyframes block. +const RM_PLAIN_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +// Two plain keyframes — removing one drops below 2 → collapse to flat tween. +const RM_TWO_KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +// Two _auto endpoints — collapse must carry the surviving `_auto` marker. +const RM_TWO_KF_AUTO_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2); +`; + +// Survivor carries a per-keyframe `ease`, plus a sibling `easeEach`. Collapse +// must drop both `ease` (per-keyframe) and `easeEach` (keyframes-only). +const RM_TWO_KF_EASE_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1, ease: "power2.in" }, easeEach: "none" }, duration: 0.5 }, 0.2); +`; + +// Survivor is empty — collapse yields a tween with NO authored props. +const RM_TWO_KF_EMPTY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": {}, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +describe("parity: removeKeyframeFromScript (recast vs acorn)", () => { + function expectParity(script: string, percentage: number) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + expect(modelOf(removeKeyframeAcorn(script, id, percentage))).toEqual( + modelOf(removeKeyframeRecast(script, id, percentage)), + ); + } + + it("removes an interior keyframe and stays a keyframes block (3 → 2)", () => { + expectParity(RM_PLAIN_SCRIPT, 50); + }); + + it("targets a near-coincident percentage (51 → the 50% keyframe) at parity", () => { + expectParity(RM_PLAIN_SCRIPT, 51); + }); + + it("collapses to a flat tween when only one keyframe would remain (2 → 1)", () => { + expectParity(RM_TWO_KF_SCRIPT, 0); + }); + + it("collapses an _auto endpoint pair, carrying the surviving _auto marker", () => { + expectParity(RM_TWO_KF_AUTO_SCRIPT, 0); + }); + + it("collapses and drops per-keyframe ease + easeEach", () => { + expectParity(RM_TWO_KF_EASE_SCRIPT, 0); + }); + + it("collapses to a propless flat tween when the surviving keyframe is empty", () => { + expectParity(RM_TWO_KF_EMPTY_SCRIPT, 100); + }); + + it("no-op on unknown id agrees between writers", () => { + expect(removeKeyframeAcorn(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT); + expect(removeKeyframeRecast(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT); + }); +}); + +// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ─────────────── +// WS-3.C add path: both writers insert a new keyframed tl.to() call. The +// inserted statement's authored model (selector, keyframes, duration, ease, +// position) must match — comparing the LAST animation each writer produced. +const ADD_WITH_KF_BASE = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#existing", { opacity: 1, duration: 1 }, 0); +`; + +function lastModelOf(script: string) { + const arr = modelOf(script); + return arr[arr.length - 1]; +} + +describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => { + it("minimal: two-keyframe insert, no ease", () => { + const kfs = [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ]; + const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script; + const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script; + expect(lastModelOf(acorn)).toEqual(lastModelOf(recast)); + }); + + it("moderate: three keyframes, per-keyframe ease, easeEach, nonzero position", () => { + const kfs = [ + { percentage: 0, properties: { x: 0, opacity: 0 } }, + { percentage: 50, properties: { x: 100, opacity: 0.5 }, ease: "power2.out" }, + { percentage: 100, properties: { x: 300, opacity: 1 } }, + ]; + const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script; + const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script; + expect(lastModelOf(acorn)).toEqual(lastModelOf(recast)); + }); +}); + +// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ── + +const POSITIONS_MULTI = `const tl = gsap.timeline({ paused: true }); +tl.from("#hero", { opacity: 0, duration: 1 }, 0); +tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5); +tl.from("#bg", { scale: 0, duration: 1 }, 1);`; + +describe("parity: shiftPositionsInScript (recast vs acorn)", () => { + it("shifts only the target selector's numeric positions", () => { + const a = shiftAcorn(POSITIONS_MULTI, "#hero", 3); + const r = shiftRecast(POSITIONS_MULTI, "#hero", 3); + expect(modelOf(a)).toEqual(modelOf(r)); + }); + + it("clamps negative-going positions to zero", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 0.3); +tl.to("#el", { y: 50, duration: 1 }, 1.5);`; + expect(modelOf(shiftAcorn(s, "#el", -1))).toEqual(modelOf(shiftRecast(s, "#el", -1))); + }); + + it("skips string (relative) positions", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 2); +tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; + expect(modelOf(shiftAcorn(s, "#el", 1))).toEqual(modelOf(shiftRecast(s, "#el", 1))); + }); + + it("adjacent positions do not collide", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0); +tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`; + expect(modelOf(shiftAcorn(s, "#burst", 0.5))).toEqual(modelOf(shiftRecast(s, "#burst", 0.5))); + }); + + it("implicit-position tween gains an explicit shifted position", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 1, duration: 1 });`; + expect(modelOf(shiftAcorn(s, "#el", 2))).toEqual(modelOf(shiftRecast(s, "#el", 2))); + }); + + it("no matching selector is a no-op", () => { + expect(shiftAcorn(POSITIONS_MULTI, "#nope", 3)).toBe(POSITIONS_MULTI); + }); +}); + +describe("parity: scalePositionsInScript (recast vs acorn)", () => { + it("scales positions and durations proportionally for the target", () => { + const a = scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 2); + const r = scaleRecast(POSITIONS_MULTI, "#hero", 0, 1, 2, 2); + expect(modelOf(a)).toEqual(modelOf(r)); + }); + + it("skips string (relative) positions", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 2); +tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; + expect(modelOf(scaleAcorn(s, "#el", 0, 1, 1, 2))).toEqual( + modelOf(scaleRecast(s, "#el", 0, 1, 1, 2)), + ); + }); + + it("no-op when oldDuration <= 0", () => { + expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 0, 2, 2)).toBe(POSITIONS_MULTI); + }); + + it("no-op when newDuration <= 0", () => { + expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 0)).toBe(POSITIONS_MULTI); + }); +}); diff --git a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts new file mode 100644 index 000000000..11ed3c460 --- /dev/null +++ b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts @@ -0,0 +1,317 @@ +// fallow-ignore-file code-duplication +/** + * Correctness regressions for the SDK-cutover review (PR #1539). + * + * Each test asserts the REAL-WORLD-CORRECT result of a write op — NOT mere + * agreement between the two writers. Several of these scenarios were cases where + * both writers were identically wrong (so the recast-vs-acorn parity suite stayed + * green); these tests pin the corrected behavior. + */ +import { describe, expect, it } from "vitest"; +import { + removeKeyframeFromScript, + updateKeyframeInScript, + setArcPathInScript, + updateArcSegmentInScript, + splitAnimationsInScript, + unrollDynamicAnimations, + updateAnimationInScript, + convertToKeyframesFromScript, +} from "./gsapWriterAcorn.js"; +import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; + +// ── #2 — findKfPropByPct must hit the CLOSEST keyframe, not first-within-2% ── + +const KF_DENSE = `var tl = gsap.timeline({ paused: true }); +tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "49%": { opacity: 0.4 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 1 }, 0);`; +const KF_DENSE_ID = "#box-to-0-visual"; + +describe("#2 — keyframe ops target the closest percentage, not first-within-tolerance", () => { + it("removing 50% removes 50% and KEEPS the neighboring 49%", () => { + const result = removeKeyframeFromScript(KF_DENSE, KF_DENSE_ID, 50); + expect(result).not.toContain('"50%"'); + expect(result).toContain('"49%"'); + expect(result).toContain("opacity: 0.4"); // 49% body intact + expect(result).toContain('"0%"'); + expect(result).toContain('"100%"'); + }); + + it("updating ~50% overwrites 50% (closest), not 49%", () => { + const result = updateKeyframeInScript(KF_DENSE, KF_DENSE_ID, 51, { opacity: 0.99 }); + // 50% is closest to 51 (dist 1) vs 49 (dist 2) — 50% gets the new value. + expect(result).toContain('"50%": { opacity: 0.99 }'); + // 49% must be untouched. + expect(result).toContain('"49%": { opacity: 0.4 }'); + }); +}); + +// ── #4 — enableArcPath on an x/y-only tween must not produce '{}' ── + +describe("#4 — enableArcPath on x/y-only vars yields a real motionPath", () => { + const XY_ONLY = `var tl = gsap.timeline({ paused: true }); +tl.to("#h", { x: 100, y: 50 }, 0);`; + + it("emits a motionPath, drops x/y, and reparses with the arc enabled", () => { + const out = setArcPathInScript(XY_ONLY, "#h-to-0-position", { + enabled: true, + autoRotate: false, + segments: [], + }); + expect(out).toContain("motionPath"); + expect(out).toContain("path:"); + // The collision bug produced a bare '{}' (no motionPath, no x/y). + expect(out).not.toMatch(/\{\s*\}/); + + // x/y are folded into the motionPath waypoints, not left as top-level vars. + const reparsed = parseGsapScriptAcornForWrite(out); + const anim = reparsed?.located[0]?.animation; + expect(anim?.arcPath?.enabled).toBe(true); + expect("x" in (anim?.properties ?? {})).toBe(false); + expect("y" in (anim?.properties ?? {})).toBe(false); + }); +}); + +// ── #5 — split midpoint uses forward baseline (earlier tweens), not reverse ── + +describe("#5 — split-spanning midpoint interpolates from the forward baseline", () => { + // A ends at x:100 (t=0..1). B runs x:?→300 over t=1..3. Split at t=2 lands at + // B's 50% point: mid = 100 + (300-100)*0.5 = 200 (NOT 150 from a 0 baseline). + const TWO_TWEENS = `var tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 0); +tl.to("#el", { x: 300, duration: 2 }, 1);`; + + it("computes midpoint x = 200 (100 + (300-100)*0.5), not 150", () => { + const { script } = splitAnimationsInScript(TWO_TWEENS, { + originalId: "el", + newId: "el2", + splitTime: 2, + }); + expect(script).toContain("x: 200"); + expect(script).not.toContain("x: 150"); + // The new element's second half starts from the midpoint, not from 0. + expect(script).toContain('tl.fromTo("#el2", { x: 200 }'); + }); +}); + +// ── #9 — unroll preserves non-target statements (tl.set) per iteration ── + +describe("#9 — unrollDynamicAnimations keeps sibling statements in the loop body", () => { + const LOOP = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { autoAlpha: 0 }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + + it("the tl.set initial-state lines survive after unrolling the tl.to", () => { + const parsed = parseGsapScriptAcornForWrite(LOOP); + const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; + const out = unrollDynamicAnimations(LOOP, targetId, [ + { + selector: "#a", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + { + selector: "#b", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + ]); + // Both tl.set lines must remain (one per iteration) — the blanket-overwrite + // bug destroyed every non-target statement in the loop body. + expect((out.match(/tl\.set\(/g) ?? []).length).toBe(2); + expect(out).toContain("autoAlpha: 0"); + // The for-loop itself is gone (unrolled). + expect(out).not.toContain("for ("); + // The target tween is unrolled to static selectors. + expect(out).toContain('tl.to("#a"'); + expect(out).toContain('tl.to("#b"'); + }); + + it("an empty element list is a no-op, not an animation-deleting overwrite", () => { + const parsed = parseGsapScriptAcornForWrite(LOOP); + const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; + // Empty elements has no unrolled form — overwriting the loop with zero calls + // would silently delete the animation. Writer must return the script verbatim. + expect(unrollDynamicAnimations(LOOP, targetId, [])).toBe(LOOP); + }); +}); + +// ── R3 — unsafe sibling reproduction must refuse (no-op), never corrupt/drop ── + +const TWO_EL = [ + { selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + { selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, +]; +function targetToId(script: string): string { + return ( + parseGsapScriptAcornForWrite(script)?.located.find((l) => l.animation.method === "to")?.id ?? "" + ); +} +function parses(src: string): boolean { + try { + new Function(src); + return true; + } catch { + return false; + } +} + +describe("R3 — unroll refuses (no-ops) when siblings can't be safely reproduced", () => { + // R2 carried a forEach WITH a sibling tl.set to the blanket overwrite, which + // dropped the tl.set (elements start visible instead of hidden). The numeric + // index a `for` loop provides isn't available, so we now refuse instead. + it("forEach with a sibling statement is left untouched, not flattened-and-dropped", () => { + const FOREACH = `var tl = gsap.timeline({ paused: true }); +items.forEach((item, i) => { + tl.set(item, { autoAlpha: 0 }, 0); + tl.to(item, { opacity: 1, duration: 1 }, 0); +});`; + expect(unrollDynamicAnimations(FOREACH, targetToId(FOREACH), TWO_EL)).toBe(FOREACH); + }); + + // R3 #1 — object shorthand { i }: substituting the value yields `{ 0 }` (invalid). + it("object shorthand using the index refuses rather than emit invalid `{ 0 }`", () => { + const SHORTHAND = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { data: { i } }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + const out = unrollDynamicAnimations(SHORTHAND, targetToId(SHORTHAND), TWO_EL); + expect(out).toBe(SHORTHAND); + expect(parses(out)).toBe(true); + }); + + // R3 #2 — a sibling that re-declares the index (nested for / shadowing). + it("a sibling shadowing the index refuses rather than rewrite the inner binding", () => { + const SHADOW = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { onStart() { for (let i = 0; i < 3; i++) log(i); } }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + const out = unrollDynamicAnimations(SHADOW, targetToId(SHADOW), TWO_EL); + expect(out).toBe(SHADOW); + expect(parses(out)).toBe(true); + }); + + // The safe for-loop sibling case must still unroll (regression guard). + it("a plain for-loop with an items[i] sibling still unrolls and preserves it", () => { + const SAFE = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { autoAlpha: 0 }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + const out = unrollDynamicAnimations(SAFE, targetToId(SAFE), TWO_EL); + expect((out.match(/tl\.set\(/g) ?? []).length).toBe(2); + expect(out).not.toContain("for ("); + expect(parses(out)).toBe(true); + }); +}); + +// ── R2 #5 — index substitution is AST-based: string literals are never corrupted ── + +describe("R2 — unroll substitutes real index uses but not the index char in strings", () => { + const LOOP_STR = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { id: "row-i" }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + + it('rewrites items[i] per iteration but leaves the "row-i" string intact', () => { + const parsed = parseGsapScriptAcornForWrite(LOOP_STR); + const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; + const out = unrollDynamicAnimations(LOOP_STR, targetId, [ + { selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + { selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + ]); + // Real uses of the index are substituted… + expect(out).toContain("items[0]"); + expect(out).toContain("items[1]"); + // …but the literal "row-i" is untouched (the regex bug rewrote it to "row-0"). + expect(out).toContain('"row-i"'); + expect(out).not.toContain('"row-0"'); + }); +}); + +// ── #10 — per-segment curviness survives serialization ── + +describe("#10 — updateArcSegment on a non-first segment reflects its curviness", () => { + // 3-waypoint arc, uniform curviness 1.5 → change segment 1 to curviness 3. + const ARC = `var tl = gsap.timeline({ paused: true }); +tl.to("#h", { motionPath: { path: [{x: 0, y: 0}, {x: 100, y: 50}, {x: 200, y: 0}], curviness: 1.5 }, duration: 1 }, 0);`; + + it("does not drop the second segment's curve (no longer serializes only segments[0])", () => { + const parsed = parseGsapScriptAcornForWrite(ARC); + const id = parsed?.located[0]?.id ?? ""; + const out = updateArcSegmentInScript(ARC, id, 1, { curviness: 3 }); + + // With differing per-segment curviness, the only representation that carries + // both is the cubic form. The simple form (which only emits one scalar + // curviness) would silently drop segment 1's change. + expect(out).toContain('type: "cubic"'); + + // Compare against the SAME-shape arc left at uniform curviness 1.5: the + // segment-1 control points must DIFFER, proving curviness 3 took effect. + const uniformOut = updateArcSegmentInScript(ARC, id, 1, { curviness: 1.5 }); + expect(out).not.toBe(uniformOut); + }); +}); + +// ── #11 — disableArcPath recovers NEGATIVE destination coordinates ── + +describe("#11 — disableArcPath restores negative waypoint coords", () => { + it("restores x:-120, y:-40 on the flattened tween", () => { + const XY_NEG = `var tl = gsap.timeline({ paused: true }); +tl.to("#h", { x: -120, y: -40, duration: 1 }, 0);`; + const enabled = setArcPathInScript(XY_NEG, "#h-to-0-position", { + enabled: true, + autoRotate: false, + segments: [], + }); + const reEnabled = parseGsapScriptAcornForWrite(enabled); + const id = reEnabled?.located[0]?.id ?? ""; + const disabled = setArcPathInScript(enabled, id, { + enabled: false, + autoRotate: false, + segments: [], + }); + // The negative destination must come back — the UnaryExpression bug lost it. + expect(disabled).toContain("x: -120"); + expect(disabled).toContain("y: -40"); + expect(disabled).not.toContain("motionPath"); + }); +}); + +// ── #7 — updating ease on a keyframe tween routes to easeEach, not top-level ── + +describe("#7 — ease update on a keyframe tween targets keyframes.easeEach", () => { + const KF = `var tl = gsap.timeline({ paused: true }); +tl.to(".a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1, ease: "none" }, 0);`; + + it("writes easeEach (per-keyframe), not a no-op top-level ease", () => { + const id = parseGsapScriptAcornForWrite(KF)?.located[0]?.id ?? ""; + const out = updateAnimationInScript(KF, id, { ease: "power2.inOut" }); + expect(out).toContain('easeEach: "power2.inOut"'); + // The original top-level `ease: "none"` is untouched (no second top-level ease). + expect((out.match(/ease: "power2.inOut"/g) ?? []).length).toBe(0); + }); +}); + +// ── #8 — convertToKeyframes preserves builtin vars like `delay` ── + +describe("#8 — convertToKeyframes keeps delay (was dropped, shifting start time)", () => { + const DELAY = `var tl = gsap.timeline({ paused: true }); +tl.to(".a", { x: 100, duration: 1, delay: 0.3 }, 0);`; + + it("preserves delay on the converted vars object", () => { + const id = parseGsapScriptAcornForWrite(DELAY)?.located[0]?.id ?? ""; + const out = convertToKeyframesFromScript(DELAY, id); + expect(out).toContain("keyframes:"); + expect(out).toContain("delay: 0.3"); // was lost → tween started 0.3s early + expect(out).toContain("duration: 1"); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index a936e37e6..cfd459cc7 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -7,14 +7,31 @@ * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. */ import MagicString from "magic-string"; -import { serializeValue, safeJsKey, type GsapAnimation } from "./gsapSerialize.js"; +import type { + GsapAnimation, + GsapPercentageKeyframe, + ArcPathConfig, + ArcPathSegment, +} from "./gsapSerialize.js"; +import { + resolveConversionProps, + extractArcWaypoints, + buildMotionPathObjectCode, +} from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite, type TweenCallInfo, } from "./gsapParserAcorn.js"; +import { classifyPropertyGroup } from "./gsapConstants.js"; +import type { PropertyGroupName } from "./gsapConstants.js"; +import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapParser.js"; import * as acornWalk from "acorn-walk"; +// acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / +// gsapInline.ts rather than re-deriving the full ESTree union for every access. +type Node = any; + // ── Code generation helpers ────────────────────────────────────────────────── // Local serializer for the tween-statement path, which may carry boolean/object @@ -60,15 +77,15 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit= 0; i--) { if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i]; } @@ -85,11 +108,11 @@ function findEnclosingExpressionStatement(ancestors: any[]): any | null { } /** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */ -function findTimelineDeclarationStatement(ast: any, timelineVar: string): any | null { - let found: any = null; +function findTimelineDeclarationStatement(ast: Node, timelineVar: string): Node | null { + let found: Node = null; acornWalk.simple(ast, { // fallow-ignore-next-line complexity - VariableDeclaration(node: any) { + VariableDeclaration(node: Node) { if (found) return; for (const decl of node.declarations ?? []) { if ( @@ -113,7 +136,7 @@ function findTimelineDeclarationStatement(ast: any, timelineVar: string): any | * Remove a property from a properties array, handling its comma. * `editableProps` must be the isObjectProperty-filtered subset in source order. */ -function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void { +function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): void { const idx = editableProps.indexOf(propNode); if (idx === -1) return; if (editableProps.length === 1) { @@ -127,11 +150,23 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void } } +/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */ +function buildVarsObjectCode(record: Record): string { + const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}"; +} + +/** Overwrite a tween call's vars ObjectExpression with freshly-built source. */ +function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string): void { + if (!call.varsArg) return; + ms.overwrite(call.varsArg.start, call.varsArg.end, objCode); +} + /** * Update a property value if it exists, or append a new key: val before the * closing `}`. Call with the full ObjectExpression node. */ -function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown): void { +function upsertProp(ms: MagicString, objNode: Node, key: string, value: unknown): void { if (objNode?.type !== "ObjectExpression") return; const existing = findPropertyNode(objNode, key); if (existing) { @@ -183,7 +218,7 @@ function isEditableVarKey(key: string): boolean { * the entries plus the set of keys it kept, so callers can append new keys. */ function preservedEntries( - objNode: any, + objNode: Node, source: string, drop: (key: string) => boolean, overrides: Record, @@ -215,7 +250,7 @@ function preservedEntries( */ function reconcileEditableProps( ms: MagicString, - objNode: any, + objNode: Node, source: string, newProps: Record, nonEditableOverrides?: Record, @@ -235,7 +270,7 @@ function reconcileEditableProps( // ── Insertion helpers ───────────────────────────────────────────────────────── /** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */ -function isTimelineRooted(node: any, timelineVar: string): boolean { +function isTimelineRooted(node: Node, timelineVar: string): boolean { if (node?.type === "Identifier") return node.name === timelineVar; if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar); return false; @@ -247,8 +282,9 @@ function isTimelineRooted(node: any, timelineVar: string): boolean { * not emit `tl.xxx()` calls in that case as `tl` would be undefined at render. */ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null { - if (parsed.located.length > 0) { - const lastCall = parsed.located[parsed.located.length - 1]!.call; + const lastLocated = parsed.located[parsed.located.length - 1]; + if (lastLocated) { + const lastCall = lastLocated.call; const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors); return exprStmt?.end ?? lastCall.node.end; } @@ -289,7 +325,12 @@ export function updateAnimationInScript( upsertProp(ms, call.varsArg, "duration", updates.duration); } if (updates.ease !== undefined) { - upsertProp(ms, call.varsArg, "ease", updates.ease); + // For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe), + // not a top-level ease. Writing top-level ease would leave the per-keyframe + // easing unchanged — the user's edit would silently do nothing. + const kfNode = keyframesObjectNode(call.varsArg); + if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease); + else upsertProp(ms, call.varsArg, "ease", updates.ease); } if (updates.extras) { for (const [key, value] of Object.entries(updates.extras)) { @@ -306,18 +347,87 @@ export function updateAnimationInScript( } if (updates.position !== undefined) { - const posIdx = call.method === "fromTo" ? 3 : 2; - const posArgNode = call.node.arguments?.[posIdx]; - if (posArgNode) { - ms.overwrite(posArgNode.start, posArgNode.end, valueToCode(updates.position)); - } else { - ms.appendLeft(call.node.end - 1, `, ${valueToCode(updates.position)}`); - } + overwritePosition(ms, call, updates.position); } return ms.toString(); } +/** + * Overwrite a tween call's numeric position argument (the positionArg the parser + * located: 3rd arg for fromTo, else 2nd), or append one when the call has no + * explicit position. Shared by updateAnimationInScript and the + * shift/scalePositionsInScript timeline ops. + */ +function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void { + if (call.positionArg) { + ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position)); + } else { + ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`); + } +} + +/** + * Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0), + * rewriting each call's position argument. Mirrors recast's shiftPositionsInScript + * (used by timeline clip-move to keep GSAP positions in sync with the clip start). + */ +export function shiftPositionsInScript( + script: string, + targetSelector: string, + delta: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const ms = new MagicString(script); + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); + overwritePosition(ms, entry.call, newPos); + changed = true; + } + return changed ? ms.toString() : script; +} + +/** + * Linearly remap every tween targeting `targetSelector` from the old clip + * [oldStart, oldDuration] onto the new [newStart, newDuration] (position and, + * when present, duration scaled by the duration ratio). Mirrors recast's + * scalePositionsInScript (used by timeline clip-resize). + */ +export function scalePositionsInScript( + script: string, + targetSelector: string, + oldStart: number, + oldDuration: number, + newStart: number, + newDuration: number, +): string { + if (oldDuration <= 0 || newDuration <= 0) return script; + const ratio = newDuration / oldDuration; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const ms = new MagicString(script); + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max( + 0, + Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, + ); + overwritePosition(ms, entry.call, newPos); + if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { + const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000); + upsertProp(ms, entry.call.varsArg, "duration", newDur); + } + changed = true; + } + return changed ? ms.toString() : script; +} + export function addAnimationToScript( script: string, animation: Omit, @@ -433,7 +543,7 @@ function conversionEndpoints(animation: GsapAnimation): { } /** Collect preserved (non-editable) `key: value` entries from the original vars node. */ -function preservedVarsEntries(varsNode: any, source: string): string[] { +function preservedVarsEntries(varsNode: Node, source: string): string[] { const entries: string[] = []; if (varsNode?.type !== "ObjectExpression") return entries; for (const prop of varsNode.properties ?? []) { @@ -446,7 +556,7 @@ function preservedVarsEntries(varsNode: any, source: string): string[] { } /** Build the rebuilt vars-object code for a converted flat tween. */ -function buildConvertedVarsCode(animation: GsapAnimation, varsNode: any, source: string): string { +function buildConvertedVarsCode(animation: GsapAnimation, varsNode: Node, source: string): string { const { fromProps, toProps } = conversionEndpoints(animation); const easeEach = animation.ease; const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; @@ -460,8 +570,8 @@ function buildConvertedVarsCode(animation: GsapAnimation, varsNode: any, source: function convertMethodToTo( ms: MagicString, animation: GsapAnimation, - call: any, - varsNode: any, + call: Node, + varsNode: Node, ): void { if (animation.method !== "from" && animation.method !== "fromTo") return; const calleeProp = call.node.callee?.property; @@ -470,7 +580,7 @@ function convertMethodToTo( if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start); } -function convertFlatTweenToKeyframes(script: string, target: any): string { +function convertFlatTweenToKeyframes(script: string, target: Node): string { const animation: GsapAnimation = target.animation; if (animation.keyframes || animation.method === "set") return script; const call = target.call; @@ -507,13 +617,13 @@ function percentageFromKey(key: string): number { /** Serialize a final keyframe property record (number|string values) to code. */ function recordToCode(record: Record): string { - const entries = Object.entries(record).map(([k, v]) => `${safeJsKey(k)}: ${serializeValue(v)}`); + const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); return `{ ${entries.join(", ")} }`; } /** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */ -function percentagePropsOf(kfNode: any): any[] { - return (kfNode.properties ?? []).filter((p: any) => { +function percentagePropsOf(kfNode: Node): Node[] { + return (kfNode.properties ?? []).filter((p: Node) => { if (!isObjectProperty(p)) return false; const key = propKeyName(p); return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); @@ -524,7 +634,7 @@ const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral" /** Read one value node: a number/string literal, a negative number, or raw source. */ // fallow-ignore-next-line complexity -function readValueNode(v: any, source: string): number | string { +function readValueNode(v: Node, source: string): number | string { if ( LITERAL_NODE_TYPES.has(v?.type) && (typeof v.value === "number" || typeof v.value === "string") @@ -547,7 +657,7 @@ function readValueNode(v: any, source: string): number | string { * preserved as `__raw:` so serializeValue round-trips it verbatim. * Keyframe values are literals in practice, so the raw fallback is rarely hit. */ -function valueNodeToRecord(valueNode: any, source: string): Record { +function valueNodeToRecord(valueNode: Node, source: string): Record { const record: Record = {}; if (valueNode?.type !== "ObjectExpression") return record; for (const prop of valueNode.properties ?? []) { @@ -572,7 +682,7 @@ function recordHasAuto(record: Record): boolean { * records (never a separate splice). */ function autoEndpointOverwrites( - kfNode: any, + kfNode: Node, source: string, percentage: number, properties: Record, @@ -581,7 +691,7 @@ function autoEndpointOverwrites( if (percentage <= 0 || percentage >= 100) return result; const pctProps = percentagePropsOf(kfNode); const allPcts = pctProps - .map((p: any) => percentageFromKey(propKeyName(p) ?? "")) + .map((p: Node) => percentageFromKey(propKeyName(p) ?? "")) .filter((n: number) => !Number.isNaN(n) && n !== percentage) .sort((a: number, b: number) => a - b); const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop(); @@ -589,7 +699,7 @@ function autoEndpointOverwrites( for (const endPct of [0, 100]) { const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100; if (!isNeighbor) continue; - const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct); + const endProp = pctProps.find((p: Node) => percentageFromKey(propKeyName(p) ?? "") === endPct); if (!endProp) continue; const rec = valueNodeToRecord(endProp.value, source); if (!recordHasAuto(rec)) continue; @@ -598,17 +708,27 @@ function autoEndpointOverwrites( return result; } -function findKfPropByPct(kfNode: any, percentage: number): { prop: any; idx: number } | null { +function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: number } | null { + // Match the CLOSEST keyframe within tolerance, not the first one within range. + // Keyframes at e.g. 0/49/50/100 are all valid (the SDK dedups to a unique + // match at TOLERANCE=0.001 upstream); picking the first-within-PCT_TOLERANCE=2 + // would hit 49% when the caller meant 50%. Tie-break on the earliest index so + // the choice stays deterministic. const props = kfNode.properties ?? []; + let best: { prop: Node; idx: number } | null = null; + let bestDist = Number.POSITIVE_INFINITY; for (let i = 0; i < props.length; i++) { const prop = props[i]; if (!isObjectProperty(prop)) continue; const key = propKeyName(prop); - if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) <= PCT_TOLERANCE) { - return { prop, idx: i }; + if (typeof key !== "string") continue; + const dist = Math.abs(percentageFromKey(key) - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + best = { prop, idx: i }; + bestDist = dist; } } - return null; + return best; } export function updateKeyframeInScript( @@ -643,7 +763,7 @@ export function updateKeyframeInScript( * ease when the op omits one); otherwise it's just the new props. */ function buildTargetRecord( - existing: { prop: any; idx: number } | null, + existing: { prop: Node; idx: number } | null, source: string, properties: Record, ease: string | undefined, @@ -669,7 +789,7 @@ function buildTargetRecord( * nothing changes (so the caller emits no overwrite for it). */ function backfilledSiblingRecord( - valueNode: any, + valueNode: Node, source: string, newPropKeys: string[], backfillDefaults: Record, @@ -690,7 +810,7 @@ function backfilledSiblingRecord( function locateWithKeyframes( script: string, animationId: string, -): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null { +): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return null; // Converting from()/fromTo() to to() rewrites the content-derived id; match @@ -709,7 +829,7 @@ function locateWithKeyframes( function ensureKeyframesNode( script: string, animationId: string, -): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null { +): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { const direct = locateWithKeyframes(script, animationId); if (direct) return direct; @@ -727,11 +847,11 @@ function ensureKeyframesNode( * target keyframe and any node already being overwritten as an `_auto` endpoint. */ function collectBackfillOverwrites( - kfNode: any, + kfNode: Node, src: string, properties: Record, backfillDefaults: Record | undefined, - skip: { existingProp: any; endpoints: Map }, + skip: { existingProp: Node; endpoints: Map }, ): Map> { const result = new Map>(); if (!backfillDefaults) return result; @@ -774,7 +894,17 @@ export function addKeyframeToScript( // Emit exactly one overwrite per changed node, plus one insert for a new key. const ms = new MagicString(src); if (existing) { - ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord)); + // Merge into the existing keyframe at this percentage, preserving sibling + // properties — overwrite only the given keys. (A whole-value overwrite here + // would silently drop other properties already keyframed at this percent.) + if (existing.prop.value?.type === "ObjectExpression") { + for (const [k, v] of Object.entries(properties)) { + upsertProp(ms, existing.prop.value, k, v); + } + if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease); + } else { + ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord)); + } } else { insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord)); } @@ -788,13 +918,13 @@ export function addKeyframeToScript( /** Insert a brand-new `"pct%": {...}` property in sorted order. */ function insertNewKeyframe( ms: MagicString, - kfNode: any, + kfNode: Node, percentage: number, pctKey: string, valueCode: string, ): void { - const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); - let insertBeforeProp: any = null; + const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); + let insertBeforeProp: Node = null; for (const prop of allProps) { const key = propKeyName(prop); if (typeof key === "string" && percentageFromKey(key) > percentage) { @@ -820,7 +950,7 @@ function insertNewKeyframe( */ function collapseKeyframesToFlat( ms: MagicString, - varsNode: any, + varsNode: Node, source: string, remainingRecord: Record, ): void { @@ -858,22 +988,448 @@ export function removeKeyframeFromScript( // node — the two edits would overlap. const remaining = percentagePropsOf(kfNode).filter((p) => p !== match.prop); if (remaining.length < 2) { - const record = remaining.length === 1 ? valueNodeToRecord(remaining[0]!.value, script) : {}; + const sole = remaining[0]; + const record = sole ? valueNodeToRecord(sole.value, script) : {}; collapseKeyframesToFlat(ms, target.call.varsArg, script, record); return ms.toString(); } - const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); + const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); removeProp(ms, match.prop, allProps); return ms.toString(); } +export function removePropertyFromAnimation( + script: string, + animationId: string, + property: string, + from = false, +): 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 objNode = from ? (call.method === "fromTo" ? call.fromArg : null) : call.varsArg; + if (!objNode) return script; + const propNode = findPropertyNode(objNode, property); + if (!propNode) return script; + const allProps = (objNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); + const ms = new MagicString(script); + removeProp(ms, propNode, allProps); + return ms.toString(); +} + +/** + * Remove all keyframes from a tween, collapsing to a flat tween with one + * keyframe's properties: the first for `from()`, the last otherwise (the + * destination = the visible resting state). + */ +export function removeAllKeyframesFromScript(script: string, animationId: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const kfs = target.animation.keyframes?.keyframes; + if (!kfs || kfs.length === 0) return script; + + const sorted = [...kfs].sort((a, b) => a.percentage - b.percentage); + const collapse = target.call.method === "from" ? sorted[0] : sorted[sorted.length - 1]; + if (!collapse) return script; + + const ms = new MagicString(script); + overwriteVarsArg( + ms, + target.call, + buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)), + ); + return ms.toString(); +} + +// Flat vars for a tween collapsing its keyframes onto one stop: existing +// top-level props, then the collapse keyframe's props (skip per-keyframe +// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission. +function buildCollapsedFlatVars( + animation: GsapAnimation, + collapse: { properties: Record }, +): Record { + const flat: Record = { ...animation.properties }; + for (const [k, v] of Object.entries(collapse.properties)) { + if (k !== "ease") flat[k] = v; + } + if (animation.duration !== undefined) flat.duration = animation.duration; + if (animation.ease) flat.ease = animation.ease; + for (const [k, v] of Object.entries(animation.extras ?? {})) { + if (typeof v === "number" || typeof v === "string") flat[k] = v; + } + return flat; +} + +/** Build the full replacement vars object for a tween being converted to keyframes. */ +function buildKeyframesVarsCode( + animation: GsapAnimation, + fromProps: Record, + toProps: Record, + varsNode: Node, + source: string, +): string { + const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : ""; + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; + // Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…) + // verbatim from source — rebuilding from the animation object alone dropped + // `delay` (not a GsapAnimation field), shifting the tween's start time. + const parts: string[] = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)]; + if (animation.ease) parts.push(`ease: "none"`); + return `{ ${parts.join(", ")} }`; +} + +/** + * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. + * `resolvedFromValues` supplies the current DOM state: overrides the 0% endpoint + * for `to()`, the 100% endpoint for `from()`, or merges into toProps for `fromTo()`. + */ +export function convertToKeyframesFromScript( + script: string, + animationId: string, + resolvedFromValues?: Record, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const { animation, call } = target; + if (animation.keyframes || call.method === "set") return script; + + const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); + const ms = new MagicString(script); + + if (call.method === "from" || call.method === "fromTo") { + ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to"); + } + if (call.method === "fromTo" && call.fromArg) { + ms.remove(call.fromArg.start, call.varsArg.start); + } + overwriteVarsArg( + ms, + call, + buildKeyframesVarsCode(animation, fromProps, toProps, call.varsArg, script), + ); + + 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 { + // An empty keyframe list has no materialized form — rebuilding vars with an + // empty keyframes object would empty the animation. No-op instead. + if (keyframes.length === 0) return script; + 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: Node) => 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; + } + } + const largest = largestGroup ? groupProps.get(largestGroup) : undefined; + if (largest) largest.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 ─────────────────────────────────────────────────────────── +/** True when `expr` is `tl.(…)` rooted at the timeline var. */ +function isTimelineMethodCall(expr: Node, timelineVar: string, method: string): boolean { + return ( + expr?.type === "CallExpression" && + expr.callee?.type === "MemberExpression" && + isTimelineRooted(expr.callee.object, timelineVar) && + expr.callee.property?.name === method + ); +} + +/** True when `expr` is `tl.addLabel("", …)` rooted at the timeline var. */ +function isAddLabelCall(expr: Node, timelineVar: string, name: string): boolean { + const firstArg = expr?.arguments?.[0]; + return ( + isTimelineMethodCall(expr, timelineVar, "addLabel") && + firstArg?.type === "Literal" && + firstArg.value === name + ); +} + +/** Every `tl.addLabel("", …)` ExpressionStatement in the script. */ +function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): Node[] { + const targets: Node[] = []; + acornWalk.simple(parsed.ast, { + ExpressionStatement(node: Node) { + if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node); + }, + }); + return targets; +} + export function addLabelToScript(script: string, name: string, position: number): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; + // If the label already exists, MOVE it (overwrite its position) rather than + // appending a duplicate. Two same-named addLabel statements make removeLabel + // over-remove — it deletes every match, including a pre-existing label the + // user never touched. + const existing = findLabelStatements(parsed, name)[0]; + if (existing) { + const ms = new MagicString(script); + const posArg = existing.expression.arguments?.[1]; + if (posArg) ms.overwrite(posArg.start, posArg.end, valueToCode(position)); + else ms.appendLeft(existing.expression.end - 1, `, ${valueToCode(position)}`); + return ms.toString(); + } + const insertionPoint = findInsertionPoint(parsed); if (insertionPoint === null) return script; @@ -887,24 +1443,7 @@ export function removeLabelFromScript(script: string, name: string): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; - const targets: any[] = []; - acornWalk.simple(parsed.ast, { - // fallow-ignore-next-line complexity - ExpressionStatement(node: any) { - const expr = node.expression; - if ( - expr?.type === "CallExpression" && - expr.callee?.type === "MemberExpression" && - isTimelineRooted(expr.callee.object, parsed.timelineVar) && - expr.callee.property?.name === "addLabel" && - expr.arguments?.[0]?.type === "Literal" && - expr.arguments[0].value === name - ) { - targets.push(node); - } - }, - }); - + const targets = findLabelStatements(parsed, name); if (!targets.length) return script; const ms = new MagicString(script); @@ -915,3 +1454,746 @@ export function removeLabelFromScript(script: string, name: string): string { } return ms.toString(); } + +// ── Arc path helpers ───────────────────────────────────────────────────────── + +/** + * Remove a set of properties from an ObjectExpression in a single pass. + * Groups consecutive marked props into blocks to avoid overlapping remove ranges. + */ +function removePropsByKey(ms: MagicString, objNode: Node, keys: Set): void { + if (objNode?.type !== "ObjectExpression") return; + const allProps = (objNode.properties ?? []).filter(isObjectProperty); + const marked = allProps.map((p: Node) => keys.has(propKeyName(p) ?? "")); + let i = 0; + while (i < allProps.length) { + if (!marked[i]) { + i++; + continue; + } + const blockStart = i; + while (i < allProps.length && marked[i]) i++; + ms.remove(...blockRemoveRange(allProps, blockStart, i)); + } +} + +function blockRemoveRange( + allProps: Node[], + blockStart: number, + blockEnd: number, +): [number, number] { + if (blockStart === 0 && blockEnd === allProps.length) + return [allProps[0].start, allProps[allProps.length - 1].end]; + if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start]; + return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end]; +} + +// fallow-ignore-next-line complexity +function readLastWaypointXY(mpVal: Node): { x: number | null; y: number | null } { + if (mpVal?.type !== "ObjectExpression") return { x: null, y: null }; + const pathProp = findPropertyNode(mpVal, "path"); + if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null }; + const elems: Node[] = pathProp.value.elements ?? []; + const last = elems[elems.length - 1]; + if (last?.type !== "ObjectExpression") return { x: null, y: null }; + return { + x: readNumericLiteralNode(findPropertyNode(last, "x")?.value), + y: readNumericLiteralNode(findPropertyNode(last, "y")?.value), + }; +} + +/** + * Read a numeric value node — a plain numeric literal or a unary-minus negative + * literal (e.g. `-120`). Returns null for anything non-numeric. Without the + * UnaryExpression branch, negative waypoint coords (parsed as a UnaryExpression + * with no `.value`) would be lost when disabling an arc path. + */ +function readNumericLiteralNode(v: Node): number | null { + if (LITERAL_NODE_TYPES.has(v?.type) && typeof v.value === "number") return v.value; + if ( + v?.type === "UnaryExpression" && + v.operator === "-" && + typeof v.argument?.value === "number" + ) { + return -v.argument.value; + } + return null; +} + +function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean { + const mpProp = findPropertyNode(call.varsArg, "motionPath"); + if (!mpProp) return false; + const { x, y } = readLastWaypointXY(mpProp.value); + if (x === null && y === null) { + const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty); + removeProp(ms, mpProp, allProps); + return true; + } + // Overwrite the entire motionPath property with the recovered x/y pair — avoids + // the appendLeft+remove range-boundary issue in MagicString. + const parts: string[] = []; + if (x !== null) parts.push(`x: ${x}`); + if (y !== null) parts.push(`y: ${y}`); + ms.overwrite(mpProp.start, mpProp.end, parts.join(", ")); + return true; +} + +function stripXYFromKeyframes(ms: MagicString, kfPropNode: Node): void { + if (kfPropNode?.value?.type !== "ObjectExpression") return; + const xyKeys = new Set(["x", "y"]); + for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) { + const k = propKeyName(pctProp); + if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") { + removePropsByKey(ms, pctProp.value, xyKeys); + } + } +} + +function enableArcPath( + ms: MagicString, + call: TweenCallInfo, + animation: GsapAnimation, + config: ArcPathConfig, +): boolean { + const waypoints = extractArcWaypoints(animation); + if (waypoints.length < 2) return false; + const segments: ArcPathSegment[] = + config.segments.length === waypoints.length - 1 + ? config.segments + : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 })); + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: config.autoRotate, + }); + const vars = call.varsArg; + if (vars?.type !== "ObjectExpression") return false; + // Insert motionPath right after the opening `{` (appendRight at start+1) so the + // insertion point can never coincide with the end boundary of the x/y removal + // range. upsertProp would appendLeft at `end - 1`, which collides with a + // remove-range that ends at the same offset when x/y are the only props — + // MagicString then discards the append and the output loses everything. + const editable = (vars.properties ?? []).filter(isObjectProperty); + const survivesRemoval = editable.some((p: Node) => { + const k = propKeyName(p); + return k !== "x" && k !== "y"; + }); + const sep = survivesRemoval ? ", " : ""; + ms.appendRight(vars.start + 1, ` motionPath: ${motionPathCode}${sep}`); + stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes")); + removePropsByKey(ms, call.varsArg, new Set(["x", "y"])); + return true; +} + +export function setArcPathInScript( + script: string, + animationId: string, + config: ArcPathConfig, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const ms = new MagicString(script); + const handled = config.enabled + ? enableArcPath(ms, target.call, target.animation, config) + : disableArcPath(ms, target.call); + return handled ? ms.toString() : script; +} + +export function updateArcSegmentInScript( + script: string, + animationId: string, + segmentIndex: number, + update: Partial, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const { call, animation } = target; + if (!animation.arcPath?.enabled) return script; + + const segments = [...animation.arcPath.segments]; + const existingSeg = segments[segmentIndex]; + if (segmentIndex < 0 || segmentIndex >= segments.length || !existingSeg) return script; + + segments[segmentIndex] = { ...existingSeg, ...update }; + + const waypoints = extractArcWaypoints(animation); + if (waypoints.length < 2) return script; + + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: animation.arcPath.autoRotate, + }); + + const mpProp = findPropertyNode(call.varsArg, "motionPath"); + if (!mpProp) return script; + + const ms = new MagicString(script); + ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode); + return ms.toString(); +} + +export function removeArcPathFromScript(script: string, animationId: string): string { + return setArcPathInScript(script, animationId, { + enabled: false, + autoRotate: false, + segments: [], + }); +} + +// ── splitAnimationsInScript helpers ────────────────────────────────────────── + +/** Overwrite the selector (first arg) of a tween call. */ +function updateAnimationSelectorInScript( + script: string, + animationId: string, + newSelector: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const selectorArg = target.call.node.arguments?.[0]; + if (!selectorArg) return script; + const ms = new MagicString(script); + ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector)); + return ms.toString(); +} + +/** + * Insert a `tl.set()` call immediately after the timeline declaration + * (before existing tweens) to establish inherited state on a new element. + */ +function insertInheritedStateSetInScript( + script: string, + selector: string, + position: number, + properties: Record, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const props = Object.entries(properties) + .map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`) + .join(", "); + const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`; + const ms = new MagicString(script); + const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar); + const firstLocated = parsed.located[0]; + if (tlDecl) { + ms.appendLeft(tlDecl.end, "\n" + code); + } else if (firstLocated) { + const firstCall = firstLocated.call; + const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors); + const insertAt = exprStmt?.start ?? firstCall.node.start; + ms.prependLeft(insertAt, code + "\n"); + } else { + ms.append("\n" + code); + } + return ms.toString(); +} + +/** + * Compute, in forward (timeline) order, the inherited-props baseline available + * BEFORE each matching tween, plus the final cumulative state at the split point. + * A tween contributes to later baselines when it ends at/before the split (full + * props or last keyframe), spans the split via keyframes (kfs at/before split), + * or spans the split as a flat tween (its interpolated midpoint). Decoupled from + * the reverse write loop so the spanning-tween midpoint reads earlier tweens. + */ +// fallow-ignore-next-line complexity +function computeForwardBaselines( + matching: GsapAnimation[], + splitTime: number, +): { before: Array>; final: Record } { + const before: Array> = []; + const acc: Record = {}; + for (const anim of matching) { + before.push({ ...acc }); + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + const kfs = anim.keyframes.keyframes; + if (pos >= splitTime) { + // Moves wholly to the new element — contributes nothing to the baseline. + } else if (animEnd > splitTime) { + for (const kf of kfs) { + const kfTime = pos + (kf.percentage / 100) * dur; + if (kfTime <= splitTime) { + for (const [k, v] of Object.entries(kf.properties)) acc[k] = v; + } + } + } else { + const lastKf = kfs[kfs.length - 1]; + if (lastKf) { + for (const [k, v] of Object.entries(lastKf.properties)) acc[k] = v; + } + } + continue; + } + + if (animEnd <= splitTime) { + for (const [k, v] of Object.entries(anim.properties)) acc[k] = v; + continue; + } + + if (pos >= splitTime) continue; + + // Flat tween spanning the split — its midpoint becomes the inherited value. + const progress = dur > 0 ? (splitTime - pos) / dur : 0; + const fromSource = anim.fromProperties ?? acc; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + acc[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + acc[k] = fromVal + (v - fromVal) * progress; + } + } + return { before, final: { ...acc } }; +} + +// Split one tween that straddles the split point: trim the original to the +// first half (interpolated midpoint as its new end) and add a fromTo for the +// second half on the new element. `fromSource` is the forward baseline. +function buildSpanningSplit( + result: string, + anim: GsapAnimation, + pos: number, + dur: number, + fromSource: Record, + ctx: { splitTime: number; newSelector: string; newElementStart: number }, +): string { + const progress = dur > 0 ? (ctx.splitTime - pos) / dur : 0; + const midProps: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + midProps[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + midProps[k] = fromVal + (v - fromVal) * progress; + } + const trimmed = updateAnimationInScript(result, anim.id, { + duration: ctx.splitTime - pos, + properties: midProps, + }); + return addAnimationToScript(trimmed, { + targetSelector: ctx.newSelector, + method: "fromTo", + position: ctx.newElementStart, + duration: pos + dur - ctx.splitTime, + properties: { ...anim.properties }, + fromProperties: { ...midProps }, + ease: anim.ease, + extras: anim.extras, + }).script; +} + +type SplitCtx = { + splitTime: number; + originalSelector: string; + newSelector: string; + newElementStart: number; +}; + +// Decide what one matching tween does at the split point: move to the new +// element (wholly after), stay (wholly before / keyframes before), get skipped +// (keyframes spanning), or get interpolated in half (spanning). Returns the +// updated script; pushes any skip reason into `skippedSelectors`. +function applyTweenSplit( + result: string, + anim: GsapAnimation, + baselineBefore: Record, + ctx: SplitCtx, + skippedSelectors: string[], +): string { + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + if (animEnd > ctx.splitTime) { + skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`); + } + // Inherited-state for kf tweens is handled by computeForwardBaselines. + return result; + } + // Wholly before the split — kept on the original element. + if (animEnd <= ctx.splitTime) return result; + // Wholly after — move to the new element. + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + // Spans the split — interpolate the midpoint from the FORWARD baseline. + const fromSource = anim.fromProperties ?? baselineBefore; + return buildSpanningSplit(result, anim, pos, dur, fromSource, ctx); +} + +export function splitAnimationsInScript( + script: string, + opts: SplitAnimationsOptions, +): SplitAnimationsResult { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, skippedSelectors: [] }; + + const originalSelector = `#${opts.originalId}`; + const newSelector = `#${opts.newId}`; + + const animations = parsed.located.map((l) => l.animation); + const skippedSelectors: string[] = []; + + for (const a of animations) { + if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) { + skippedSelectors.push(a.targetSelector); + } + } + + const matching = animations.filter((a) => a.targetSelector === originalSelector); + if (matching.length === 0) return { script, skippedSelectors }; + + let result = script; + const newElementStart = opts.splitTime; + + // Forward pre-pass: compute the inherited-props baseline available BEFORE each + // matching tween, in source/timeline order. The write loop below runs in + // REVERSE (so updateAnimationSelectorInScript's selector edits can't shift the + // count-based IDs of not-yet-processed tweens), but the spanning-tween midpoint + // interpolation needs the baseline from EARLIER tweens — which a reverse + // accumulator hasn't seen yet. Decoupling the two fixes the wrong midpoint. + const { before: baselineBefore, final: finalInheritedProps } = computeForwardBaselines( + matching, + opts.splitTime, + ); + + // Reverse iteration: updateAnimationSelectorInScript mutates selectors which + // can shift count-based ID suffixes for later animations. + const ctx = { splitTime: opts.splitTime, originalSelector, newSelector, newElementStart }; + for (let i = matching.length - 1; i >= 0; i--) { + const anim = matching[i]; + if (!anim) continue; + result = applyTweenSplit(result, anim, baselineBefore[i] ?? {}, ctx, skippedSelectors); + } + + if (Object.keys(finalInheritedProps).length > 0) { + result = insertInheritedStateSetInScript( + result, + newSelector, + newElementStart, + finalInheritedProps, + ); + } + + return { script: result, skippedSelectors }; +} + +// ── Unroll dynamic animations ──────────────────────────────────────────────── + +function isLoopNode(node: Node): boolean { + const t = node?.type; + return ( + t === "ForStatement" || + t === "ForInStatement" || + t === "ForOfStatement" || + t === "WhileStatement" + ); +} + +function isForEachStatement(node: Node): boolean { + return ( + node?.type === "ExpressionStatement" && + node.expression?.type === "CallExpression" && + node.expression.callee?.property?.name === "forEach" + ); +} + +/** The nearest enclosing loop / forEach AST node (not just its byte range). */ +function findEnclosingLoopNode(ancestors: Node[]): Node | null { + for (let i = ancestors.length - 2; i >= 0; i--) { + const node = ancestors[i]; + if (isLoopNode(node) || isForEachStatement(node)) return node; + } + return null; +} + +/** Statements making up a loop's body block, or null when not a simple block. */ +function loopBodyStatements(loopNode: Node): Node[] | null { + let body: Node; + if (loopNode?.type === "ExpressionStatement") { + // forEach(cb): body is the callback's block. + const cb = loopNode.expression?.arguments?.[0]; + body = cb?.body; + } else { + body = loopNode?.body; + } + if (body?.type !== "BlockStatement") return null; + return (body.body ?? []).filter((s: Node) => s?.type === "ExpressionStatement"); +} + +/** The loop's index identifier name (`for (let i …)`), used for per-iteration substitution. */ +function loopIndexVarName(loopNode: Node): string | null { + if (loopNode?.type === "ForStatement") { + const decl = loopNode.init?.declarations?.[0]; + return typeof decl?.id?.name === "string" ? decl.id.name : null; + } + return null; +} + +/** + * Rewrite one body statement's source for iteration `idx`: replace USES of the + * loop index variable (AST Identifier nodes) with the literal index. AST-based, + * not a text regex, so the index name appearing inside a string literal (e.g. a + * selector ".row-i") or as a non-computed member/key (`obj.i`, `{ i: … }`) is + * left untouched — only real references to the variable are substituted. + */ +// An identifier in "binding position" is a name, not a value reference: a +// non-computed member property (`obj.i`) or object-literal key (`{ i: … }`). +// Those must NOT be substituted with the iteration index. +function isIndexBindingPosition(node: Node, parent: Node): boolean { + if (parent?.type === "MemberExpression") return parent.property === node && !parent.computed; + if (parent?.type === "Property" || parent?.type === "ObjectProperty") { + return parent.key === node && !parent.computed; + } + return false; +} + +function substituteLoopIndex(stmt: Node, indexVar: string, idx: number, script: string): string { + const base = stmt.start as number; + const src = script.slice(base, stmt.end as number); + const ranges: Array<[number, number]> = []; + acornWalk.ancestor(stmt, { + Identifier(node: Node, _state: unknown, ancestors: Node[]) { + if (node.name !== indexVar) return; + if (isIndexBindingPosition(node, ancestors[ancestors.length - 2])) return; + ranges.push([(node.start as number) - base, (node.end as number) - base]); + }, + }); + if (ranges.length === 0) return src; + ranges.sort((a, b) => b[0] - a[0]); + let out = src; + for (const [s, e] of ranges) out = out.slice(0, s) + String(idx) + out.slice(e); + return out; +} + +function buildUnrollReplacement( + timelineVar: string, + animation: GsapAnimation, + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>, +): string { + const duration = typeof animation.duration === "number" ? animation.duration : 8; + const ease = typeof animation.ease === "string" ? animation.ease : "none"; + const pos = animation.position ?? 0; + const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); + const calls = elements.map((el) => { + const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); + const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); + return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; + }); + return calls.join("\n "); +} + +export type UnrollElement = { + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; +}; + +/** Build one element's unrolled `tl.to(...)` call from the target animation. */ +function buildUnrollCallForElement( + timelineVar: string, + animation: GsapAnimation, + el: UnrollElement, +): string { + const duration = typeof animation.duration === "number" ? animation.duration : 8; + const ease = typeof animation.ease === "string" ? animation.ease : "none"; + const pos = animation.position ?? 0; + const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); + const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); + const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); + return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; +} + +/** Sentinel: the unroll cannot safely reproduce the loop body — caller no-ops. */ +const REFUSE_UNROLL = Symbol("refuse-unroll"); + +/** Every statement in a loop's body block (unfiltered), or [] when not a block. */ +function loopBodyRawStatements(loopNode: Node): Node[] { + const body = + loopNode?.type === "ExpressionStatement" + ? loopNode.expression?.arguments?.[0]?.body + : loopNode?.body; + return body?.type === "BlockStatement" ? (body.body ?? []) : []; +} + +/** A node that re-binds `indexVar`: a re-declaration or a function param. */ +function rebindsIndex(node: Node, indexVar: string): boolean { + if (node.type === "VariableDeclarator") return node.id?.name === indexVar; + if ( + node.type === "FunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "ArrowFunctionExpression" + ) { + return (node.params ?? []).some((p: Node) => p?.name === indexVar); + } + return false; +} + +/** Object shorthand `{ i }` — substituting the value would yield invalid `{ 0 }`. */ +function isShorthandIndexUse(node: Node, indexVar: string): boolean { + return ( + (node.type === "Property" || node.type === "ObjectProperty") && + node.shorthand === true && + propKeyName(node) === indexVar + ); +} + +/** + * A sibling statement can't be safely index-substituted when it re-binds the + * loop index (shadowing — a nested `for (let i …)`, a callback param `i`) or + * uses it in object shorthand (`{ i }`, which would splice to the invalid + * `{ 0 }`). substituteLoopIndex has no scope analysis, so in these cases it + * would emit broken or wrong code — the unroll must refuse instead. + */ +function hasUnsafeLoopIndexUse(stmt: Node, indexVar: string): boolean { + let unsafe = false; + acornWalk.full(stmt, (node: Node) => { + if (!unsafe && (isShorthandIndexUse(node, indexVar) || rebindsIndex(node, indexVar))) { + unsafe = true; + } + }); + return unsafe; +} + +/** How to handle the loop body's non-target siblings when unrolling. */ +function unrollSiblingStrategy( + loopNode: Node, + targetStmt: Node, + stmts: Node[], + indexVar: string | null, +): "blanket" | "refuse" | "preserve" { + const siblings = stmts.filter((s) => s !== targetStmt); + // A sibling the filtered statement list doesn't model (non-ExpressionStatement) + // would be silently lost by either path — refuse if any exists. + const hasUnmodeledSibling = loopBodyRawStatements(loopNode).some( + (s) => s !== targetStmt && !stmts.includes(s), + ); + if (siblings.length === 0 && !hasUnmodeledSibling) return "blanket"; + if (hasUnmodeledSibling || !indexVar) return "refuse"; + return siblings.some((s) => hasUnsafeLoopIndexUse(s, indexVar)) ? "refuse" : "preserve"; +} + +/** Emit the per-iteration unrolled lines (target → static tl.to, siblings → index-substituted). */ +function emitUnrolledLines( + stmts: Node[], + targetStmt: Node, + elements: UnrollElement[], + timelineVar: string, + animation: GsapAnimation, + indexVar: string, + script: string, +): string { + const lines: string[] = []; + for (let idx = 0; idx < elements.length; idx++) { + const el = elements[idx]; + if (!el) continue; + for (const stmt of stmts) { + lines.push( + stmt === targetStmt + ? buildUnrollCallForElement(timelineVar, animation, el) + : substituteLoopIndex(stmt, indexVar, idx, script), + ); + } + } + return lines.join("\n "); +} + +/** + * Unroll the loop body, preserving every statement that is NOT the target tween. + * For each iteration, emit each non-target statement with the loop index + * substituted (e.g. `tl.set(items[i], …)` → `tl.set(items[0], …)`), and replace + * the target tween statement with that element's static `tl.to()` call. + * + * Returns null when a blanket overwrite is lossless (no sibling statements), and + * REFUSE_UNROLL when siblings exist but can't be safely reproduced — a non-`for` + * loop (no numeric index to splice), a statement we don't model, or an unsafe + * index use (shadowing / shorthand). Refusing no-ops the unroll, which is safe: + * the dynamic loop keeps rendering correctly, just un-flattened. + */ +function buildLoopUnrollPreserving( + script: string, + timelineVar: string, + animation: GsapAnimation, + elements: UnrollElement[], + loopNode: Node, + targetStmt: Node, +): string | null | typeof REFUSE_UNROLL { + const stmts = loopBodyStatements(loopNode); + if (!stmts || !stmts.includes(targetStmt)) return null; + const indexVar = loopIndexVarName(loopNode); + const strategy = unrollSiblingStrategy(loopNode, targetStmt, stmts, indexVar); + if (strategy === "blanket") return null; + if (strategy === "refuse" || !indexVar) return REFUSE_UNROLL; + return emitUnrolledLines(stmts, targetStmt, elements, timelineVar, animation, indexVar, script); +} + +/** + * Replace a dynamic loop that generates multiple tween calls with individual + * static `tl.to()` calls — one per element. Finds the loop containing the + * animation and replaces the loop with unrolled static calls, preserving every + * non-target statement in the loop body per iteration. + */ +export function unrollDynamicAnimations( + script: string, + animationId: string, + elements: UnrollElement[], +): string { + // An empty element list has no unrolled form — replacing the loop/statement + // with zero calls would silently delete the animation. No-op instead. + if (elements.length === 0) return script; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const ms = new MagicString(script); + const loopNode = findEnclosingLoopNode(target.call.ancestors); + if (loopNode) { + const targetStmt = findEnclosingExpressionStatement(target.call.ancestors); + const preserving = targetStmt + ? buildLoopUnrollPreserving( + script, + parsed.timelineVar, + target.animation, + elements, + loopNode, + targetStmt, + ) + : null; + // Siblings exist but can't be safely reproduced — leave the loop untouched + // rather than drop or corrupt them. The op no-ops (before === after). + if (preserving === REFUSE_UNROLL) return script; + // Fall back to the simple whole-body replacement when the body isn't a plain + // block of statements we can preserve. + const replacement = + preserving ?? buildUnrollReplacement(parsed.timelineVar, target.animation, elements); + ms.overwrite(loopNode.start as number, loopNode.end as number, replacement); + } else { + const stmt = findEnclosingExpressionStatement(target.call.ancestors); + if (!stmt) return script; + const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements); + ms.overwrite(stmt.start as number, stmt.end as number, replacement); + } + return ms.toString(); +} diff --git a/packages/core/src/parsers/gsapWriterParity.corpus.test.ts b/packages/core/src/parsers/gsapWriterParity.corpus.test.ts index 8270056ba..cb272c9f1 100644 --- a/packages/core/src/parsers/gsapWriterParity.corpus.test.ts +++ b/packages/core/src/parsers/gsapWriterParity.corpus.test.ts @@ -616,17 +616,19 @@ describe("correctness — addLabelToScript / removeLabelFromScript", () => { expect(removeLabelFromScript(SYN_SINGLE, "nope")).toBe(SYN_SINGLE); }); - it("adding the same label twice yields two addLabel calls (no dedup contract)", () => { + it("adding the same label twice MOVES it instead of duplicating (dedup contract)", () => { + // A second addLabel for an existing name must not append a duplicate — + // duplicates make removeLabel over-remove. It moves the label's position. const once = addLabelToScript(SYN_SINGLE, "mid", 1.0); const twice = addLabelToScript(once, "mid", 2.0); - expect(labelCallCount(twice, "mid")).toBe(2); + expect(labelCallCount(twice, "mid")).toBe(1); + expect(twice).toContain('tl.addLabel("mid", 2)'); }); - it("removeLabel deletes ALL matching addLabel calls for the name", () => { - const once = addLabelToScript(SYN_SINGLE, "mid", 1.0); - const twice = addLabelToScript(once, "mid", 2.0); - const cleared = removeLabelFromScript(twice, "mid"); - expect(labelCallCount(cleared, "mid")).toBe(0); + it("removeLabel deletes ALL matching addLabel calls for the name (hand-authored dups)", () => { + const dup = `var tl = gsap.timeline({ paused: true });\ntl.addLabel("mid", 1);\ntl.addLabel("mid", 2);\nwindow.__timelines["t"] = tl;`; + expect(labelCallCount(dup, "mid")).toBe(2); + expect(labelCallCount(removeLabelFromScript(dup, "mid"), "mid")).toBe(0); }); it("the added label is observable by the parser when a tween references it", () => { diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index 2d786a7d3..cd7543109 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -536,6 +536,29 @@ describe("removeElementFromHtml", () => { expect(updated).not.toContain('id="el1"'); expect(updated).toContain('id="el2"'); }); + + it("strips ALL gsap tweens for the removed element, not just the first", () => { + // Two tweens on the same element → count-based ids renumber when the first is + // removed, so a single up-front parse left the second tween orphaned. + const html = ` + +
+
box
+
+ +`; + + const updated = removeElementFromHtml(html, "box"); + + expect(updated).not.toContain('data-hf-id="box"'); + // Neither tween may survive — the orphaned second tl.to referenced a deleted element. + expect(updated).not.toContain("x: 100"); + expect(updated).not.toContain("x: 200"); + }); }); describe("validateCompositionHtml", () => { diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 7a9d6c2d2..5a9082544 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -12,6 +12,8 @@ import type { } from "../core.types"; import { validateCompositionGsap } from "./gsapSerialize"; import { ensureHfIds } from "./hfIds.js"; +import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; +import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; import type { ValidationResult } from "../core.types"; const MEDIA_TYPES = new Set(["video", "image", "audio"]); @@ -672,15 +674,49 @@ export function addElementToHtml( }; } -export function removeElementFromHtml(html: string, elementId: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); +function selectorTargetsId(selector: string, id: string): boolean { + return ( + selector === `#${id}` || + selector === `[data-hf-id="${id}"]` || + selector === `[data-hf-id='${id}']` + ); +} - const el = doc.getElementById(elementId); - if (el) { - el.remove(); +function stripGsapForId(script: string, elementId: string): string { + // Re-parse after every removal. Animation ids are count-based (positional), so + // removing one tween renumbers the survivors — ids captured from a single + // up-front parse go stale and silently no-op, orphaning later tweens on the + // now-deleted element. Always remove the FIRST still-matching animation in a + // freshly-parsed script until none remain. + let current = script; + for (;;) { + const parsed = parseGsapScriptAcornForWrite(current); + if (!parsed) return current; + const match = parsed.located.find((l) => + selectorTargetsId(l.animation.targetSelector, elementId), + ); + if (!match) return current; + const updated = removeAnimationFromScript(current, match.id); + // Guard against a non-removing match (would otherwise loop forever). + if (updated === current) return current; + current = updated; } +} +function cascadeRemoveGsapById(doc: Document, elementId: string): void { + for (const script of Array.from(doc.querySelectorAll("script"))) { + const text = script.textContent ?? ""; + if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; + const updated = stripGsapForId(text, elementId); + if (updated !== text) script.textContent = updated; + } +} + +export function removeElementFromHtml(html: string, elementId: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + doc.getElementById(elementId)?.remove(); + cascadeRemoveGsapById(doc, elementId); return "\n" + doc.documentElement.outerHTML; } diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts new file mode 100644 index 000000000..c01861900 --- /dev/null +++ b/packages/sdk/src/adapters/iframe.test.ts @@ -0,0 +1,374 @@ +/** + * Unit tests for the pure functions in iframe.ts (no browser needed). + * + * elementFromPoint requires a real layout engine — the adapter's elementAtPoint() + * is NOT tested here. Cover it with an integration test mounting a same-origin + * iframe (WS-A1 follow-on). + * + * applyDraft / commitPreview / cancelPreview require HTMLElement.style + querySelector + * which are also browser-only. They are tested via a lightweight fake-DOM helper + * that simulates style.setProperty / getAttribute / removeProperty. + */ + +import { describe, it, expect, vi } from "vitest"; +import { + resolveNearestHfElement, + computeDraftPosition, + createIframePreviewAdapter, +} from "./iframe.js"; +import type { ElementAtPointResult } from "./types.js"; +import type { EditOp } from "../types.js"; + +// ─── Minimal fake element ──────────────────────────────────────────────────── + +interface FakeEl { + attrs: Record; + tagName: string; + parentElement: FakeEl | null; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; +} + +function fakeEl( + attrs: Record, + tagName: string, + parent: FakeEl | null = null, +): FakeEl { + return { + attrs, + tagName, + parentElement: parent, + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null; + }, + hasAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attrs, name); + }, + }; +} + +const visible = () => true; +const invisible = () => false; + +// ─── resolveNearestHfElement ────────────────────────────────────────────────── + +describe("resolveNearestHfElement", () => { + it("returns null for a null input", () => { + expect(resolveNearestHfElement(null, visible)).toBeNull(); + }); + + it("returns the element itself when it carries data-hf-id", () => { + const el = fakeEl({ "data-hf-id": "hf-abc" }, "div"); + const result = resolveNearestHfElement(el as unknown as Element, visible); + expect(result).toEqual({ id: "hf-abc", tag: "div" }); + }); + + it("walks up to a parent that carries data-hf-id", () => { + const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section"); + const child = fakeEl({}, "span", parent); + const result = resolveNearestHfElement(child as unknown as Element, visible); + expect(result).toEqual({ id: "hf-parent", tag: "section" }); + }); + + it("returns null when the nearest data-hf-id node is data-hf-root", () => { + const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div"); + const child = fakeEl({}, "p", root); + expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull(); + }); + + it("returns null when the element itself is data-hf-root", () => { + const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div"); + expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull(); + }); + + it("returns null when isVisible returns false for the matching element", () => { + const el = fakeEl({ "data-hf-id": "hf-abc" }, "div"); + expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull(); + }); + + it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => { + const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div"); + const child = fakeEl({}, "span", parent); + const isVisible = vi.fn((el: Element) => { + const fe = el as unknown as FakeEl; + return fe.attrs["data-hf-id"] !== "hf-parent"; + }); + expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull(); + expect(isVisible).toHaveBeenCalledTimes(1); + }); + + it("returns null when no data-hf-id found in any ancestor", () => { + const grandparent = fakeEl({}, "body"); + const parent = fakeEl({}, "div", grandparent); + const child = fakeEl({}, "span", parent); + expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull(); + }); + + it("tag is lowercased", () => { + const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV"); + const result = resolveNearestHfElement(el as unknown as Element, visible); + expect(result?.tag).toBe("div"); + }); + + it("stops at the nearest ancestor — does not continue past first data-hf-id", () => { + const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section"); + const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer); + const child = fakeEl({}, "span", inner); + const result = resolveNearestHfElement(child as unknown as Element, visible); + expect(result?.id).toBe("hf-inner"); + }); +}); + +// ─── computeDraftPosition ───────────────────────────────────────────────────── + +describe("computeDraftPosition", () => { + it("applies delta to base data-x/data-y", () => { + expect(computeDraftPosition("100", "200", 30, -10)).toEqual({ x: 130, y: 190 }); + }); + + it("defaults missing data-x/data-y to 0", () => { + expect(computeDraftPosition(null, null, 50, 25)).toEqual({ x: 50, y: 25 }); + }); + + it("defaults non-numeric data-x/data-y to 0", () => { + expect(computeDraftPosition("abc", "xyz", 10, 5)).toEqual({ x: 10, y: 5 }); + }); + + it("works with zero delta (no-move commit)", () => { + expect(computeDraftPosition("40", "80", 0, 0)).toEqual({ x: 40, y: 80 }); + }); + + it("handles negative base positions", () => { + expect(computeDraftPosition("-20", "0", 5, 10)).toEqual({ x: -15, y: 10 }); + }); +}); + +// ─── IframePreviewAdapter selection ────────────────────────────────────────── + +function stubIframe() { + return {} as HTMLIFrameElement; +} + +describe("IframePreviewAdapter selection", () => { + it("on('selection') fires when select() is called", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + adapter.on("selection", cb); + adapter.select(["hf-abc"]); + expect(cb).toHaveBeenCalledWith(["hf-abc"]); + }); + + it("off unsubscribes the handler", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + const off = adapter.on("selection", cb); + off(); + adapter.select(["hf-abc"]); + expect(cb).not.toHaveBeenCalled(); + }); + + it("additive select merges with prior selection", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + adapter.on("selection", cb); + adapter.select(["hf-a"]); + adapter.select(["hf-b"], { additive: true }); + expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"])); + }); + + it("non-additive select replaces prior selection", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + adapter.on("selection", cb); + adapter.select(["hf-a"]); + adapter.select(["hf-b"]); + expect(cb).toHaveBeenLastCalledWith(["hf-b"]); + }); + + it("multiple handlers all fire", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + adapter.on("selection", cb1); + adapter.on("selection", cb2); + adapter.select(["hf-abc"]); + expect(cb1).toHaveBeenCalledOnce(); + expect(cb2).toHaveBeenCalledOnce(); + }); +}); + +// ─── applyDraft / commitPreview / cancelPreview ─────────────────────────────── +// Tests use a fake iframe+element because HTMLElement.style requires a browser. + +interface FakeStyle { + _props: Record; + setProperty(name: string, value: string): void; + getPropertyValue(name: string): string; + removeProperty(name: string): void; +} + +interface FakeDomEl { + "data-hf-id": string; + "data-x": string | null; + "data-y": string | null; + style: FakeStyle; + isConnected: boolean; + getAttribute(name: string): string | null; + querySelector(sel: string): FakeDomEl | null; +} + +function fakeDomEl(id: string, dataX: string | null, dataY: string | null): FakeDomEl { + const style: FakeStyle = { + _props: {}, + setProperty(name, value) { + this._props[name] = value; + }, + getPropertyValue(name) { + return this._props[name] ?? ""; + }, + removeProperty(name) { + delete this._props[name]; + }, + }; + const el: FakeDomEl = { + "data-hf-id": id, + "data-x": dataX, + "data-y": dataY, + style, + isConnected: true, + getAttribute(name) { + if (name === "data-x") return this["data-x"]; + if (name === "data-y") return this["data-y"]; + if (name === "data-hf-id") return this["data-hf-id"]; + return null; + }, + querySelector(_sel: string) { + return null; + }, + }; + return el; +} + +function fakeIframe(el: FakeDomEl | null): HTMLIFrameElement { + return { + contentDocument: { + querySelector(_sel: string) { + return el; + }, + }, + } as unknown as HTMLIFrameElement; +} + +describe("IframePreviewAdapter draft / commit / cancel", () => { + it("commitPreview without applyDraft is a no-op", () => { + const dispatch = vi.fn(); + const adapter = createIframePreviewAdapter(stubIframe(), dispatch); + adapter.commitPreview(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("cancelPreview without applyDraft is a no-op", () => { + const dispatch = vi.fn(); + const adapter = createIframePreviewAdapter(stubIframe(), dispatch); + adapter.cancelPreview(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("commitPreview dispatches moveElement with correct absolute position", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "100", "200"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 30, dy: -20 }); + adapter.commitPreview(); + + expect(dispatch).toHaveBeenCalledWith<[EditOp]>({ + type: "moveElement", + target: "hf-abc", + x: 130, + y: 180, + }); + }); + + it("commitPreview with missing data-x/data-y defaults base to 0", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", null, null); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 50, dy: 25 }); + adapter.commitPreview(); + + expect(dispatch).toHaveBeenCalledWith<[EditOp]>({ + type: "moveElement", + target: "hf-abc", + x: 50, + y: 25, + }); + }); + + it("applyDraft reuses the cached element across repeated calls (no re-query)", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + let queryCount = 0; + const iframe = { + contentDocument: { + querySelector(_sel: string) { + queryCount++; + return el; + }, + }, + } as unknown as HTMLIFrameElement; + const adapter = createIframePreviewAdapter(iframe); + adapter.applyDraft("hf-abc", { dx: 1, dy: 1 }); + adapter.applyDraft("hf-abc", { dx: 2, dy: 2 }); + adapter.applyDraft("hf-abc", { dx: 3, dy: 3 }); + // Queried once on the first call; the next two reuse the connected cache. + expect(queryCount).toBe(1); + }); + + it("commitPreview without a dispatch callback is a no-op", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el)); + + adapter.applyDraft("hf-abc", { dx: 10, dy: 10 }); + // should not throw + adapter.commitPreview(); + }); + + it("cancelPreview clears draft vars without dispatching", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "100", "200"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 30, dy: 20 }); + adapter.cancelPreview(); + + expect(dispatch).not.toHaveBeenCalled(); + // CSS vars cleared + expect(el.style.getPropertyValue("--hf-studio-dx")).toBe(""); + expect(el.style.getPropertyValue("--hf-studio-dy")).toBe(""); + }); + + it("commitPreview clears draft vars after dispatching", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 10, dy: 5 }); + adapter.commitPreview(); + + expect(el.style.getPropertyValue("--hf-studio-dx")).toBe(""); + expect(el.style.getPropertyValue("--hf-studio-dy")).toBe(""); + }); + + it("second commitPreview after first is a no-op (draft cleared)", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 10, dy: 5 }); + adapter.commitPreview(); + adapter.commitPreview(); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts new file mode 100644 index 000000000..a0af067f9 --- /dev/null +++ b/packages/sdk/src/adapters/iframe.ts @@ -0,0 +1,231 @@ +/** + * Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection) + + * WS-A2 (applyDraft / commitPreview / cancelPreview → moveElement). + * + * Requirements: + * - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to + * contentDocument throws a DOMException; this adapter does not guard that — + * the caller is responsible for ensuring same-origin. + */ + +import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js"; +import type { EditOp } from "../types.js"; + +// ─── CSS var names written onto elements during drag ───────────────────────── + +const VAR_DX = "--hf-studio-dx"; +const VAR_DY = "--hf-studio-dy"; + +// ─── Pure resolver (testable without a browser) ─────────────────────────────── + +/** + * Walk from `el` upward through parentElement, looking for the nearest node + * that carries `[data-hf-id]` and is NOT `[data-hf-root]`. + * + * Returns null when: + * - The walk exits the tree without finding `[data-hf-id]` + * - The matching node is `[data-hf-root]` (transparent to hit-testing) + * - `isVisible(node)` returns false for the matching node + * + * Keeping this a pure function (no elementFromPoint, no window access) makes + * it unit-testable in a plain Node environment. + */ +export function resolveNearestHfElement( + el: Element | null, + isVisible: (el: Element) => boolean, +): ElementAtPointResult | null { + let node = el; + while (node !== null) { + const id = node.getAttribute("data-hf-id"); + if (id !== null) { + if (node.hasAttribute("data-hf-root")) return null; + if (!isVisible(node)) return null; + return { id, tag: node.tagName.toLowerCase() }; + } + node = node.parentElement; + } + return null; +} + +// ─── Draft position math (pure — testable without a browser) ───────────────── + +/** + * Compute the new absolute x/y for a moveElement op given: + * - the element's current `data-x` / `data-y` string values (may be null) + * - the accumulated drag delta (dx, dy) from applyDraft calls + * + * `data-x` / `data-y` default to 0 when absent or non-numeric. + */ +export function computeDraftPosition( + dataX: string | null, + dataY: string | null, + dx: number, + dy: number, +): { x: number; y: number } { + const baseX = parseFloat(dataX ?? "0") || 0; + const baseY = parseFloat(dataY ?? "0") || 0; + return { x: baseX + dx, y: baseY + dy }; +} + +// ─── Visibility check ───────────────────────────────────────────────────────── + +/** + * Returns true when no element in the ancestor chain (inclusive) has + * computed opacity === 0. Checks ancestors because a parent at opacity:0 + * makes the child invisible even if the child's own opacity is 1. + * + * This reflects the current GSAP timeline state (whatever the player has + * seeked to). For atTime values matching the live playhead this is always + * accurate. For speculative times this is NOT seeked — WS-A1 does not mutate + * the timeline; accurate out-of-band opacity queries are WS-G follow-on. + */ +function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean { + let node: Element | null = el; + while (node !== null) { + const style = win.getComputedStyle(node); + if (parseFloat(style.opacity) === 0) return false; + node = node.parentElement; + } + return true; +} + +// ─── IframePreviewAdapter ───────────────────────────────────────────────────── + +type SelectionHandler = (ids: string[]) => void; + +class IframePreviewAdapter implements PreviewAdapter { + private readonly iframe: HTMLIFrameElement; + private readonly _dispatch: ((op: EditOp) => void) | undefined; + + private _selection: string[] = []; + private _handlers: SelectionHandler[] = []; + + /** Tracked id and element for the in-progress drag. */ + private _draftId: string | null = null; + private _draftEl: HTMLElement | null = null; + + constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) { + this.iframe = iframe; + this._dispatch = dispatch; + } + + /** + * Synchronous hit-test. Returns the nearest `[data-hf-id]` element under + * (x, y) in the iframe's coordinate space, or null for a transparent hit + * (root, opacity-0, or nothing at all). + * + * atTime: reflects the GSAP state at the playhead when this is called. + * Seeking to a different time to check visibility is WS-G scope. + */ + elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null { + const doc = this.iframe.contentDocument; + if (!doc) return null; + const win = this.iframe.contentWindow as (Window & typeof globalThis) | null; + if (!win) return null; + + const hit = doc.elementFromPoint(x, y); + return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win)); + } + + /** + * Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto + * the target element inside the iframe at 60fps. The composition's CSS uses + * these vars to visually translate the element without touching the model. + * + * Calling applyDraft with a new id replaces the tracked element (does not + * cancel the prior draft — call cancelPreview first if switching targets). + * + * width/height in DraftProps are not yet wired (resize → setStyle, future op). + */ + applyDraft(id: string, props: DraftProps): void { + const doc = this.iframe.contentDocument; + if (!doc) return; + + // Reuse the tracked element across the 60fps drag; only re-query when the id + // changes or the cached node detached (e.g. an iframe reload mid-drag). + const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null; + const el = + cached ?? + doc.querySelector( + `[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, + ); + if (!el) return; + + this._draftId = id; + this._draftEl = el; + + if (props.dx !== undefined) el.style.setProperty(VAR_DX, String(props.dx)); + if (props.dy !== undefined) el.style.setProperty(VAR_DY, String(props.dy)); + } + + /** + * Read the accumulated draft deltas, derive a moveElement op, dispatch it, + * then clear the CSS vars and draft state. + * + * No-ops when: + * - No applyDraft was called (nothing to commit) + * - No dispatch callback was provided at construction + */ + commitPreview(): void { + if (!this._draftId || !this._draftEl || !this._dispatch) { + this._clearDraft(); + return; + } + + const el = this._draftEl; + const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0; + const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0; + const dataX = el.getAttribute("data-x"); + const dataY = el.getAttribute("data-y"); + const { x, y } = computeDraftPosition(dataX, dataY, dx, dy); + + this._dispatch({ type: "moveElement", target: this._draftId, x, y }); + this._clearDraft(); + } + + /** Revert draft CSS vars without dispatching any op. */ + cancelPreview(): void { + this._clearDraft(); + } + + private _clearDraft(): void { + if (this._draftEl) { + this._draftEl.style.removeProperty(VAR_DX); + this._draftEl.style.removeProperty(VAR_DY); + } + this._draftId = null; + this._draftEl = null; + } + + // Selection ----------------------------------------------------------------- + + select(ids: string[], opts?: { additive?: boolean }): void { + if (opts?.additive) { + const merged = new Set([...this._selection, ...ids]); + this._selection = [...merged]; + } else { + this._selection = [...ids]; + } + this._emit(); + } + + on(event: "selection", handler: SelectionHandler): () => void { + if (event !== "selection") return () => {}; + this._handlers.push(handler); + return () => { + this._handlers = this._handlers.filter((h) => h !== handler); + }; + } + + private _emit(): void { + const ids = [...this._selection]; + for (const h of this._handlers) h(ids); + } +} + +export function createIframePreviewAdapter( + iframe: HTMLIFrameElement, + dispatch?: (op: EditOp) => void, +): PreviewAdapter { + return new IframePreviewAdapter(iframe, dispatch); +} diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 771eff236..5d60d6b59 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -32,17 +32,6 @@ function fresh(script = GSAP_SCRIPT) { return parseMutable(makeHtml(script)); } -// A sub-composition host: data-hf-id="hf-host" (its own leaf id) AND -// data-composition-id="sub-1" (the id studio passes when targeting the root). -function freshSubComp(script = GSAP_SCRIPT) { - return parseMutable( - `
-
- -
`.trim(), - ); -} - function getScript(parsed: ReturnType): string { const doc = serializeDocument(parsed); const m = / +`); + } + + it("retargets post-split tween to newId", () => { + const parsed = freshSplit(); + const result = applyOp(parsed, { + type: "splitAnimations", + originalId: "hero", + newId: "hero-2", + splitTime: 3, + elementStart: 0, + elementDuration: 4, + }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("#hero-2"); + }); + + it("spanning tween produces fromTo on new element", () => { + const parsed = freshSplit(); + const result = applyOp(parsed, { + type: "splitAnimations", + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain(".fromTo("); + expect(newScript).toContain("#hero-2"); + }); + + it("no-op when originalId not found", () => { + const parsed = freshSplit(); + const result = applyOp(parsed, { + type: "splitAnimations", + originalId: "nonexistent", + newId: "x", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }); + expect(result.forward).toHaveLength(0); + }); +}); + +// ─── setTiming — per-tween GSAP shift/scale (review #3) ─────────────────────── + +describe("setTiming — GSAP sync shifts/scales each tween (not absolute)", () => { + // Two staggered tweens on ONE element: positions 2.0 and 5.0, clip [2, 7]. + const STAGGER_SCRIPT = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 2); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 5); +window.__timelines["t"] = tl;`; + + function freshStagger() { + return parseMutable(`
+
+ +
`); + } + + function gsapPatch(result: ReturnType): string { + const v = result.forward + .map((p) => p.value) + .find((val) => typeof val === "string" && val.includes("tl.")); + return typeof v === "string" ? v : ""; + } + + it("moving the clip +1 shifts BOTH tweens by the delta, preserving the stagger", () => { + const result = applyOp(freshStagger(), { type: "setTiming", target: "hf-box", start: 3 }); + const script = gsapPatch(result); + // 2.0 → 3.0 and 5.0 → 6.0 — NOT both collapsed onto the new absolute start. + expect(script).toContain("{ x: 100, duration: 1 }, 3)"); + expect(script).toContain("{ x: 200, duration: 1 }, 6)"); + // The stagger gap (3s) is preserved; durations are untouched. + expect(script).not.toContain("duration: 5"); + }); + + it("resizing the clip x2 scales each tween's duration by the ratio (not full clip)", () => { + // duration 5 → 10 (ratio 2); positions remap about the clip start (2). + const result = applyOp(freshStagger(), { type: "setTiming", target: "hf-box", duration: 10 }); + const script = gsapPatch(result); + // pos 2 (offset 0) stays 2; pos 5 → 2 + (5-2)*2 = 8. durations 1 → 2. + expect(script).toContain("{ x: 100, duration: 2 }, 2)"); + expect(script).toContain("{ x: 200, duration: 2 }, 8)"); + // The bug blew every duration up to the full clip duration (10). + expect(script).not.toContain("duration: 10"); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { @@ -617,4 +810,221 @@ window.__timelines["t"] = tl;`; expect(newScript).not.toContain("hf-box"); expect(newScript).toContain("hf-stage"); }); + + it("strips ALL tweens for the element, not just the first (positional-id renumber)", () => { + // Two tweens on the same element: removing the first renumbers the survivor's + // count-based id, so a single up-front parse left the second tween orphaned. + const twoOwnTweens = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 1); +window.__timelines["t"] = tl;`; + const parsed = fresh(twoOwnTweens); + const result = applyOp(parsed, { type: "removeElement", target: "hf-box" }); + const newScript = String(result.forward[1]?.value ?? ""); + expect(newScript).not.toContain("hf-box"); + expect(newScript).not.toContain("x: 100"); + expect(newScript).not.toContain("x: 200"); + }); +}); + +// ─── GSAP ops on composition with no script block ──────────────────────────── + +const NO_SCRIPT_HTML = `
+
+
`.trim(); + +describe("GSAP ops on composition with no GSAP script block", () => { + function freshNoScript() { + return parseMutable(NO_SCRIPT_HTML); + } + + it("addGsapTween throws instead of silent no-op", () => { + expect(() => + applyOp(freshNoScript(), { + type: "addGsapTween", + target: "hf-box", + tween: { method: "to", properties: { x: 100 } }, + }), + ).toThrow(); + }); + + it("setGsapTween throws instead of silent no-op", () => { + expect(() => + applyOp(freshNoScript(), { + type: "setGsapTween", + animationId: "anim-1", + properties: { ease: "power2.out" }, + }), + ).toThrow(); + }); + + it("removeGsapTween throws instead of silent no-op", () => { + expect(() => + applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }), + ).toThrow(); + }); + + it("addGsapKeyframe throws when script element is null", () => { + expect(() => + applyOp(freshNoScript(), { + type: "addGsapKeyframe", + animationId: "a1", + percentage: 0, + value: { opacity: 0 }, + }), + ).toThrow("No GSAP script block found"); + }); +}); + +// ─── arc path ops ───────────────────────────────────────────────────────────── + +const ARC_SCRIPT = `var tl = gsap.timeline({ paused: true }); +tl.to('[data-hf-id="hf-hero"]', { x: 100, y: 50, duration: 2 }, 0); +window.__timelines["t"] = tl;`; +const ARC_ANIM_ID = `[data-hf-id="hf-hero"]-to-0-position`; +const ARC_ENABLED_CONFIG = { + enabled: true as const, + autoRotate: false as const, + segments: [{ curviness: 1 }], +}; + +function freshArc() { + return parseMutable(makeHtml(ARC_SCRIPT)); +} + +function enableArc(parsed: ReturnType) { + applyOp(parsed, { type: "setArcPath", animationId: ARC_ANIM_ID, config: ARC_ENABLED_CONFIG }); +} + +describe("setArcPath", () => { + it("enabled: true adds motionPath to script", () => { + const parsed = freshArc(); + enableArc(parsed); + expect(getScript(parsed)).toContain("motionPath"); + }); + + it("enabled: false removes motionPath and restores x/y", () => { + const parsed = freshArc(); + enableArc(parsed); + applyOp(parsed, { + type: "setArcPath", + animationId: ARC_ANIM_ID, + config: { enabled: false, autoRotate: false, segments: [] }, + }); + const s = getScript(parsed); + expect(s).not.toContain("motionPath"); + }); + + it("no-op when animation not found", () => { + const parsed = freshArc(); + const before = getScript(parsed); + applyOp(parsed, { type: "setArcPath", animationId: "nonexistent", config: ARC_ENABLED_CONFIG }); + expect(getScript(parsed)).toBe(before); + }); +}); + +describe("updateArcSegment", () => { + it("changes curviness of segment", () => { + const parsed = freshArc(); + enableArc(parsed); + applyOp(parsed, { + type: "updateArcSegment", + animationId: ARC_ANIM_ID, + segmentIndex: 0, + update: { curviness: 2 }, + }); + expect(getScript(parsed)).toContain("motionPath"); + }); +}); + +describe("removeArcPath", () => { + it("removes motionPath from script", () => { + const parsed = freshArc(); + enableArc(parsed); + applyOp(parsed, { type: "removeArcPath", animationId: ARC_ANIM_ID }); + expect(getScript(parsed)).not.toContain("motionPath"); + }); +}); + +// ─── R3 #6 — validateOp rejects unappliable arc-segment edits ───────────────── + +describe("validateOp updateArcSegment (R3 #6)", () => { + it("E_ARC_NOT_ENABLED when the tween has no enabled arc path", () => { + const r = validateOp(freshArc(), { + type: "updateArcSegment", + animationId: ARC_ANIM_ID, + segmentIndex: 0, + update: { curviness: 2 }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_ARC_NOT_ENABLED"); + }); + + it("E_INVALID_ARGS when the segment index is out of range", () => { + const parsed = freshArc(); + enableArc(parsed); + const r = validateOp(parsed, { + type: "updateArcSegment", + animationId: ARC_ANIM_ID, + segmentIndex: 9, + update: { curviness: 2 }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); + }); +}); + +// ─── R3 #13b — deleteAllForSelector matches across quote styles ──────────────── + +describe("deleteAllForSelector quote-insensitive match (R3 #13b)", () => { + it("removes a tween authored with double quotes when given a single-quoted selector", () => { + const html = `
+
+ +
`; + const parsed = parseMutable(html); + const result = applyOp(parsed, { + type: "deleteAllForSelector", + selector: `[data-hf-id='hf-box']`, + }); + expect(result.forward.length).toBeGreaterThan(0); + expect(getScript(parsed)).not.toContain("tl.to("); + }); +}); + +// ─── CF2 #15/#16 — handleSetTiming syncs #domId tweens + resizes data-duration ─ + +describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => { + function timingDoc(attrs: string, tween: string) { + return parseMutable( + `
+
+ +
`, + ); + } + + it("#15: a #domId-targeted tween shifts when the clip moves", () => { + const parsed = timingDoc( + `data-start="2" data-end="5"`, + `tl.to("#box", { x: 100, duration: 1 }, 2);`, + ); + applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 }); + // position remapped 2 → 5 (delta +3); the bug left it at 2. + expect(getScript(parsed)).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/); + }); + + it("#16: a data-duration clip updates data-duration and scales its tween", () => { + const parsed = timingDoc( + `data-start="2" data-duration="4"`, + `tl.to("#box", { x: 100, duration: 4 }, 2);`, + ); + applyOp(parsed, { type: "setTiming", target: "hf-box", duration: 8 }); + const el = parsed.document.querySelector('[data-hf-id="hf-box"]'); + // data-duration updated (not a stale value beside a fresh data-end). + expect(el?.getAttribute("data-duration")).toBe("8"); + expect(el?.getAttribute("data-end")).toBeNull(); + // tween duration scaled 4 → 8 (ratio 2). + expect(getScript(parsed)).toContain("duration: 8"); + }); }); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index e1e991ba9..03b8f209d 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -469,14 +469,14 @@ describe("validateOp", () => { // ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ──────── describe("Phase 3b ops", () => { - it("applyOp returns EMPTY when no GSAP script is present", () => { - const result = applyOp(fresh(), { - type: "addGsapTween", - target: "hf-title", - tween: { method: "from", properties: { opacity: 0 } }, - }); - expect(result.forward).toHaveLength(0); - expect(result.inverse).toHaveLength(0); + it("applyOp throws when no GSAP script is present", () => { + expect(() => + applyOp(fresh(), { + type: "addGsapTween", + target: "hf-title", + tween: { method: "from", properties: { opacity: 0 } }, + }), + ).toThrow(); }); it("validateOp returns ok:false / E_NO_GSAP_SCRIPT when no GSAP script present", () => { @@ -492,6 +492,34 @@ describe("Phase 3b ops", () => { if (!r2.ok) expect(r2.code).toBe("E_NO_GSAP_SCRIPT"); }); + it("unrollDynamicAnimations rejects an empty element list (would delete the animation)", () => { + const parsed = parseMutable( + `
` + + ``, + ); + const r = validateOp(parsed, { + type: "unrollDynamicAnimations", + animationId: "#x-to-0-position", + elements: [], + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); + }); + + it("materializeKeyframes rejects an empty keyframe list (would empty the animation)", () => { + const parsed = parseMutable( + `
` + + ``, + ); + const r = validateOp(parsed, { + type: "materializeKeyframes", + animationId: "#x-to-0-position", + keyframes: [], + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); + }); + it("setClassStyle no longer throws — implemented in Phase 3b", () => { expect(() => applyOp(fresh(), { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index f21f6a1e6..6b5051d7a 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -47,11 +47,21 @@ import { addAnimationToScript, updateAnimationInScript, removeAnimationFromScript, + removePropertyFromAnimation, addKeyframeToScript, removeKeyframeFromScript, + removeAllKeyframesFromScript, + convertToKeyframesFromScript, + materializeKeyframesFromScript, + splitIntoPropertyGroupsFromScript, + splitAnimationsInScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, + setArcPathInScript, + updateArcSegmentInScript, + removeArcPathFromScript, + unrollDynamicAnimations, } from "@hyperframes/core/gsap-writer-acorn"; import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; @@ -136,7 +146,102 @@ function targets(target: HfId | HfId[]): HfId[] { // ─── Op dispatch ──────────────────────────────────────────────────────────── +function dispatchRemoveGsapKeyframe( + parsed: ParsedDocument, + op: Extract, +): MutationResult { + return handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage); +} + +function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { + switch (op.type) { + case "setGsapKeyframe": + return handleSetGsapKeyframe( + parsed, + op.animationId, + op.keyframeIndex, + op.position, + op.value, + op.ease, + ); + case "addGsapKeyframe": + return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); + case "removeGsapKeyframe": + return dispatchRemoveGsapKeyframe(parsed, op); + case "removeAllKeyframes": + 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); + case "splitAnimations": + return handleSplitAnimations(parsed, op); + default: + return undefined; + } +} + +function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { + const s = getGsapScript(parsed.document) ?? ""; + switch (op.type) { + case "setArcPath": { + const cfg = { + ...op.config, + segments: op.config.segments.map((seg) => ({ ...seg, curviness: seg.curviness ?? 1 })), + }; + return handleArcPathScript(parsed, s, setArcPathInScript(s, op.animationId, cfg)); + } + case "updateArcSegment": + return handleArcPathScript( + parsed, + s, + updateArcSegmentInScript(s, op.animationId, op.segmentIndex, op.update), + ); + case "removeArcPath": + return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId)); + case "unrollDynamicAnimations": + return handleArcPathScript( + parsed, + s, + unrollDynamicAnimations(s, op.animationId, op.elements), + ); + default: + return undefined; + } +} + +function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { + const kf = applyGsapKeyframeOp(parsed, op); + if (kf !== undefined) return kf; + const arc = applyArcPathOp(parsed, op); + if (arc !== undefined) return arc; + switch (op.type) { + case "addGsapTween": + return handleAddGsapTween(parsed, op.target, op.tween); + case "setGsapTween": + return handleSetGsapTween(parsed, op.animationId, op.properties); + case "removeGsapProperty": + return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from); + case "removeGsapTween": + return handleRemoveGsapTween(parsed, op.animationId); + case "deleteAllForSelector": + return handleDeleteAllForSelector(parsed, op.selector); + default: + return undefined; + } +} + export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { + const gsap = applyGsapOp(parsed, op); + if (gsap !== undefined) return gsap; switch (op.type) { case "setStyle": return handleSetStyle(parsed, targets(op.target), op.styles); @@ -160,31 +265,14 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { return handleSetCompositionMetadata(parsed, op); case "setVariableValue": return handleSetVariableValue(parsed, op.id, op.value); - case "addGsapTween": - return handleAddGsapTween(parsed, op.target, op.tween); - case "setGsapTween": - return handleSetGsapTween(parsed, op.animationId, op.properties); - case "removeGsapTween": - return handleRemoveGsapTween(parsed, op.animationId); - case "setGsapKeyframe": - return handleSetGsapKeyframe( - parsed, - op.animationId, - op.keyframeIndex, - op.position, - op.value, - op.ease, - ); - case "addGsapKeyframe": - return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); - case "removeGsapKeyframe": - return handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex); + case "setClassStyle": + return handleSetClassStyle(parsed, op.selector, op.styles); case "addLabel": return handleAddLabel(parsed, op.name, op.position); case "removeLabel": return handleRemoveLabel(parsed, op.name); - case "setClassStyle": - return handleSetClassStyle(parsed, op.selector, op.styles); + default: + throw new UnsupportedOpError((op as EditOp).type); } } @@ -307,11 +395,22 @@ function handleSetTiming( const oldStartStr = el.getAttribute("data-start"); const oldEndStr = el.getAttribute("data-end"); + const oldDurationStr = el.getAttribute("data-duration"); const oldTrackStr = el.getAttribute("data-track-index"); const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null; const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null; - const oldDuration = oldStart !== null && oldEnd !== null ? oldEnd - oldStart : null; + const oldDurationAttr = oldDurationStr !== null ? parseFloat(oldDurationStr) : null; + // Prefer an explicit data-duration — the attribute clips are authored with and + // the runtime reads — falling back to data-end − data-start. Reading only + // data-end left oldDuration null for duration-authored clips, collapsing the + // GSAP duration-scale ratio to 1 and scaling nothing. + const oldDuration = + oldDurationAttr !== null + ? oldDurationAttr + : oldStart !== null && oldEnd !== null + ? oldEnd - oldStart + : null; const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null; const newStart = timing.start ?? oldStart; @@ -325,7 +424,20 @@ function handleSetTiming( el.setAttribute("data-start", String(newStart)); } - if ( + // Write to whichever timing attribute the clip actually uses. A data-duration + // clip updates data-duration only on a real resize (duration is invariant + // under a move); a data-end clip updates data-end whenever start or duration + // changes (end = start + duration). Writing a fresh data-end beside a stale + // data-duration had no playback effect. + if (oldDurationStr !== null) { + if (timing.duration !== undefined && newDuration !== null) { + const path = timingPath(id, "duration"); + const p = scalarChange(path, oldDurationAttr, newDuration); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + el.setAttribute("data-duration", String(newDuration)); + } + } else if ( (timing.duration !== undefined || timing.start !== undefined) && newStart !== null && newDuration !== null @@ -352,16 +464,39 @@ function handleSetTiming( // Sync GSAP tween positions: the GSAP script is the source of truth at play time — // the timeline rebuilds from it on every seek. Without this, DOM attribute edits // have zero playback effect; the script's position/duration silently overrides them. - // Match against the resolved element's own data-hf-id (the canonical form - // tweens are stored under) so a comp-root target ("sub-1") whose tween lives - // at [data-hf-id="hf-host"] still syncs. - const matchId = el.getAttribute("data-hf-id") ?? id; - if (parsedGsap && currentScript) { + // Match against BOTH the element's data-hf-id (the canonical form) AND its DOM + // id: the Studio GSAP panel / ensureElementAddressable author tweens as + // `#domId`, which selectorMatchesId(hfId) never matched — so moving/resizing + // those clips left their tweens unsynced. + const matchHfId = el.getAttribute("data-hf-id") ?? id; + const matchDomId = el.getAttribute("id"); + if (parsedGsap && currentScript && oldStart !== null) { + // Per-tween shift/scale (mirrors shiftGsapPositions/scaleGsapPositions): a + // multi-tween stagger maps each tween's own intra-clip position by the + // start DELTA and scales its duration by the clip-duration RATIO. Writing + // the absolute newStart/newDuration onto every tween would collapse the + // stagger onto one point and blow each tween's duration to the full clip. + const startChanged = timing.start !== undefined && newStart !== null; + const durChanged = timing.duration !== undefined && newDuration !== null; + const ratio = + durChanged && oldDuration !== null && oldDuration > 0 && newDuration !== null + ? newDuration / oldDuration + : 1; + const remapStart = startChanged && newStart !== null ? newStart : oldStart; for (const { id: animId, animation } of parsedGsap.located) { - if (!selectorMatchesId(animation.targetSelector, matchId)) continue; + const matches = + selectorMatchesId(animation.targetSelector, matchHfId) || + (matchDomId !== null && selectorMatchesId(animation.targetSelector, matchDomId)); + if (!matches) continue; + if (typeof animation.position !== "number") continue; const updates: Partial = {}; - if (timing.start !== undefined && newStart !== null) updates.position = newStart; - if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration; + if (startChanged || durChanged) { + const shifted = remapStart + (animation.position - oldStart) * ratio; + updates.position = Math.max(0, Math.round(shifted * 1000) / 1000); + } + if (durChanged && typeof animation.duration === "number" && animation.duration > 0) { + updates.duration = Math.max(0.001, Math.round(animation.duration * ratio * 1000) / 1000); + } if (Object.keys(updates).length === 0) continue; currentScript = updateAnimationInScript(currentScript, animId, updates); } @@ -549,15 +684,20 @@ function collectSubtreeHfIds(el: Element): string[] { } function cascadeRemoveAnimations(script: string, id: HfId): string { - const parsedGsap = parseGsapScriptAcornForWrite(script); - if (!parsedGsap) return script; + // Re-parse after each removal: animation ids are positional, so removing one + // tween renumbers the survivors — ids from a single up-front parse go stale and + // no-op, orphaning later tweens on the removed element. Same fix as + // stripGsapForId in htmlParser.ts (R3 #3); this is its SDK-side twin. let current = script; - for (const { id: animId, animation } of parsedGsap.located) { - if (selectorMatchesId(animation.targetSelector, id)) { - current = removeAnimationFromScript(current, animId); - } + for (;;) { + const parsedGsap = parseGsapScriptAcornForWrite(current); + if (!parsedGsap) return current; + const match = parsedGsap.located.find((l) => selectorMatchesId(l.animation.targetSelector, id)); + if (!match) return current; + const next = removeAnimationFromScript(current, match.id); + if (next === current) return current; // guard against a non-removing match + current = next; } - return current; } // ─── setClassStyle handler ──────────────────────────────────────────────────── @@ -624,7 +764,7 @@ function handleAddGsapTween( tween: GsapTweenSpec, ): MutationResult { const script = getGsapScript(parsed.document); - if (!script) return EMPTY; + if (!script) throw new Error("No GSAP script block found in the composition."); const extras: Record = {}; if (tween.repeat !== undefined) extras.repeat = tween.repeat; @@ -665,7 +805,7 @@ function handleSetGsapTween( properties: Partial, ): MutationResult { const script = getGsapScript(parsed.document); - if (!script) return EMPTY; + if (!script) throw new Error("No GSAP script block found in the composition."); const updates: Partial = {}; if (properties.duration !== undefined) updates.duration = properties.duration; @@ -689,23 +829,149 @@ function handleSetGsapTween( return gsapScriptChange(script, newScript); } -function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult { +function handleRemoveGsapProperty( + parsed: ParsedDocument, + animationId: string, + property: string, + from: boolean | undefined, +): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; + const newScript = removePropertyFromAnimation(script, animationId, property, from ?? false); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + +function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) throw new Error("No GSAP script block found in the composition."); const newScript = removeAnimationFromScript(script, animationId); if (newScript === script) return EMPTY; setGsapScript(parsed.document, newScript); return gsapScriptChange(script, newScript); } +function handleRemoveAllKeyframes(parsed: ParsedDocument, animationId: string): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = removeAllKeyframesFromScript(script, animationId); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + +function handleConvertToKeyframes( + parsed: ParsedDocument, + animationId: string, + resolvedFromValues?: Record, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = convertToKeyframesFromScript(script, animationId, resolvedFromValues); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + 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 handleSplitAnimations( + parsed: ParsedDocument, + op: Extract, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const { script: newScript } = splitAnimationsInScript(script, { + originalId: op.originalId, + newId: op.newId, + splitTime: op.splitTime, + elementStart: op.elementStart, + elementDuration: op.elementDuration, + }); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + +function handleArcPathScript( + parsed: ParsedDocument, + oldScript: string, + newScript: string, +): MutationResult { + if (newScript === oldScript) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(oldScript, newScript); +} + +function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const parsedForWrite = parseGsapScriptAcornForWrite(script); + if (!parsedForWrite) return EMPTY; + // Compare quote-insensitively: [data-hf-id='x'] and [data-hf-id="x"] are the + // same selector. A strict === missed the alternate quote style and matched + // nothing while can() reported ok. + const wanted = selector.replace(/'/g, '"'); + const matching = parsedForWrite.located.filter( + (l) => l.animation.targetSelector.replace(/'/g, '"') === wanted, + ); + if (matching.length === 0) return EMPTY; + let newScript = script; + for (const m of [...matching].reverse()) { + newScript = removeAnimationFromScript(newScript, m.id); + } + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + // ponytail: skips stripStudioEditsFromTarget (data-hf-studio-path-offset cleanup) — + // studio path offset is cosmetic once all animations are gone; session reloads after write + return gsapScriptChange(script, newScript); +} + function resolveKeyframe(parsed: ParsedDocument, animationId: string, keyframeIndex: number) { const script = getGsapScript(parsed.document); if (!script) return null; const parsedForWrite = parseGsapScriptAcornForWrite(script); const located = parsedForWrite?.located.find((l) => l.id === animationId); const kfs = located?.animation.keyframes?.keyframes; - if (!kfs || keyframeIndex < 0 || keyframeIndex >= kfs.length) return null; - return { script, kf: kfs[keyframeIndex]!, kfs }; + const kf = kfs?.[keyframeIndex]; + if (!kfs || !kf || keyframeIndex < 0) return null; + return { script, kf, kfs }; } // fallow-ignore-next-line complexity @@ -757,7 +1023,7 @@ function handleAddGsapKeyframe( value: Record, ): MutationResult { const script = getGsapScript(parsed.document); - if (!script) return EMPTY; + if (!script) throw new Error("No GSAP script block found in the composition."); const props = value as Record; const newScript = addKeyframeToScript( script, @@ -772,18 +1038,23 @@ function handleAddGsapKeyframe( return gsapScriptChange(script, newScript); } -function handleRemoveGsapKeyframe( +function handleRemoveGsapKeyframeByPercentage( parsed: ParsedDocument, animationId: string, - keyframeIndex: number, + percentage: number, ): MutationResult { - const resolved = resolveKeyframe(parsed, animationId, keyframeIndex); - if (!resolved) return EMPTY; - const { script, kf, kfs } = resolved; - const pct = kf.percentage; - // removeKeyframeFromScript matches by percentage; bail if two keyframes share - // the same percentage to avoid removing the wrong one. - if (kfs.filter((k) => k.percentage === pct).length > 1) return EMPTY; + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const parsedForWrite = parseGsapScriptAcornForWrite(script); + const located = parsedForWrite?.located.find((l) => l.id === animationId); + const kfs = located?.animation.keyframes?.keyframes; + if (!kfs) return EMPTY; + // No-op on ambiguity: duplicate-percentage keyframes can't be disambiguated. + const TOLERANCE = 0.001; + const matches = kfs.filter((k) => Math.abs(k.percentage - percentage) <= TOLERANCE); + const sole = matches[0]; + if (matches.length !== 1 || !sole) return EMPTY; + const pct = sole.percentage; const newScript = removeKeyframeFromScript(script, animationId, pct); if (newScript === script) return EMPTY; setGsapScript(parsed.document, newScript); @@ -816,6 +1087,60 @@ function canErr(code: string, message: string, hint?: string): CanResult { return hint ? { ok: false, code, message, hint } : { ok: false, code, message }; } +/** E_NO_GSAP_SCRIPT CanResult when the composition has no GSAP script, else null. */ +function gsapScriptMissing(parsed: ParsedDocument): CanResult | null { + return getGsapScript(parsed.document) === null + ? canErr( + "E_NO_GSAP_SCRIPT", + "No GSAP script block found in the composition.", + "This composition does not use GSAP animations.", + ) + : null; +} + +/** The located GSAP animation for `animationId`, or undefined. */ +function locateGsapAnimation(parsed: ParsedDocument, animationId: string) { + const script = getGsapScript(parsed.document); + if (!script) return undefined; + return parseGsapScriptAcornForWrite(script)?.located.find((l) => l.id === animationId); +} + +/** + * E_TARGET_NOT_FOUND CanResult when no GSAP animation resolves to `animationId`, + * else null. Without this, can() returned ok for stale/positional ids that then + * no-op'd at apply — the caller believed the edit would land. + */ +function gsapAnimationMissing(parsed: ParsedDocument, animationId: string): CanResult | null { + if (getGsapScript(parsed.document) === null) return null; // reported by gsapScriptMissing + return locateGsapAnimation(parsed, animationId) + ? null + : canErr( + "E_TARGET_NOT_FOUND", + `No GSAP animation found with id "${animationId}".`, + "Animation ids are positional and shift after edits — re-read them from comp before dispatching.", + ); +} + +/** Validate updateArcSegment: the tween must have an enabled arc with that segment. */ +function validateArcSegment( + parsed: ParsedDocument, + op: Extract, +): CanResult { + const arc = locateGsapAnimation(parsed, op.animationId)?.animation.arcPath; + if (!arc?.enabled) + return canErr( + "E_ARC_NOT_ENABLED", + `Animation "${op.animationId}" has no enabled arc path.`, + "Call setArcPath({ enabled: true }) before updating a segment.", + ); + if (op.segmentIndex < 0 || op.segmentIndex >= arc.segments.length) + return canErr( + "E_INVALID_ARGS", + `Segment index ${op.segmentIndex} is out of range (0..${arc.segments.length - 1}).`, + ); + return CAN_OK; +} + /** Dry-run validation — returns CanResult for the given op against current document state. */ // fallow-ignore-next-line complexity export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { @@ -873,15 +1198,48 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "setGsapKeyframe": case "addGsapKeyframe": case "removeGsapKeyframe": + case "removeGsapProperty": case "removeGsapTween": + case "removeAllKeyframes": + case "convertToKeyframes": + case "splitIntoPropertyGroups": + case "setArcPath": + case "removeArcPath": + return gsapScriptMissing(parsed) ?? gsapAnimationMissing(parsed, op.animationId) ?? CAN_OK; + case "updateArcSegment": + return ( + gsapScriptMissing(parsed) ?? + gsapAnimationMissing(parsed, op.animationId) ?? + validateArcSegment(parsed, op) + ); + case "splitAnimations": + case "deleteAllForSelector": case "removeLabel": - if (getGsapScript(parsed.document) === null) - return canErr( - "E_NO_GSAP_SCRIPT", - "No GSAP script block found in the composition.", - "This composition does not use GSAP animations.", - ); - return CAN_OK; + return gsapScriptMissing(parsed) ?? CAN_OK; + case "unrollDynamicAnimations": + return ( + gsapScriptMissing(parsed) ?? + gsapAnimationMissing(parsed, op.animationId) ?? + (op.elements.length === 0 + ? canErr( + "E_INVALID_ARGS", + "unrollDynamicAnimations requires at least one element.", + "An empty element list would delete the animation; pass the resolved element list.", + ) + : CAN_OK) + ); + case "materializeKeyframes": + return ( + gsapScriptMissing(parsed) ?? + gsapAnimationMissing(parsed, op.animationId) ?? + (op.keyframes.length === 0 + ? canErr( + "E_INVALID_ARGS", + "materializeKeyframes requires at least one keyframe.", + "An empty keyframe list would empty the animation; pass the resolved keyframes.", + ) + : CAN_OK) + ); default: return canErr("E_UNKNOWN_OP", `Unknown op type: "${(op as EditOp).type}".`); } diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index a1776d928..8cfd4afca 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -5,7 +5,7 @@ * /elements/{hfId}/inlineStyles/{camelCaseProp} * /elements/{hfId}/text * /elements/{hfId}/attributes/{name} - * /elements/{hfId}/timing/{start|end|trackIndex} ← end = computed absolute data-end + * /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end * /elements/{hfId}/hold/{start|end|fill} * /elements/{hfId} ← whole subtree (removeElement) * /variables/{variableId} @@ -59,7 +59,7 @@ export function attrPath(id: string, name: string): string { return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`; } -export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string { +export function timingPath(id: string, field: "start" | "end" | "duration" | "trackIndex"): string { return `/elements/${escapeIdForPath(id)}/timing/${field}`; } @@ -154,8 +154,9 @@ export function keyToPath(key: string): string | null { // the already-encoded attr segment. Reconstruct manually. if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`; - const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key); - if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex"); + const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key); + if (timing?.[1]) + return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex"); const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key); if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill"); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 127110347..60acf256d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -39,3 +39,4 @@ export { createMemoryAdapter } from "./adapters/memory.js"; export { createHeadlessAdapter } from "./adapters/headless.js"; export { createHttpAdapter } from "./adapters/http.js"; export type { HttpAdapterOptions } from "./adapters/http.js"; +export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js"; diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts index d5a3b543a..e6e1ccc32 100644 --- a/packages/sdk/src/session.test.ts +++ b/packages/sdk/src/session.test.ts @@ -299,6 +299,41 @@ describe("override-set orphan cleanup on removeElement", () => { }); }); +describe("single-dispatch undo reverses the inverse patch list", () => { + // A single dispatch that emits order-dependent inverse patches (here a nested + // parent+child removeElement) must undo in reverse application order. Without + // the reverse, undo replays 'add child' before 'add parent' → the child has no + // parent to attach to and is dropped. + it("removeElement([child, parent]) undo restores both, child included", async () => { + const NESTED = `
+
x
+
`; + const comp = await openComposition(NESTED); + comp.dispatch({ type: "removeElement", target: ["hf-child", "hf-parent"] }); + expect(comp.getElement("hf-parent")).toBeNull(); + expect(comp.getElement("hf-child")).toBeNull(); + + comp.undo(); + expect(comp.getElement("hf-parent")).not.toBeNull(); + expect(comp.getElement("hf-child")).not.toBeNull(); + }); + + // Defense-in-depth: an aliased multi-target (the same element twice) makes the + // 2nd id capture the value the 1st already wrote; undo must replay the inverse + // in reverse to land on the ORIGINAL, not the intermediate. + it("setStyle with a duplicate target undoes to the original, not the intermediate", async () => { + const comp = await openComposition(BASE_HTML); + comp.dispatch({ + type: "setStyle", + target: ["hf-title", "hf-title"], + styles: { fontSize: "96px" }, + }); + expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("96px"); + comp.undo(); + expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("64px"); + }); +}); + // ─── setSelection / getSelection / selectionchange ─────────────────────────── describe("setSelection", () => { diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 149b770b6..b8d897f97 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -48,6 +48,12 @@ export interface OpenCompositionOptions { trackedOrigins?: unknown[]; /** Auto-coalesce window for history entries (ms). Default: 300. */ coalesceMs?: number; + /** + * Pass `false` to skip attaching the history module (undo/redo). + * Default: history is attached in standalone (non-embedded) mode. + * Use when the host owns the undo stack and SDK undo is dead weight. + */ + history?: false; } // ─── Implementation ─────────────────────────────────────────────────────────── @@ -291,7 +297,13 @@ class CompositionImpl implements Composition { this.batchInverse.push(...inverse); if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type); } else { - const event = buildPatchEvent(forward, inverse, origin, [op.type]); + // Reverse the inverse list (parity with batch() below): an op that emits + // multiple patches whose undo order matters — same path (reorderElements + // with a duplicate target), an aliased multi-target, or a nested + // parent+child removeElement — must undo in reverse application order, or + // undo lands on an intermediate value / drops a subtree. Harmless for the + // common single-patch / independent-path case. + const event = buildPatchEvent(forward, [...inverse].reverse(), origin, [op.type]); this.patchHandlers.forEach((h) => h(event)); this.changeHandlers.forEach((h) => h()); } @@ -496,11 +508,16 @@ export async function openComposition( const isEmbedded = opts?.overrides !== undefined; if (!isEmbedded) { - const history = createHistory(session, { - coalesceMs: opts?.coalesceMs ?? 300, - trackedOrigins: opts?.trackedOrigins, - }); - session.attachHistory(history); + // history:false opts out of the SDK undo stack ONLY. Persist (auto-save) is + // independent — gating it on the history flag too would silently drop every + // disk write for a caller that just wanted to disable undo (data loss). + if (opts?.history !== false) { + const history = createHistory(session, { + coalesceMs: opts?.coalesceMs ?? 300, + trackedOrigins: opts?.trackedOrigins, + }); + session.attachHistory(history); + } if (opts?.persist) { const pq = createPersistQueue(session, opts.persist, { diff --git a/packages/sdk/src/smoke.test.ts b/packages/sdk/src/smoke.test.ts index dbf69d746..c1d038df6 100644 --- a/packages/sdk/src/smoke.test.ts +++ b/packages/sdk/src/smoke.test.ts @@ -227,6 +227,20 @@ describe("persist adapter", () => { expect(content).toContain("color: #f00"); }); + it("still persists when history:false (undo opt-out must not disable auto-save)", async () => { + const adapter = createMemoryAdapter(); + const writeSpy = vi.spyOn(adapter, "write"); + + const comp = await openComposition(BASE_HTML, { persist: adapter, history: false }); + expect(comp.canUndo()).toBe(false); // undo is off… + comp.setStyle("hf-title", { color: "#f00" }); + await comp.flush(); + + expect(writeSpy).toHaveBeenCalled(); // …but the write still happened + const [, content] = writeSpy.mock.calls[0] as [string, string]; + expect(content).toContain("color: #f00"); + }); + it("surfaces persist errors via on('persist:error')", async () => { const adapter = createMemoryAdapter(); const errors: unknown[] = []; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 4220acc8f..66a453ad7 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -101,10 +101,71 @@ export type EditOp = position: number; value: Record; } - | { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number } + | { type: "removeGsapKeyframe"; animationId: string; percentage: number } + | { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean } | { type: "removeGsapTween"; animationId: string } + | { type: "removeAllKeyframes"; animationId: string } + | { + type: "convertToKeyframes"; + animationId: string; + 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: "splitAnimations"; + originalId: string; + newId: string; + splitTime: number; + elementStart: number; + elementDuration: number; + } | { type: "addLabel"; name: string; position: number } - | { type: "removeLabel"; name: string }; + | { type: "removeLabel"; name: string } + | { + type: "setArcPath"; + animationId: string; + config: { + enabled: boolean; + autoRotate: boolean | number; + segments: Array<{ + curviness?: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; + }>; + }; + } + | { + type: "updateArcSegment"; + animationId: string; + segmentIndex: number; + update: { + curviness?: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; + }; + } + | { type: "removeArcPath"; animationId: string } + | { + type: "unrollDynamicAnimations"; + animationId: string; + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>; + }; export interface ElasticHold { start: number; @@ -113,7 +174,7 @@ export interface ElasticHold { } export interface GsapTweenSpec { - method: "from" | "to" | "fromTo"; + method: "from" | "to" | "fromTo" | "set"; position?: number | string; duration?: number; ease?: string; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index fd0bd2f7c..de6ed6a47 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -143,9 +143,7 @@ export function StudioApp() { const domEditSaveTimestampRef = useRef(0); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); - const reloadPreview = useCallback(() => { - setRefreshKey((k) => k + 1); - }, []); + const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); const fileManager = useFileManager({ projectId, showToast, @@ -153,6 +151,7 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); + const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -175,7 +174,6 @@ export function StudioApp() { reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); - const sdkSession = useSdkSession(projectId, activeCompPath ?? "index.html"); const timelineEditing = useTimelineEditing({ projectId, activeCompPath, @@ -189,7 +187,8 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession, + sdkSession: sdkHandle.session, + forceReloadSdkSession: sdkHandle.forceReload, }); const { activeBlockParams, @@ -257,18 +256,16 @@ export function StudioApp() { onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + activeCompPath, + forceReloadSdkSession: sdkHandle.forceReload, onToggleRecording: STUDIO_KEYFRAMES_ENABLED ? () => handleToggleRecordingRef.current() : undefined, }); - const selectSidebarTabStable = useCallback( - (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), - [], - ); - const getSidebarTabStable = useCallback( - () => leftSidebarRef.current?.getTab() ?? "compositions", - [], - ); + const sidebarTabRef = useRef({ + select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t), + get: () => leftSidebarRef.current?.getTab() ?? "compositions", + }); const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -301,9 +298,10 @@ export function StudioApp() { reloadPreview, setRefreshKey, openSourceForSelection: fileManager.openSourceForSelection, - selectSidebarTab: selectSidebarTabStable, - getSidebarTab: getSidebarTabStable, - sdkSession, + selectSidebarTab: sidebarTabRef.current.select, + getSidebarTab: sidebarTabRef.current.get, + sdkSession: sdkHandle.session, + forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; @@ -320,7 +318,7 @@ export function StudioApp() { } }; useSdkSelectionSync( - sdkSession, + sdkHandle.session, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); @@ -358,7 +356,6 @@ export function StudioApp() { resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); const dragOverlay = useDragOverlay(fileManager.handleImportFiles); - // Gesture recording const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 52ac92e69..5148ca6e4 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -94,15 +94,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; -// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK -// session alongside the server patch path and logs mismatches via telemetry. -// Default on: server stays authoritative (no user-visible change), so we want -// the sdk_shadow_dispatch parity signal from all traffic. Disable via -// VITE_STUDIO_SDK_SHADOW_ENABLED=false. -export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( +// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch +// instead of the server patch-element API. Default false; enable via +// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open. +export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag( env, - ["VITE_STUDIO_SDK_SHADOW_ENABLED"], - true, + ["VITE_STUDIO_SDK_CUTOVER_ENABLED"], + false, ); export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index afc949256..60a4b4ba3 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -2,8 +2,6 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; -import type { ShadowGsapOp } from "../utils/sdkShadow"; -import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe"; export interface MutationResult { ok: boolean; @@ -28,10 +26,6 @@ export interface CommitMutationOptions { * (and under distinct keys) run concurrently as before. */ serializeKey?: string; - /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */ - shadowGsapOp?: ShadowGsapOp; - /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */ - shadowKeyframeOp?: ShadowKeyframeOp; } export type CommitMutation = ( @@ -70,6 +64,9 @@ export interface GsapScriptCommitsParams { onCacheInvalidate: () => void; onFileContentChanged?: (path: string, content: string) => void; showToast: (message: string, tone?: "error" | "info") => void; - /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */ + /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */ sdkSession?: Composition | null; + writeProjectFile?: (path: string, content: string) => Promise; + /** Resync the in-memory SDK session after a server-authoritative write. */ + forceReloadSdkSession?: () => void; } diff --git a/packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts b/packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts new file mode 100644 index 000000000..3f6ee6354 --- /dev/null +++ b/packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + hashContent, + markSelfWrite, + isSelfWriteEcho, + resetSelfWriteRegistry, +} from "./sdkSelfWriteRegistry"; +import { shouldReloadOnFileChange } from "./useSdkSession"; + +describe("sdkSelfWriteRegistry (finding #14)", () => { + beforeEach(() => resetSelfWriteRegistry()); + + it("recognizes the echo of bytes we just wrote", () => { + markSelfWrite("/comp.html", "A"); + expect(isSelfWriteEcho("/comp.html", "A")).toBe(true); + }); + + it("does NOT match different content on the same path (an undo's reverted bytes)", () => { + markSelfWrite("/comp.html", "A"); + expect(isSelfWriteEcho("/comp.html", "REVERTED")).toBe(false); + }); + + it("is keyed per file — a self-write to one file can't mask a change to another", () => { + markSelfWrite("/a.html", "A"); + expect(isSelfWriteEcho("/b.html", "A")).toBe(false); + }); + + it("consumes a matched entry so a later genuine external write isn't suppressed", () => { + markSelfWrite("/comp.html", "A"); + expect(isSelfWriteEcho("/comp.html", "A")).toBe(true); + // A second arrival of identical bytes is NOT our echo — must reload. + expect(isSelfWriteEcho("/comp.html", "A")).toBe(false); + }); + + it("expires entries past the TTL so a stale self-write can't suppress forever", () => { + const t0 = 1_000_000; + markSelfWrite("/comp.html", "A", t0); + // 3 s later (> 2 s TTL) the entry is gone. + expect(isSelfWriteEcho("/comp.html", "A", t0 + 3000)).toBe(false); + }); + + it("hashes are stable and distinguish different content", () => { + expect(hashContent("x")).toBe(hashContent("x")); + expect(hashContent("x")).not.toBe(hashContent("y")); + }); +}); + +describe("shouldReloadOnFileChange (finding #14)", () => { + beforeEach(() => resetSelfWriteRegistry()); + + it("suppresses the reload when content matches a registered self-write (cutover echo)", () => { + markSelfWrite("/comp.html", "SELF"); + expect(shouldReloadOnFileChange("/comp.html", "SELF", true)).toBe(false); + }); + + it("reloads on an undo write even inside the suppress window (content differs)", () => { + // The cutover registered SELF; the undo writes REVERTED bytes within the + // same 2 s window. Time-only suppression dropped this; identity reloads it. + markSelfWrite("/comp.html", "SELF"); + expect(shouldReloadOnFileChange("/comp.html", "REVERTED", true)).toBe(true); + }); + + it("falls back to the time window only when content is unavailable", () => { + expect(shouldReloadOnFileChange("/comp.html", null, true)).toBe(false); + expect(shouldReloadOnFileChange("/comp.html", null, false)).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/sdkSelfWriteRegistry.ts b/packages/studio/src/hooks/sdkSelfWriteRegistry.ts new file mode 100644 index 000000000..3b6976d1f --- /dev/null +++ b/packages/studio/src/hooks/sdkSelfWriteRegistry.ts @@ -0,0 +1,77 @@ +/** + * Self-write identity registry — discriminates an SDK cutover ECHO from a genuine + * external write (notably undo/redo) in the file-change reload-suppression path. + * + * The old suppression was purely time-based: any file-change within 2 s of the + * shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover + * self-write AND an undo write set that same timestamp, so the window could not + * tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes + * an undo just wrote" (must reload). An undo that landed inside the window was + * silently dropped, leaving the in-memory SDK doc on stale pre-undo content. + * + * Fix: tag each cutover self-write with the CONTENT it wrote (by hash). A + * file-change reload is suppressed only when the new on-disk content matches a + * recently-registered self-write hash — i.e. it is provably our own echo. Undo + * writes are never registered (they don't flow through persistSdkSerialize), so + * their content won't match and the reload always fires. Identity, not a clock. + */ + +const SELF_WRITE_TTL_MS = 2000; + +interface SelfWriteEntry { + hash: string; + at: number; +} + +// Module-scoped: the studio process has a single SDK session lifecycle at a time +// and persists are funnelled through one persistSdkSerialize. Keyed by file path +// so a self-write to one file can't mask a real external change to another. +const registry = new Map(); + +/** + * Stable 32-bit FNV-1a hash of content. Collisions only risk SUPPRESSING a real + * reload, and only within the 2 s TTL for the exact same file — negligible, and + * strictly safer than the prior time-only window it replaces. + */ +export function hashContent(content: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < content.length; i++) { + h ^= content.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16); +} + +function prune(entries: SelfWriteEntry[], now: number): SelfWriteEntry[] { + return entries.filter((e) => now - e.at < SELF_WRITE_TTL_MS); +} + +/** Record that WE wrote `content` to `path` (an SDK cutover self-write). */ +export function markSelfWrite(path: string, content: string, now: number = Date.now()): void { + const next = prune(registry.get(path) ?? [], now); + next.push({ hash: hashContent(content), at: now }); + registry.set(path, next); +} + +/** + * True when `content` matches a self-write registered for `path` within the TTL. + * Consumes the matched entry so a later genuinely-external write of identical + * bytes isn't suppressed forever. + */ +export function isSelfWriteEcho(path: string, content: string, now: number = Date.now()): boolean { + const entries = prune(registry.get(path) ?? [], now); + const hash = hashContent(content); + const idx = entries.findIndex((e) => e.hash === hash); + if (idx === -1) { + registry.set(path, entries); + return false; + } + entries.splice(idx, 1); + registry.set(path, entries); + return true; +} + +/** Test-only: drop all registered self-writes. */ +export function resetSelfWriteRegistry(): void { + registry.clear(); +} diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 1fd7de1ac..b35f8ee39 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -97,6 +97,7 @@ export interface PersistTimelineEditInput { recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: React.MutableRefObject; pendingTimelineEditPathRef: React.MutableRefObject>; + coalesceKey?: string; } export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise { @@ -119,6 +120,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom projectId: input.projectId, label: input.label, kind: "timeline", + coalesceKey: input.coalesceKey, files: { [targetPath]: patchedContent }, readFile: async () => originalContent, writeFile: input.writeProjectFile, diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index c62b9f20c..56cfffd98 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -117,6 +117,14 @@ interface UseAppHotkeysParams { onDeleteSelectedKeyframes: () => void; onAfterUndoRedo?: () => void; onToggleRecording?: () => void; + /** Active composition path — used to decide whether undo/redo must resync the SDK session. */ + activeCompPath?: string | null; + /** + * Force-reload the SDK session after undo/redo reverts the active comp file, + * bypassing the self-write suppress window. Without this, the suppress window + * blocks the file-change reload and the SDK session stays on pre-undo content. + */ + forceReloadSdkSession?: () => void; } // ── Extracted keydown dispatch (pure function, no hooks) ── @@ -302,6 +310,8 @@ export function useAppHotkeys({ onDeleteSelectedKeyframes, onAfterUndoRedo, onToggleRecording, + activeCompPath, + forceReloadSdkSession, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const previewHistoryCleanupRef = useRef<(() => void) | null>(null); @@ -349,6 +359,14 @@ export function useAppHotkeys({ } if (result.ok && result.label) { onAfterUndoRedo?.(); + // If the active composition was among the written files, force-reload + // the SDK session so its in-memory doc matches the reverted content. + // writeHistoryFile sets domEditSaveTimestampRef which activates the + // 2 s suppress window — without this call the file-change event would + // be swallowed and the SDK session would stay on stale pre-undo content. + if (activeCompPath && result.paths?.includes(activeCompPath)) { + forceReloadSdkSession?.(); + } await syncHistoryPreviewAfterApply(result.paths); showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info"); } @@ -361,6 +379,8 @@ export function useAppHotkeys({ waitForPendingDomEditSaves, writeHistoryFile, onAfterUndoRedo, + activeCompPath, + forceReloadSdkSession, ], ); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7e97930b0..aae11d62d 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -16,9 +16,6 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; -// Re-export so existing consumers keep their import path -export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; - // ── Helpers ── function formatUnsafeFieldList(fields: Array<{ path: string }>): string { @@ -45,8 +42,6 @@ interface RecordEditInput { files: Record; } -export type { PersistDomEditOperations } from "./domEditCommitTypes"; - export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; @@ -73,10 +68,20 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; - /** Stage 7 Step 3b: called after a successful server-side element patch. */ - onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; - /** Stage 7 Step 3b: called after a successful server-side element delete. */ - onElementDeleted?: (selection: DomEditSelection) => void; + /** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK + * path, whose session is already current) so a later SDK edit doesn't + * serialize the pre-write doc and revert the server's change. */ + forceReloadSdkSession?: () => void; + /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ + onTrySdkPersist?: ( + selection: DomEditSelection, + operations: PatchOperation[], + originalContent: string, + targetPath: string, + options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean }, + ) => Promise; + /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */ + onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; } export function useDomEditCommits({ @@ -97,8 +102,9 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted, - onElementDeleted, + forceReloadSdkSession, + onTrySdkPersist, + onTrySdkDelete, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -148,7 +154,22 @@ export function useDomEditCommits({ } if (options?.shouldSave && !options.shouldSave()) return; - + // Skip the SDK path when prepareContent is set (e.g. @font-face injection + // for a custom font): sdkCutoverPersist serializes only the patched DOM + // and would drop the injected content. Let the server path run prepareContent. + if ( + onTrySdkPersist && + !options?.prepareContent && + (await onTrySdkPersist(selection, operations, originalContent, targetPath, { + label: options?.label, + coalesceKey: options?.coalesceKey, + skipRefresh: options?.skipRefresh, + })) + ) { + // SDK handled it — its in-memory doc is already current, so do NOT + // forceReload (that would echo-reload the session we just wrote). + return; + } const patchTarget = buildDomEditPatchTarget(selection); const patchBody = { target: patchTarget, operations }; const unsafeFields = findUnsafeDomPatchValues(patchBody); @@ -220,7 +241,7 @@ export function useDomEditCommits({ coalesceKey: options?.coalesceKey, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); - onDomEditPersisted?.(selection, operations); + forceReloadSdkSession?.(); if (!options?.skipRefresh) { reloadPreview(); @@ -234,7 +255,8 @@ export function useDomEditCommits({ domEditSaveTimestampRef, reloadPreview, showToast, - onDomEditPersisted, + forceReloadSdkSession, + onTrySdkPersist, ], ); @@ -295,8 +317,9 @@ export function useDomEditCommits({ projectIdRef, reloadPreview, clearDomSelection, + onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, - onElementDeleted, }); return { diff --git a/packages/studio/src/hooks/useDomEditSession.test.ts b/packages/studio/src/hooks/useDomEditSession.test.ts new file mode 100644 index 000000000..040d83b3b --- /dev/null +++ b/packages/studio/src/hooks/useDomEditSession.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { shouldUseSdkCutover } from "../utils/sdkCutover"; +import type { PatchOperation } from "../utils/sourcePatcher"; + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when flag is disabled", () => { + expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no SDK session", () => { + expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when selection has no hfId", () => { + expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops array is empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + it("returns true when all conditions met with supported op types", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + expect( + shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]), + ).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 7b85e4c30..1fa260c0f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,15 +1,15 @@ -import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; import type { PatchTarget } from "../utils/sourcePatcher"; import type { SidebarTab } from "../components/sidebar/LeftSidebar"; +import type { Composition } from "@hyperframes/sdk"; +import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -60,8 +60,8 @@ export interface UseDomEditSessionParams { openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; - /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */ sdkSession?: Composition | null; + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -101,6 +101,7 @@ export function useDomEditSession({ selectSidebarTab, getSidebarTab, sdkSession, + forceReloadSdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -195,6 +196,8 @@ export function useDomEditSession({ onFileContentChanged: updateEditingFileContent, showToast, sdkSession, + writeProjectFile, + forceReloadSdkSession, }); // ── DOM commit handlers ── @@ -234,10 +237,35 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted: sdkSession - ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops) + forceReloadSdkSession, + onTrySdkPersist: sdkSession + ? (selection, operations, originalContent, targetPath, options) => + sdkCutoverPersist( + selection, + operations, + originalContent, + targetPath, + sdkSession, + { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, + options, + ) + : undefined, + onTrySdkDelete: sdkSession + ? (hfId, originalContent, targetPath) => + sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }) : undefined, - onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 42d0fc2a8..ffe3822da 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -42,15 +42,11 @@ export function useDomGeometryCommits({ }: UseDomGeometryCommitsParams) { const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { - const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element); - console.log( - "[drag:7] handleDomPathOffsetCommit (CSS path)", - JSON.stringify({ - sel: selection.id, - gsapBlocked, - }), - ); - if (gsapBlocked) { + // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path + // elements fall through to commitPositionPatchToHtml → persistDomEditOperations → + // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done). + // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR. + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); showToast(error.message, "error"); return Promise.reject(error); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index a30c5bb03..1ee885a62 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -26,6 +26,10 @@ interface UseElementLifecycleOpsParams { projectIdRef: React.MutableRefObject; reloadPreview: () => void; clearDomSelection: () => void; + /** Route delete through SDK when session resolves the hf-id; returns true if handled. */ + onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; + /** Resync the SDK session after a server-fallback delete. */ + forceReloadSdkSession?: () => void; commitPositionPatchToHtml: ( selection: DomEditSelection, patches: PatchOperation[], @@ -44,6 +48,8 @@ export function useElementLifecycleOps({ projectIdRef, reloadPreview, clearDomSelection, + onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, onElementDeleted, }: UseElementLifecycleOpsParams) { @@ -74,6 +80,16 @@ export function useElementLifecycleOps({ throw new Error("Selected element has no patchable target"); } + if (onTrySdkDelete && selection.hfId) { + const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath); + if (handled) { + clearDomSelection(); + usePlayerStore.getState().setSelectedElementId(null); + showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); + return; + } + } + domEditSaveTimestampRef.current = Date.now(); const removeResponse = await fetch( `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, @@ -93,6 +109,12 @@ export function useElementLifecycleOps({ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; const patchedContent = typeof removeData.content === "string" ? removeData.content : originalContent; + // ponytail: the server remove-element route (removeElementFromHtml) strips + // only the element node — it does NOT cascade-remove GSAP tweens targeting + // it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This + // fallback runs only when the element isn't in the SDK doc (e.g. runtime- + // generated / unaddressable), where targeting tweens are unlikely. Upgrade + // path: cascade in removeElementFromHtml by selector/hf-id to fully match. await saveProjectFilesWithHistory({ projectId: pid, label: "Delete element", @@ -105,6 +127,9 @@ export function useElementLifecycleOps({ clearDomSelection(); usePlayerStore.getState().setSelectedElementId(null); + // Server wrote the file; resync the stale in-memory SDK doc so a later + // SDK edit doesn't resurrect the deleted element. + forceReloadSdkSession?.(); reloadPreview(); onElementDeleted?.(selection); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); @@ -118,7 +143,9 @@ export function useElementLifecycleOps({ clearDomSelection, domEditSaveTimestampRef, editHistory.recordEdit, + onTrySdkDelete, onElementDeleted, + forceReloadSdkSession, projectIdRef, reloadPreview, showToast, @@ -126,6 +153,9 @@ export function useElementLifecycleOps({ ], ); + // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml → + // persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle. + // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed. const handleDomZIndexReorderCommit = useCallback( ( entries: Array<{ diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index aa953fb2d..701576e01 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -2,21 +2,28 @@ import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow"; +import { + sdkGsapTweenPersist, + sdkGsapDeleteAllForSelectorPersist, + type CutoverDeps, +} from "../utils/sdkCutover"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, } from "./gsapScriptCommitHelpers"; import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; -interface GsapAnimationOpsParams { +interface SdkAnimationDeps { + sdkSession?: Composition | null; + sdkDeps?: CutoverDeps | null; +} + +interface GsapAnimationOpsParams extends SdkAnimationDeps { projectIdRef: React.MutableRefObject; activeCompPath: string | null; commitMutation: CommitMutation; commitMutationSafely: SafeGsapCommitMutation; showToast: (message: string, tone?: "error" | "info") => void; - /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */ - sdkSession?: Composition | null; } export function useGsapAnimationOps({ @@ -26,60 +33,79 @@ export function useGsapAnimationOps({ commitMutationSafely, showToast, sdkSession, + sdkDeps, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { - // Shadow op (server animationId shares the SDK id-space): existence via - // runShadowGsapTween (live session) + value fidelity via the chokepoint. - const shadowGsapOp: ShadowGsapOp = { - kind: "set", - animationId, - properties: { duration: updates.duration, ease: updates.ease, position: updates.position }, - }; - // coalesceKey groups rapid meta edits into one history entry. Request - // serialization is now handled per-file at the commitMutation chokepoint - // (useGsapScriptCommits), so no per-op serializeKey is needed here. - const metaKey = `gsap:${animationId}:meta`; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: updates }, + sdkSession, + sdkDeps, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-meta", animationId, updates }, - { label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp }, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); - if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely, sdkSession], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteGsapAnimation = useCallback( - (selection: DomEditSelection, animationId: string) => { - const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId }; + async (selection: DomEditSelection, animationId: string) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "remove", animationId }, + sdkSession, + sdkDeps, + { label: "Delete GSAP animation" }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "delete", animationId, stripStudioEdits: true }, - { label: "Delete GSAP animation", shadowGsapOp }, + { label: "Delete GSAP animation" }, ); - if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely, sdkSession], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteAllForSelector = useCallback( - (selection: DomEditSelection, targetSelector: string) => { + async (selection: DomEditSelection, targetSelector: string) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapDeleteAllForSelectorPersist( + targetPath, + targetSelector, + sdkSession, + sdkDeps, + { label: "Delete all animations for element" }, + ); + if (handled) return; + } void commitMutation( selection, { type: "delete-all-for-selector", targetSelector }, { label: "Delete all animations for element" }, ); }, - [commitMutation], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); - // Pre-existing complexity (auto-id assignment + per-method defaults); this PR - // adds only a guarded shadow-op construction at the tail. + // fallow-ignore-next-line complexity const addGsapAnimation = useCallback( // fallow-ignore-next-line complexity async ( @@ -114,25 +140,28 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; - // Shadow op (server stays authoritative). "set" has no SDK method, so it - // is not shadowed; otherwise: existence via runShadowGsapTween (live) + - // value fidelity via the chokepoint (shadowGsapOp in options). - const shadowGsapOp: ShadowGsapOp | undefined = - selection.hfId && method !== "set" - ? { - kind: "add", - target: selection.hfId, - tween: { - method, - position, - duration, - ease: "power2.out", - ...(method === "fromTo" - ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] } - : { properties: toDefaults[method] ?? { opacity: 1 } }), - }, - } - : undefined; + // Skip SDK path when an id was just assigned server-side (autoId): the + // SDK session hasn't reloaded that write yet, so persisting its + // serialization would clobber the new id — let the server add the tween + // atomically with the id it wrote. + if (!autoId && selection.hfId && sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const spec = { + method, + position, + ...(method !== "set" ? { duration, ease: "power2.out" as const } : {}), + properties: toDefaults[method] ?? { opacity: 1 }, + ...(method === "fromTo" ? { fromProperties: { opacity: 0 } } : {}), + }; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "add", target: selection.hfId, spec }, + sdkSession, + sdkDeps, + { label: `Add GSAP ${method} animation` }, + ); + if (handled) return; + } await commitMutation( selection, @@ -146,12 +175,10 @@ export function useGsapAnimationOps({ properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, }, - { label: `Add GSAP ${method} animation`, shadowGsapOp }, + { label: `Add GSAP ${method} animation` }, ); - - if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession], + [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps], ); return { diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 809363c4d..8ff6c2aa4 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -1,8 +1,15 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; +import { + sdkGsapKeyframePersist, + sdkGsapRemoveKeyframePersist, + sdkGsapRemoveAllKeyframesPersist, + sdkGsapConvertToKeyframesPersist, + type CutoverDeps, +} from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; @@ -31,7 +38,12 @@ function executeOptimisticKeyframeCacheUpdate(options: { }); } -interface GsapKeyframeOpsParams { +interface SdkKeyframeDeps { + sdkSession?: Composition | null; + sdkDeps?: CutoverDeps | null; +} + +interface GsapKeyframeOpsParams extends SdkKeyframeDeps { activeCompPath: string | null; commitMutation: CommitMutation; commitMutationSafely: SafeGsapCommitMutation; @@ -43,6 +55,8 @@ export function useGsapKeyframeOps({ commitMutation, commitMutationSafely, trackGsapSaveFailure, + sdkSession, + sdkDeps, }: GsapKeyframeOpsParams) { const addKeyframe = useCallback( ( @@ -59,111 +73,172 @@ export function useGsapKeyframeOps({ percentage, properties: { [property]: value }, }; - // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint. - const shadowKeyframeOp: ShadowKeyframeOp = { - kind: "add", - animationId, - percentage, - properties: { [property]: value }, - }; void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, - apply: (prev) => ({ - ...prev, - keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( - (a, b) => a.percentage - b.percentage, - ), - }), - persist: () => - commitMutation(selection, mutation, { + // Merge into an existing keyframe at this percentage rather than + // appending a duplicate — matches addKeyframeToScript, which writes one + // keyframe per percentage (merging properties). + apply: (prev) => { + const idx = prev.keyframes.findIndex( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) < 0.001, + ); + if (idx >= 0) { + const keyframes = prev.keyframes.slice(); + keyframes[idx] = { + ...keyframes[idx], + properties: { ...keyframes[idx].properties, [property]: value }, + }; + return { ...prev, keyframes }; + } + return { + ...prev, + keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( + (a, b) => a.percentage - b.percentage, + ), + }; + }, + persist: async () => { + if (sdkSession && sdkDeps) { + const handled = await sdkGsapKeyframePersist( + sourceFile, + animationId, + percentage, + { [property]: value }, + sdkSession, + sdkDeps, + { + label: `Add keyframe at ${percentage}%`, + coalesceKey: `gsap:${animationId}:kf:${percentage}`, + }, + ); + if (handled) return; + } + await commitMutation(selection, mutation, { label: `Add keyframe at ${percentage}%`, softReload: true, - shadowKeyframeOp, - }), + }); + }, }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); }); }, - [activeCompPath, commitMutation, trackGsapSaveFailure], + [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps], ); const addKeyframeBatch = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, percentage: number, properties: Record, ) => { - const shadowKeyframeOp: ShadowKeyframeOp = { - kind: "add", - animationId, - percentage, - properties, - }; + if (sdkSession && sdkDeps) { + const sourceFile = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapKeyframePersist( + sourceFile, + animationId, + percentage, + properties, + sdkSession, + sdkDeps, + { label: `Add keyframe at ${percentage}%` }, + ); + if (handled) return; + } return commitMutation( selection, { type: "add-keyframe", animationId, percentage, properties }, - { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, ); }, - [commitMutation], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); const removeKeyframe = useCallback( (selection: DomEditSelection, animationId: string, percentage: number) => { const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const mutation = { type: "remove-keyframe", animationId, percentage }; - // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main, - // so the runner resolves percentage → keyframeIndex against the pre-op - // script and no-ops on ambiguity (duplicate-percentage keyframes). - const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage }; void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, apply: (prev) => ({ ...prev, keyframes: prev.keyframes.filter( - (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2, + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.001, ), }), - persist: () => - commitMutation(selection, mutation, { + persist: async () => { + if (sdkSession && sdkDeps) { + const handled = await sdkGsapRemoveKeyframePersist( + sourceFile, + animationId, + percentage, + sdkSession, + sdkDeps, + { label: `Remove keyframe at ${percentage}%` }, + ); + if (handled) return; + } + await commitMutation(selection, mutation, { label: `Remove keyframe at ${percentage}%`, softReload: true, - shadowKeyframeOp, - }), + }); + }, }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`); }); }, - [activeCompPath, commitMutation, trackGsapSaveFailure], + [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps], ); const convertToKeyframes = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, resolvedFromValues?: Record, ) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapConvertToKeyframesPersist( + targetPath, + animationId, + resolvedFromValues, + sdkSession, + sdkDeps, + { label: "Convert to keyframes" }, + ); + if (handled) return; + } return commitMutation( selection, { type: "convert-to-keyframes", animationId, resolvedFromValues }, { label: "Convert to keyframes" }, ); }, - [commitMutation], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); const removeAllKeyframes = useCallback( - (selection: DomEditSelection, animationId: string) => { + async (selection: DomEditSelection, animationId: string) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapRemoveAllKeyframesPersist( + targetPath, + animationId, + sdkSession, + sdkDeps, + { label: "Remove all keyframes" }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "remove-all-keyframes", animationId }, { label: "Remove all keyframes", softReload: true }, ); }, - [commitMutationSafely], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const commitKeyframeAtTime = useCallback( diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.test.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.test.ts new file mode 100644 index 000000000..4c2a67fa9 --- /dev/null +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { openComposition } from "@hyperframes/sdk"; +import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; +import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; +import { mergeTweenProperties } from "./useGsapPropertyDebounce"; +import { extractGsapScriptText } from "../utils/gsapSoftReload"; + +const HTML = ` +
+ +`; + +const FROMTO_HTML = ` +
+ +`; + +function tweenProps(comp: { serialize(): string }) { + const parsed = parseGsapScriptAcorn(extractGsapScriptText(comp.serialize()) ?? ""); + const anim = parsed.animations[0]; + return { id: anim?.id, properties: anim?.properties, fromProperties: anim?.fromProperties }; +} + +describe("setGsapTween replace semantics (finding #1)", () => { + it("REGRESSION: a single-key set drops the tween's other animated props", async () => { + // This documents the bug the merge fixes: setGsapTween REPLACES the property + // set, so sending only the edited key loses the siblings. + const comp = await openComposition(HTML, { persist: createMemoryAdapter() }); + const id = tweenProps(comp).id ?? ""; + comp.setGsapTween(id, { properties: { x: 200 } }); + const after = tweenProps(comp); + expect(after.properties).toEqual({ x: 200 }); + expect(after.properties).not.toHaveProperty("y"); + expect(after.properties).not.toHaveProperty("opacity"); + }); +}); + +describe("mergeTweenProperties (finding #1)", () => { + it("editing x preserves y and opacity through a real SDK write", async () => { + const comp = await openComposition(HTML, { persist: createMemoryAdapter() }); + const id = tweenProps(comp).id ?? ""; + // Mirror the send site: merge the single edited prop into the existing set. + const merged = mergeTweenProperties(comp, id, { x: 200 }, "to"); + expect(merged).toEqual({ x: 200, y: 50, opacity: 1 }); + comp.setGsapTween(id, { properties: merged }); + const after = tweenProps(comp); + expect(after.properties).toMatchObject({ x: 200, y: 50, opacity: 1 }); + }); + + it("editing a from-property preserves the other from-properties", async () => { + const comp = await openComposition(FROMTO_HTML, { persist: createMemoryAdapter() }); + const id = tweenProps(comp).id ?? ""; + const merged = mergeTweenProperties(comp, id, { x: 25 }, "from"); + expect(merged).toEqual({ x: 25, y: 0 }); + }); + + it("returns the single edit unchanged when the tween id is unknown", async () => { + const comp = await openComposition(HTML, { persist: createMemoryAdapter() }); + expect(mergeTweenProperties(comp, "no-such-id", { x: 5 }, "to")).toEqual({ x: 5 }); + }); +}); diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 26f00e2a2..60e031c81 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,11 +1,56 @@ import { useCallback, useEffect, useRef } from "react"; +import type { Composition } from "@hyperframes/sdk"; +import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { + sdkGsapTweenPersist, + sdkGsapRemovePropertyPersist, + type CutoverDeps, +} from "../utils/sdkCutover"; +import { extractGsapScriptText } from "../utils/gsapSoftReload"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; const DEBOUNCE_MS = 150; -export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMutation) { +/** + * The SDK `setGsapTween` 'set' path REPLACES a tween's editable property set + * (engine `handleSetGsapTween` → `updateAnimationInScript`), so sending only the + * single edited key would silently drop the tween's other animated props. Mirror + * the legacy server path (`{ ...anim.properties, [property]: val }`): read the + * tween's CURRENT properties from the in-memory SDK doc and merge the one edit in, + * so REPLACE semantics preserve siblings. Returns the single-key map unchanged + * when the tween/script can't be found (best-effort; before===after then falls + * back to the server path). + */ +export function mergeTweenProperties( + sdkSession: Composition, + animationId: string, + edited: Record, + kind: "to" | "from", +): Record { + try { + const script = extractGsapScriptText(sdkSession.serialize()); + if (!script) return { ...edited }; + const anim = parseGsapScriptAcorn(script).animations.find((a) => a.id === animationId); + if (!anim) return { ...edited }; + const existing = kind === "from" ? (anim.fromProperties ?? {}) : anim.properties; + return { ...existing, ...edited }; + } catch { + return { ...edited }; + } +} + +interface SdkPropertyDeps { + sdkSession?: Composition | null; + sdkDeps?: CutoverDeps | null; + activeCompPath?: string | null; +} + +export function useGsapPropertyDebounce( + commitMutationSafely: SafeGsapCommitMutation, + sdk?: SdkPropertyDeps, +) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; animationId: string; @@ -14,11 +59,38 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta } | null>(null); const debounceTimerRef = useRef | null>(null); - const flushPendingPropertyEdit = useCallback(() => { + // The caller passes `sdk` as a fresh object literal every render. Keying any + // callback's deps on it (esp. flushPendingPropertyEdit, whose identity drives + // the unmount-flush cleanup effect) re-fires the cleanup on EVERY parent + // re-render — so a playhead tick mid-slider-drag would flush + record an undo + // entry per render. Hold the latest value in a ref instead so every callback + // reads current deps without re-subscribing on identity churn. + const sdkRef = useRef(sdk); + sdkRef.current = sdk; + + const flushPendingPropertyEdit = useCallback(async () => { const pending = pendingPropertyEditRef.current; if (!pending) return; pendingPropertyEditRef.current = null; const { selection, animationId, property, value } = pending; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { + properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"), + }, + }, + sdkSession, + sdkDeps, + { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-property", animationId, property, value }, @@ -39,7 +111,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta ) => { pendingPropertyEditRef.current = { selection, animationId, property, value }; if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); + debounceTimerRef.current = setTimeout(() => { + void flushPendingPropertyEdit(); + }, DEBOUNCE_MS); }, [flushPendingPropertyEdit], ); @@ -47,12 +121,14 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - flushPendingPropertyEdit(); + void flushPendingPropertyEdit(); }; }, [flushPendingPropertyEdit]); + // fallow-ignore-next-line complexity const addGsapProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { + // fallow-ignore-next-line complexity + async (selection: DomEditSelection, animationId: string, property: string) => { let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; const el = selection.element; if (property === "width" || property === "height") { @@ -62,6 +138,29 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta const cs = el.ownerDocument.defaultView?.getComputedStyle(el); defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; } + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { + properties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: defaultValue }, + "to", + ), + }, + }, + sdkSession, + sdkDeps, + { label: `Add GSAP ${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "add-property", animationId, property, defaultValue }, @@ -71,24 +170,82 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta [commitMutationSafely], ); - const removeGsapProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { - commitMutationSafely( - selection, - { type: "remove-property", animationId, property }, - { label: `Remove GSAP ${property}` }, - ); + const removeProperty = useCallback( + async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => { + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapRemovePropertyPersist( + targetPath, + animationId, + property, + from, + sdkSession, + sdkDeps, + { label: `Remove GSAP ${from ? `from-${property}` : property}` }, + ); + if (handled) return; + } + if (from) { + commitMutationSafely( + selection, + { type: "remove-from-property", animationId, property }, + { + label: `Remove GSAP from-${property}`, + }, + ); + } else { + commitMutationSafely( + selection, + { type: "remove-property", animationId, property }, + { + label: `Remove GSAP ${property}`, + }, + ); + } }, [commitMutationSafely], ); + const removeGsapProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => + removeProperty(selection, animationId, property, false), + [removeProperty], + ); + const updateGsapFromProperty = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { + fromProperties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: value }, + "from", + ), + }, + }, + sdkSession, + sdkDeps, + { + label: `Edit GSAP from-${property}`, + coalesceKey: `gsap:${animationId}:from:${property}`, + }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-from-property", animationId, property, value }, @@ -102,8 +259,31 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta ); const addGsapFromProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { + async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { + fromProperties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: defaultValue }, + "from", + ), + }, + }, + sdkSession, + sdkDeps, + { label: `Add GSAP from-${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "add-from-property", animationId, property, defaultValue }, @@ -114,14 +294,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta ); const removeGsapFromProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { - commitMutationSafely( - selection, - { type: "remove-from-property", animationId, property }, - { label: `Remove GSAP from-${property}` }, - ); - }, - [commitMutationSafely], + (selection: DomEditSelection, animationId: string, property: string) => + removeProperty(selection, animationId, property, true), + [removeProperty], ); return { diff --git a/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts b/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts new file mode 100644 index 000000000..7890f0d00 --- /dev/null +++ b/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import React, { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// The SDK path is gated on STUDIO_SDK_CUTOVER_ENABLED; keep it OFF so the flush +// routes through commitMutationSafely (the spy we count), keeping the test about +// flush TIMING, not the SDK write path. +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: false, +})); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +const selection = { sourceFile: "index.html" } as unknown as DomEditSelection; + +describe("useGsapPropertyDebounce flush stability (finding #7)", () => { + let container: HTMLDivElement; + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + it("re-rendering the parent while an edit is pending does NOT flush early or duplicate commits", () => { + const commitMutationSafely = vi.fn(); + let queueEdit: (() => void) | null = null; + let forceRerender: (() => void) | null = null; + + function Harness() { + const [tick, setTick] = useState(0); + forceRerender = () => setTick((t) => t + 1); + // A FRESH sdk wrapper literal every render — the exact churn that, before + // the ref-stabilization fix, re-fired the unmount-flush cleanup effect. + const ops = useGsapPropertyDebounce(commitMutationSafely, { + sdkSession: null, + sdkDeps: null, + activeCompPath: "index.html", + }); + queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", tick + 1); + return React.createElement("div", null, String(tick)); + } + + const root = createRoot(container); + act(() => { + root.render(React.createElement(Harness)); + }); + + // Queue one pending edit. + act(() => { + queueEdit?.(); + }); + expect(commitMutationSafely).not.toHaveBeenCalled(); + + // Re-render the parent several times BEFORE the debounce elapses. The bug + // flushed (and recorded a commit) on every re-render via the cleanup effect. + act(() => { + forceRerender?.(); + }); + act(() => { + forceRerender?.(); + }); + act(() => { + forceRerender?.(); + }); + expect(commitMutationSafely).not.toHaveBeenCalled(); + + // The debounce fires exactly once. + act(() => { + vi.advanceTimersByTime(200); + }); + expect(commitMutationSafely).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 1e4b4c7d9..1c15af70d 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,9 +1,8 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { applySoftReload } from "../utils/gsapSoftReload"; -import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity"; -import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe"; +import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; +import type { CutoverDeps } from "../utils/sdkCutover"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { createKeyedSerializer } from "./serializeByKey"; import { @@ -46,15 +45,12 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { // Serializer for per-key commits (options.serializeKey). Keyed by // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for - // the same animationId so their POSTs can't interleave — which is what made - // the shadow fidelity diff pair an op with a stale server result and report - // false ease mismatches. Held in a ref so the chain survives re-renders. + // the same animationId so their POSTs can't interleave. Held in a ref so the + // chain survives re-renders. const serializerRef = useRef(createKeyedSerializer()); - // Pre-existing complexity (server mutate + history + reload branches); this PR - // adds only a guarded shadow-fidelity dispatch. // fallow-ignore-next-line complexity const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const pid = projectIdRef.current; @@ -76,32 +72,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra } if (result.changed === false) return; domEditSaveTimestampRef.current = Date.now(); - // Shadow value fidelity: diff the SDK's GSAP writer output against the - // server's, from the same pre-op file. Fire-and-forget; server authoritative. - // Meta-level ops carry shadowGsapOp (add / update-meta / delete via - // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via - // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property - // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up. - // scriptText is null when the composition has no GSAP script; nothing to diff. - const fidelityArgs = resolveGsapFidelityArgs( - sdkSession, - options.shadowGsapOp, - result.before, - result.scriptText, - ); - if (fidelityArgs) { - void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript); - } - // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but - // the SDK has no keyframe reader so there is no live-existence path — the diff - // is the only signal. Guarded on a live session + both scripts to diff. - if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) { - void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText); - } if (result.before != null && result.after != null) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } if (result.after != null) onFileContentChanged?.(targetPath, result.after); + // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a + // later SDK-routed edit doesn't serialize the pre-write doc and revert this. + forceReloadSdkSession?.(); if (options.skipReload) return; if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); options.beforeReload?.(); @@ -111,12 +88,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview(); } onCacheInvalidate(); - }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]); // Every GSAP-script commit is a read-modify-write of one file. Overlapping - // commits to the SAME file (any op type, any animation) interleave server-side - // and make the shadow fidelity diff pair an op with a stale server result — - // the false ease/value mismatches this serializer exists to prevent. So - // serialize per target file by default; an explicit serializeKey overrides. + // commits to the SAME file (any op type, any animation) interleave server-side, + // so serialize per target file by default; an explicit serializeKey overrides. const commitMutation = useCallback( (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const file = selection.sourceFile || activeCompPath || "index.html"; @@ -127,9 +102,89 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra ); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); - const propertyOps = useGsapPropertyDebounce(commitMutationSafely); - const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession }); - const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); + + // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the + // hooks' callbacks keep a stable identity (an inline literal here re-fired the + // property-debounce flush on every render). refresh() soft-reloads (preserving + // the playhead) and invalidates the panel cache, matching the server path. + const sdkRefresh = useCallback( + (after: string) => { + const script = extractGsapScriptText(after); + if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview(); + onCacheInvalidate(); + }, + [previewIframeRef, reloadPreview, onCacheInvalidate], + ); + // Reuse the SAME per-file serializer the legacy commitMutation path uses, so + // SDK gsap-write flushes serialize against legacy commits AND each other — + // overlapping same-file read-modify-writes can't interleave and lose an edit. + const serializeByFile = useCallback( + (key: string, task: () => Promise): Promise => serializerRef.current(key, task), + [], + ); + // Read the on-disk bytes of targetPath so the SDK GSAP persist captures the + // exact prior content as its undo `before` (matching the style/delete paths), + // instead of a normalized full-DOM re-emit that would reformat the whole file. + const readProjectFileContent = useCallback( + async (path: string): Promise => { + const pid = projectIdRef.current; + if (!pid) throw new Error("No active project"); + const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`); + if (!res.ok) throw new Error(`Failed to read ${path}`); + const data = (await res.json()) as { content?: string }; + if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`); + return data.content; + }, + [projectIdRef], + ); + const sdkDeps = useMemo( + () => + writeProjectFile + ? { + editHistory: { recordEdit: editHistory.recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + refresh: sdkRefresh, + compositionPath: activeCompPath, + serialize: serializeByFile, + readProjectFile: readProjectFileContent, + } + : null, + [ + editHistory.recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + sdkRefresh, + activeCompPath, + serializeByFile, + readProjectFileContent, + ], + ); + + const propertyOps = useGsapPropertyDebounce(commitMutationSafely, { + sdkSession, + sdkDeps, + activeCompPath, + }); + const animationOps = useGsapAnimationOps({ + projectIdRef, + activeCompPath, + commitMutation, + commitMutationSafely, + showToast, + sdkSession, + sdkDeps, + }); + const keyframeOps = useGsapKeyframeOps({ + activeCompPath, + commitMutation, + commitMutationSafely, + trackGsapSaveFailure, + sdkSession, + sdkDeps, + }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; } diff --git a/packages/studio/src/hooks/useSdkSession.test.ts b/packages/studio/src/hooks/useSdkSession.test.ts index b4b81b49b..27155fdd4 100644 --- a/packages/studio/src/hooks/useSdkSession.test.ts +++ b/packages/studio/src/hooks/useSdkSession.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest"; import { shouldReloadSdkSession } from "./useSdkSession"; +// ── undo-sync contract ──────────────────────────────────────────────────────── +// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write +// suppress window. useAppHotkeys calls forceReload() after a successful +// undo/redo that wrote the active composition path. Without it, the suppress +// window swallows the file-change event and the SDK session stays stale. +// +// The React hook internals (useState / useEffect) cannot be unit-tested without +// a full render environment; the correctness of the suppress-bypass path is +// covered by the integration tests in usePersistentEditHistory.test.ts +// (which verify undo writes the correct before-content to disk). +// ───────────────────────────────────────────────────────────────────────────── + describe("shouldReloadSdkSession", () => { it("reloads when the changed file is the active composition", () => { expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 0e22ba1b8..e7b8c7e23 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,8 +1,10 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; import type { Composition } from "@hyperframes/sdk"; import { readStudioFileChangePath } from "../components/editor/manualEdits"; +import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; /** * True when an external file-change payload targets the active composition and @@ -20,33 +22,88 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * (projectId, activeCompPath) change, disposes the old one on cleanup, and * re-opens it when the active composition file changes on disk (code editor, * agent, or server-side patch) so the in-memory linkedom document never goes - * stale. - * - * Opened WITHOUT a persist queue: this session is shadow-telemetry + - * selection-sync only — it reads from the server but must NEVER write back. - * Shadow dispatch ops mutate the in-memory model and are discarded on the next - * reload-on-change (the studio's own authoritative write triggers it). Routing - * authoritative writes through this session (cutover, Step 3c+) must re-add - * persist TOGETHER WITH self-write suppression — without it, the SDK's - * serialize() output races and clobbers the studio's authoritative write. + * stale. The session has NO persist queue — Studio is the sole file writer; see + * the open effect below. + */ +// Reload-suppression baseline: a file-change within this window of our own SDK +// cutover write is a CANDIDATE echo, but the decision is content-identity based +// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window +// still reloads (its reverted bytes were never registered as a self-write). The +// window only bounds how long a registered self-write stays suppressible. +const SELF_WRITE_SUPPRESS_MS = 2000; + +/** Best-effort read of the changed file's content from a file-change payload. */ +function readFileChangeContent(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + if (typeof record.content === "string") return record.content; + if ("data" in record) return readFileChangeContent(record.data); + return null; +} + +/** + * Decide whether a file-change for the active composition should reload the SDK + * session. `content` is the new on-disk bytes (from the payload or a re-read); + * pass null when unavailable. Content-identity wins: a change whose bytes match a + * registered self-write is our own echo (suppress). Without content we can't prove + * identity, so we fall back to the time window ONLY to suppress an echo — an undo + * write outside the window (or any non-self-write) still reloads. Exported for test. */ +export function shouldReloadOnFileChange( + activeCompPath: string, + content: string | null, + withinSuppressWindow: boolean, +): boolean { + if (content != null) return !isSelfWriteEcho(activeCompPath, content); + // No content to compare — preserve the old time-window echo suppression. + return !withinSuppressWindow; +} + +export interface SdkSessionHandle { + session: Composition | null; + /** + * Force a session reload immediately, bypassing the self-write suppress + * window. Call after undo/redo writes the active composition file so the + * SDK in-memory document reflects the reverted content. + */ + forceReload: () => void; +} + export function useSdkSession( projectId: string | null, activeCompPath: string | null, -): Composition | null { + domEditSaveTimestampRef?: MutableRefObject, +): SdkSessionHandle { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); // ── Re-open on external change to the active composition ── useEffect(() => { if (!activeCompPath) return; - // Pre-existing clone of the file-change reload handler (usePreviewPersistence); - // surfaced by this PR's adjacent edits, not introduced by it. - // fallow-ignore-next-line code-duplication + const compPath = activeCompPath; + const readAdapter = + projectId != null + ? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` }) + : null; const handler = (payload?: unknown) => { - if (shouldReloadSdkSession(payload, activeCompPath)) { - setReloadToken((t) => t + 1); + if (!shouldReloadSdkSession(payload, compPath)) return; + const withinWindow = + !!domEditSaveTimestampRef && + Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS; + const decide = (content: string | null) => { + if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1); + }; + const payloadContent = readFileChangeContent(payload); + // Prefer payload content; otherwise re-read so the decision is by IDENTITY + // (an undo's reverted bytes won't match a registered self-write → reload). + if (payloadContent != null || !readAdapter) { + decide(payloadContent); + return; } + readAdapter + .read(compPath) + .then((c) => decide(typeof c === "string" ? c : null)) + .catch(() => decide(null)); }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); @@ -56,7 +113,8 @@ export function useSdkSession( const es = new EventSource("/api/events"); es.addEventListener("file-change", handler); return () => es.close(); - }, [activeCompPath]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeCompPath, projectId]); // ── Open / re-open the session ── useEffect(() => { @@ -66,7 +124,7 @@ export function useSdkSession( } let cancelled = false; - let comp: Composition | null = null; + const compRef = { current: null as Composition | null }; const adapter = createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}`, @@ -75,15 +133,21 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - // No persist — shadow/selection only; see the hook docstring. The SDK - // must not write back to the server while it shadows the authoritative - // studio path. - comp = await openComposition(content); + // No persist queue: Studio's writeProjectFile (via sdkCutover's + // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist + // queue too would double-write the file (queue auto-writes on every + // 'change' AND Studio writes explicitly) and race on disk; it would + // also write the full active-composition serialization to the fixed + // persistPath even when an edit targeted a sub-composition file. + // Studio's editHistory is the authoritative undo stack — SDK history + // is unused dead weight here (forceReloadSdkSession discards it on undo). + const comp = await openComposition(content, { history: false }); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); return; } + compRef.current = comp; setSession(comp); }) .catch(() => { @@ -92,10 +156,12 @@ export function useSdkSession( return () => { cancelled = true; - const c = comp; - if (c) void c.flush().finally(() => c.dispose()); + // No queue to flush; dispose only. (Flushing here would serialize the + // pre-undo in-memory doc and race the revert write on undo/redo reload.) + compRef.current?.dispose(); }; }, [projectId, activeCompPath, reloadToken]); - return session; + const forceReload = useCallback(() => setReloadToken((t) => t + 1), []); + return { session, forceReload }; } diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 260bbb310..688d4127a 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,11 +1,7 @@ // Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale + -// playback-start resolution); this PR adds guarded shadow-timing dispatches in -// the move/resize .then() chains, which nudges several callbacks over the CC -// threshold. The added branches are telemetry-only. +// playback-start resolution). // fallow-ignore-file complexity import { useCallback, useRef } from "react"; -import type { Composition } from "@hyperframes/sdk"; -import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { useRazorSplit } from "./useRazorSplit"; @@ -37,6 +33,8 @@ import { scaleGsapPositions, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; +import { sdkTimingPersist } from "../utils/sdkCutover"; +import type { Composition } from "@hyperframes/sdk"; // ── Types ── @@ -60,8 +58,10 @@ interface UseTimelineEditingOptions { pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: React.RefObject; - /** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */ + /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ sdkSession?: Composition | null; + /** Resync the SDK session after a server-authoritative timeline write. */ + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -80,6 +80,7 @@ export function useTimelineEditing({ uploadProjectFiles, isRecordingRef, sdkSession, + forceReloadSdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -92,6 +93,7 @@ export function useTimelineEditing({ element: TimelineElement, label: string, buildPatches: PersistTimelineEditInput["buildPatches"], + coalesceKey?: string, ): Promise => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); @@ -99,19 +101,25 @@ export function useTimelineEditing({ } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); - const queued = editQueueRef.current.then(() => - persistTimelineEdit({ - projectId: pid, - element, - activeCompPath, - label, - buildPatches, - writeProjectFile, - recordEdit, - domEditSaveTimestampRef, - pendingTimelineEditPathRef, - }), - ); + const queued = editQueueRef.current + .then(() => + persistTimelineEdit({ + projectId: pid, + element, + activeCompPath, + label, + buildPatches, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + coalesceKey, + }), + ) + .then(() => { + // Server wrote the file; resync the stale in-memory SDK doc. + forceReloadSdkSession?.(); + }); editQueueRef.current = queued.catch((error) => { console.error(`[Timeline] Failed to persist: ${label}`, error); }); @@ -125,18 +133,20 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, showToast, isRecordingRef, + forceReloadSdkSession, ], ); + // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( + // fallow-ignore-next-line complexity (element: TimelineElement, updates: Pick) => { patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-track-index", String(updates.track)], ]); - const delta = updates.start - element.start; - const filePath = element.sourceFile || activeCompPath || "index.html"; - return enqueueEdit(element, "Move timeline clip", (original, target) => { + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { let patched = applyPatchByTarget(original, target, { type: "attribute", property: "start", @@ -147,24 +157,63 @@ export function useTimelineEditing({ property: "track-index", value: String(updates.track), }); - }).then(() => { - if (sdkSession) - runShadowTiming(sdkSession, element.hfId, { - start: updates.start, - trackIndex: updates.track, - }); - const pid = projectIdRef.current; - if (delta !== 0 && element.domId && pid) { - return shiftGsapPositions(pid, filePath, element.domId, delta) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err)); - } - }); + }; + // Server-path fallback (no SDK session): persist the attr patch, then + // shift GSAP tween positions on the server and reload the preview — the + // SDK path folds both into setTiming, but the fallback must do them + // explicitly or the clip moves while its GSAP tweens stay put + the + // preview never refreshes. coalesceKey mirrors the SDK branch so undo + // granularity is identical on either path. + const coalesceKey = `timeline-move:${element.hfId ?? element.id}`; + const moveFallback = () => + enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => { + const pid = projectIdRef.current; + const delta = updates.start - element.start; + if (delta !== 0 && element.domId && pid) { + return shiftGsapPositions(pid, targetPath, element.domId, delta) + .then(() => reloadPreview()) + .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err)); + } + return reloadPreview(); + }); + if (sdkSession && element.hfId) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, trackIndex: updates.track }, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + // Capture on-disk bytes as the undo `before` so undoing a timing move + // restores the file verbatim, not a normalized full-DOM re-emit. + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Move timeline clip", coalesceKey }, + ).then((handled) => { + if (!handled) return moveFallback(); + }); + } + return moveFallback(); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementResize = useCallback( + // fallow-ignore-next-line complexity ( element: TimelineElement, updates: Pick, @@ -173,6 +222,9 @@ export function useTimelineEditing({ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], ]; + // Patch the live playback-start/media-start attr too, or a resize that + // trims the playback start leaves the preview showing the old in-point + // until the next reload (the persisted patch handles it via pbs below). if (updates.playbackStart != null) { const liveAttr = element.playbackStartAttr === "playback-start" @@ -181,10 +233,8 @@ export function useTimelineEditing({ liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); } patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); - const filePath = element.sourceFile || activeCompPath || "index.html"; - const timingChanged = - updates.start !== element.start || updates.duration !== element.duration; - return enqueueEdit(element, "Resize timeline clip", (original, target) => { + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { const pbs = resolveResizePlaybackStart(original, target, element, updates); let patched = applyPatchByTarget(original, target, { type: "attribute", @@ -204,34 +254,77 @@ export function useTimelineEditing({ }); } return patched; - }).then(() => { - if (sdkSession) - runShadowTiming(sdkSession, element.hfId, { - start: updates.start, - duration: updates.duration, - }); - const pid = projectIdRef.current; - if (timingChanged && element.domId && pid) { - return scaleGsapPositions( - pid, - filePath, - element.domId, - element.start, - element.duration, - updates.start, - updates.duration, - ) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err)); - } - return reloadPreview(); - }); + }; + // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). + // The second clause fires because trimming the start of a clip that has a + // playback-start attribute implicitly shifts that in-point — which the SDK + // setTiming op can't express — so those resizes must take the server path. + const hasPbsAdjustment = + updates.playbackStart != null || + (updates.start !== element.start && element.playbackStart != null); + // Server-path fallback: after persisting the attr patch, scale GSAP tween + // positions/durations on the server and reload the preview. The SDK path + // folds both into setTiming; the fallback must do them explicitly or the + // clip resizes while its GSAP tweens keep their old timing + the preview + // never refreshes. coalesceKey mirrors the SDK branch for undo parity. + const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; + const timingChanged = + updates.start !== element.start || updates.duration !== element.duration; + const resizeFallback = () => + enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => { + const pid = projectIdRef.current; + if (timingChanged && element.domId && pid) { + return scaleGsapPositions( + pid, + targetPath, + element.domId, + element.start, + element.duration, + updates.start, + updates.duration, + ) + .then(() => reloadPreview()) + .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err)); + } + return reloadPreview(); + }); + if (sdkSession && element.hfId && !hasPbsAdjustment) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, duration: updates.duration }, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + // Capture on-disk bytes as the undo `before` so undoing a timing + // resize restores the file verbatim, not a normalized full-DOM re-emit. + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Resize timeline clip", coalesceKey }, + ).then((handled) => { + if (!handled) return resizeFallback(); + }); + } + return resizeFallback(); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementDelete = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (element: TimelineElement) => { if (isRecordingRef?.current) { @@ -287,8 +380,8 @@ export function useTimelineEditing({ timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)), ); usePlayerStore.getState().setSelectedElementId(null); + forceReloadSdkSession?.(); reloadPreview(); - if (sdkSession) runShadowDelete(sdkSession, element.hfId); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete timeline clip"; @@ -304,12 +397,12 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, - sdkSession, + forceReloadSdkSession, ], ); + // fallow-ignore-next-line complexity const handleTimelineAssetDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async ( assetPath: string, @@ -373,6 +466,7 @@ export function useTimelineEditing({ recordEdit, }); + forceReloadSdkSession?.(); reloadPreview(); } catch (error) { const message = @@ -389,11 +483,12 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, + forceReloadSdkSession, ], ); + // fallow-ignore-next-line complexity const handleTimelineFileDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (files: File[], placement?: Pick) => { if (isRecordingRef?.current) { diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 584658a76..e60e001d8 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] { return results; } +/** + * Extract the GSAP timeline script text from a serialized HTML document, for + * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts + * are present (ambiguous — caller should fall back to a full reload), matching + * applySoftReload's own single-script requirement. + */ +export function extractGsapScriptText(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + const scripts = findGsapScriptElements(doc); + if (scripts.length !== 1) return null; + return scripts[0].textContent || null; +} + /** Check that the new script repopulated __timelines with at least one entry. */ function verifyTimelinesPopulated(win: IframeWindow): boolean { const tlKeys = win.__timelines @@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st // full iframe reload that destroys the very WebGL context we're preserving. let deferredToAsync = false; + // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; const allTargets: Element[] = []; diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts new file mode 100644 index 000000000..c8592dba8 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover +// persist chokepoint must return false so the caller takes the legacy server +// path — even when a valid SDK session exists (one always does, for +// shadow/selection). This is the contract the prod flag-flip rests on; a future +// refactor of the gate guards that silently re-enables cutover on flag-off +// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.) +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: false, +})); +vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +import { sdkTimingPersist, sdkGsapTweenPersist, sdkDeletePersist } from "./sdkCutover"; + +const makeSession = () => + ({ + getElement: () => ({ inlineStyles: {} }), + serialize: () => "", + batch: (fn: () => void) => fn(), + setTiming: vi.fn(), + dispatch: vi.fn(), + }) as never; + +const makeDeps = () => + ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + }) as never; + +describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => { + it("sdkTimingPersist falls back without writing", async () => { + const deps = makeDeps(); + expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe( + false, + ); + expect( + (deps as unknown as { writeProjectFile: ReturnType }).writeProjectFile, + ).not.toHaveBeenCalled(); + }); + + it("sdkGsapTweenPersist (shared GSAP-op chokepoint) falls back", async () => { + expect( + await sdkGsapTweenPersist( + "/c.html", + { kind: "remove", animationId: "a" }, + makeSession(), + makeDeps(), + ), + ).toBe(false); + }); + + it("sdkDeletePersist falls back", async () => { + expect( + await sdkDeletePersist("hf-a", "", "/c.html", makeSession(), makeDeps()), + ).toBe(false); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts new file mode 100644 index 000000000..17279a890 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -0,0 +1,818 @@ +import { describe, expect, it, vi } from "vitest"; +import { + shouldUseSdkCutover, + sdkCutoverPersist, + sdkDeletePersist, + sdkTimingPersist, + sdkGsapTweenPersist, + sdkGsapKeyframePersist, +} from "./sdkCutover"; +import { openComposition } from "@hyperframes/sdk"; +import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; +import type { PatchOperation } from "./sourcePatcher"; +import type { MutableRefObject } from "react"; + +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: true, +})); +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: vi.fn(), +})); + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const textOp = (value: string): PatchOperation => ({ + type: "text-content", + property: "text", + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +const htmlAttrOp = (property: string, value: string): PatchOperation => ({ + type: "html-attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when flag disabled", () => { + expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no session", () => { + expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no hfId", () => { + expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + it("returns true for inline-style ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + }); + + it("returns true for text-content ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true); + }); + + it("returns true for attribute ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true); + }); + + it("returns true for html-attribute ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true); + }); + + it("returns true when ops mix all supported types", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [ + styleOp("color", "red"), + textOp("hello"), + attrOp("x", "1"), + htmlAttrOp("class", "foo"), + ]), + ).toBe(true); + }); +}); + +describe("sdkCutoverPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + + const makeDeps = (overrides: Partial[5]> = {}) => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + ...overrides, + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), + dispatch: vi.fn(), + // Distinct before/after so the no-op guard (after === before → fall back) + // treats this as a real change; "after" matches the write assertions. + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue(""), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + const deps = makeDeps(); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + null, + deps, + ); + expect(result).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const deps = makeDeps(); + const session = makeSession(false); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + session, + deps, + ); + expect(result).toBe(false); + }); + + it("dispatches setStyle for inline-style ops", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setStyle", + target: "hf-abc", + styles: { color: "red", opacity: "0.5" }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", ""); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("dispatches setText for text-content op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [textOp("Hello world")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setText", + target: "hf-abc", + value: "Hello world", + }); + }); + + it("dispatches setAttribute for attribute op with data- prefix", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [attrOp("x", "42")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "data-x", + value: "42", + }); + }); + + it("dispatches setAttribute for html-attribute op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [htmlAttrOp("class", "foo bar")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "class", + value: "foo bar", + }); + }); + + it("passes caller label to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + label: "Resize layer box", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ label: "Resize layer box" }), + ); + }); + + it("passes caller coalesceKey to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + coalesceKey: "my-key", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ coalesceKey: "my-key" }), + ); + }); + + it("returns false and does not throw on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); + + it("wraps all dispatches in session.batch() for atomic rollback", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect( + (session as unknown as { batch: ReturnType }).batch, + ).toHaveBeenCalledOnce(); + }); + + it("returns false when second dispatch throws (batch prevents partial mutation)", async () => { + // inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches. + const deps = makeDeps(); + const session = makeSession(true); + let callCount = 0; + (session!.dispatch as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 2) throw new Error("2nd op failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), textOp("hello")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); + +describe("sdkDeletePersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null), + removeElement: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before-snap") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe( + false, + ); + }); + + it("calls removeElement and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(result).toBe(true); + expect(session!.removeElement).toHaveBeenCalledWith("hf-abc"); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("records edit history with before/after diff", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Delete element", + files: { "/comp.html": { before: "before-content", after: "after" } }, + }), + ); + }); + + it("calls reloadPreview on success", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not write on removeElement error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.removeElement as ReturnType).mockImplementation(() => { + throw new Error("remove failed"); + }); + const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); + +describe("sdkTimingPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null), + setTiming: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe( + false, + ); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe( + false, + ); + }); + + it("calls setTiming with provided update and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkTimingPersist( + "hf-clip", + "/comp.html", + { start: 2, duration: 5, trackIndex: 1 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", { + start: 2, + duration: 5, + trackIndex: 1, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("captures before-state before setTiming dispatch", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { "/comp.html": { before: "before", after: "after" } }, + }), + ); + }); + + it("returns false and does not write on setTiming error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.setTiming as ReturnType).mockImplementation(() => { + throw new Error("timing error"); + }); + const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); + + // Finding #12: undo baseline must be the EXACT on-disk bytes (matching the + // style/delete paths), not a normalized SDK serialize() re-emit — otherwise + // undoing a timing edit reformats the whole file. + it("records the on-disk content (not serialize()) as the undo before when a reader is provided", async () => { + const deps = { + ...makeDeps(), + readProjectFile: vi.fn().mockResolvedValue("EXACT ON-DISK BYTES"), + }; + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html"); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { + "/comp.html": { before: "EXACT ON-DISK BYTES", after: "after" }, + }, + }), + ); + }); + + it("falls back to serialize() before when the reader throws", async () => { + const deps = { + ...makeDeps(), + readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")), + }; + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { "/comp.html": { before: "before", after: "after" } }, + }), + ); + }); +}); + +describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeSession = () => + ({ + getElement: vi.fn().mockReturnValue({ id: "hf-box" }), + setGsapTween: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("serialized-before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[2]; + + it("records the on-disk content as the undo before, not serialize()", async () => { + const deps = { + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + readProjectFile: vi.fn().mockResolvedValue("on-disk gsap bytes"), + }; + const session = makeSession(); + await sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } }, + session, + deps, + ); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { + "/comp.html": { before: "on-disk gsap bytes", after: "after" }, + }, + }), + ); + }); +}); + +describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + + it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => { + const order: string[] = []; + let writeResolve: (() => void) | null = null; + const deps = { + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + // First write blocks until we release it, so without serialization the + // second op's serialize()/dispatch would interleave ahead of it. + writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => { + order.push(`write-start:${content}`); + if (content === "after-1") { + return new Promise((res) => { + writeResolve = () => { + order.push(`write-done:${content}`); + res(); + }; + }); + } + order.push(`write-done:${content}`); + return Promise.resolve(); + }), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + // A real per-key serializer: tasks under the same key run strictly in order. + serialize: (() => { + const inFlight = new Map>(); + return (key: string, task: () => Promise): Promise => { + const prior = inFlight.get(key) ?? Promise.resolve(); + const next = prior.then(task, task); + inFlight.set(key, next); + return next as Promise; + }; + })(), + }; + + let serializeCall = 0; + const session = { + getElement: vi.fn().mockReturnValue({ id: "hf-box" }), + setGsapTween: vi.fn(() => order.push("dispatch")), + serialize: vi.fn(() => { + serializeCall++; + // before-1, after-1, before-2, after-2 + return `${serializeCall % 2 === 1 ? "before" : "after"}-${Math.ceil(serializeCall / 2)}`; + }), + batch: vi.fn((fn: () => void) => fn()), + } as unknown as Parameters[2]; + + const p1 = sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "a" } }, + session, + deps, + ); + const p2 = sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "b" } }, + session, + deps, + ); + // Let the first op reach its (blocked) write before releasing it. + await Promise.resolve(); + await Promise.resolve(); + writeResolve?.(); + await Promise.all([p1, p2]); + + // The second op's write must NOT start before the first op's write completes. + const firstWriteDone = order.indexOf("write-done:after-1"); + const secondWriteStart = order.indexOf("write-start:after-2"); + expect(firstWriteDone).toBeGreaterThanOrEqual(0); + expect(secondWriteStart).toBeGreaterThan(firstWriteDone); + }); +}); + +describe("sdkGsapTweenPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) => + ({ + getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null), + addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"), + setGsapTween: vi.fn(), + removeGsapTween: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[2]; + + it("returns false when session is null", async () => { + expect( + await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + null, + makeDeps(), + ), + ).toBe(false); + }); + + it("calls addGsapTween and writes for kind=add", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { + kind: "add", + target: "hf-box", + spec: { method: "to", duration: 1, properties: { opacity: 1 } }, + }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.addGsapTween).toHaveBeenCalledWith( + "hf-box", + expect.objectContaining({ method: "to" }), + ); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("returns false for kind=add when element not found", async () => { + const deps = makeDeps(); + const session = makeSession({ hasEl: false }); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); + + it("calls setGsapTween and writes for kind=set", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" }); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("calls removeGsapTween for kind=remove", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1"); + }); + + it("returns false and does not write on SDK error", async () => { + const deps = makeDeps(); + const session = makeSession(); + (session!.removeGsapTween as ReturnType).mockImplementation(() => { + throw new Error("gsap error"); + }); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + +describe("sdkGsapKeyframePersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = () => + ({ + dispatch: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + expect( + await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()), + ).toBe(false); + }); + + it("dispatches addGsapKeyframe and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapKeyframePersist( + "/comp.html", + "tw-1", + 50, + { opacity: 0.5 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "addGsapKeyframe", + animationId: "tw-1", + position: 50, + value: { opacity: 0.5 }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not write on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const result = await sdkGsapKeyframePersist( + "/comp.html", + "tw-1", + 25, + { x: 100 }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + +describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + it("preserves GSAP +`; + const comp = await openComposition(html, { persist: createMemoryAdapter() }); + const deps = makeDeps(); + const sel = { hfId: "hf-layer" } as never; + const result = await sdkCutoverPersist( + sel, + [{ type: "inline-style", property: "color", value: "red" }], + html, + "/comp.html", + comp, + deps, + ); + expect(result).toBe(true); + const written = (deps.writeProjectFile as ReturnType).mock + .calls[0]?.[1] as string; + expect(written).toContain("data-hf-gsap"); + expect(written).toContain('data-position-mode="relative"'); + expect(written).toContain("gsap.timeline()"); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts new file mode 100644 index 000000000..09668be60 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.ts @@ -0,0 +1,414 @@ +import type { MutableRefObject } from "react"; +import type { Composition, EditOp, GsapTweenSpec } from "@hyperframes/sdk"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { EditHistoryKind } from "./editHistory"; +import type { PatchOperation } from "./sourcePatcher"; +import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; +import { trackStudioEvent } from "./studioTelemetry"; +import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; + +const CUTOVER_OP_TYPES = new Set([ + "inline-style", + "text-content", + "attribute", + "html-attribute", +]); + +/** + * Map Studio PatchOperations for a given hf-id to SDK EditOps. + * + * Multiple inline-style ops are coalesced into a single setStyle (SDK batches + * style changes naturally). One SDK op is emitted per non-style op. + */ +function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { + const result: EditOp[] = []; + const styles: Record = {}; + let hasStyles = false; + + for (const op of ops) { + if (op.type === "inline-style") { + styles[op.property] = op.value; + hasStyles = true; + } else if (op.type === "text-content") { + result.push({ type: "setText", target: hfId, value: op.value ?? "" }); + } else if (op.type === "attribute") { + result.push({ + type: "setAttribute", + target: hfId, + name: op.property.startsWith("data-") ? op.property : `data-${op.property}`, + value: op.value, + }); + } else if (op.type === "html-attribute") { + result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); + } + } + + if (hasStyles) { + result.unshift({ type: "setStyle", target: hfId, styles }); + } + + return result; +} + +export function shouldUseSdkCutover( + flagEnabled: boolean, + hasSession: boolean, + hfId: string | null | undefined, + ops: PatchOperation[], +): boolean { + return ( + flagEnabled && + hasSession && + !!hfId && + ops.length > 0 && + ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) + ); +} + +export interface CutoverDeps { + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + writeProjectFile: (path: string, content: string) => Promise; + reloadPreview: () => void; + domEditSaveTimestampRef: MutableRefObject; + /** + * Optional post-write refresh. When provided, it REPLACES the default + * reloadPreview() — the GSAP path passes one that soft-reloads (preserving + * the playhead) and invalidates the keyframe/gsap panel cache. Receives the + * serialized document just written. + */ + refresh?: (after: string) => void; + /** + * Path of the composition the SDK session was opened for. The session models + * ONLY this file (serialize() emits the whole active composition), so any edit + * whose targetPath differs (a sub-composition file) must take the server path + * — otherwise we'd write the full active-comp serialization into that file. + */ + compositionPath?: string | null; + /** + * Optional per-key task serializer (the same `gsap-file:${file}` serializer the + * legacy `commitMutation` uses). When provided, every GSAP-op persist routes its + * read-serialize → dispatch → serialize → write through it so two concurrent + * same-file flushes can't interleave their read-modify-write and lose an edit. + * Absent (e.g. in unit tests) → ops run unserialized as before. + */ + serialize?: (key: string, task: () => Promise) => Promise; + /** + * Optional reader for the on-disk content of targetPath. Timing/GSAP persists + * use it to capture the EXACT prior bytes as the undo-history `before`, so undo + * restores the file verbatim instead of a normalized SDK re-emit (which would + * reformat the whole file). The style/delete paths already thread originalContent + * in explicitly; this gives timing/GSAP parity without touching every call site. + * Absent → falls back to the SDK's pre-edit serialize() (the prior behavior). + */ + readProjectFile?: (path: string) => Promise; +} + +/** + * Capture the undo-history `before` baseline for timing/GSAP persists: the exact + * on-disk bytes when a reader is available (so undo restores them verbatim), + * falling back to the SDK's pre-edit serialization when it isn't. Never throws — + * a failed read degrades to the serialized fallback rather than aborting the edit. + */ +async function captureOnDiskBefore( + deps: CutoverDeps, + targetPath: string, + serializedFallback: string, +): Promise { + if (!deps.readProjectFile) return serializedFallback; + try { + return await deps.readProjectFile(targetPath); + } catch { + return serializedFallback; + } +} + +/** True when targetPath isn't the composition the SDK session models. */ +function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { + return deps.compositionPath != null && targetPath !== deps.compositionPath; +} + +interface CutoverOptions { + label?: string; + coalesceKey?: string; + /** Skip the preview reload (mirrors the server path's skipRefresh). */ + skipRefresh?: boolean; +} + +// ponytail: internal; export only if a third caller appears. +// `after` is serialized once by the caller (which also did the no-op check +// against its pre-dispatch snapshot), so this never re-serializes. +async function persistSdkSerialize( + after: string, + targetPath: string, + originalContent: string, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + deps.domEditSaveTimestampRef.current = Date.now(); + // Tag this write with the exact content (by hash) so the file-change + // reload-suppression can recognize its own echo by IDENTITY, not just a 2 s + // clock — an undo write (different bytes, not registered here) then always + // reloads instead of being swallowed by the time window. + markSelfWrite(targetPath, after); + await deps.writeProjectFile(targetPath, after); + await deps.editHistory.recordEdit({ + label: options?.label ?? "Edit layer", + kind: "manual", + ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), + files: { [targetPath]: { before: originalContent, after } }, + }); + if (deps.refresh) deps.refresh(after); + else if (!options?.skipRefresh) deps.reloadPreview(); +} + +export async function sdkCutoverPersist( + selection: DomEditSelection, + ops: PatchOperation[], + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops)) + return false; + if (!sdkSession) return false; + const hfId = selection.hfId; + if (!hfId) return false; + if (!sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.batch(() => { + for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { + sdkSession.dispatch(editOp); + } + }); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, options); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { + hfId: selection.hfId ?? null, + error: String(err), + }); + return false; + } +} + +export async function sdkTimingPersist( + hfId: string, + targetPath: string, + timingUpdate: { start?: number; duration?: number; trackIndex?: number }, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + // Dark-launch gate: without this, timing cutover runs whenever an SDK session + // exists (it always does, for shadow/selection) — flipping the flag OFF would + // NOT disable it. Gate here so flag-off routes back to the legacy server path. + if (!STUDIO_SDK_CUTOVER_ENABLED) return false; + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const serializedBefore = sdkSession.serialize(); + sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate)); + const after = sdkSession.serialize(); + if (after === serializedBefore) return false; + // Undo baseline = exact on-disk bytes (matching the style/delete paths), so + // undoing a timing edit restores the file verbatim instead of a normalized + // full-DOM re-emit. Falls back to serializedBefore when no reader is wired. + const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore); + await persistSdkSerialize(after, targetPath, undoBefore, deps, options); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} + +type SdkGsapTweenOp = + | { kind: "add"; target: string; spec: GsapTweenSpec } + | { kind: "set"; animationId: string; properties: Partial } + | { kind: "remove"; animationId: string }; + +export function sdkGsapTweenPersist( + targetPath: string, + op: SdkGsapTweenOp, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — + // matches the other three chokepoints' discipline. + if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false); + if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target)) + return Promise.resolve(false); + // dispatchGsapOpAndPersist returns false on before===after — that catches stale + // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling + // back to the server path. This subsumes explicit existence guards for set/remove. + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => { + s.batch(() => { + if (op.kind === "add") { + s.addGsapTween(op.target, op.spec); + } else if (op.kind === "set") { + s.setGsapTween(op.animationId, op.properties); + } else { + s.removeGsapTween(op.animationId); + } + }); + }); +} + +async function dispatchGsapOpAndPersist( + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options: CutoverOptions | undefined, + dispatch: (s: Composition) => void, +): Promise { + // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist): + // flag OFF → return false → caller falls back to the legacy server path. + if (!STUDIO_SDK_CUTOVER_ENABLED) return false; + if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + const session = sdkSession; + // Route the whole read-serialize → dispatch → serialize → write through the + // per-file serializer (when provided) so overlapping same-file flushes can't + // interleave their read-modify-write and drop an edit, matching the legacy + // commitMutation path's `gsap-file:${file}` serialization. + const run = async (): Promise => { + try { + const serializedBefore = session.serialize(); + dispatch(session); + const after = session.serialize(); + if (after === serializedBefore) return false; + // Undo baseline = exact on-disk bytes (matching the style/delete paths), so + // undoing a GSAP edit restores the file verbatim instead of a normalized + // full-DOM re-emit. Falls back to serializedBefore when no reader is wired. + const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore); + await persistSdkSerialize(after, targetPath, undoBefore, deps, options); + trackStudioEvent("sdk_cutover_success", { opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } + }; + return deps.serialize ? deps.serialize(`gsap-file:${targetPath}`, run) : run(); +} + +export function sdkGsapKeyframePersist( + targetPath: string, + animationId: string, + position: number, + value: Record, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })), + ); +} + +export function sdkGsapRemoveKeyframePersist( + targetPath: string, + animationId: string, + percentage: number, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }), + ); +} + +export function sdkGsapRemovePropertyPersist( + targetPath: string, + animationId: string, + property: string, + from: boolean, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "removeGsapProperty", animationId, property, from }), + ); +} + +export function sdkGsapDeleteAllForSelectorPersist( + targetPath: string, + selector: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "deleteAllForSelector", selector }), + ); +} + +export function sdkGsapRemoveAllKeyframesPersist( + targetPath: string, + animationId: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "removeAllKeyframes", animationId }), + ); +} + +export function sdkGsapConvertToKeyframesPersist( + targetPath: string, + animationId: string, + resolvedFromValues: Record | undefined, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }), + ); +} + +export async function sdkDeletePersist( + hfId: string, + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, +): Promise { + // Dark-launch gate: flag OFF → legacy server delete path. + if (!STUDIO_SDK_CUTOVER_ENABLED) return false; + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.batch(() => sdkSession.removeElement(hfId)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, { + label: "Delete element", + }); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts deleted file mode 100644 index 462b8d2dc..000000000 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { - patchOpsToSdkEditOps, - runShadowDelete, - runShadowTiming, - runShadowGsapTween, - runShadowGsapFidelity, - gsapFidelityMismatches, - resolveGsapFidelityArgs, - SdkShadowMismatch, -} from "./sdkShadow"; -import type { ShadowGsapOp } from "./sdkShadow"; -import { makeSelectorResolver } from "./sdkShadowGsapFidelity"; -import type { PatchOperation } from "./sourcePatcher"; -import { openComposition } from "@hyperframes/sdk"; -import { Window } from "happy-dom"; - -// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners. -const trackedEvents: Array<{ event: string; props: Record }> = []; -vi.mock("./studioTelemetry", () => ({ - trackStudioEvent: (event: string, props: Record) => - trackedEvents.push({ event, props }), -})); -beforeEach(() => { - trackedEvents.length = 0; -}); -const lastShadow = () => - trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props; - -const BASE_HTML = /* html */ ` - -
Hello
-`; - -describe("patchOpsToSdkEditOps", () => { - it("maps inline-style ops to a single setStyle EditOp", () => { - const ops: PatchOperation[] = [ - { type: "inline-style", property: "color", value: "#00f" }, - { type: "inline-style", property: "opacity", value: "0.5" }, - ]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setStyle", - target: "hf-box", - styles: { color: "#00f", opacity: "0.5" }, - }); - }); - - it("maps text-content op to setText EditOp", () => { - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" }); - }); - - it("maps attribute op to setAttribute with data- prefix", () => { - const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "data-name", - value: "hero", - }); - }); - - it("maps html-attribute op to setAttribute without prefix", () => { - const ops: PatchOperation[] = [ - { type: "html-attribute", property: "contenteditable", value: "true" }, - ]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "contenteditable", - value: "true", - }); - }); - - it("handles null value for attribute removal", () => { - const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "hidden", - value: null, - }); - }); - - it("returns empty array for unknown op types", () => { - const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[]; - expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0); - }); -}); - -describe("sdkShadowDispatch (integration)", () => { - it("applies ops and returns no mismatches when SDK matches expected values", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); - expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f"); - }); - - // fallow-ignore-next-line code-duplication - it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [ - { type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix - expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)"); - }); - - it("returns dispatched:false when hfId not found in session", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - const result = sdkShadowDispatch(session, "hf-missing", ops); - - expect(result.dispatched).toBe(false); - expect(result.mismatches).toHaveLength(1); - expect(result.mismatches[0]).toMatchObject({ - kind: "element_not_found", - hfId: "hf-missing", - }); - }); - - it("applies text op and reads back via session.getElement", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }]; - sdkShadowDispatch(session, "hf-box", ops); - - expect(session.getElement("hf-box")?.text).toBe("Updated"); - }); - - // Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a - // trailing-whitespace-only difference between the op value and the snapshot must - // not flag. - it("does NOT false-mismatch trailing-whitespace-only text difference", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // trimmed both sides - }); - - // Empty-string op value vs an absent (null) snapshot text must collapse to equal - // — both mean "no text content". - it("treats empty-string text op and null snapshot text as equal", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const EMPTY_HTML = /* html */ ` -`; - const session = await openComposition(EMPTY_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }]; - const result = sdkShadowDispatch(session, "hf-img", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // "" vs null → both null - }); - - // Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style - // read-back already reads only the AUTHORED style attribute (getElementStyles → - // parseStyleAttr), never computed styles. The transform-origin divergence - // (expected null actual "center center") was a genuine SDK bug — setStyle - // removal of a HYPHENATED property silently no-opped because setElementStyles - // deleted the kebab key while the style map is keyed camelCase. Now FIXED in - // the SDK (model.ts setElementStyles normalizes the key via toCamel), so the - // shadow sees parity: removal applies and there is no mismatch. - it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const TO_HTML = /* html */ ` -
x
`; - const session = await openComposition(TO_HTML); - - const ops: PatchOperation[] = [ - { type: "inline-style", property: "transform-origin", value: null }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - // The SDK now removes the hyphenated property, so the shadow read-back agrees. - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); - }); - - it("applies attribute op and reads back via session.getElement", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; - sdkShadowDispatch(session, "hf-box", ops); - - expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero"); - }); - - // fallow-ignore-next-line code-duplication - it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - // path-offset drags emit these already-data-prefixed, SDK-excluded markers. - const ops: PatchOperation[] = [ - { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged - }); - - it("returns dispatch_error when dispatch throws — does not propagate", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - // Poison dispatch so it throws on any call - session.dispatch = () => { - throw new Error("sdk internal error"); - }; - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }]; - let result: ReturnType | undefined; - expect(() => { - result = sdkShadowDispatch(session, "hf-box", ops); - }).not.toThrow(); - - expect(result!.dispatched).toBe(false); - expect(result!.mismatches).toHaveLength(1); - expect(result!.mismatches[0]).toMatchObject({ - kind: "dispatch_error", - hfId: "hf-box", - error: expect.stringContaining("sdk internal error"), - }); - }); -}); - -const TIMING_HTML = /* html */ ` - -
clip
-`; - -const GSAP_HTML = `
-
- -
`; - -const NO_TIMELINE_HTML = `
-
- -
`; - -describe("runShadowDelete", () => { - it("removes the element from the SDK session and reports parity", async () => { - const session = await openComposition(BASE_HTML); - runShadowDelete(session, "hf-box"); - expect(session.getElement("hf-box")).toBeNull(); - expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 }); - }); - - it("reports no_hf_id when selection has no hf-id", async () => { - const session = await openComposition(BASE_HTML); - runShadowDelete(session, null); - expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" }); - }); - - it("reports cannot_dispatch when the element is not addressable", async () => { - const session = await openComposition(BASE_HTML); - runShadowDelete(session, "hf-missing"); - expect(lastShadow()).toMatchObject({ - op: "delete", - dispatched: false, - reason: "cannot_dispatch", - }); - }); - - // Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a - // bare hf-id collides between a sub-composition element (scopedId - // "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement - // resolves the bare id via resolveScoped → querySelector (document-order-first, - // removes the INNER instance), but getElement prefers the canonical top-level - // match (scopedId === id) which SURVIVES. The shadow then correctly reports - // expected "removed" / actual "present". The readback here is correct (it checks - // the same id it dispatched); the fix belongs in the SDK's id resolution - // (resolveScoped vs getElement agreement), not in this file. - const DUP_ID_HTML = /* html */ ` -
-
-
inner
-
-
outer
-
- `; - - it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => { - const session = await openComposition(DUP_ID_HTML); - runShadowDelete(session, "hf-dup"); - // SDK fix (agree removeElement/getElement on duplicate bare ids): both now - // resolve a bare id to the canonical (top-level) instance, so removeElement - // drops exactly the element the readback checks → no mismatch. (Previously - // removeElement dropped the inner instance while the top-level survived, - // which this shadow correctly flagged; that divergence is now fixed.) - expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 }); - }); -}); - -describe("runShadowTiming", () => { - it("applies timing and reports parity against the snapshot", async () => { - const session = await openComposition(TIMING_HTML); - runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 }); - const el = session.getElement("hf-clip"); - expect(el?.start).toBe(2); - expect(el?.duration).toBe(3); - expect(el?.trackIndex).toBe(1); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 }); - }); - - // Fix 1: float-precision tolerance. The SDK computes durations arithmetically - // (returning e.g. 3.0999999999999996); the server stores the rounded literal - // (3.1). A relative epsilon must treat these as equal, while a real difference - // still flags. A fake session returns the imprecise value on read-back. - type FakeTiming = { start?: number; duration?: number; trackIndex?: number }; - function fakeTimingSession(readback: FakeTiming) { - return { - can: () => ({ ok: true }), - batch: (fn: () => void) => fn(), - dispatch: () => {}, - getElement: () => readback, - } as unknown as Parameters[0]; - } - - it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => { - const session = fakeTimingSession({ duration: 3.0999999999999996 }); - runShadowTiming(session, "hf-clip", { duration: 3.1 }); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 }); - }); - - it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => { - const session = fakeTimingSession({ start: 21.360000000000014 }); - runShadowTiming(session, "hf-clip", { start: 21.36 }); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 }); - }); - - it("STILL flags a real duration difference (3.1 vs 3.5)", () => { - const session = fakeTimingSession({ duration: 3.5 }); - runShadowTiming(session, "hf-clip", { duration: 3.1 }); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 }); - }); -}); - -describe("runShadowGsapTween", () => { - it("add reports success and the new tween lands on the target's animationIds", async () => { - const session = await openComposition(GSAP_HTML); - const before = session.getElement("hf-box")?.animationIds.length ?? 0; - runShadowGsapTween(session, { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - }); - expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1); - expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); - }); - - it("remove drops the tween from animationIds and reports parity", async () => { - const session = await openComposition(GSAP_HTML); - const animationId = session.getElement("hf-box")?.animationIds[0]; - expect(animationId).toBeDefined(); - runShadowGsapTween(session, { kind: "remove", animationId: animationId! }); - expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId); - expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); - }); - - it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => { - const session = await openComposition(NO_TIMELINE_HTML); - runShadowGsapTween(session, { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 } }, - }); - expect(lastShadow()).toMatchObject({ - op: "gsap", - dispatched: false, - reason: "cannot_dispatch", - code: "E_NO_GSAP_TIMELINE", - }); - }); -}); - -const SCRIPT_A = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2); -window.__timelines["t"] = tl;`; - -describe("gsapFidelityMismatches", () => { - it("returns no mismatches for identical scripts", () => { - expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]); - }); - - it("flags a per-field value drift (duration)", () => { - const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9"); - const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A); - expect(mismatches.some((m) => m.property === "duration")).toBe(true); - }); - - it("does NOT flag sub-ULP float-formatting noise in duration", () => { - // 3.1 vs 3.0999999999999996 is the same value after writer round-trips; - // relative-epsilon compare must treat it as equal, not drift. - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(sdk, server)).toEqual([]); - }); - - it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => { - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0); -window.__timelines["t"] = tl;`; - const mismatches = gsapFidelityMismatches(sdk, server); - expect(mismatches.some((m) => m.property === "duration")).toBe(true); - }); - - it("flags a tween present in one script but not the other", () => { - const empty = `var tl = gsap.timeline({ paused: true }); -window.__timelines["t"] = tl;`; - const mismatches = gsapFidelityMismatches(empty, SCRIPT_A); - expect(mismatches.some((m) => m.property === "tween")).toBe(true); - }); - - it("does NOT flag property key-order differences (canonical compare)", () => { - const ab = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - const ba = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(ab, ba)).toEqual([]); - }); - - it("does NOT flag number-vs-string-equivalent property values", () => { - const numeric = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - const stringy = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]); - }); - - it("matches the same element across different selector forms when a resolver is given", () => { - // SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween. - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to(".x", { x: 200, duration: 0.8 }, 0.5); -window.__timelines["t"] = tl;`; - const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel); - // Without a resolver: selector-form divergence → present/absent mismatch. - expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0); - // With a resolver: matched by element → no mismatch. - expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]); - }); - - // Drive makeSelectorResolver against a real DOM (happy-dom shims the - // browser-only DOMParser the resolver depends on; the studio test env is node). - describe("makeSelectorResolver unifies selector forms (real DOM)", () => { - const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser; - beforeEach(() => { - (globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser; - }); - afterEach(() => { - (globalThis as { DOMParser?: unknown }).DOMParser = origDomParser; - }); - - it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => { - // Element carries all three forms; the server may emit #id or .class while - // the SDK emits [data-hf-id]. All must resolve to the same canonical key. - const html = `
`; - const resolve = makeSelectorResolver(html); - const viaHfId = resolve('[data-hf-id="hf-9flp"]'); - expect(resolve(".caption-layer")).toBe(viaHfId); - expect(resolve("#intro-layer")).toBe(viaHfId); - }); - - it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => { - const html = `
`; - const resolve = makeSelectorResolver(html); - const sdkScript = `var tl = gsap.timeline({ paused: true }); -tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0); -window.__timelines["t"] = tl;`; - const serverScript = `var tl = gsap.timeline({ paused: true }); -tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0); -window.__timelines["t"] = tl;`; - // Without unification these flag present/absent; the resolver collapses them. - expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0); - expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]); - }); - - it("collapses different selector forms for an element WITHOUT a data-hf-id", () => { - // No hf-id present: the resolver must still key both forms to the same node - // (not leave .class vs #id as distinct raw-selector keys). - const html = `
`; - const resolve = makeSelectorResolver(html); - expect(resolve(".caption-layer")).toBe(resolve("#intro-layer")); - // And it is NOT the raw selector fallback. - expect(resolve(".caption-layer")).not.toBe(".caption-layer"); - }); - }); -}); - -describe("runShadowGsapFidelity", () => { - const BEFORE_HTML = `
-
- -
`; - - it("reports zero mismatches when the SDK output matches the server script", async () => { - // Produce the "server" script by applying the same op via the SDK, so a - // faithful SDK writer must reproduce it exactly. - const ref = await openComposition(BEFORE_HTML); - const op = { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - } as const; - ref.addGsapTween(op.target, op.tween); - const serverScript = - ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""; - - await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); - expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 }); - }); - - it("reports mismatches when the server script diverges", async () => { - const op = { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - } as const; - const ref = await openComposition(BEFORE_HTML); - ref.addGsapTween(op.target, op.tween); - const serverScript = ( - ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "" - ).replace("100", "999"); - - await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); - const ev = lastShadow(); - expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true }); - expect(ev?.mismatchCount as number).toBeGreaterThan(0); - }); -}); - -describe("resolveGsapFidelityArgs (chokepoint wiring)", () => { - const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" }; - const session = {} as object; - - it("returns narrowed args when session, op, before, and serverScript are all present", () => { - expect(resolveGsapFidelityArgs(session, op, "before", "tl.to(...)")).toEqual({ - before: "before", - op, - serverScript: "tl.to(...)", - }); - }); - - it("returns null when no session (shadow not wired)", () => { - expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull(); - }); - - it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => { - expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull(); - }); - - it("returns null when serverScript is null (composition has no GSAP script)", () => { - expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull(); - }); - - it("returns null when before is null", () => { - expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull(); - }); -}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts deleted file mode 100644 index 08ed05b08..000000000 --- a/packages/studio/src/utils/sdkShadow.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * SDK shadow dispatch utilities for Stage 7 Step 3b. - * - * Shadow mode keeps the server patch path authoritative while also dispatching - * the equivalent op to the SDK session, then compares the result to detect - * addressing gaps (blocker E: no-hf-id elements) and serialization drift - * (blocker B: linkedom whole-doc serialize). Results are reported as structured - * mismatches for telemetry — no user-visible change. - */ - -import type { Composition } from "@hyperframes/sdk"; -import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import { relEqual } from "./sdkShadowNumeric"; -import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import type { PatchOperation } from "./sourcePatcher"; - -// ─── Op mapping ────────────────────────────────────────────────────────────── - -/** - * Map Studio PatchOperations for a given hf-id to SDK EditOps. - * - * Multiple inline-style ops are coalesced into a single setStyle (SDK batches - * style changes naturally). One SDK op is emitted per non-style op. - */ -// "attribute" PatchOperations carry the data- attribute NAME. Studio passes -// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare -// (e.g. "name"); prefix only when needed, never double-prefix. -function attrName(property: string): string { - return property.startsWith("data-") ? property : `data-${property}`; -} - -// The SDK element model excludes data-hf-* attributes (document.ts skips them), -// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can -// never match — drop those ops from the shadow instead of false-mismatching. -function isShadowableOp(op: PatchOperation): boolean { - if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-"); - if (op.type === "html-attribute") return !op.property.startsWith("data-hf-"); - return true; -} - -// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by -// runShadowDispatch to flag any unmapped type as visible telemetry rather than -// silently dropping it (see the unmapped_type guard there). -const MAPPED_PATCH_OP_TYPES: ReadonlySet = new Set([ - "inline-style", - "text-content", - "attribute", - "html-attribute", -]); - -export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { - const result: EditOp[] = []; - const styles: Record = {}; - let hasStyles = false; - - for (const op of ops) { - if (op.type === "inline-style") { - styles[op.property] = op.value; - hasStyles = true; - } else if (op.type === "text-content") { - result.push({ type: "setText", target: hfId, value: op.value ?? "" }); - } else if (op.type === "attribute") { - result.push({ - type: "setAttribute", - target: hfId, - name: attrName(op.property), - value: op.value, - }); - } else if (op.type === "html-attribute") { - result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); - } - // unknown op types produce no SDK op - } - - if (hasStyles) { - result.unshift({ type: "setStyle", target: hfId, styles }); - } - - return result; -} - -// ─── Shadow result types ────────────────────────────────────────────────────── - -export interface SdkShadowMismatch { - kind: "element_not_found" | "value_mismatch" | "dispatch_error"; - hfId: string; - property?: string; - expected?: string | null; - actual?: string | null | undefined; - error?: string; -} - -export interface SdkShadowResult { - /** False if the element was not found in the SDK session. */ - dispatched: boolean; - mismatches: SdkShadowMismatch[]; -} - -// ─── Shadow dispatch ────────────────────────────────────────────────────────── - -type ElementSnapshot = ReturnType; -type OpFields = { - property: string; - expected: string | null | undefined; - actual: string | null | undefined; -}; - -type FlatSnapshot = { - styles: Record; - attrs: Record; - text: string | null; -}; - -function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot { - return { - styles: snap?.inlineStyles ?? {}, - attrs: Object.fromEntries( - Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]), - ), - text: snap?.text ?? null, - }; -} - -type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields; - -// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation -// style properties are kebab-case ("background-color"). Convert for read-back, else -// every hyphenated property false-mismatches against a null actual. -function kebabToCamel(prop: string): string { - return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); -} - -// Text parity: the SDK snapshot.text is trimmed, so trim the op value too. -// An empty string and absent text (null) are treated as equivalent (collapsed -// to null) so "" vs null does not flag — both mean "no text content". -function normalizeText(value: string | null | undefined): string | null { - if (value == null) return null; - const trimmed = value.trim(); - return trimmed === "" ? null : trimmed; -} - -const OP_FIELD_RESOLVERS: Record = { - "inline-style": (op, flat) => ({ - property: op.property, - expected: op.value, - actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null, - }), - // snapshot.text is already TRIMMED; trim the expected op value to match, so - // trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is - // collapsed in checkOpParity. A genuinely different text value still flags. - "text-content": (op, flat) => ({ - property: "text", - expected: normalizeText(op.value), - actual: normalizeText(flat.text), - }), - attribute: (op, flat) => ({ - property: attrName(op.property), - expected: op.value ?? null, - actual: flat.attrs[attrName(op.property)] ?? null, - }), - "html-attribute": (op, flat) => ({ - property: op.property, - expected: op.value ?? null, - actual: flat.attrs[op.property] ?? null, - }), -}; - -function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null { - return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null; -} - -function checkOpParity( - op: PatchOperation, - flat: FlatSnapshot, - hfId: string, -): SdkShadowMismatch | null { - const fields = resolveOpFields(op, flat); - if (!fields || fields.actual === fields.expected) return null; - return { kind: "value_mismatch", hfId, ...fields }; -} - -/** - * Dispatch PatchOperations to the SDK session and return a parity report. - * - * If the element is not found by hfId, returns dispatched:false with a - * element_not_found mismatch (signals blocker E — element has no hf-id or - * SDK can't address it). - * - * On success, verifies that the SDK element snapshot reflects the applied - * values. Value mismatches indicate serialization or normalization drift. - * - * **persist:error drift risk**: the HTTP adapter fires persist:error on - * network failure but the SDK session is already mutated at that point. If - * the server file was not updated (e.g. 503), subsequent shadow parity - * comparisons here will see a diverged SDK session and produce false - * positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow - * window is clear of persist:error events. - */ - -export function sdkShadowDispatch( - session: Composition, - hfId: string, - ops: PatchOperation[], -): SdkShadowResult { - if (!session.getElement(hfId)) { - return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; - } - // Drop studio-internal markers the SDK model can't represent (data-hf-*), so - // canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs. - const shadowable = ops.filter(isShadowableOp); - try { - const sdkOps = patchOpsToSdkEditOps(hfId, shadowable); - session.batch(() => { - for (const op of sdkOps) session.dispatch(op); - }); - } catch (err) { - return { - dispatched: false, - mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }], - }; - } - const flat = flattenSnapshot(session.getElement(hfId)); - const mismatches = shadowable - .map((op) => checkOpParity(op, flat, hfId)) - .filter((m): m is SdkShadowMismatch => m !== null); - return { dispatched: true, mismatches }; -} - -// ─── Telemetry reporting ────────────────────────────────────────────────────── - -/** - * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry. - * Despite the telemetry focus, this function does mutate the SDK session — it - * is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false. - */ -// Property-path mismatches carry user content (inline-style values, edited -// text) in expected/actual. Scrub before telemetry: fully redact text-content -// values, length-cap the rest. The in-memory parity result keeps raw values. -function redactValueForTelemetry( - property: string | undefined, - value: string | null | undefined, -): string | null | undefined { - if (value == null) return value; - if (property === "text") return `[redacted len=${value.length}]`; - return value.length > 64 ? `${value.slice(0, 64)}…` : value; -} - -function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] { - return mismatches.map((m) => ({ - ...m, - expected: redactValueForTelemetry(m.property, m.expected), - actual: redactValueForTelemetry(m.property, m.actual), - })); -} - -export function runShadowDispatch( - session: Composition, - selection: DomEditSelection, - ops: PatchOperation[], -): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - const hfId = selection.hfId; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - // Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it - // doesn't map. PatchOperation.type is a closed union today, but emit a visible - // unmapped_type event if a future type ever slips through, so the gap surfaces - // in telemetry instead of vanishing. - // Map to the type string before find, so a future unmapped type is read as a - // plain string (no object cast; find on the closed union narrows to never). - const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t)); - if (unmappedType !== undefined) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: false, - reason: "unmapped_type", - type: unmappedType, - mismatchCount: 0, - }); - return; - } - const result = sdkShadowDispatch(session, hfId, ops); - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: result.dispatched, - mismatchCount: result.mismatches.length, - mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)), - }); -} - -// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ─────────────── -// -// These ops never flow through persistDomEditOperations, so the property-path -// shadow above never sees them. Each runner keeps the server authoritative and -// only observes the SDK: can() pre-checks addressing/validity (pure, no -// mutation — works even for GSAP, which has no element-snapshot value), then a -// dispatch into the live session with a snapshot-based parity check. -// -// Parity coverage by op: -// delete → getElement(id) === null (full) -// timing → snapshot.start/duration/trackIndex (full) -// gsap → tween id present/absent in animationIds (existence only — the -// tween's property values are script-level, not in the snapshot) - -/** - * can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`. - * Mutates the SDK session (not read-only); server stays authoritative. - * No-op when STUDIO_SDK_SHADOW_ENABLED is false. - */ -function runShadowEditOp( - session: Composition, - op: EditOp, - opLabel: string, - dispatchAndCheck: () => SdkShadowMismatch[], -): void { - const verdict = session.can(op); - if (!verdict.ok) { - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: false, - reason: "cannot_dispatch", - code: verdict.code, - mismatchCount: 0, - }); - return; - } - let mismatches: SdkShadowMismatch[]; - try { - mismatches = dispatchAndCheck(); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: false, - reason: "dispatch_error", - error: String(err), - mismatchCount: 0, - }); - return; - } - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); -} - -/** Shadow an element delete. Parity: the element is gone from the SDK session. */ -export function runShadowDelete(session: Composition, hfId: string | null | undefined): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "delete", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const op: EditOp = { type: "removeElement", target: hfId }; - runShadowEditOp(session, op, "delete", () => { - session.batch(() => session.dispatch(op)); - return session.getElement(hfId) - ? [ - { - kind: "value_mismatch", - hfId, - property: "exists", - expected: "removed", - actual: "present", - }, - ] - : []; - }); -} - -export interface ShadowTiming { - start?: number; - duration?: number; - trackIndex?: number; -} - -// start/duration tolerate float-precision drift (SDK computes them -// arithmetically, server stores a rounded literal) via the shared relative -// epsilon; trackIndex (integer track slot) is compared exactly. -function timingFieldEqual( - key: keyof ShadowTiming, - actual: number | null | undefined, - expected: number, -): boolean { - if (typeof actual === "number" && key !== "trackIndex") { - return relEqual(actual, expected); - } - return actual === expected; -} - -/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */ -export function runShadowTiming( - session: Composition, - hfId: string | null | undefined, - timing: ShadowTiming, -): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "timing", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const op: EditOp = { type: "setTiming", target: hfId, ...timing }; - runShadowEditOp(session, op, "timing", () => { - session.batch(() => session.dispatch(op)); - const el = session.getElement(hfId); - const mismatches: SdkShadowMismatch[] = []; - const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [ - ["start", el?.start], - ["duration", el?.duration], - ["trackIndex", el?.trackIndex], - ]; - for (const [key, actual] of fields) { - const expected = timing[key]; - if (expected === undefined || timingFieldEqual(key, actual, expected)) continue; - mismatches.push({ - kind: "value_mismatch", - hfId, - property: key, - expected: String(expected), - actual: actual == null ? null : String(actual), - }); - } - return mismatches; - }); -} - -export type ShadowGsapOp = - | { kind: "add"; target: string; tween: GsapTweenSpec } - | { kind: "set"; animationId: string; properties: Partial } - | { kind: "remove"; animationId: string }; - -/** - * Shadow a GSAP tween mutation (add / set / remove). The server's animationId - * shares the SDK's id-space (both derive `targetSelector-method-position` from - * the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is. - * - * Parity via the now-populated ElementSnapshot.animationIds: - * add → the returned tween id is present on the target element - * remove → the id is gone from every element - * set → existence only (the SDK exposes no per-tween property reader; value - * fidelity would need serialize()-script round-trip diffing). - */ -export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - const op: EditOp = - gsapOp.kind === "add" - ? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween } - : gsapOp.kind === "set" - ? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties } - : { type: "removeGsapTween", animationId: gsapOp.animationId }; - // fallow-ignore-next-line complexity - runShadowEditOp(session, op, "gsap", () => { - let newId: string | undefined; - session.batch(() => { - if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween); - else session.dispatch(op); - }); - if (gsapOp.kind === "add") { - const onTarget = session.getElement(gsapOp.target)?.animationIds ?? []; - if (!newId || !onTarget.includes(newId)) { - return [ - { - kind: "value_mismatch", - hfId: gsapOp.target, - property: "animationIds", - expected: newId ?? "non-empty", - actual: onTarget.join(",") || null, - }, - ]; - } - } else if (gsapOp.kind === "remove") { - const stillPresent = session - .getElements() - .some((el) => el.animationIds.includes(gsapOp.animationId)); - if (stillPresent) { - return [ - { - kind: "value_mismatch", - hfId: gsapOp.animationId, - property: "animationIds", - expected: "removed", - actual: "present", - }, - ]; - } - } - return []; - }); -} - -// GSAP value-fidelity diff lives in its own module to keep this file under the -// 600-line studio cap; re-exported here so the shadow surface stays in one place. -export { - gsapFidelityMismatches, - resolveGsapFidelityArgs, - runShadowGsapFidelity, -} from "./sdkShadowGsapFidelity"; diff --git a/packages/studio/src/utils/sdkShadowGsapFidelity.ts b/packages/studio/src/utils/sdkShadowGsapFidelity.ts deleted file mode 100644 index 45f800d7f..000000000 --- a/packages/studio/src/utils/sdkShadowGsapFidelity.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * GSAP value-fidelity shadow (serialize round-trip diff). Split out of - * sdkShadow.ts to keep that file under the 600-line studio cap. - * - * Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not - * that its VALUES (duration / ease / position / properties) match the server. - * The SDK exposes no per-tween property reader, so we compare the two writers' - * output: apply the same op to a fresh SDK doc opened from the server's pre-op - * file, then structurally diff the SDK's GSAP script against the server's - * resulting script. Both are re-parsed, so formatting/whitespace differences - * never produce false positives — only real value drift does. - */ - -import { openComposition } from "@hyperframes/sdk"; -import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import { relEqual } from "./sdkShadowNumeric"; -import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow"; - -// Marker set must match document.ts extractGsapScript so both pick the same -// `) — HTML5 ignores junk - // before the `>`, e.g. `` or `` (CodeQL js/bad-tag-filter). - const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); - if (!scripts) return null; - for (const block of scripts) { - const body = block.replace(/^]*>/i, "").replace(/<\/script[^>]*>$/i, ""); - if (isGsapScriptBody(body)) return body; - } - return null; -} - -function posKey(position: unknown): string { - if (typeof position === "number") return String(position); - const n = Number(position); - return Number.isNaN(n) ? String(position) : String(n); -} - -// Key a tween by its RESOLVED target element (not raw selector) + method + -// position. The SDK writer emits [data-hf-id="X"] selectors while the server -// emits class/other selectors for the SAME element; keying by resolved element -// matches them so the diff compares values instead of flagging present/absent. -// -// ponytail: one-tween-per-(element, method, position) assumption — coincident -// tweens (same element+method+position, different props) collapse, last wins, -// so the diff under-reports them. Props can't go in the key (a matched pair -// must share a key for the field-diff to run; raw props would split real value -// drift into present/absent). Not seen in studio-emitted templates; add a -// property-NAME hash to the key if coincident tweens show up in the wild. -function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string { - const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector; - return `${sel}|${anim.method}|${posKey(anim.position)}`; -} - -function animByKey( - script: string, - resolveSelector?: (sel: string) => string, -): Map { - const map = new Map(); - const parsed = parseGsapScriptAcorn(script); - for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim); - return map; -} - -// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT -// writers, so the same tween can serialize with different property key order or -// number-vs-string forms. Compare canonically — sort keys, coerce numeric -// strings — so only real value drift registers, not formatting differences. - -// Coerce string operands to numbers, then compare with the shared relative -// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996) -// isn't flagged as drift while a real 2 vs 1 still is. -function numericEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - const na = typeof a === "string" ? Number(a) : a; - const nb = typeof b === "string" ? Number(b) : b; - if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) { - return false; - } - return relEqual(na, nb); -} - -function canonicalProps(obj: Record | undefined): string { - if (!obj) return "{}"; - const out: Record = {}; - for (const key of Object.keys(obj).sort()) { - const v = obj[key]; - // normalize "0.5" → 0.5 so a number/string writer difference isn't drift - out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; - } - return JSON.stringify(out); -} - -/** - * Structurally diff two GSAP scripts. Tweens are matched by resolved target - * element + method + position (see tweenKey), so the SDK's [data-hf-id] - * selectors and the server's class selectors for the same element don't - * false-flag present/absent. Reports a tween present in one but not the other, - * and per-field value drift (duration, ease, properties, fromProperties). - * Comparison is canonical so writer formatting differences don't register. - * - * Pass resolveSelector (selector → canonical element id) to enable the - * element-based matching; without it, matching falls back to raw selector. - */ -// fallow-ignore-next-line complexity -export function gsapFidelityMismatches( - sdkScript: string, - serverScript: string, - resolveSelector?: (sel: string) => string, -): SdkShadowMismatch[] { - const sdk = animByKey(sdkScript, resolveSelector); - const server = animByKey(serverScript, resolveSelector); - const mismatches: SdkShadowMismatch[] = []; - const keys = new Set([...sdk.keys(), ...server.keys()]); - for (const key of keys) { - const a = sdk.get(key); - const b = server.get(key); - if (!a || !b) { - mismatches.push({ - kind: "value_mismatch", - hfId: key, - property: "tween", - expected: b ? "present" : "absent", - actual: a ? "present" : "absent", - }); - continue; - } - // method + position are part of the key (already equal); compare values. - const fields: Array<[string, unknown, unknown, boolean]> = [ - ["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)], - ["ease", a.ease, b.ease, a.ease === b.ease], - [ - "properties", - a.properties, - b.properties, - canonicalProps(a.properties) === canonicalProps(b.properties), - ], - [ - "fromProperties", - a.fromProperties, - b.fromProperties, - canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties), - ], - ]; - for (const [property, av, bv, equal] of fields) { - if (!equal) { - mismatches.push({ - kind: "value_mismatch", - hfId: key, - property, - expected: bv == null ? null : JSON.stringify(bv), - actual: av == null ? null : JSON.stringify(av), - }); - } - } - } - return mismatches; -} - -export interface GsapFidelityArgs { - before: string; - op: ShadowGsapOp; - serverScript: string; -} - -/** - * Wiring gate for the commitMutation chokepoint: return the narrowed fidelity - * args only when there is a live session, a typed shadow op, and both the - * pre-op file and the server's resulting script to diff against (scriptText is - * null when the composition has no GSAP script). Returns null otherwise. Pure + - * narrowing so the wiring decision is unit-testable without rendering the hook - * and the caller needs no non-null assertions. - */ -export function resolveGsapFidelityArgs( - sdkSession: unknown, - shadowGsapOp: ShadowGsapOp | undefined, - before: string | null | undefined, - serverScript: string | null | undefined, -): GsapFidelityArgs | null { - if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null; - return { before, op: shadowGsapOp, serverScript }; -} - -// Resolve a CSS selector to a canonical element key using the pre-op document, -// so tweens that target the same element via different selectors -// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff. -// -// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id -// selector for the SAME element. Keying both forms to the same node prevents a -// false present/absent mismatch. Resolution order, for whatever element the -// selector matches: -// 1. data-hf-id present → "hfid:" (the common, stable case) -// 2. no data-hf-id → "node:" (per-document node index; identical -// regardless of which selector form found the node, so .x and [data-hf-id] -// pointing at the same attribute-less node still collapse) -// 3. selector resolves to no node / parse error / no DOM → the raw selector -// (last resort; only diverges when the two writers genuinely target -// different — or unresolvable — nodes, which is real drift to surface) -// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never -// collide with a raw-selector fallback. -// -// ponytail: first-match heuristic — querySelector returns the FIRST match, so an -// ambiguous selector (e.g. .x shared by two elements) may map to a different -// node than the SDK side's [data-hf-id] target and still flag present/absent. -// Safe for studio templates (one tween per element); upgrade to querySelectorAll -// + uniqueness check if ambiguous selectors appear. -export function makeSelectorResolver(html: string): (sel: string) => string { - let doc: Document | null = null; - try { - doc = new DOMParser().parseFromString(html, "text/html"); - } catch { - doc = null; - } - // Stable per-node index so an attribute-less element keys identically no - // matter which selector form (class vs id vs [data-hf-id]) resolved it. - const nodeKeys = new WeakMap(); - let nextNode = 0; - const keyForNode = (el: Element): string => { - const hfId = el.getAttribute("data-hf-id"); - if (hfId != null && hfId !== "") return `hfid:${hfId}`; - const existing = nodeKeys.get(el); - if (existing != null) return existing; - const key = `node:${nextNode++}`; - nodeKeys.set(el, key); - return key; - }; - return (sel) => { - if (!doc) return sel; - try { - const el = doc.querySelector(sel); - return el ? keyForNode(el) : sel; - } catch { - return sel; - } - }; -} - -/** - * Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op - * file, apply the same tween op, serialize, and diff the SDK's GSAP script - * against the server's resulting script. Emits sdk_shadow_dispatch op: - * "gsap_fidelity". Async, fire-and-forget; server stays authoritative. - */ -export async function runShadowGsapFidelity( - beforeHtml: string, - gsapOp: ShadowGsapOp, - serverScript: string, -): Promise { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - // No server script to diff against → skip the (costly) openComposition. - if (!serverScript || !beforeHtml) return; - try { - const session = await openComposition(beforeHtml); - session.batch(() => { - if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween); - else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties); - else session.removeGsapTween(gsapOp.animationId); - }); - const sdkScript = extractGsapScript(session.serialize()); - if (sdkScript == null) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_fidelity", - dispatched: false, - reason: "no_sdk_script", - mismatchCount: 0, - }); - return; - } - const mismatches = gsapFidelityMismatches( - sdkScript, - serverScript, - makeSelectorResolver(beforeHtml), - ); - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_fidelity", - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_fidelity", - dispatched: false, - reason: "fidelity_error", - error: String(err), - mismatchCount: 0, - }); - } -} diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts deleted file mode 100644 index 20f08313a..000000000 --- a/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { openComposition } from "@hyperframes/sdk"; -import { - resolveKeyframeIndexByPercentage, - keyframeOpToEditOp, - gsapKeyframeFidelityMismatches, - runShadowGsapKeyframeFidelity, - type ShadowKeyframeOp, -} from "./sdkShadowGsapKeyframe"; -import { runShadowDispatch } from "./sdkShadow"; -import type { PatchOperation } from "./sourcePatcher"; - -// Capture sdk_shadow_dispatch telemetry. -const trackedEvents: Array<{ event: string; props: Record }> = []; -vi.mock("./studioTelemetry", () => ({ - trackStudioEvent: (event: string, props: Record) => - trackedEvents.push({ event, props }), -})); -// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the -// runners are active here without mocking the availability module. - -beforeEach(() => { - trackedEvents.length = 0; -}); -const lastShadow = () => - trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props; - -const ANIM_ID = "#hero-to-0-position"; - -function gsapHtml(scriptBody: string): string { - return /* html */ ` -
x
- -`; -} - -const KF_SCRIPT = ` - const tl = gsap.timeline({ paused: true }); - tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`; - -// A script body string (not full HTML) for the index-resolution helpers. -const KF_SCRIPT_BODY = KF_SCRIPT; -const DUP_SCRIPT_BODY = ` - const tl = gsap.timeline({ paused: true }); - tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`; - -describe("resolveKeyframeIndexByPercentage", () => { - it("resolves a unique percentage to its 0-based index", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({ - keyframeIndex: 1, - }); - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({ - keyframeIndex: 2, - }); - }); - - it("matches within ~0.001 tolerance", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe( - 1, - ); - }); - - it("returns null with not_found when no percentage matches", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({ - keyframeIndex: null, - reason: "not_found", - }); - }); - - it("returns null with no_keyframes for an unknown animation", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({ - keyframeIndex: null, - reason: "no_keyframes", - }); - }); - - it("returns null with no_keyframes when script is empty", () => { - expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes"); - }); - - it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => { - expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({ - keyframeIndex: null, - reason: "ambiguous", - }); - }); - - // Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a - // "-from-"/"-fromTo-" animationId must fall back to the converted id (matching - // the writer's locateAnimationWithFallback) — else the keyframe diff goes blind. - it("falls back from a -from- id to the -to- tween", () => { - const fromId = ANIM_ID.replace("-to-", "-from-"); - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({ - keyframeIndex: 1, - }); - }); -}); - -describe("keyframeOpToEditOp", () => { - it("maps add → addGsapKeyframe with position = percentage", () => { - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({ - op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } }, - }); - }); - - it("maps remove → removeGsapKeyframe with resolved index", () => { - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({ - op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 }, - }); - }); - - it("returns null op + reason when remove percentage is ambiguous", () => { - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" }); - }); -}); - -describe("gsapKeyframeFidelityMismatches", () => { - it("reports no mismatches when keyframe arrays match", () => { - expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]); - }); - - it("reports a keyframes mismatch when arrays diverge", () => { - const other = ` - const tl = gsap.timeline({ paused: true }); - tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`; - const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID); - expect(mismatches.some((m) => m.property === "keyframes")).toBe(true); - }); -}); - -describe("runShadowGsapKeyframeFidelity (add)", () => { - it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => { - const beforeHtml = gsapHtml(KF_SCRIPT); - // server script unchanged (server "failed" to add the 25% keyframe) → drift - const session = await openComposition(beforeHtml); - const serverScript = session - .serialize() - .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; - expect(serverScript).toBeTruthy(); - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(true); - expect(props?.mismatchCount).toBe(1); - }); - - it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => { - const beforeHtml = gsapHtml(KF_SCRIPT); - // Build the server's resulting script by applying the same op via the SDK. - const serverSession = await openComposition(beforeHtml); - serverSession.batch(() => - serverSession.dispatch({ - type: "addGsapKeyframe", - animationId: ANIM_ID, - position: 25, - value: { x: 50 }, - }), - ); - const serverScript = serverSession - .serialize() - .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(true); - expect(props?.mismatchCount).toBe(0); - }); -}); - -describe("runShadowGsapKeyframeFidelity (remove)", () => { - it("no-ops with reason when remove percentage is ambiguous", async () => { - const beforeHtml = gsapHtml(DUP_SCRIPT_BODY); - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap"); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(false); - expect(props?.reason).toBe("ambiguous"); - }); - - it("dispatches a resolved remove and diffs", async () => { - const beforeHtml = gsapHtml(KF_SCRIPT); - const serverSession = await openComposition(beforeHtml); - serverSession.batch(() => - serverSession.dispatch({ - type: "removeGsapKeyframe", - animationId: ANIM_ID, - keyframeIndex: 1, - }), - ); - const serverScript = serverSession - .serialize() - .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(true); - expect(props?.mismatchCount).toBe(0); - }); -}); - -describe("runShadowGsapKeyframeFidelity (guards)", () => { - it("skips when there is no server script", async () => { - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null); - expect(lastShadow()).toBeUndefined(); - }); -}); - -describe("runShadowDispatch unmapped-type guard", () => { - const ELEMENT_HTML = /* html */ ` -
Hi
- `; - - it("emits unmapped_type when a PatchOperation type isn't mapped", async () => { - const session = await openComposition(ELEMENT_HTML); - // PatchOperation.type is a closed union today; cast to exercise the defensive - // guard for a future unmapped type. - const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation]; - runShadowDispatch(session, { hfId: "hf-box" } as never, ops); - const props = lastShadow(); - expect(props?.op).toBe("property"); - expect(props?.dispatched).toBe(false); - expect(props?.reason).toBe("unmapped_type"); - expect(props?.type).toBe("future-op"); - }); - - it("dispatches normally for known PatchOperation types", async () => { - const session = await openComposition(ELEMENT_HTML); - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - runShadowDispatch(session, { hfId: "hf-box" } as never, ops); - const props = lastShadow(); - expect(props?.dispatched).toBe(true); - expect(props?.reason).toBeUndefined(); - }); -}); diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.ts deleted file mode 100644 index 38a633162..000000000 --- a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * GSAP keyframe-op shadow (serialize round-trip diff). New module for the Stage 7 - * shadow-parity push — kept out of sdkShadow.ts / sdkShadowGsapFidelity.ts so the - * shared files stay untouched (only additive imports) and the studio 600-line cap - * holds. - * - * Unlike tweens, the SDK exposes NO keyframe reader on ElementSnapshot, so there - * is no existence-parity path here. Instead we compare the two writers' output: - * open a fresh SDK doc from the server's pre-op file, dispatch the equivalent - * keyframe op, serialize, and diff the SDK's GSAP script against the server's - * resulting script. - * - * gsapFidelityMismatches (reused) matches tweens by resolved target element + - * method + position and diffs tween-level fields — but it does NOT look inside a - * tween's `keyframes` array. Keyframe drift therefore needs a dedicated diff, - * layered on top of the reused tween-level diff, matched by the GSAP animation id. - * - * SDK mapping (main, pre PR #1498 percentage-variant): - * add → addGsapKeyframe{animationId, position: percentage, value: properties} - * remove → removeGsapKeyframe{animationId, keyframeIndex} — the studio op is - * percentage-based, so we resolve percentage → index against the pre-op - * script (KF_PERCENT_TOLERANCE, aligned with the writer ~0.001) and - * no-op on ambiguity (duplicate-percentage keyframes can't be told - * apart by percentage — landmine from PR #1498). - */ - -import { openComposition } from "@hyperframes/sdk"; -import type { EditOp } from "@hyperframes/sdk"; -import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; -import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import type { SdkShadowMismatch } from "./sdkShadow"; -import { - extractGsapScript, - gsapFidelityMismatches, - makeSelectorResolver, -} from "./sdkShadowGsapFidelity"; - -// Match the GSAP writer's percentage equality tolerance so a remove resolves to -// the same keyframe the server would pick (writer rounds to ~3 decimals). -const KF_PERCENT_TOLERANCE = 0.001; - -export type ShadowKeyframeOp = - | { - kind: "add"; - animationId: string; - percentage: number; - properties: Record; - } - | { kind: "remove"; animationId: string; percentage: number }; - -// ─── percentage → SDK op mapping ────────────────────────────────────────────── - -function findAnimationKeyframes( - script: string, - animationId: string, -): GsapPercentageKeyframe[] | null { - const parsed = parseGsapScriptAcorn(script); - // Match the writer's locateAnimationWithFallback (gsapParser.ts): a from/fromTo - // tween's derived id may be normalized to "-to-" on write, so fall back to the - // converted id when the exact one isn't found — otherwise the keyframe diff - // goes blind (both scripts resolve null → falsely "clean") on converted tweens. - const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - const anim = - parsed.animations.find((a) => a.id === animationId) ?? - parsed.animations.find((a) => a.id === convertedId); - return anim?.keyframes?.keyframes ?? null; -} - -export interface KeyframeRemoveResolution { - /** Resolved 0-based index, or null when it can't be safely resolved. */ - keyframeIndex: number | null; - /** Why no index — for telemetry when keyframeIndex is null. */ - reason?: "no_keyframes" | "not_found" | "ambiguous"; -} - -/** - * Resolve a percentage-based remove to a keyframe index against the pre-op - * script. Returns null index (with a reason) when there are no keyframes, the - * percentage matches none, or — per the PR #1498 landmine — more than one - * keyframe shares the percentage (can't be disambiguated by percentage alone). - * Pure + exported so the mapping is unit-testable without an SDK session. - */ -export function resolveKeyframeIndexByPercentage( - script: string | null | undefined, - animationId: string, - percentage: number, -): KeyframeRemoveResolution { - if (!script) return { keyframeIndex: null, reason: "no_keyframes" }; - const kfs = findAnimationKeyframes(script, animationId); - if (!kfs || kfs.length === 0) return { keyframeIndex: null, reason: "no_keyframes" }; - const matches: number[] = []; - for (let i = 0; i < kfs.length; i++) { - if (Math.abs(kfs[i]?.percentage - percentage) <= KF_PERCENT_TOLERANCE) matches.push(i); - } - if (matches.length === 0) return { keyframeIndex: null, reason: "not_found" }; - if (matches.length > 1) return { keyframeIndex: null, reason: "ambiguous" }; - return { keyframeIndex: matches[0] }; -} - -/** - * Map a studio keyframe op to the SDK EditOp. For a remove this needs the pre-op - * script to resolve percentage → index; returns null (with a reason) when the - * index can't be safely resolved so the caller can emit a no-op-with-reason - * event instead of dispatching the wrong keyframe. - */ -export function keyframeOpToEditOp( - op: ShadowKeyframeOp, - beforeScript: string | null | undefined, -): { op: EditOp } | { op: null; reason: string } { - if (op.kind === "add") { - return { - op: { - type: "addGsapKeyframe", - animationId: op.animationId, - position: op.percentage, - value: op.properties, - }, - }; - } - const resolved = resolveKeyframeIndexByPercentage(beforeScript, op.animationId, op.percentage); - if (resolved.keyframeIndex === null) { - return { op: null, reason: resolved.reason ?? "unresolved" }; - } - return { - op: { - type: "removeGsapKeyframe", - animationId: op.animationId, - keyframeIndex: resolved.keyframeIndex, - }, - }; -} - -// ─── Keyframe-aware fidelity diff ───────────────────────────────────────────── - -function canonicalKeyframe(kf: GsapPercentageKeyframe): string { - const props: Record = {}; - for (const key of Object.keys(kf.properties).sort()) { - const v = kf.properties[key]; - props[key] = - typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; - } - return JSON.stringify({ pct: Math.round(kf.percentage * 1000) / 1000, ease: kf.ease, props }); -} - -function canonicalKeyframes(kfs: GsapPercentageKeyframe[] | null): string { - if (!kfs) return "[]"; - return JSON.stringify( - [...kfs].sort((a, b) => a.percentage - b.percentage).map(canonicalKeyframe), - ); -} - -/** - * Diff two GSAP scripts for a keyframe op: the reused tween-level diff PLUS a - * keyframe-array comparison for the targeted animation (which the tween-level - * diff doesn't inspect). Reports a `keyframes` value_mismatch when the SDK and - * server keyframe arrays diverge canonically. - */ -export function gsapKeyframeFidelityMismatches( - sdkScript: string, - serverScript: string, - animationId: string, - resolveSelector?: (sel: string) => string, -): SdkShadowMismatch[] { - const mismatches = gsapFidelityMismatches(sdkScript, serverScript, resolveSelector); - const sdkKfs = findAnimationKeyframes(sdkScript, animationId); - const serverKfs = findAnimationKeyframes(serverScript, animationId); - const sdkCanon = canonicalKeyframes(sdkKfs); - const serverCanon = canonicalKeyframes(serverKfs); - if (sdkCanon !== serverCanon) { - mismatches.push({ - kind: "value_mismatch", - hfId: animationId, - property: "keyframes", - expected: serverCanon, - actual: sdkCanon, - }); - } - return mismatches; -} - -// ─── Telemetry runner ───────────────────────────────────────────────────────── - -/** - * Shadow a GSAP keyframe op: open a fresh SDK doc from the server's pre-op file, - * apply the equivalent keyframe op, serialize, and diff against the server's - * resulting script. Emits sdk_shadow_dispatch op: "gsap_keyframe". Async, - * fire-and-forget; server stays authoritative. No-op when shadow is disabled. - */ -export async function runShadowGsapKeyframeFidelity( - beforeHtml: string | null | undefined, - op: ShadowKeyframeOp, - serverScript: string | null | undefined, -): Promise { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - // No server script to diff against → skip the (costly) openComposition. - if (!serverScript || !beforeHtml) return; - const beforeScript = extractGsapScript(beforeHtml); - const mapped = keyframeOpToEditOp(op, beforeScript); - if (mapped.op === null) { - // Ambiguous / not-found percentage: don't dispatch the wrong keyframe. - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: mapped.reason, - mismatchCount: 0, - }); - return; - } - const editOp = mapped.op; - try { - const session = await openComposition(beforeHtml); - const verdict = session.can(editOp); - if (!verdict.ok) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: "cannot_dispatch", - code: verdict.code, - mismatchCount: 0, - }); - return; - } - session.batch(() => session.dispatch(editOp)); - const sdkScript = extractGsapScript(session.serialize()); - if (sdkScript == null) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: "no_sdk_script", - mismatchCount: 0, - }); - return; - } - const mismatches = gsapKeyframeFidelityMismatches( - sdkScript, - serverScript, - op.animationId, - makeSelectorResolver(beforeHtml), - ); - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: "fidelity_error", - error: String(err), - mismatchCount: 0, - }); - } -} diff --git a/packages/studio/src/utils/sdkShadowNumeric.ts b/packages/studio/src/utils/sdkShadowNumeric.ts deleted file mode 100644 index bf8ecd136..000000000 --- a/packages/studio/src/utils/sdkShadowNumeric.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Relative-epsilon numeric equality shared by the shadow diffs (timing parity + - * GSAP value fidelity). Both writers round-trip durations/positions through JS - * number formatting, so a value like 3.1 can read back as 3.0999999999999996. - * Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a - * real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise. - */ -export function relEqual(a: number, b: number): boolean { - if (a === b) return true; - return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b)); -}