diff --git a/.gitignore b/.gitignore index 33c7fe236d..77f369bd90 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ hyperframes-bench/ tmp/ # Studio-generated preview thumbnails .thumbnails/ + +# Local editor settings +.zed/ diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index af649ed614..89a70b5247 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -978,106 +978,6 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); - // gsap_studio_edit_blocked - it("warns when script registers timeline AND has GSAP tweens targeting #id selectors", async () => { - const html = ` - -
-
Hello
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("warning"); - expect(finding?.message).toContain('"#headline"'); - }); - - it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => { - const html = ` - -
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeDefined(); - expect(finding?.message).toContain('".box"'); - }); - - it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => { - const html = ` - -
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeUndefined(); - }); - - it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => { - const html = ` - -
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeUndefined(); - }); - - it("lists all unique targeted selectors in the warning message", async () => { - const html = ` - -
-
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked"); - expect(finding).toBeDefined(); - expect(finding?.message).toContain('"#title"'); - expect(finding?.message).toContain('"#sub"'); - }); - it("scene_layer_missing_visibility_kill: fires when multi-scene exit lacks hard kill", async () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 4e2d67b743..257d01af69 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -27,7 +27,6 @@ import { truncateSnippet, stripJsComments, WINDOW_TIMELINE_ASSIGN_PATTERN, - TIMELINE_REGISTRY_ASSIGN_PATTERN, } from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -855,39 +854,4 @@ export const gsapRules: LintRule[] = [ } return findings; }, - - // gsap_studio_edit_blocked - // When a script both registers a timeline on window.__timelines AND contains - // GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted - // check returns true for those elements and silently skips saving drag/resize - // position changes back to source HTML. - ({ scripts }) => { - const findings: HyperframeLintFinding[] = []; - const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g; - - for (const script of scripts) { - const content = stripJsComments(script.content); - if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue; - - const targets = new Set(); - let match: RegExpExecArray | null; - const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g"); - while ((match = re.exec(content)) !== null) { - if (match[1]) targets.add(match[1]); - } - if (targets.size === 0) continue; - - const selList = [...targets].map((s) => `"${s}"`).join(", "); - findings.push({ - code: "gsap_studio_edit_blocked", - severity: "warning", - message: `GSAP tweens target ${selList} in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.`, - fixHint: - "The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " + - "For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " + - "If GSAP must own these elements' positions, avoid drag-editing them in Studio.", - }); - } - return findings; - }, ]; diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 0153623956..5976e4b664 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -77,7 +77,10 @@ export function classifyTweenPropertyGroup( ): PropertyGroupName | undefined { const groups = new Set(); for (const key of Object.keys(properties)) { - if (key === "transformOrigin") continue; + // transformOrigin is a modifier; `_auto` is Studio's internal endpoint marker; + // `data` is GSAP-reserved (carries the Studio hold-set tag). None is an animated + // property, so none should affect the group. + if (key === "transformOrigin" || key === "_auto" || key === "data") continue; const g = classifyPropertyGroup(key); groups.add(g); } diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 7f56fd0a71..3d785c6426 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -14,11 +14,16 @@ import { addKeyframeToScript, removeKeyframeFromScript, updateKeyframeInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, convertToKeyframesInScript, removeAllKeyframesFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, splitIntoPropertyGroups, + syncPositionHoldsBeforeKeyframes, shiftPositionsInScript, scalePositionsInScript, } from "./gsapParser.js"; @@ -483,6 +488,20 @@ describe("property group classification", () => { ); }); + it("ignores the internal `_auto` endpoint marker when classifying", () => { + // Regression: the `_auto: 1` sentinel on auto-generated endpoint keyframes must + // not pull a position tween into a mixed group, or drag-intercept can't resolve it. + expect(classifyTweenPropertyGroup({ x: 100, y: 50, _auto: 1 })).toBe("position"); + }); + + it("ignores the GSAP-reserved `data` key when classifying", () => { + // Regression: `data` is GSAP-reserved (Studio stores its hold-set tag there). + // It is not an animated property, so it must not pull a single-group tween into + // a mixed group (which would return undefined and break group-scoped editing). + expect(classifyTweenPropertyGroup({ x: 100, y: 50, data: "hold" })).toBe("position"); + expect(classifyTweenPropertyGroup({ scale: 0.5, data: "hold" })).toBe("scale"); + }); + it("returns undefined for mixed-group tweens", () => { expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); @@ -1560,6 +1579,98 @@ describe("keyframe mutations", () => { expect(kfs[1].properties.x).toBe(999); }); + // ── backfillDefaults: editing one keyframe must not move the others ────── + // UX invariant (CapCut/AE): keyframes are independent. Introducing a property + // to one keyframe (e.g. `y` on an x-only tween) must backfill the other + // keyframes at the element's base value — otherwise GSAP holds the new prop's + // value across keyframes that omit it, dragging them to the same position. + const X_ONLY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#puck", { keyframes: { "0%": { x: 0 }, "100%": { x: -260 } }, duration: 2.2 }, 1.2); + `; + + it("addKeyframeToScript — WITHOUT backfill, the other keyframe omits the new prop (GSAP would hold it)", () => { + const id = getAnimId(X_ONLY_SCRIPT); + const updated = addKeyframeToScript(X_ONLY_SCRIPT, id, 0, { x: 240, y: 780 }); + const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes; + const kf100 = kfs.find((k) => k.percentage === 100)!; + expect(kf100.properties.x).toBe(-260); + expect("y" in kf100.properties).toBe(false); // <- the bug surface + }); + + it("addKeyframeToScript — WITH backfill, the new prop is added to the other keyframe at base (it stays put)", () => { + const id = getAnimId(X_ONLY_SCRIPT); + const updated = addKeyframeToScript(X_ONLY_SCRIPT, id, 0, { x: 240, y: 780 }, undefined, { + x: 0, + y: 0, + }); + const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes; + const kf0 = kfs.find((k) => k.percentage === 0)!; + const kf100 = kfs.find((k) => k.percentage === 100)!; + // edited keyframe holds the drag + expect(kf0.properties).toMatchObject({ x: 240, y: 780 }); + // other keyframe keeps its own x and gets y at base (0) — not 780 + expect(kf100.properties.x).toBe(-260); + expect(kf100.properties.y).toBe(0); + }); + + // ── syncPositionHoldsBeforeKeyframes (hold before first keyframe) ──────── + // UX invariant (every NLE): before the first keyframe, the element holds that + // keyframe's value — it must NOT snap to its CSS base then jump when the tween + // starts. Implemented as a tagged `tl.set(...,0)` kept in sync with the tween. + describe("syncPositionHoldsBeforeKeyframes", () => { + const posTweenAt = (start: number) => + `const tl = gsap.timeline({ paused: true });\n` + + `tl.to("#p", { keyframes: { "0%": { x: -1500, y: 700 }, "100%": { x: -260, y: 0 } }, duration: 2.2 }, ${start});`; + + it("inserts a hold set holding the first keyframe's position at t=0", () => { + const out = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const anims = parseGsapScript(out).animations; + const hold = anims.find((a) => a.method === "set"); + expect(hold).toBeDefined(); + expect(hold!.position).toBe(0); + expect(hold!.properties).toMatchObject({ x: -1500, y: 700 }); + }); + + it("is idempotent (re-running does not stack holds)", () => { + const once = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + expect(syncPositionHoldsBeforeKeyframes(once)).toBe(once); + expect((once.match(/hf-hold/g) ?? []).length).toBe(1); + }); + + it("re-syncs the hold value when the first keyframe changes", () => { + const out1 = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const moved = updateKeyframeInScript( + out1, + parseGsapScript(out1).animations.find((a) => a.keyframes)!.id, + 0, + { x: 99, y: 88 }, + ); + const out2 = syncPositionHoldsBeforeKeyframes(moved); + const hold = parseGsapScript(out2).animations.find((a) => a.method === "set"); + expect(hold!.properties).toMatchObject({ x: 99, y: 88 }); + expect((out2.match(/hf-hold/g) ?? []).length).toBe(1); // still just one + }); + + it("adds no hold for a tween that already starts at t=0", () => { + expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold"); + }); + + it("adds no hold for an opacity-only keyframed tween (position-scoped)", () => { + const opacity = + `const tl = gsap.timeline({ paused: true });\n` + + `tl.to("#b", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 1 }, 2);`; + expect(syncPositionHoldsBeforeKeyframes(opacity)).not.toContain("hf-hold"); + }); + + it("removes an orphaned hold when its tween is gone", () => { + const withHold = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const tweenId = parseGsapScript(withHold).animations.find((a) => a.keyframes)!.id; + const deleted = removeAnimationFromScript(withHold, tweenId); + expect(syncPositionHoldsBeforeKeyframes(deleted)).not.toContain("hf-hold"); + }); + }); + // ── _auto endpoint updates ──────────────────────────────────────────── const AUTO_SCRIPT = ` @@ -1681,6 +1792,241 @@ describe("keyframe mutations", () => { expect(kf100.properties.y).toBe(50); }); + // Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP + // distributes them evenly. The motion-path overlay drags/adds by percentage, + // which used to no-op on array-authored tweens (#puck-b / #shuttle). + const ARRAY_KF_SCRIPT = + "const tl = gsap.timeline();\n" + + 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);'; + + it("updateKeyframeInScript — array-form: drags node 2 (pct 33.3) by index", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 33.3, { x: 503, y: 642 }); + expect(updated).not.toBe(ARRAY_KF_SCRIPT); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect([kf[1]!.properties.x, kf[1]!.properties.y]).toEqual([503, 642]); + expect([kf[0]!.properties.x, kf[0]!.properties.y]).toEqual([0, 0]); + expect([kf[2]!.properties.x, kf[2]!.properties.y]).toEqual([1040, 0]); + }); + + it("addKeyframeToScript — array-form: normalizes to object form + inserts 50%", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = addKeyframeToScript(ARRAY_KF_SCRIPT, id, 50, { x: 780, y: 60 }); + expect(updated).not.toBe(ARRAY_KF_SCRIPT); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect(kf.length).toBe(5); + const at50 = kf.find((k) => Math.abs(k.percentage - 50) < 1)!; + expect([at50.properties.x, at50.properties.y]).toEqual([780, 60]); + }); + + it("removeKeyframeFromScript — array-form: drops node 3 (pct 66.7)", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = removeKeyframeFromScript(ARRAY_KF_SCRIPT, id, 66.7); + expect(updated).not.toBe(ARRAY_KF_SCRIPT); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect(kf.length).toBe(3); + }); + + it("updateKeyframeInScript — stale position-id resolves to the nearest same-selector tween", () => { + // Tween authored at 1.0s → id "#el-to-1000-position". A client that cached the + // pre-reposition id "#el-to-1200-position" (a gesture/convert moved it) must + // still resolve, instead of no-op'ing. + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#el", { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 50, y: 50 } }, duration: 2 }, 1);'; + const updated = updateKeyframeInScript(script, "#el-to-1200-position", 100, { x: 77, y: 88 }); + expect(updated).not.toBe(script); + const at100 = parseGsapScript(updated).animations[0].keyframes!.keyframes.find( + (k) => k.percentage === 100, + )!; + expect([at100.properties.x, at100.properties.y]).toEqual([77, 88]); + }); + + // ── updateMotionPathPointInScript ─────────────────────────────────────── + + const MOTION_PATH_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { + motionPath: { + path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}], + curviness: 1.5 + }, + duration: 2 + }, 0); + `; + + it("updateMotionPathPointInScript — moves one waypoint, preserves the rest and curviness", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = updateMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1, { x: 250, y: -140 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + const wp = anim.keyframes!.keyframes; + expect(wp.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [250, -140], + [400, 50], + ]); + expect(anim.arcPath!.segments[0].curviness).toBe(1.5); + expect(anim.arcPath!.segments[1].curviness).toBe(1.5); + }); + + it("updateMotionPathPointInScript — out-of-range index leaves the script unchanged", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + expect(updateMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 9, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("updateMotionPathPointInScript — unknown animation id leaves the script unchanged", () => { + expect(updateMotionPathPointInScript(MOTION_PATH_SCRIPT, "nope", 0, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("updateMotionPathPointInScript — moves a cubic anchor, keeps control points", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { + motionPath: { + path: [ + {x: 0, y: 0}, + {x: 50, y: -80}, {x: 150, y: -120}, + {x: 200, y: -100} + ], + type: "cubic" + }, + duration: 2 + }, 0); + `; + const id = getAnimId(script); + const updated = updateMotionPathPointInScript(script, id, 1, { x: 220, y: -130 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + // anchor 1 moved; the segment's control points are untouched. + expect(anim.keyframes!.keyframes[1].properties).toMatchObject({ x: 220, y: -130 }); + expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 }); + expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 }); + }); + + // ── add/removeMotionPathPointInScript ─────────────────────────────────── + + it("addMotionPathPointInScript — inserts a waypoint between anchors, keeps curviness", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1, { x: 100, y: -50 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [100, -50], + [200, -100], + [400, 50], + ]); + // 4 anchors → 3 segments, all curviness 1.5 + expect(anim.arcPath!.segments).toHaveLength(3); + expect(anim.arcPath!.segments.every((s) => s.curviness === 1.5)).toBe(true); + }); + + it("addMotionPathPointInScript — refuses an index at the ends or out of range", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + expect(addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 0, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + expect(addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 3, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("removeMotionPathPointInScript — drops a waypoint, preserves the rest", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = removeMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [400, 50], + ]); + expect(anim.arcPath!.segments).toHaveLength(1); + }); + + it("removeMotionPathPointInScript — refuses to drop below two anchors", () => { + const two = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { motionPath: { path: [{x: 0, y: 0}, {x: 400, y: 50}], curviness: 1 }, duration: 2 }, 0); + `; + const id = getAnimId(two); + expect(removeMotionPathPointInScript(two, id, 0)).toBe(two); + }); + + it("add/removeMotionPathPointInScript — leave cubic paths untouched", () => { + const cubic = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { motionPath: { path: [{x:0,y:0},{x:50,y:-80},{x:150,y:-120},{x:200,y:-100}], type: "cubic" }, duration: 2 }, 0); + `; + const id = getAnimId(cubic); + expect(addMotionPathPointInScript(cubic, id, 1, { x: 1, y: 1 })).toBe(cubic); + expect(removeMotionPathPointInScript(cubic, id, 1)).toBe(cubic); + }); + + // ── addMotionPathToScript ─────────────────────────────────────────────── + + it("addMotionPathToScript — authors a new 2-anchor motionPath tween", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.from("#title", { opacity: 0, duration: 0.5 }, 0); + `; + const { script: updated, id } = addMotionPathToScript(script, "#el", 2.0, 1.5, { + x: 300, + y: -100, + }); + expect(id).not.toBeNull(); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations.find((a) => a.targetSelector === "#el")!; + expect(anim).toBeDefined(); + expect(anim.arcPath!.enabled).toBe(true); + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [300, -100], + ]); + expect(anim.duration).toBe(1.5); + }); + + it("addMotionPathToScript — returns id:null (not '') when there is no timeline", () => { + // No `gsap.timeline()` and no located tweens → failure. The sentinel must be + // null so a downstream caller chaining on the id can null-check instead of + // silently feeding an empty selector into a locate call that matches nothing. + const { script: updated, id } = addMotionPathToScript("const x = 1;", "#el", 0, 1, { + x: 10, + y: 10, + }); + expect(id).toBeNull(); + expect(updated).toBe("const x = 1;"); + }); + + it("addMotionPathToScript + hold-sync — holds (0,0) at t=0 when authored past t=0", () => { + // A motionPath authored at position > 0 parses with a first keyframe of (0,0). + // Without a pre-tween hold the element would snap to its CSS home at frame 0 and + // jump when the tween starts — this is why `add-motion-path` is hold-synced. + const script = `const tl = gsap.timeline({ paused: true });`; + const { script: withPath } = addMotionPathToScript(script, "#el", 2.0, 1.5, { + x: 300, + y: -100, + }); + const synced = syncPositionHoldsBeforeKeyframes(withPath); + const hold = parseGsapScript(synced).animations.find((a) => a.method === "set"); + expect(hold).toBeDefined(); + expect(hold!.position).toBe(0); + expect(hold!.properties).toMatchObject({ x: 0, y: 0 }); + }); + + it("addMotionPathToScript + hold-sync — adds no hold when authored at t=0", () => { + const script = `const tl = gsap.timeline({ paused: true });`; + const { script: withPath } = addMotionPathToScript(script, "#el", 0, 1.5, { + x: 300, + y: -100, + }); + expect(syncPositionHoldsBeforeKeyframes(withPath)).not.toContain("hf-hold"); + }); + // ── convertToKeyframesInScript ────────────────────────────────────────── it("convertToKeyframesInScript — converts flat to() tween", () => { @@ -1984,6 +2330,19 @@ describe("splitAnimationsInScript", () => { expect(forNew[0]!.position).toBe(opts.splitTime); }); + it("does not pin the clone to from-values for a completed .from() before the split", () => { + // A .from() that finished before the split leaves the element at its natural + // state. Carrying its from-values (opacity:0) into the clone's `set` made the + // clone invisible. The clone should get NO inherited set for those props. + const script = `${baseScript}\ntl.from("#el1", { y: 70, opacity: 0, duration: 0.9 }, 0.4);`; + const result = split(script); + const parsed = parseGsapScript(result); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + const inheritedSet = forNew.find((a) => a.method === "set"); + expect(inheritedSet).toBeUndefined(); + expect(result).not.toContain("#el1-split"); + }); + it("retargets animation entirely in second half to new element", () => { const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);`; const selectors = parseSplitAndAssert(script, (s) => split(s), 1); @@ -2053,6 +2412,42 @@ tl.to("#el1", { y: 200, duration: 1 }, 3);`; expect(continuation!.properties.opacity).toBe(1); }); + it("splits a mid-flight fromTo straddling the split into two fromTo halves", () => { + // Mid-flight: pos(0) < splitTime(2) < animEnd(4). The first half keeps the + // original on #el1 ending at the interpolated mid-value; the clone continues + // as a fromTo from that mid-value to the original to-value. + const script = `${baseScript}\ntl.fromTo("#el1", { x: 0 }, { x: 100, duration: 4 }, 0);`; + const result = split(script); + const parsed = parseGsapScript(result); + const first = parsed.animations.find((a) => a.targetSelector === "#el1")!; + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + const continuation = forNew.find((a) => a.method === "fromTo")!; + expect(first.duration).toBe(2); + expect(first.properties.x).toBe(50); + expect(continuation.duration).toBe(2); + expect(continuation.fromProperties?.x).toBe(50); + expect(continuation.properties.x).toBe(100); + }); + + it("splits a mid-flight from straddling the split (no fromProperties on source)", () => { + // A .from() has no explicit fromProperties, so the spanning branch seeds the + // from-value from accumulated inherited state (defaulting to 0). The clone + // continues from the interpolated mid-value as a fromTo so both halves play + // a contiguous range. + const script = `${baseScript}\ntl.from("#el1", { x: 80, duration: 4 }, 0);`; + const result = split(script); + const parsed = parseGsapScript(result); + const first = parsed.animations.find((a) => a.targetSelector === "#el1")!; + const continuation = parsed.animations + .filter((a) => a.targetSelector === "#el1-split") + .find((a) => a.method === "fromTo")!; + expect(first.duration).toBe(2); + expect(first.properties.x).toBe(40); + expect(continuation.duration).toBe(2); + expect(continuation.fromProperties?.x).toBe(40); + expect(continuation.properties.x).toBe(80); + }); + it("round-trips correctly through parseGsapScript", () => { const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 4 }, 0);`; const result = split(script); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 0f383bc673..a2f1047128 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1563,6 +1563,67 @@ function insertInheritedStateSet( return recast.print(parsed.ast).code; } +/** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved + * config key (attached to the tween, never applied to the target), so it carries + * the tag without triggering GSAP's "Invalid property" warning. */ +const STUDIO_HOLD_MARKER = "hf-hold"; + +/** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween. + * The Studio filters these out so they never appear as user keyframes/diamonds. */ +export function isStudioHoldSet(anim: GsapAnimation): boolean { + return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER; +} + +/** + * Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed + * tween that starts after t=0, so the element holds its first keyframe's position + * BEFORE the tween plays instead of snapping to its CSS base (the universal NLE + * "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"` + * so this pass owns it: every call wipes the prior holds and recomputes from the + * current keyframes, keeping them in sync as keyframes are added/moved/deleted. + * + * Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale + * keep their authored pre-tween behavior. A tween already starting at 0 needs no + * hold (no gap before it). + */ +export function syncPositionHoldsBeforeKeyframes(script: string): string { + let parsed: ParsedGsap; + try { + parsed = parseGsapScript(script); + } catch { + return script; + } + // 1. Drop every hold this pass previously emitted, so we recompute fresh. + let result = script; + const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id); + for (const id of staleHoldIds) result = removeAnimationFromScript(result, id); + + // 2. Re-add a hold for each position-keyframed tween that starts after t=0. + let reparsed: ParsedGsap; + try { + reparsed = parseGsapScript(result); + } catch { + return result; + } + for (const anim of reparsed.animations) { + if (!anim.keyframes) continue; + const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); + if (!(start > 0.001)) continue; + const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0]; + if (!firstKf) continue; + const posProps: Record = {}; + for (const [k, v] of Object.entries(firstKf.properties)) { + if (classifyPropertyGroup(k) === "position" && typeof v === "number") posProps[k] = v; + } + if (Object.keys(posProps).length === 0) continue; + result = insertInheritedStateSet(result, anim.targetSelector, 0, { + ...posProps, + data: STUDIO_HOLD_MARKER, + }); + } + return result; +} + // ── Split Animation Functions ───────────────────────────────────────────── export interface SplitAnimationsOptions { @@ -1639,9 +1700,25 @@ export function splitAnimationsInScript( continue; } + // `<=` (not `<`) is deliberate: a tween whose end coincides exactly with + // the split boundary has fully played by splitTime, so it belongs to the + // first half and contributes its resting state to the clone. The spanning + // branch below handles only strictly-mid-flight tweens (pos < split < end). if (animEnd <= opts.splitTime) { - for (const [k, v] of Object.entries(anim.properties)) { - inheritedProps[k] = v; + // Only a completed .from() reverts the element to its natural state, so + // its recorded properties are the HIDDEN start (e.g. opacity:0), not the + // resting state — clearing them keeps the clone at its natural value + // instead of pinning it to the from-values (which made it invisible). + // .fromTo() and .to() both END at their to-values (no revert), so they + // fall through to `else` and inherit `anim.properties` (the to-values) — + // .fromTo() must NOT join the .from() clear-branch or the clone would + // drop the very state the fromTo just established. + if (anim.method === "from") { + for (const k of Object.keys(anim.properties)) delete inheritedProps[k]; + } else { + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } } continue; } @@ -1787,6 +1864,14 @@ function locateAnimation( return target ? { parsed, target } : null; } +// Animation ids encode the tween's timeline position in ms +// (`#puck-a-to-1200-position`). A gesture/convert can re-emit a tween at a +// different position, changing its id — so a client that cached the old id (its +// selectedGsapAnimations hasn't refreshed) edits a now-nonexistent id and the op +// no-ops. Parse `{selector}-{method}-{posMs}-{group}` so we can fall back to the +// same selector+method+group tween nearest the requested position. +const ANIM_ID_RE = /^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$/; + function locateAnimationWithFallback( script: string, animationId: string, @@ -1794,8 +1879,34 @@ function locateAnimationWithFallback( const loc = locateAnimation(script, animationId); if (loc) return loc; const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - if (convertedId === animationId) return null; - return locateAnimation(script, convertedId); + if (convertedId !== animationId) { + const converted = locateAnimation(script, convertedId); + if (converted) return converted; + } + // Position-drift fallback: match by stable identity (selector+method+group), + // disambiguating by the position closest to the one the caller asked for. + const want = ANIM_ID_RE.exec(animationId); + if (!want) return null; + const [, sel, method, wantPosStr, group] = want; + const wantPos = Number(wantPosStr); + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return null; + } + let best: ParsedGsapAst["located"][number] | null = null; + let bestDist = Number.POSITIVE_INFINITY; + for (const l of parsed.located) { + const m = ANIM_ID_RE.exec(l.id); + if (!m || m[1] !== sel || m[2] !== method || m[4] !== group) continue; + const dist = Math.abs(Number(m[3]) - wantPos); + if (dist < bestDist) { + best = l; + bestDist = dist; + } + } + return best ? { parsed, target: best } : null; } /** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */ @@ -1804,6 +1915,33 @@ function findKeyframesObjectNode(varsArg: AstNode): AstNode | null { return node?.type === "ObjectExpression" ? node : null; } +/** + * Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object + * form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node + * (or null if not array-form). GSAP distributes an array evenly, so this is + * runtime-identical — but it gives the percentage-keyed write ops something to + * target. Needed before INSERTING a keyframe at an arbitrary percentage, which an + * even array can't host. + */ +function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null { + if (varsArg?.type !== "ObjectExpression") return null; + const prop = (varsArg.properties ?? []).find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes", + ); + if (!prop || prop.value?.type !== "ArrayExpression") return null; + const els: AstNode[] = (prop.value.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = els.length; + if (n === 0) return null; + const entries = els.map((el: AstNode, i: number) => { + const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; + return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`; + }); + prop.value = parseExpr(`{ ${entries.join(", ")} }`); + return prop.value; +} + /** Filter percentage-keyed properties from a keyframes ObjectExpression. */ function filterPercentageProps(kfNode: AstNode): AstNode[] { return kfNode.properties.filter((p: AstNode) => { @@ -1856,6 +1994,11 @@ export function addKeyframeToScript( if (!loc) return script; let kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + // Array-form keyframes can't host an arbitrary new percentage — normalize to + // object form in place first. (convertToKeyframesInScript below only converts + // FLAT tweens; it early-returns when keyframes already exist.) + if (!kfNode) kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg); + if (!kfNode) { script = convertToKeyframesInScript(script, animationId); loc = locateAnimationWithFallback(script, animationId); @@ -1967,6 +2110,43 @@ export function removeKeyframeFromScript( animationId: string, percentage: number, ): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The object-form path below can't see them + // (findKeyframesObjectNode only matches ObjectExpression), so removing from an + // array-form tween silently no-op'd. Resolve the element by its implicit + // percentage and splice it; collapse to a flat tween when fewer than two remain. + const arrLoc = locateAnimationWithFallback(script, animationId); + // findPropertyNode here returns the property's VALUE node directly. + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {}; + collapseKeyframesToFlat(arrLoc.target.call.varsArg, record); + } else { + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements.splice(realIdx, 1); + } + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; @@ -1999,6 +2179,36 @@ export function updateKeyframeInScript( properties: Record, ease?: string, ): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The percentage-keyed object path below can't + // match them (findKeyframesObjectNode only matches ObjectExpression), so dragging + // a motion-path node on an array-authored tween silently no-op'd. Resolve the + // element by its implicit percentage and replace it in place. Mirrors the array + // branch in removeKeyframeFromScript. + const arrLoc = locateAnimationWithFallback(script, animationId); + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease); + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; @@ -2346,6 +2556,176 @@ export function updateArcSegmentInScript( return recast.print(loc.parsed.ast).code; } +/** + * Move a single motionPath waypoint (anchor) to a new position. The waypoint + * list is normalized to anchors for both straight and cubic paths, so + * `pointIndex` matches the node order the studio overlay renders; cubic control + * points are preserved. No-op when the animation/arc is missing or the index is + * out of range. + */ +export function updateMotionPathPointInScript( + script: string, + animationId: string, + pointIndex: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (!anim.arcPath?.enabled) return script; + + const waypoints = extractArcWaypoints(anim); + if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2) return script; + + const nextWaypoints = waypoints.map((wp, i) => + i === pointIndex ? { x: point.x, y: point.y } : wp, + ); + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: nextWaypoints, + segments: anim.arcPath.segments, + autoRotate: anim.arcPath.autoRotate, + }); + + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) { + existingProp.value = parseExpr(motionPathCode); + } + + return recast.print(loc.parsed.ast).code; +} + +/** True when any segment carries explicit cubic control points. Add/remove are + * restricted to curviness (non-cubic) paths — synthesizing control points for + * an inserted cubic anchor is out of scope. */ +function hasCubicSegments(segments: ArcPathSegment[]): boolean { + return segments.some((s) => s.cp1 != null || s.cp2 != null); +} + +function writeMotionPathValue( + loc: NonNullable>, + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], + autoRotate: boolean | number, +): string { + const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate }); + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) existingProp.value = parseExpr(motionPathCode); + return recast.print(loc.parsed.ast).code; +} + +/** + * Insert a waypoint at `index` (between existing anchors), splitting the segment + * it lands on so the new neighbor inherits its curviness. Non-cubic paths only. + * No-op for missing animation/arc, out-of-range index, or cubic paths. + */ +export function addMotionPathPointInScript( + script: string, + animationId: string, + index: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + // Insert strictly between two anchors: index 1..length-1. + if (index < 1 || index > waypoints.length - 1) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 0, { x: point.x, y: point.y }); + const splitCurviness = segments[index - 1]?.curviness ?? 1; + segments.splice(index - 1, 0, { curviness: splitCurviness }); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Remove the waypoint at `index`. Refuses to drop below two anchors (a path + * can't have fewer). Non-cubic paths only. No-op for missing animation/arc, + * out-of-range index, cubic paths, or a 2-point path. + */ +export function removeMotionPathPointInScript( + script: string, + animationId: string, + index: number, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 1); + // Drop the segment on the side that still exists (last anchor → preceding segment). + segments.splice(Math.min(index, segments.length - 1), 1); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Author a fresh 2-anchor motionPath tween on a target element: a straight line + * from the element's home (0,0) to `point`, gentle ease, ready for waypoint + * editing. Mirrors `addAnimationWithKeyframesToScript`. + */ +export function addMotionPathToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + point: { x: number; y: number }, + ease = "power1.inOut", +): { script: string; id: string | null } { + // `id: null` on the failure paths is a deliberate sentinel: callers must + // null-check before chaining (e.g. locating the new tween). An empty string + // would silently flow into selector/locate calls and match nothing. + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); + return { script, id: null }; + } + if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + return { script, id: null }; + } + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: [ + { x: 0, y: 0 }, + { x: point.x, y: point.y }, + ], + segments: [{ curviness: 1 }], + autoRotate: false, + }); + const selector = JSON.stringify(targetSelector); + const varEntries = [ + `motionPath: ${motionPathCode}`, + `duration: ${valueToCode(duration)}`, + `ease: ${JSON.stringify(ease)}`, + ]; + const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`; + const newStatement = parseScript(stmtCode).program.body[0]; + insertAfterAnchor(parsed, newStatement); + + const result = recast.print(parsed.ast).code; + const reParsed = parseGsapAst(result); + const newId = reParsed.located[reParsed.located.length - 1]?.id ?? null; + return { script: result, id: newId }; +} + export function removeArcPathFromScript(script: string, animationId: string): string { return setArcPathInScript(script, animationId, { enabled: false, diff --git a/packages/core/src/parsers/gsapParserExports.ts b/packages/core/src/parsers/gsapParserExports.ts index eb9c3aec0e..5917d13ded 100644 --- a/packages/core/src/parsers/gsapParserExports.ts +++ b/packages/core/src/parsers/gsapParserExports.ts @@ -31,6 +31,10 @@ export { SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize.js"; +// Studio position-hold predicate (`tl.set(...,{data:"hf-hold"})`). A pure +// GsapAnimation helper — re-exported here so studio can filter holds via the +// public entry even though gsapParser.ts is otherwise an internal module. +export { isStudioHoldSet } from "./gsapParser.js"; export type { PropertyGroupName } from "./gsapConstants.js"; export { PROPERTY_GROUPS, diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index 839b1079b1..0c0b16fa65 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -250,6 +250,41 @@ describe("T6c — keyframe write ops", () => { expect(result).toContain("}, 0.2)"); }); + it("updateKeyframeInScript edits ARRAY-form keyframes by percentage→index (the #shuttle case)", () => { + // Array-form keyframes carry no explicit percentages; GSAP distributes 4 of + // them evenly → 0 / 33.3 / 66.7 / 100. Dragging the 2nd motion-path node + // (pct 33.3) must rewrite array index 1 — not no-op (regression: array form + // bailed the ObjectExpression check, so the drag committed nothing). + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);'; + const result = updateKeyframeInScript(script, "#shuttle-to-5200-position", 33.3, { + x: 503, + y: 642, + }); + expect(result).not.toBe(script); // actually changed (not a no-op) + expect(result).toContain("x: 503"); + expect(result).toContain("y: 642"); + expect(result).not.toContain("x: 520"); // index 1 replaced + // Sibling array entries untouched. + expect(result).toContain("{ x: 0, y: 0 }"); + expect(result).toContain("{ x: 1040, y: 0 }"); + expect(result).toContain("{ x: 1480, y: 160 }"); + }); + + it("addKeyframeToScript — ARRAY-form normalizes to object form + inserts 50%", () => { + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);'; + const result = addKeyframeToScript(script, "#shuttle-to-5200-position", 50, { x: 780, y: 60 }); + expect(result).not.toBe(script); // not a no-op + expect(result).toContain('"50%"'); // converted to percentage-object form + expect(result).toContain("x: 780"); + // Original even-distribution stops preserved as percentage keys. + expect(result).toContain('"0%"'); + expect(result).toContain('"100%"'); + }); + it("addKeyframeToScript inserts new percentage in sorted order", () => { const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 }); expect(result).toContain('"25%"'); diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 6977ced7d2..5b07b8fbbc 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -23,6 +23,7 @@ import { removeArcPathFromScript as removeArcRecast, unrollDynamicAnimations as unrollRecast, addKeyframeToScript as addKeyframeRecast, + updateKeyframeInScript as updateKeyframeRecast, removeKeyframeFromScript as removeKeyframeRecast, addAnimationWithKeyframesToScript as addWithKfRecast, shiftPositionsInScript as shiftRecast, @@ -45,6 +46,7 @@ import { removeArcPathFromScript as removeArcAcorn, unrollDynamicAnimations as unrollAcorn, addKeyframeToScript as addKeyframeAcorn, + updateKeyframeInScript as updateKeyframeAcorn, removeKeyframeFromScript as removeKeyframeAcorn, addAnimationWithKeyframesToScript as addWithKfAcorn, removeAnimationFromScript as removeAnimAcorn, @@ -162,6 +164,57 @@ describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => { }); }); +// Array-form keyframes (`keyframes: [{x,y}, …]`, no explicit %) used to no-op on +// removal in BOTH writers — the object-form path couldn't see the array, so the +// keyframe survived while downstream hold-sync stranded an `hf-hold`. +describe("removeKeyframeFromScript: array-form keyframes (recast + acorn parity)", () => { + const arrayScript = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#p", { + keyframes: [ { x: 0, y: 0 }, { x: -180, y: -60 }, { x: -320, y: 40 }, { x: -460, y: -20 } ], + duration: 3.4, + ease: "power1.inOut" + }, 1.0); + `; + + it("removes the matched element (implicit %) — both writers, parity", () => { + const id = acornId(arrayScript); + expect(parseGsapScript(arrayScript).animations[0]!.id).toBe(id); + + const recastOut = removeKeyframeRecast(arrayScript, id, 67); + const acornOut = removeKeyframeAcorn(arrayScript, id, 67); + + expect(recastOut).not.toBe(arrayScript); + expect(acornOut).not.toBe(arrayScript); + + const recShape = shapeOf(recastOut); + expect(recShape.keyframes?.keyframes.length).toBe(3); + // the 67% element { x: -320, y: 40 } is the one removed + expect(JSON.stringify(recShape.keyframes)).not.toContain("-320"); + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + + it("collapses to a flat tween when fewer than two remain — both writers, parity", () => { + const twoScript = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#p", { keyframes: [ { x: 0, y: 0 }, { x: 100, y: 50 } ], duration: 1 }, 0); + `; + const id = acornId(twoScript); + const recastOut = removeKeyframeRecast(twoScript, id, 100); + const acornOut = removeKeyframeAcorn(twoScript, id, 100); + + expect(shapeOf(recastOut).keyframes).toBeUndefined(); + expect(shapeOf(acornOut).keyframes).toBeUndefined(); + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + + it("no-op when the percentage matches no element", () => { + const id = acornId(arrayScript); + expect(removeKeyframeAcorn(arrayScript, id, 12)).toBe(arrayScript); + expect(removeKeyframeRecast(arrayScript, id, 12)).toBe(arrayScript); + }); +}); + const CONVERT_FIXTURES: Array<{ name: string; script: string; @@ -887,6 +940,143 @@ describe("parity: removeKeyframeFromScript (recast vs acorn)", () => { }); }); +// ── addKeyframeToScript: array-form parity (recast vs acorn) ───────────────── +// The object-form add parity lives above (KF_ADD_* fixtures). Array-form +// keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages — GSAP +// distributes them evenly. Adding an arbitrary percentage can't live in an +// array, so BOTH writers normalize the array to percentage-keyed object form +// first (recast: convertArrayKeyframesToObjectNode; acorn: ensureKeyframesNode +// → convertArrayKeyframesToObject) and then insert/merge. The normalized result +// must reparse identically across writers. +const KF_ADD_ARRAY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#dot", { keyframes: [{ x: 0, y: 0 }, { x: 50, y: 80 }, { x: 100, y: 0 }], duration: 1 }, 0.2); +`; + +describe("parity: addKeyframeToScript array-form (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("normalizes the array then inserts a new percentage in sorted order", () => { + expectParity(KF_ADD_ARRAY_SCRIPT, 25, { x: 20, y: 40 }); + }); + + it("merges a new property into the evenly-distributed mid element", () => { + expectParity(KF_ADD_ARRAY_SCRIPT, 50, { x: 55 }); + }); + + it("carries an ease and backfills a new property across normalized siblings", () => { + expectParity(KF_ADD_ARRAY_SCRIPT, 75, { opacity: 0.5 }, "power1.in", { opacity: 0 }); + }); + + it("no-op on unknown id agrees between writers", () => { + expect(addKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, "bad-id", 25, { x: 1 })).toBe(KF_ADD_ARRAY_SCRIPT); + expect(addKeyframeRecast(KF_ADD_ARRAY_SCRIPT, "bad-id", 25, { x: 1 })).toBe( + KF_ADD_ARRAY_SCRIPT, + ); + }); +}); + +// ── updateKeyframeInScript parity (recast vs acorn) ────────────────────────── +// updateKeyframeInScript REPLACES the value at the targeted keyframe with the +// given properties (it is not a merge — see the object-form below: untouched +// sibling props at that percentage are dropped). Studio's motion-path drag and +// the SDK move/edit path both call it with the COMPLETE property set for the +// keyframe (mutate.ts spreads existingKf.properties), so replace == the caller's +// intent. Object form keys by percentage; array form (no explicit percentages) +// maps the percentage to an evenly-distributed index and replaces in place, +// preserving the array literal. +const UPD_OBJ_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { x: 10, opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; +const UPD_ARRAY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#dot", { keyframes: [{ x: 0, y: 0 }, { x: 50, y: 80 }, { x: 100, y: 0 }], duration: 1 }, 0.2); +`; + +describe("parity: updateKeyframeInScript (recast vs acorn)", () => { + function expectParity( + script: string, + percentage: number, + properties: Record, + ease?: string, + ) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + const acorn = updateKeyframeAcorn(script, id, percentage, properties, ease); + const recast = updateKeyframeRecast(script, id, percentage, properties, ease); + expect(modelOf(acorn)).toEqual(modelOf(recast)); + } + + it("replaces an object-form keyframe value, dropping untouched siblings", () => { + // `50%` was { x: 10, opacity: 0.5 }; both writers replace it with { opacity: 0.7 }. + expectParity(UPD_OBJ_SCRIPT, 50, { opacity: 0.7 }); + }); + + it("replaces an object-form keyframe and carries an ease", () => { + expectParity(UPD_OBJ_SCRIPT, 100, { opacity: 0.9 }, "none"); + }); + + it("replaces an array-form element at its distributed percentage", () => { + expectParity(UPD_ARRAY_SCRIPT, 50, { x: 60, y: 90 }); + }); + + it("replaces an array-form endpoint and carries an ease", () => { + expectParity(UPD_ARRAY_SCRIPT, 0, { x: 5, y: 5 }, "power2.out"); + }); + + it("targets a near-coincident percentage (49 → the 50% array element)", () => { + expectParity(UPD_ARRAY_SCRIPT, 49, { x: 55, y: 85 }); + }); + + it("no-op when the object-form percentage is absent (both writers)", () => { + const id = acornId(UPD_OBJ_SCRIPT); + expect(updateKeyframeAcorn(UPD_OBJ_SCRIPT, id, 33, { opacity: 0.4 })).toBe(UPD_OBJ_SCRIPT); + expect(updateKeyframeRecast(UPD_OBJ_SCRIPT, id, 33, { opacity: 0.4 })).toBe(UPD_OBJ_SCRIPT); + }); + + it("no-op on unknown id agrees between writers", () => { + expect(updateKeyframeAcorn(UPD_OBJ_SCRIPT, "bad-id", 50, { opacity: 0.4 })).toBe( + UPD_OBJ_SCRIPT, + ); + expect(updateKeyframeRecast(UPD_OBJ_SCRIPT, "bad-id", 50, { opacity: 0.4 })).toBe( + UPD_OBJ_SCRIPT, + ); + }); + + // KNOWN DIVERGENCE (acorn-array bug, follow-up — NOT a test artifact): + // For PARTIAL props on ARRAY-form keyframes the two writers disagree. recast's + // array branch (gsapParser.updateKeyframeInScript) does a whole-value REPLACE + // — `arrVal.elements[i] = buildKeyframeValueNode(properties, ease)` — matching + // its own object-form branch and the documented replace contract. acorn's + // array branch (updateArrayKeyframeByPct in gsapWriterAcorn) MERGES instead — + // `{ ...valueNodeToRecord(el), ...properties }` — so updating `50%` with only + // `{ x: 60 }` leaves recast at { x: 60 } but acorn at { x: 60, y: 80 }. acorn's + // array path is inconsistent with both recast AND acorn's own object path. + // Real callers (Studio drag, SDK mutate.ts) always pass the COMPLETE keyframe + // value, so the bug is latent in production — but it's a genuine writer gap to + // fix in gsapWriterAcorn, out of scope for this test-only change. Skipped (not + // deleted) so the contract is documented and the fix has a ready assertion. + it.skip("array-form PARTIAL props: recast replaces, acorn merges (acorn bug)", () => { + const id = acornId(UPD_ARRAY_SCRIPT); + const acorn = updateKeyframeAcorn(UPD_ARRAY_SCRIPT, id, 50, { x: 60 }); + const recast = updateKeyframeRecast(UPD_ARRAY_SCRIPT, id, 50, { x: 60 }); + expect(modelOf(acorn)).toEqual(modelOf(recast)); + }); +}); + // ── 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, diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 7158e8dd4f..568bc255ab 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -744,7 +744,18 @@ export function updateKeyframeInScript( if (!target) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + if (!kfPropNode) return script; + + // Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages + // — GSAP distributes them evenly, and the runtime read assigns even percentages + // (0, 100/(n-1), …). Map the percentage back to an array index and overwrite that + // element in place (preserving the array form). Without this the function bailed + // on the ObjectExpression check, so dragging a motion-path node on an array-form + // tween committed nothing (server no-op). + if (kfPropNode.value?.type === "ArrayExpression") { + return updateArrayKeyframeByPct(script, kfPropNode.value, percentage, properties, ease); + } + if (kfPropNode.value?.type !== "ObjectExpression") return script; const match = findKfPropByPct(kfPropNode.value, percentage); if (!match) return script; @@ -756,6 +767,33 @@ export function updateKeyframeInScript( return ms.toString(); } +// ponytail: even-spacing index map; if array keyframes ever carry per-element +// `duration`, switch to matching the closest cumulative position. +function updateArrayKeyframeByPct( + script: string, + arrayNode: Node, + percentage: number, + properties: Record, + ease?: string, +): string { + const elements = ((arrayNode.elements ?? []) as Array).filter( + (el): el is Node => !!el && el.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + const idx = n > 1 ? Math.round((percentage / 100) * (n - 1)) : 0; + const el = elements[Math.max(0, Math.min(n - 1, idx))]; + if (!el) return script; + const merged: Record = { + ...valueNodeToRecord(el, script), + ...properties, + }; + if (ease) merged.ease = ease; + const ms = new MagicString(script); + ms.overwrite(el.start, el.end, recordToCode(merged)); + return ms.toString(); +} + /** * Build the final property record for the keyframe at `percentage`. If a * keyframe already exists there, MERGE the new props over the existing record @@ -826,6 +864,26 @@ function locateWithKeyframes( } /** Locate a tween's keyframes object, converting a flat tween first if absent. */ +// Array-form keyframes (`keyframes: [{x,y}, …]`) → even-percentage object form +// (`{ "0%": {…}, "33.3%": {…}, … }`). Inserting a keyframe needs percentage keys, +// which an even array can't host. Runtime-identical; mirrors the recast path. +function convertArrayKeyframesToObject(script: string, target: Node): string { + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ArrayExpression") return script; + const els = ((kfPropNode.value.elements ?? []) as Array).filter( + (el): el is Node => !!el && el.type === "ObjectExpression", + ); + const n = els.length; + if (n === 0) return script; + const entries = els.map((el, i) => { + const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; + return `${JSON.stringify(`${pct}%`)}: ${script.slice(el.start, el.end)}`; + }); + const ms = new MagicString(script); + ms.overwrite(kfPropNode.value.start, kfPropNode.value.end, `{ ${entries.join(", ")} }`); + return ms.toString(); +} + function ensureKeyframesNode( script: string, animationId: string, @@ -833,10 +891,19 @@ function ensureKeyframesNode( const direct = locateWithKeyframes(script, animationId); if (direct) return direct; - // No static keyframes object — convert the flat tween, then re-locate. const parsed = parseGsapScriptAcornForWrite(script); const target = parsed?.located.find((l) => l.id === animationId); if (!target) return null; + + // Array-form keyframes → normalize to object form, then re-locate. + const kfProp = findPropertyNode(target.call.varsArg, "keyframes"); + if (kfProp?.value?.type === "ArrayExpression") { + const normalized = convertArrayKeyframesToObject(script, target); + if (normalized !== script) return locateWithKeyframes(normalized, animationId); + return null; + } + + // No static keyframes object — convert the flat tween, then re-locate. const converted = convertFlatTweenToKeyframes(script, target); if (converted === script) return null; return locateWithKeyframes(converted, animationId); @@ -963,6 +1030,53 @@ function collapseKeyframesToFlat( ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`); } +/** Implicit tween-relative percentage of array-form keyframe index `i` of `n` + * (GSAP distributes array keyframes evenly: 0%, 1/(n-1), …, 100%). */ +function arrayKeyframePct(i: number, n: number): number { + return n > 1 ? (i / (n - 1)) * 100 : 0; +} + +// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages — +// GSAP distributes them evenly. removeKeyframeFromScript only handled the +// object-form (`keyframes: { "50%": {…} }`), so removing from an array-form tween +// was a silent no-op (and the downstream hold-sync then stranded an `hf-hold`). +// Resolve the element by its implicit percentage and splice it out; collapse to a +// flat tween when fewer than two remain (parity with the object-form path). +function removeArrayKeyframe( + ms: MagicString, + varsArg: Node, + arrNode: Node, + script: string, + percentage: number, +): boolean { + const elements: Node[] = (arrNode.elements ?? []).filter( + (e: Node | null): e is Node => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return false; + + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const dist = Math.abs(arrayKeyframePct(i, n) - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return false; + + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? valueNodeToRecord(sole, script) : {}; + collapseKeyframesToFlat(ms, varsArg, script, record); + return true; + } + removeProp(ms, elements[matchIdx], elements); + return true; +} + export function removeKeyframeFromScript( script: string, animationId: string, @@ -974,7 +1088,16 @@ export function removeKeyframeFromScript( if (!target) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + if (!kfPropNode) return script; + + if (kfPropNode.value?.type === "ArrayExpression") { + const ms = new MagicString(script); + return removeArrayKeyframe(ms, target.call.varsArg, kfPropNode.value, script, percentage) + ? ms.toString() + : script; + } + + if (kfPropNode.value?.type !== "ObjectExpression") return script; const kfNode = kfPropNode.value; const match = findKfPropByPct(kfNode, percentage); diff --git a/packages/core/src/runtime/clipTree.test.ts b/packages/core/src/runtime/clipTree.test.ts new file mode 100644 index 0000000000..4a8431a698 --- /dev/null +++ b/packages/core/src/runtime/clipTree.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { createClipTree, stableClipId } from "./clipTree"; + +describe("stableClipId", () => { + it("prefers id, falls back to data-hf-id, else null", () => { + const withId = document.createElement("div"); + withId.id = "real"; + withId.setAttribute("data-hf-id", "hf-1"); + expect(stableClipId(withId)).toBe("real"); + + const hfOnly = document.createElement("div"); + hfOnly.setAttribute("data-hf-id", "hf-2"); + expect(stableClipId(hfOnly)).toBe("hf-2"); + + expect(stableClipId(document.createElement("div"))).toBeNull(); + }); +}); + +describe("createClipTree", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + const params = { + startResolver: { resolveStartForElement: () => 0 }, + timelineRegistry: {}, + rootDuration: 10, + }; + + // Regression: id-less children (root index.html uses data-hf-id, not id) must + // get their data-hf-id as the node id — not a synthetic `__clip-N` — so the + // tree aligns with __clipManifest (which also keys on data-hf-id) and inline + // expansion can join parent↔child. + it("ids id-less timed elements by data-hf-id and links them to their parent", () => { + document.body.innerHTML = ` +
+
+

Hi

+
+
`; + + const tree = createClipTree(params); + const scene = tree.roots.find((n) => n.id === "scene"); + expect(scene).toBeDefined(); + const child = scene!.children.find((n) => n.id === "hf-headline"); + expect(child).toBeDefined(); + expect(child!.id).not.toMatch(/^__clip-/); + expect(child!.parentId).toBe("scene"); + }); +}); diff --git a/packages/core/src/runtime/clipTree.ts b/packages/core/src/runtime/clipTree.ts index 0bf87c66d1..5224617dfc 100644 --- a/packages/core/src/runtime/clipTree.ts +++ b/packages/core/src/runtime/clipTree.ts @@ -34,6 +34,18 @@ type MutableClipNode = { const DECORATIVE_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]); +/** + * Stable identity for a timed element, shared by __clipTree and __clipManifest + * so the two id spaces align. Prefers the author `id`, then the generator's + * `data-hf-id` (present on every generated element). Without the data-hf-id + * fallback an id-less child (root index.html children use data-hf-id, not id) + * gets a synthetic `__clip-N` in the tree but `null` in the manifest, so inline + * timeline expansion can't join them and never expands. + */ +export function stableClipId(el: Element): string | null { + return (el as HTMLElement).id || el.getAttribute("data-hf-id") || null; +} + interface StartResolverLike { resolveStartForElement: (element: Element, fallback?: number) => number; } @@ -110,7 +122,7 @@ export function createClipTree(params: { const absoluteStart = startResolver.resolveStartForElement(el, 0); if (resolveDuration(el, timelineRegistry, rootDuration, absoluteStart) <= 0) continue; const node: MutableClipNode = { - id: (el as HTMLElement).id || `__clip-${ordinal++}`, + id: stableClipId(el) ?? `__clip-${ordinal++}`, element: el, parentId: null, children: [], diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bdc0e2c3a2..f3962140c5 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -410,23 +410,6 @@ export function initSandboxRuntimeModular(): void { return resolveStartForElement(element, fallback); }; - const findTimedClipAncestor = ( - element: HTMLElement, - rootComp: HTMLElement | null, - ): HTMLElement | null => { - let node = element.parentElement; - while (node) { - // rootComp may be null when no composition is mounted; the walk still - // terminates via `while (node)` — node === null is never true here. - if (node === rootComp) break; - if (node.hasAttribute("data-start")) { - return node; - } - node = node.parentElement; - } - return null; - }; - const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { @@ -1073,6 +1056,21 @@ export function initSandboxRuntimeModular(): void { const dur = String(rootDuration > 0 ? rootDuration : 1); const seen = new Set(); + // Only an AUTHORED clip (data-start already in the source, captured before + // we stamp anything) should suppress stamping its descendants. An animated + // scene container we auto-stamp below (e.g. an opacity-crossfaded scene) + // must NOT suppress its own animated children — otherwise those children + // never become timeline clips and that scene can't inline-expand. + const authoredTimed = new Set(document.querySelectorAll("[data-start]")); + const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => { + let node = element.parentElement; + while (node && node !== rootComp) { + if (authoredTimed.has(node)) return true; + node = node.parentElement; + } + return false; + }; + // Stamp GSAP-targeted elements if (state.capturedTimeline.getChildren) { try { @@ -1082,7 +1080,7 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(target, rootComp)) continue; + if (hasAuthoredTimedAncestor(target)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1102,7 +1100,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(el, rootComp)) continue; + if (hasAuthoredTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); @@ -1439,6 +1437,46 @@ export function initSandboxRuntimeModular(): void { }; // fallow-ignore-next-line complexity + // Whether a timed clip participates in normal flow (static/relative/sticky). + // In-flow clips must leave the flow when hidden — `visibility:hidden` reserves + // their layout box, so a split sibling would stack below the active half + // instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper, + // and avoids disturbing absolute media playback). Computed once per element. + let timedClipInFlow = new WeakMap(); + const isTimedClipInFlow = (el: HTMLElement): boolean => { + const cached = timedClipInFlow.get(el); + if (cached !== undefined) return cached; + const pos = window.getComputedStyle(el).position; + const inFlow = pos === "static" || pos === "relative" || pos === "sticky"; + timedClipInFlow.set(el, inFlow); + return inFlow; + }; + + // `display:none` is only safe on a LEAF timed clip (no nested timed clips). On a + // container it removes the whole subtree, hiding descendants that are still inside + // their OWN visibility window — e.g. an in-flow composition root whose window + // clamps to the timeline end would black out a child video that should still + // show. `visibility:hidden` doesn't have this problem (a child can override it + // with `visibility:visible`), so containers keep that and only leaves leave-flow. + let timedClipIsLeaf = new WeakMap(); + const isTimedClipLeaf = (el: HTMLElement): boolean => { + const cached = timedClipIsLeaf.get(el); + if (cached !== undefined) return cached; + const leaf = el.querySelector("[data-start]") === null; + timedClipIsLeaf.set(el, leaf); + return leaf; + }; + + // Both caches key on live DOM facts that change when the timed-element set + // changes: leaf status flips when a clip gains/loses a nested `[data-start]` + // descendant (sub-composition load/unload, studio insert/delete), and a swapped + // element can reuse an identity whose in-flow status differs. WeakMap has no + // `clear()`, so drop both maps wholesale — re-derived lazily on next access. + const invalidateTimedClipCaches = () => { + timedClipInFlow = new WeakMap(); + timedClipIsLeaf = new WeakMap(); + }; + const syncMediaForCurrentState = () => { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); @@ -1544,6 +1582,11 @@ export function initSandboxRuntimeModular(): void { if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); } + if (isVisibleNow) { + if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display"); + } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) { + rawNode.style.display = "none"; + } } }; @@ -1605,6 +1648,10 @@ export function initSandboxRuntimeModular(): void { window.__clipManifest = payload; const currentSignature = computeClipTreeSignature(); + if (clipTreeSignature !== currentSignature) { + // The timed-element set changed — leaf/in-flow caches may be stale. + invalidateTimedClipCaches(); + } if (!window.__clipTree || clipTreeSignature !== currentSignature) { const runtimeWindow = window as Window & { __timelines?: Record; diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 333e0a08c5..f4946afbe6 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -20,6 +20,25 @@ describe("collectRuntimeTimelinePayload", () => { expect(result.compositionHeight).toBe(1080); }); + // Regression: id-less timed elements (root index.html children carry + // data-hf-id, not id) must get their data-hf-id as the clip id — not null — + // so the manifest aligns with __clipTree and inline expansion can join them. + it("ids an id-less clip by its data-hf-id", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const clip = document.createElement("h1"); + clip.setAttribute("data-hf-id", "hf-headline"); + clip.setAttribute("data-start", "1"); + clip.setAttribute("data-duration", "3"); + root.appendChild(clip); + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips[0].id).toBe("hf-headline"); + }); + it("collects clips from elements with data-start and data-duration", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 6f8e583818..b3b8e3dfa1 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -4,6 +4,7 @@ import type { RuntimeTimelineScene, RuntimeTimelineLike, } from "./types"; +import { stableClipId } from "./clipTree"; import { swallow } from "./diagnostics"; import { readElementPlaybackRate } from "./media"; import { createRuntimeStartTimeResolver } from "./startResolver"; @@ -424,7 +425,7 @@ export function collectRuntimeTimelinePayload(params: { ? "image" : "element"; clips.push({ - id: (node as HTMLElement).id || nodeCompositionId || null, + id: stableClipId(node) ?? nodeCompositionId ?? null, label: buildTimelineClipLabel(node, kind, clips.length), start, duration, diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 06bb7155e4..a0f3dedcb1 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -508,6 +508,28 @@ describe("splitElementInHtml", () => { expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false); }); + it("splits a GSAP element with no authored timing using fallback timing", () => { + // #title has no data-start/data-duration (GSAP-driven); the store supplies the range. + const gsapSource = `

Hi

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

Hi

`; + expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false); + }); + it("adjusts media playback-start for the second half", () => { const mediaSource = source.replace( 'id="box" class="clip" data-start="1" data-duration="6"', diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index ddbf1c1036..727cd326a2 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -292,12 +292,23 @@ export function splitElementInHtml( target: SourceMutationTarget, splitTime: number, newId: string, + fallbackTiming?: { start: number; duration: number }, ): SplitElementResult { const { document, wrappedFragment } = parseSourceDocument(source); const el = findTargetElement(document, target); if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; - const { start, duration, usesDataEnd } = resolveElementTiming(el); + const timing = resolveElementTiming(el); + const { usesDataEnd } = timing; + let { start, duration } = timing; + // GSAP-animated elements carry their timing in the script, not in data-* attrs, + // so the source has no authored duration. Fall back to the store's (GSAP-derived) + // range — the runtime windows visibility off data-start/data-duration regardless + // of class, so stamping both halves below makes each half show only in its window. + if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) { + start = fallbackTiming.start; + duration = fallbackTiming.duration; + } if (duration <= 0 || splitTime <= start || splitTime >= start + duration) { return { html: source, matched: false, newId: null }; } @@ -316,6 +327,9 @@ export function splitElementInHtml( const clone = el.cloneNode(true) as HTMLElement; clone.setAttribute("id", newId); clone.removeAttribute("data-hf-id"); + // Descendants carry their own data-hf-id; leaving them duplicates the id of + // every nested node (e.g. an inner ), so strip them on the clone too. + for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id"); clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); setElementDuration(clone, splitTime, secondDuration, usesDataEnd); @@ -344,7 +358,9 @@ export function splitElementInHtml( duplicateCssRulesForId(document, originalId, newId); } - // Trim the original element's duration + // Trim the original element's duration. A GSAP element had no data-start; stamp + // it so the runtime windows the first half (visibility selects on [data-start]). + el.setAttribute("data-start", String(Math.round(start * 1000) / 1000)); setElementDuration(el, start, firstDuration, usesDataEnd); // Insert clone after original diff --git a/packages/core/src/studio-api/helpers/subComposition.ts b/packages/core/src/studio-api/helpers/subComposition.ts index 30014d407b..768fe77153 100644 --- a/packages/core/src/studio-api/helpers/subComposition.ts +++ b/packages/core/src/studio-api/helpers/subComposition.ts @@ -6,6 +6,7 @@ import { rewriteCssAssetUrls, rewriteInlineStyleAssetUrls, } from "../../compiler/rewriteSubCompPaths.js"; +import { stripEmbeddedRuntimeScripts } from "../../compiler/htmlDocument.js"; /** * Detect whether `html` is a full document (has ``, ``, or @@ -92,6 +93,23 @@ function extractElementAttrs(el: Element): string { return parts.join(" "); } +/** + * Add `data-composition-file=""` to the comp's root composition + * element (the first `[data-composition-id]` that lacks the attribute), so the + * studio resolves its top-level elements to the right source file. Idempotent; + * a no-op when no composition element is present. + */ +function tagRootCompositionFile(bodyHtml: string, compPath: string): string { + const match = bodyHtml.match(/<[a-zA-Z][^>]*\bdata-composition-id=/); + if (match?.index == null) return bodyHtml; + const tagEnd = bodyHtml.indexOf(">", match.index); + if (tagEnd === -1) return bodyHtml; + if (bodyHtml.slice(match.index, tagEnd).includes("data-composition-file")) return bodyHtml; + return ( + bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd) + ); +} + /** * Build a standalone HTML page for a sub-composition. * @@ -149,6 +167,21 @@ export function buildSubCompositionHtml( rewrittenContent = contentDoc.body.innerHTML || rawComp; } + // A composition file may ship a baked inline runtime (from a prior export: + // data-hyperframes-runtime / __hyperframeRuntime…). The studio injects its own + // preview runtime below, so strip the baked one from the body — otherwise it's + // double-loaded AND the baked inline copy can fail to parse inline (the + // "Unexpected token '<'" SyntaxError seen on comps with a baked runtime). + rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent); + + // The comp's root carries data-composition-id but (unlike inlined sub-comps, + // which inlineSubCompositions tags) no data-composition-file. Without it the + // studio can't resolve which file this comp's top-level elements live in and + // falls back to "index.html" — so the GSAP panel parses the project root (which + // may be a multi-timeline master) and wrongly reports "multiple timelines", + // disabling editing for a single-timeline comp. Tag the root with its own path. + rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath); + // Use the project's index.html to preserve all dependencies const indexPath = join(projectDir, "index.html"); let headContent = ""; @@ -169,6 +202,11 @@ export function buildSubCompositionHtml( // the composition's deps take precedence (last-write-wins for scripts). if (compHeadContent) headContent += `\n${compHeadContent}`; + // Strip any baked runtime the borrowed index/comp carried, for the same + // reason as the body above — done before injecting the preview runtime so the + // injected tag (added next) is never removed. + headContent = stripEmbeddedRuntimeScripts(headContent); + // Ensure runtime is present (might differ from the one in index.html) if ( !headContent.includes("hyperframe.runtime") && diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/core/src/studio-api/routes/files.test.ts index 302831dba5..c65238a089 100644 --- a/packages/core/src/studio-api/routes/files.test.ts +++ b/packages/core/src/studio-api/routes/files.test.ts @@ -503,6 +503,46 @@ const tl = gsap.timeline(); expect(body.error).toContain("fromProperties"); }); + // A rotation-only keyframe set must strip the legacy studio rotation channel just + // as a position keyframe set strips the offset channel — otherwise --hf-studio-rotation + // double-applies on top of the new GSAP rotation tween. + it("replace-with-keyframes strips studio rotation edits for a rotation-only keyframe set", async () => { + const projectDir = createProjectDir(); + const ROT_COMP = ` +
+ +`; + writeHtml(projectDir, "rot.html", ROT_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "rot.html"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/rot.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: "#box", + position: 0, + duration: 1, + keyframes: [ + { percentage: 0, properties: { rotation: 0 } }, + { percentage: 100, properties: { rotation: 90 } }, + ], + }), + }); + const result = (await res.json()) as { ok: boolean; after: string }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.after).not.toContain("--hf-studio-rotation"); + expect(result.after).not.toContain("data-hf-studio-rotation"); + }); + it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => { const projectDir = createProjectDir(); writeComp(projectDir, "scene.html", TEMPLATE_COMP); diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 0ce977592d..b6b288b540 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -28,6 +28,7 @@ import { type UnsafeMutationValue, } from "../helpers/finiteMutation.js"; import type { GsapAnimation } from "../../parsers/gsapSerialize.js"; +import { classifyPropertyGroup } from "../../parsers/gsapConstants.js"; import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js"; import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js"; import { @@ -315,21 +316,49 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe let stripped = 0; try { for (const el of document.querySelectorAll(selector)) { - if (!el.getAttribute("data-hf-studio-path-offset")) continue; if (!isHTMLElement(el)) continue; const htmlEl = el; - const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate"); - htmlEl.style.removeProperty("--hf-studio-offset-x"); - htmlEl.style.removeProperty("--hf-studio-offset-y"); - if (originalTranslate) { - htmlEl.style.setProperty("translate", originalTranslate); - } else { - htmlEl.style.removeProperty("translate"); + let touched = false; + // Manual path offset (--hf-studio-offset / translate) — a GSAP position tween + // now owns position, so the stale offset channel must go. + if (el.getAttribute("data-hf-studio-path-offset")) { + const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate"); + htmlEl.style.removeProperty("--hf-studio-offset-x"); + htmlEl.style.removeProperty("--hf-studio-offset-y"); + if (originalTranslate) { + htmlEl.style.setProperty("translate", originalTranslate); + } else { + htmlEl.style.removeProperty("translate"); + } + el.removeAttribute("data-hf-studio-path-offset"); + el.removeAttribute("data-hf-studio-original-translate"); + el.removeAttribute("data-hf-studio-original-inline-translate"); + touched = true; + } + // Manual rotation (--hf-studio-rotation / rotate) — likewise, a GSAP rotation + // set/tween now owns rotation, so clear the legacy CSS-var channel. + if (el.getAttribute("data-hf-studio-rotation")) { + const originalRotate = el.getAttribute("data-hf-studio-original-inline-rotate"); + const originalOrigin = el.getAttribute("data-hf-studio-original-rotation-transform-origin"); + htmlEl.style.removeProperty("--hf-studio-rotation"); + if (originalRotate) { + htmlEl.style.setProperty("rotate", originalRotate); + } else { + htmlEl.style.removeProperty("rotate"); + } + if (originalOrigin) { + htmlEl.style.setProperty("transform-origin", originalOrigin); + } else { + htmlEl.style.removeProperty("transform-origin"); + } + el.removeAttribute("data-hf-studio-rotation"); + el.removeAttribute("data-hf-studio-rotation-draft"); + el.removeAttribute("data-hf-studio-original-rotate"); + el.removeAttribute("data-hf-studio-original-inline-rotate"); + el.removeAttribute("data-hf-studio-original-rotation-transform-origin"); + touched = true; } - el.removeAttribute("data-hf-studio-path-offset"); - el.removeAttribute("data-hf-studio-original-translate"); - el.removeAttribute("data-hf-studio-original-inline-translate"); - stripped++; + if (touched) stripped++; } } catch { // Invalid selector — skip silently. @@ -337,6 +366,30 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe return stripped; } +// A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP +// position tween both drive translate — keeping both stacks the offsets (a gesture or +// drag recorded over a stale offset plays shoved off-position). When a committed tween +// writes a position property, the tween owns position, so the stale offset must go. +function keyframesWritePosition( + keyframes: Array<{ properties: Record }>, +): boolean { + return keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), + ); +} + +// A studio rotation edit (--hf-studio-rotation / data-hf-studio-rotation) and a GSAP +// rotation tween both drive rotate — keeping both stacks them. When a committed keyframe +// set writes a rotation property, the tween owns rotation, so the stale CSS-var channel +// must go (the position twin of this is `keyframesWritePosition`). +function keyframesWriteRotation( + keyframes: Array<{ properties: Record }>, +): boolean { + return keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "rotation"), + ); +} + function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined { if (!kfs) return undefined; for (let i = kfs.keyframes.length - 1; i >= 0; i--) { @@ -468,6 +521,24 @@ type GsapMutationRequest = cp1?: { x: number; y: number }; cp2?: { x: number; y: number }; } + | { + type: "update-motion-path-point"; + animationId: string; + pointIndex: number; + x: number; + y: number; + } + | { type: "add-motion-path-point"; animationId: string; index: number; x: number; y: number } + | { type: "remove-motion-path-point"; animationId: string; index: number } + | { + type: "add-motion-path"; + targetSelector: string; + position: number; + duration: number; + x: number; + y: number; + ease?: string; + } | { type: "remove-arc-path"; animationId: string } | { type: "add-with-keyframes"; @@ -535,6 +606,41 @@ type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +// Mutations that can change a position tween's first keyframe (value/existence/timing) +// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. +// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts +// on every tween that has keyframes whose first percentage carries a position prop and +// whose start is > 0. So any mutation that creates such a tween, retargets it, or moves +// its start across the t=0 boundary must trigger a re-sync. +const HOLD_SYNC_MUTATION_TYPES = new Set([ + "add-keyframe", + "update-keyframe", + "remove-keyframe", + "remove-all-keyframes", + "add-with-keyframes", + "replace-with-keyframes", + "convert-to-keyframes", + "materialize-keyframes", + "update-motion-path-point", + "add-motion-path-point", + "remove-motion-path-point", + // Authors a fresh motionPath tween whose parsed first keyframe is (0,0); if it lands + // at position > 0 the element snaps home at t=0 without a pre-tween hold-`set`. + "add-motion-path", + // Can move a tween's `position` (start) across the t=0 boundary, which flips whether a + // keyframed position tween needs a hold (started at 0 → moved later, or vice versa). + "update-meta", + // Time-shift / time-scale tweens, which can move a keyframed position tween's start + // across t=0, flipping hold need; stale holds are not repositioned by these ops. + "shift-positions", + "scale-positions", + // Retargets keyframed position tweens to a cloned element's selector; the old hold is + // keyed to the prior selector, so holds must be rebuilt for the new target. + "split-animations", + "delete", + "delete-all-for-selector", +]); + async function executeGsapMutation( body: GsapMutationRequest, block: NonNullable>, @@ -824,6 +930,10 @@ async function executeGsapMutationRecast( unrollDynamicAnimations, setArcPathInScript, updateArcSegmentInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, @@ -877,6 +987,17 @@ async function executeGsapMutationRecast( if (body.fromProperties && body.method !== "fromTo") { return respond({ error: "fromProperties is only valid for method=fromTo" }, 400); } + // A new position/rotation animation owns that channel — strip the matching + // legacy studio CSS var (--hf-studio-offset / --hf-studio-rotation) so it can't + // double with the tween, matching add-with-keyframes/replace-with-keyframes. + if ( + Object.keys(body.properties).some((k) => { + const group = classifyPropertyGroup(k); + return group === "position" || group === "rotation"; + }) + ) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationToScript(block.scriptText, { targetSelector: body.targetSelector, method: body.method, @@ -987,10 +1108,39 @@ async function executeGsapMutationRecast( ...(body.cp2 ? { cp2: body.cp2 } : {}), }); } + case "update-motion-path-point": { + return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, { + x: body.x, + y: body.y, + }); + } + case "add-motion-path-point": { + return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, { + x: body.x, + y: body.y, + }); + } + case "remove-motion-path-point": { + return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index); + } + case "add-motion-path": { + const result = addMotionPathToScript( + block.scriptText, + body.targetSelector, + body.position, + body.duration, + { x: body.x, y: body.y }, + body.ease, + ); + return result.script; + } case "remove-arc-path": { return removeArcPathFromScript(block.scriptText, body.animationId); } case "add-with-keyframes": { + if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationWithKeyframesToScript( block.scriptText, body.targetSelector, @@ -1002,6 +1152,9 @@ async function executeGsapMutationRecast( return result.script; } case "replace-with-keyframes": { + if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const script = removeAnimationFromScript(block.scriptText, body.animationId); const added = addAnimationWithKeyframesToScript( script, @@ -1277,11 +1430,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { target?: { id?: string; selector?: string; selectorIndex?: number }; splitTime?: number; newId?: string; + elementStart?: number; + elementDuration?: number; }>(c); if ("error" in parsed) return parsed.error; if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) { return c.json({ error: "target, splitTime, and newId required" }, 400); } + const fallbackTiming = + typeof parsed.body.elementStart === "number" && + typeof parsed.body.elementDuration === "number" + ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } + : undefined; let originalContent: string; try { @@ -1294,6 +1454,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { parsed.target, parsed.body.splitTime, parsed.body.newId, + fallbackTiming, ); if (!result.matched) { return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); @@ -1537,7 +1698,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const result = await executeGsapMutation(body, block, respond); if (result instanceof Response) return result; - const newScript = typeof result === "string" ? result : result.script; + let newScript = typeof result === "string" ? result : result.script; + // Keep the "hold before first keyframe" sets in sync after any mutation that can + // change a position tween's first keyframe or its existence. Without it, an + // element snaps to its CSS base before the tween starts instead of holding its + // first keyframe (the universal NLE behavior). + if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) { + const parser = await loadGsapParser(); + newScript = parser.syncPositionHoldsBeforeKeyframes(newScript); + } const changed = newScript !== block.scriptText; const newHtml = changed ? block.replaceScript(newScript) : html; let backupPath: string | null = null; diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts index 8688a3c986..de1be32554 100644 --- a/packages/core/src/studio-api/routes/preview.test.ts +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -114,6 +114,57 @@ describe("registerPreviewRoutes", () => { expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply")); }); + it("injects the GSAP MotionPathPlugin when the composition uses a motionPath", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + ` + +
+ + `, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + // Plugin version is derived from the composition's own gsap (gsap@3 here). + expect(html).toContain("gsap@3/dist/MotionPathPlugin.min.js"); + // Plugin must load AFTER the core gsap script so it can register onto it. + expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("MotionPathPlugin.min.js")); + }); + + it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + ` + +
+ + `, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).not.toContain("MotionPathPlugin.min.js"); + }); + it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => { const projectDir = createProjectDir(); mkdirSync(join(projectDir, "compositions"), { recursive: true }); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index 551ddf477f..d561808a80 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -1,7 +1,7 @@ import type { Hono } from "hono"; import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { injectScriptsIntoHtml } from "../../compiler/htmlDocument.js"; +import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "../../compiler/htmlDocument.js"; import type { StudioApiAdapter } from "../types.js"; import { resolveWithinProject } from "../helpers/safePath.js"; import { getMimeType } from "../helpers/mime.js"; @@ -18,6 +18,7 @@ const PROJECT_SIGNATURE_META = "hyperframes-project-signature"; const GSAP_CDN_VERSION = "3.15.0"; const GSAP_CDN_SCRIPT = ``; const GSAP_CUSTOM_EASE_CDN_SCRIPT = ``; +const GSAP_MOTION_PATH_CDN_SCRIPT = ``; function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string { return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir); @@ -86,6 +87,42 @@ function htmlHasCustomEase(html: string): boolean { ); } +// A composition that drives motion via GSAP's `motionPath` (e.g. a studio-created +// motion path written into the single-source timeline) needs MotionPathPlugin +// registered before the timeline first renders — otherwise the initial seek +// throws "Invalid property motionPath ... Missing plugin?". Detect it anywhere in +// the bundle (the plugin registers globally, so sub-composition usage counts too). +function htmlUsesMotionPath(html: string): boolean { + return /motionPath\s*[:{]/.test(html); +} + +function htmlHasMotionPathPlugin(html: string): boolean { + return ( + /]*src=["'][^"']*MotionPathPlugin/i.test(html) || + /\bwindow\.MotionPathPlugin\b/.test(html) || + /\bMotionPathPlugin\s*=\s*/.test(html) + ); +} + +function injectMotionPathPluginIfNeeded(html: string): string { + if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html; + // The plugin registers onto an already-loaded gsap, so it must come AFTER the + // core gsap script — which often lives at body-end, not . Insert it + // directly after the gsap script tag; only fall back to if none is found + // (e.g. gsap is inlined). + const gsapScript = /]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i; + const match = html.match(gsapScript); + if (match) { + // Match the plugin version to the composition's own gsap so the plugin + // registers cleanly (a minor-version skew triggers a GSAP compatibility warning). + const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION; + const pluginTag = ``; + const end = html.indexOf(match[0]) + match[0].length; + return html.slice(0, end) + "\n" + pluginTag + html.slice(end); + } + return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT); +} + function injectStudioMotionDependencies(html: string, manifestContent: string): string { const manifest = parseStudioMotionManifestContent(manifestContent); if (!manifest.hasMotion) return html; @@ -149,8 +186,10 @@ function injectStudioPreviewAugmentations( activeCompositionPath: string, ): string { return injectStudioMotionScript( - injectGsapCdnFallback( - injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + injectMotionPathPluginIfNeeded( + injectGsapCdnFallback( + injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + ), ), projectDir, activeCompositionPath, @@ -221,7 +260,10 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi let mainCompositionPath = "index.html"; if (!bundled) { if (!diskMain) return c.text("not found", 404); - bundled = normalizedDisk ?? diskMain.html; + // Disk HTML may carry a baked inline runtime from a prior export; strip + // it so the preview runtime injected below isn't double-loaded (the + // bundled path already strips via htmlBundler). Idempotent if absent. + bundled = stripEmbeddedRuntimeScripts(normalizedDisk ?? diskMain.html); mainCompositionPath = diskMain.compositionPath; } diff --git a/packages/producer/build.mjs b/packages/producer/build.mjs index 22fceae09f..50f5daee1a 100644 --- a/packages/producer/build.mjs +++ b/packages/producer/build.mjs @@ -15,12 +15,18 @@ mkdirSync("dist", { recursive: true }); const scriptDir = dirname(fileURLToPath(import.meta.url)); -// The banner provides a real `require` function via createRequire so that -// esbuild's CJS interop (__require) works correctly in ESM output. -// Without this, bundled CJS deps (recast, yauzl, etc.) that call -// require("fs") throw "Dynamic require of 'fs' is not supported". +// The banner provides a real `require` (via createRequire) plus the CJS-only +// `__filename`/`__dirname` globals so esbuild's CJS interop works in ESM output. +// Without `require`, bundled CJS deps (recast, yauzl, etc.) that call +// require("fs") throw "Dynamic require of 'fs' is not supported"; without the +// dirname shims, deps like wawoff2 throw "__dirname is not defined in ES module". const cjsBanner = { - js: "import { createRequire as __cjsRequire } from 'module'; const require = __cjsRequire(import.meta.url);", + js: `import { createRequire as __cjsRequire } from 'module'; +import { fileURLToPath as __cjsFileURLToPath } from 'url'; +import { dirname as __cjsDirname } from 'path'; +const require = __cjsRequire(import.meta.url); +const __filename = __cjsFileURLToPath(import.meta.url); +const __dirname = __cjsDirname(__filename);`, }; const workspaceAliasPlugin = { diff --git a/packages/studio/index.html b/packages/studio/index.html index 7e8dc88ae3..b3659e0763 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -7,7 +7,7 @@ HyperFrames Studio -
+
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 15fddfda71..d3c761a7a9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -428,6 +428,7 @@ export function StudioApp() { domEditSelection: domEditSession.domEditSelection, buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget, applyDomSelection: domEditSession.applyDomSelection, + setRightPanelTab: panelLayout.setRightPanelTab, initialState: initialUrlStateRef.current, }); const studioCtxValue = buildStudioContextValue({ diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index b6b60ec3d8..f998a8db25 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -8,7 +8,6 @@ import { import { getHistoryShortcutLabel } from "../utils/studioHelpers"; import { useStudioShellContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; -import { useDomEditActionsContext } from "../contexts/DomEditContext"; import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -194,7 +193,6 @@ export function StudioHeader({ }: StudioHeaderProps) { const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext(); const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); - const { clearDomSelection } = useDomEditActionsContext(); return (
@@ -279,7 +277,8 @@ export function StudioHeader({ return; } trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); - clearDomSelection(); + // Keep the current selection when collapsing the Inspector — closing + // the panel shouldn't deselect the element. setRightCollapsed(true); }} disabled={!STUDIO_INSPECTOR_PANELS_ENABLED} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index cfa981a3e7..b42a06837d 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout"; import { CaptionOverlay } from "../captions/components/CaptionOverlay"; import { CaptionTimeline } from "../captions/components/CaptionTimeline"; import { DomEditOverlay } from "./editor/DomEditOverlay"; +import { MotionPathOverlay } from "./editor/MotionPathOverlay"; +import { useCompositionDimensions } from "../hooks/useCompositionDimensions"; import { SnapToolbar } from "./editor/SnapToolbar"; import { StudioFeedbackBar } from "./StudioFeedbackBar"; import type { TimelineElement } from "../player"; @@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing"; import { STUDIO_INSPECTOR_PANELS_ENABLED, + STUDIO_KEYFRAMES_ENABLED, STUDIO_PREVIEW_MANUAL_EDITING_ENABLED, STUDIO_PREVIEW_SELECTION_ENABLED, } from "./editor/manualEditingAvailability"; @@ -108,6 +111,7 @@ export function StudioPreviewArea({ isPlaying, refreshPreviewDocumentVersion, } = useStudioPlaybackContext(); + const compositionDimensions = useCompositionDimensions(); const { domEditHoverSelection, @@ -337,6 +341,14 @@ export function StudioPreviewArea({ onToggleRecording={onToggleRecording} /> + {STUDIO_KEYFRAMES_ENABLED && ( + + )} {gestureOverlay} ) : null diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index ecd584a145..fd9d9ac134 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -1,5 +1,10 @@ import { useRef } from "react"; -import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes"; +import { + useEnableKeyframes, + isPlayheadWithinTween, + type EnableKeyframesSession, +} from "../hooks/useEnableKeyframes"; +import { computeElementPercentage } from "../hooks/gsapShared"; import { getNextTimelineZoomPercent, getTimelineZoomPercent, @@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { const anims = session.selectedGsapAnimations; const kfAnim = anims.find((a) => a.keyframes); - const computePct = (time: number) => { - const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1; - return elDuration > 0 - ? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10)) - : 0; - }; - let state: "active" | "inactive" | "none" = "none"; + // Outside the tween, clicking extends the animation to the playhead rather than + // toggling a (clamped) edge keyframe — so the button stays an "add" affordance. + let willExtend = false; if (kfAnim?.keyframes && sel) { - const pct = computePct(currentTime); - state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) - ? "active" - : "inactive"; + if (!isPlayheadWithinTween(kfAnim, currentTime)) { + state = "inactive"; + willExtend = true; + } else { + // Tween-relative percentage (not the clip range) so the button state matches + // where the keyframe would actually land. + const pct = computeElementPercentage(currentTime, sel, kfAnim); + state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) + ? "active" + : "inactive"; + } } - return { state, onToggle: sel ? onToggle : undefined }; + return { state, willExtend, onToggle: sel ? onToggle : undefined }; } // fallow-ignore-next-line complexity @@ -76,7 +83,11 @@ export function TimelineToolbar({ const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); - const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession); + const { + state: keyframeState, + willExtend: keyframeWillExtend, + onToggle: onToggleKeyframe, + } = useKeyframeToggle(domEditSession); return (
@@ -124,7 +135,9 @@ export function TimelineToolbar({ keyframeState === "active" ? "Remove keyframe at playhead" : keyframeState === "inactive" - ? "Add keyframe at playhead" + ? keyframeWillExtend + ? "Add keyframe at playhead (extends animation)" + : "Add keyframe at playhead" : "Enable keyframes" } > diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 7fb292913c..468bebfdfe 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -282,7 +282,6 @@ describe("DomEditOverlay", () => { }; let currentSelection: DomEditSelection | null = selection; - const onToggleRecording = vi.fn(); const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; HTMLDivElement.prototype.setPointerCapture = () => {}; @@ -298,8 +297,6 @@ describe("DomEditOverlay", () => { hoverSelection: null, onSelectionChange: (next: DomEditSelection) => setSelected(next), }), - recordingState: "idle", - onToggleRecording, }); } @@ -340,16 +337,6 @@ describe("DomEditOverlay", () => { "drag", expect.objectContaining({ button: 0 }), ); - const recordButton = host.querySelector( - '[aria-label="Record gesture (R)"]', - ) as HTMLButtonElement; - expect(recordButton).toBeTruthy(); - - act(() => { - recordButton.click(); - }); - - expect(onToggleRecording).toHaveBeenCalledTimes(1); act(() => { root.unmount(); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 12789407c6..5e0f736dd4 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; import { GridOverlay } from "./GridOverlay"; -import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl"; +import type { GestureRecordingState } from "./GestureRecordControl"; // Re-exports for external consumers — preserving existing import paths. export { @@ -55,6 +55,7 @@ interface DomEditOverlayProps { onPathOffsetCommit: ( selection: DomEditSelection, next: { x: number; y: number }, + modifiers?: { altKey?: boolean }, ) => Promise | void; onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise | void; onBoxSizeCommit: ( @@ -87,8 +88,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onGroupPathOffsetCommit, onBoxSizeCommit, onRotationCommit, - recordingState, - onToggleRecording, }: DomEditOverlayProps) { const overlayRef = useRef(null); const boxRef = useRef(null); @@ -243,6 +242,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ if (!selection) return "none"; return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`; }, [selection]); + const groupBounds = useMemo( () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)), [groupOverlayItems], @@ -433,13 +433,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ />
)} - {onToggleRecording && ( - - )}
{ + it("maps anchor keyframes to their tween-relative percentages", () => { + expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5); + expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5); + }); + + it("linearly interpolates a clip-relative playhead into tween space", () => { + // clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%. + expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5); + }); + + it("falls back to the input when there's no usable mapping", () => { + expect(clipToTweenPercentage([], 40)).toBe(40); + expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40); + }); +}); diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx index 48f2f51770..c54047c305 100644 --- a/packages/studio/src/components/editor/KeyframeNavigation.tsx +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond"; interface KeyframeNavigationProps { property: string; - /** All keyframes for this element's tween, or null if no keyframes exist */ + /** All keyframes for this element's tween, or null if no keyframes exist. + * `percentage` is clip-relative (element lifetime) for display/seek; + * `tweenPercentage` is the tween-relative value the writer/runtime key on. */ keyframes: Array<{ percentage: number; + tweenPercentage?: number; properties: Record; ease?: string; }> | null; @@ -19,6 +22,26 @@ interface KeyframeNavigationProps { const TOLERANCE = 0.5; +/** + * Convert a clip-relative percentage (element lifetime, used for display/seek) to + * the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween + * map is linear, recovered from the keyframes' own (percentage, tweenPercentage) + * pairs. Falls back to the input when there's no usable mapping (e.g. parser + * keyframes that are already tween-relative, or fewer than two anchors). + */ +export function clipToTweenPercentage( + keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>, + clipPct: number, +): number { + const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number"); + if (mapped.length < 2) return clipPct; + const a = mapped[0]!; + const b = mapped[mapped.length - 1]!; + if (b.percentage === a.percentage) return a.tweenPercentage!; + const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage); + return a.tweenPercentage! + (clipPct - a.percentage) * slope; +} + function ArrowLeft({ disabled }: { disabled: boolean }) { return ( { if (diamondState === "ghost") { onConvertToKeyframes(); - } else if (diamondState === "active") { - onRemoveKeyframe(currentPercentage); + } else if (diamondState === "active" && atCurrent) { + onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage); } else { - onAddKeyframe(currentPercentage); + onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage)); } }; diff --git a/packages/studio/src/components/editor/MotionPathNode.tsx b/packages/studio/src/components/editor/MotionPathNode.tsx new file mode 100644 index 0000000000..c6d3ce43ca --- /dev/null +++ b/packages/studio/src/components/editor/MotionPathNode.tsx @@ -0,0 +1,98 @@ +import type React from "react"; + +// Editor primary color (themeable via --hf-accent). Applied through inline +// style because CSS var() isn't valid in SVG presentation attributes. +export const ACCENT = "var(--hf-accent, #3CE6AC)"; + +/** One path node: a diamond (matching the timeline keyframe), a wider transparent + * grab target (when editable), and a hover-revealed × delete badge (when removable). */ +export function MotionPathNode(props: { + cx: number; + cy: number; + r: number; + interactive: boolean; + removable: boolean; + grabbing: boolean; + selected: boolean; + onEnter: () => void; + onLeave: () => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onRemove: (e: React.PointerEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; +}) { + const { cx, cy, r, interactive, removable, grabbing, selected } = props; + const bx = cx + r * 1.8; + const by = cy - r * 1.8; + const k = r * 0.55; + // Diamond matching the timeline keyframe (a 45°-rotated rounded square). + // `side` is chosen so the diamond's points reach ~`r` from center, matching the + // old dot's footprint; selection is shown by enlarging it (no extra shape). + const side = (selected ? r * 1.5 : r) * 1.414; + return ( + + + {interactive && ( + + )} + {removable && ( + + + + + + )} + + ); +} diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx new file mode 100644 index 0000000000..e96498e2e0 --- /dev/null +++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx @@ -0,0 +1,481 @@ +import { memo, useEffect, useRef, useState, type RefObject } from "react"; +import type { DomEditSelection } from "./domEditing"; +import { useDomEditContext } from "../../contexts/DomEditContext"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit"; +import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry"; +import { editableAnimationId, selectorFor } from "./motionPathSelection"; +import { ACCENT, MotionPathNode } from "./MotionPathNode"; +import { + KeyframeDiamondContextMenu, + type KeyframeDiamondContextMenuState, +} from "../../player/components/KeyframeDiamondContextMenu"; +import { + commitAddKeyframe, + commitAddWaypoint, + commitCreatePath, + commitNode, + commitRemoveWaypoint, +} from "./motionPathCommit"; +import { + elementHome, + hasMotionPathPlugin, + isPreviewHtmlElement, + useMotionPathData, +} from "./useMotionPathData"; + +interface MotionPathOverlayProps { + iframeRef: RefObject; + selection: DomEditSelection | null; + compositionSize: { width: number; height: number } | null; + isPlaying: boolean; +} + +type Draft = { index: number; x: number; y: number }; +type DragState = { + index: number; + startX: number; + startY: number; + initX: number; + initY: number; + scale: number; + ref: MotionNodeRef; +}; + +const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom) +// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click +// (select the keyframe); at or above it the gesture commits a move. Screen-space +// (not composition px) so it behaves identically at any zoom. +const DRAG_THRESHOLD_PX = 3; + +/** + * Draws the selected element's GSAP motion path over the canvas — a dashed + * polyline through its x/y keyframes (or motionPath waypoints) with a draggable + * node at each. Dragging an x/y node rewrites the keyframe; dragging a waypoint + * rewrites the motionPath point; both commit to source (undoable). Renders in + * declared composition coordinates so the path doesn't drift under GSAP + * transforms. Read-only (no drag) while playing or when the tween isn't + * statically editable. Nothing renders when the selection has no positional + * motion. + */ +// fallow-ignore-next-line complexity +export const MotionPathOverlay = memo(function MotionPathOverlay({ + iframeRef, + selection, + compositionSize, + isPlaying, +}: MotionPathOverlayProps) { + const { + commitMutation, + selectedGsapAnimations, + handleGsapRemoveKeyframe, + handleGsapDeleteAllForElement, + } = useDomEditContext(); + const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData( + iframeRef, + selectorFor(selection), + ); + const [draft, setDraft] = useState(null); + const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null); + const [hoverNode, setHoverNode] = useState(null); + // Right-click context menu on a keyframe node — same delete actions as the + // timeline keyframe diamond. + const [kfMenu, setKfMenu] = useState(null); + // The keyframe % selected by clicking its node — highlighted, and the next drag + // modifies it rather than adding a keyframe. + const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct); + // Set-destination mode is armed from the preview toolbar (replaces the old + // double-click-on-canvas UX). See createMode effects below. + const armed = usePlayerStore((s) => s.motionPathArmed); + const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed); + const setMotionPathCreateAvailable = usePlayerStore((s) => s.setMotionPathCreateAvailable); + const dragRef = useRef(null); + // Park-on-click is debounced so a double-click cancels the seek (see onUp). + const parkTimerRef = useRef | undefined>(undefined); + // The animation id whose path is currently editable. Computed at hook level (not + // just in render, after the early returns) so the park-timer cleanup can key on + // it: a pending park seek belongs to the OLD animation, so firing it after the + // active animation changed would jump the playhead onto a stale keyframe. + const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry?.kind ?? "linear"); + // Clear the debounced park timer on unmount AND whenever the active animation id + // changes — not unmount-only, or a queued seek from the previous selection still + // fires against the new one. + useEffect(() => () => clearTimeout(parkTimerRef.current), [animId]); + + // Create mode: a selected element with no positional motion can be given a new + // motionPath. Gated on `geometryResolved` so a fresh selection never counts as + // "no path" before the first runtime read confirms it (see useMotionPathData). + const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying; + const createSelector = createMode ? selectorFor(selection) : null; + const compW = compositionSize?.width ?? null; + const canCreate = createMode && hasMotionPathPlugin(iframeRef.current); + + // Publish whether the selected element can take a path so the preview toolbar + // shows its "set destination" toggle. Drops to false when this overlay unmounts + // or the context changes, so the button never lingers for a stale selection. + useEffect(() => { + setMotionPathCreateAvailable(Boolean(canCreate)); + return () => setMotionPathCreateAvailable(false); + }, [canCreate, setMotionPathCreateAvailable]); + + // Disarm when set-destination is no longer possible (element gains a path, gets + // deselected, or playback starts) so a toggle left on can't fire later. + useEffect(() => { + if (armed && !canCreate) setMotionPathArmed(false); + }, [armed, canCreate, setMotionPathArmed]); + + // While armed, the next canvas press sets the destination (replaces the old + // double-click). Scoped to the preview pan-surface in the CAPTURE phase, on + // pointerdown, so it fires before the selection/drag handler underneath — a + // press on empty canvas would otherwise deselect (and disarm) before a later + // click could land. stopPropagation keeps that handler from also running. + // fallow-ignore-next-line complexity + useEffect(() => { + if (!armed || !createSelector || !compW) return; + const surface = + (iframeRef.current?.ownerDocument?.querySelector( + "[data-preview-pan-surface]", + ) as HTMLElement | null) ?? null; + if (!surface) return; + const prevCursor = surface.style.cursor; + surface.style.cursor = "crosshair"; + // fallow-ignore-next-line complexity + const onDown = (e: PointerEvent) => { + if (e.button !== 0) return; // primary press only + const frame = iframeRef.current; + if (!frame || !hasMotionPathPlugin(frame)) return; + const r = frame.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) { + return; + } + // Resolve the element LIVE from the current iframe document — the selected + // node may be detached after a soft-reload, which would skew home. + const live = frame.contentDocument?.querySelector(createSelector); + if (!isPreviewHtmlElement(live, frame)) return; + e.stopPropagation(); + e.preventDefault(); + const sc = r.width / compW; + const elHome = elementHome(live); + const px = Math.round((e.clientX - r.left) / sc - elHome.x); + const py = Math.round((e.clientY - r.top) / sc - elHome.y); + const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100; + void commitCreatePath(createSelector, t, px, py, commitMutation); + setMotionPathArmed(false); + }; + surface.addEventListener("pointerdown", onDown, true); + return () => { + surface.removeEventListener("pointerdown", onDown, true); + surface.style.cursor = prevCursor; + }; + }, [armed, createSelector, compW, iframeRef, commitMutation, setMotionPathArmed]); + + if (!rect || rect.width <= 0 || !compositionSize || compositionSize.width <= 0) return null; + // Hide the whole overlay (path + create hint) when the element isn't painted — + // same "what you see in the preview" rule as the selection box. + if (!visibleInPreview) return null; + // No live anchor (element not in the current document) → can't place the path. + if (!home) return null; + + if (!geometry) { + // Create mode draws nothing by default — the destination is set via the + // preview toolbar's "set destination" toggle (no text sprawled over the + // canvas). Only while armed do we show a faint ring at the element as a + // "click to place" cue (the surface cursor is also crosshair, set above). + if (!armed || !canCreate) return null; + const sc = rect.width / compositionSize.width; + const hr = (NODE_PX / sc) * 1.6; + return ( + + + + ); + } + + const scale = rect.width / compositionSize.width; + const nodeR = NODE_PX / scale; + const interactive = Boolean(animId) && !isPlaying; + // The × "quick remove" badge applies to non-cubic motionPath arcs only (cubic + // anchors carry control points we don't synthesize; keyframe paths remove via + // the right-click menu instead). + const structural = geometry.kind === "arc" && interactive; + const removable = structural && geometry.nodes.length > 2; + // Click-on-path to insert a node works for both kinds: a motionPath waypoint + // (arc paths, including cubic — GSAP recomputes curves around the new point), + // or an x/y keyframe (linear paths) at the projected tween-%. + const addable = interactive; + + const nodes = draft + ? geometry.nodes.map((n, i) => (i === draft.index ? { ...n, x: draft.x, y: draft.y } : n)) + : geometry.nodes; + // ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y + // stay offsets so the drag commit writes the right tween values. + const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y })); + const points = abs.map((p) => `${p.ax},${p.ay}`).join(" "); + // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport + // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the + // absolute-positioned SVG), so subtracting them from a viewport clientX/Y would + // offset the projection by the surface's gutter (panel/toolbar), and the add- + // ghost wouldn't track the cursor. `scale` is unaffected (width is stored raw). + const clientToComp = (e: React.PointerEvent) => { + const vr = iframeRef.current?.getBoundingClientRect(); + const left = vr ? vr.left : rect.left; + const top = vr ? vr.top : rect.top; + return { x: (e.clientX - left) / scale, y: (e.clientY - top) / scale }; + }; + + const onDown = ( + e: React.PointerEvent, + index: number, + x: number, + y: number, + ref: MotionNodeRef, + ) => { + if (!interactive) return; + if (e.button !== 0) return; // primary button only — right-click is the context menu + e.stopPropagation(); + (e.target as Element).setPointerCapture(e.pointerId); + dragRef.current = { + index, + startX: e.clientX, + startY: e.clientY, + initX: x, + initY: y, + scale, + ref, + }; + setDraft({ index, x, y }); + }; + const onMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + setDraft({ + index: d.index, + x: d.initX + (e.clientX - d.startX) / d.scale, + y: d.initY + (e.clientY - d.startY) / d.scale, + }); + }; + // fallow-ignore-next-line complexity + const onUp = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + dragRef.current = null; + setDraft(null); + if (!animId) return; + const screenDx = e.clientX - d.startX; + const screenDy = e.clientY - d.startY; + const x = Math.round(d.initX + screenDx / d.scale); + const y = Math.round(d.initY + screenDy / d.scale); + // Click-vs-drag is decided in SCREEN space, not composition px: the old guard + // compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real + // multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would + // never move. A screen-distance threshold registers any genuine pointer drag + // at any zoom; below it the gesture is a click (select + park the playhead). + const movedScreenPx = Math.hypot(screenDx, screenDy); + if (movedScreenPx < DRAG_THRESHOLD_PX) { + // No drag → treat as a click: select this keyframe and park the playhead on + // it. Selecting it makes the next drag MODIFY this keyframe (honored via + // activeKeyframePct) instead of creating a new one. + if (d.ref.type === "keyframe") { + usePlayerStore.getState().setActiveKeyframePct(d.ref.pct); + const ref = d.ref; + // Debounce the playhead seek: a double-click cancels it (e.detail >= 2), + // so only a lone single-click parks the playhead on the keyframe. + clearTimeout(parkTimerRef.current); + if (e.detail < 2) { + parkTimerRef.current = setTimeout(() => { + const anim = selectedGsapAnimations?.find((a) => a.id === animId); + if (anim) parkPlayheadOnKeyframe(anim, ref.pct); + }, 250); + } + } + return; // no commit + } + // A real drag that still rounds to the same integer comp-px (sub-px move at + // high zoom) would commit an identical value — a no-op undo entry. Skip the + // commit, but don't treat it as a click either (the user did drag). + if (x === Math.round(d.initX) && y === Math.round(d.initY)) return; + void commitNode(d.ref, x, y, animId, commitMutation); + // Park the playhead on the edited keyframe's time so the element previews AT + // that keyframe. Without it, a playhead sitting before the tween renders the + // element's base pose — the edit (correct on the path) looks like it vanished. + if (d.ref.type === "keyframe") { + const anim = selectedGsapAnimations?.find((a) => a.id === animId); + if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct); + } + }; + + // Ghost "add" affordance: project the cursor onto the path; click inserts. + const onPathHover = (e: React.PointerEvent) => { + const c = clientToComp(e); + const np = nearestPointOnPath( + c.x, + c.y, + abs.map((p) => ({ x: p.ax, y: p.ay })), + ); + setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null); + }; + const onPathDown = (e: React.PointerEvent) => { + if (!animId) return; + // Compute the insertion point from the event directly so a click works + // without (or faster than) a preceding hover. + const c = clientToComp(e); + const np = nearestPointOnPath( + c.x, + c.y, + abs.map((p) => ({ x: p.ax, y: p.ay })), + ); + if (!np) return; + const x = Math.round(np.x - home.x); + const y = Math.round(np.y - home.y); + if (geometry.kind === "arc") { + e.stopPropagation(); + void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation); + } else { + // Linear keyframe path: interpolate the new stop's tween-% from the two + // keyframes bounding the clicked segment (np.t = fraction along it), then + // insert it. Lands ON the current line, so the dot doesn't jump — drag it + // after to bend the path. + const a = abs[np.segIndex]?.ref; + const b = abs[np.segIndex + 1]?.ref; + if (a?.type !== "keyframe" || b?.type !== "keyframe") return; + const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000; + e.stopPropagation(); + void commitAddKeyframe(animId, pct, x, y, commitMutation); + } + setGhost(null); + }; + const onRemove = (e: React.PointerEvent, index: number) => { + e.stopPropagation(); + if (!animId) return; + setHoverNode(null); + void commitRemoveWaypoint(animId, index, commitMutation); + }; + + const elementId = selection?.id ?? null; + // Right-click a keyframe node → the timeline's keyframe context menu (delete + // this keyframe / delete all), so motion-path keyframes are removable in place. + const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => { + if (ref.type !== "keyframe" || !animId || !elementId) return; + e.preventDefault(); + e.stopPropagation(); + setKfMenu({ + x: e.clientX, + y: e.clientY, + elementId, + percentage: ref.pct, + tweenPercentage: ref.pct, + }); + }; + + return ( + <> + + {/* Wide transparent hit path drives the add-ghost; drawn under the nodes. + Renders for keyframe paths and non-cubic arcs (see `addable`). */} + {addable && ( + setGhost(null)} + onPointerDown={onPathDown} + /> + )} + + {ghost && ( + + )} + {abs.map((p, i) => ( + setHoverNode(i)} + onLeave={() => setHoverNode((h) => (h === i ? null : h))} + onPointerDown={(e) => onDown(e, i, p.x, p.y, p.ref)} + onPointerMove={onMove} + onPointerUp={onUp} + onRemove={(e) => onRemove(e, i)} + onContextMenu={(e) => onNodeContextMenu(e, p.ref)} + /> + ))} + + {kfMenu && ( + setKfMenu(null)} + onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)} + onDeleteAll={(elId) => handleGsapDeleteAllForElement(`#${elId}`)} + /> + )} + + ); +}); diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx index a63264f450..6b75110da9 100644 --- a/packages/studio/src/components/editor/SnapToolbar.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.tsx @@ -1,7 +1,7 @@ -// fallow-ignore-file unused-file import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { MagnetStraight, GridFour } from "@phosphor-icons/react"; +import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; +import { usePlayerStore } from "../../player/store/playerStore"; const SNAP_DEFAULTS = { snapEnabled: true, @@ -34,6 +34,11 @@ interface SnapToolbarProps { export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) { const [prefs, setPrefs] = useState(readSnapPrefs); const [gridPopoverOpen, setGridPopoverOpen] = useState(false); + // Motion-path "set destination" toggle — shown only when the selected element + // can take a path; arms a single canvas click to place it (MotionPathOverlay). + const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable); + const motionPathArmed = usePlayerStore((s) => s.motionPathArmed); + const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed); const popoverRef = useRef(null); const gridButtonRef = useRef(null); @@ -89,7 +94,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb }, [gridPopoverOpen]); return ( -
+
e.stopPropagation()} + > + {motionPathCreateAvailable && ( + + )}