feat(studio): instant, flicker-free manual editing (#1605)

* chore(producer): shim __filename/__dirname in the CJS banner

Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.

* chore(producer): use a template literal for the CJS banner (review nit)

* feat(core): add GSAP keyframe + motion-path source mutations

Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.

* fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks

- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
  from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
  removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
  documented as it.skip with a ready assertion (acorn cutover follow-up).

* feat(core): route motion-path mutations through studio-api + fix clip stamping

Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.

Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.

* feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule

A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.

Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.

* fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel

- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
  authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
  shift-positions, scale-positions, split-animations. (add stays out: flat tweens
  only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
  visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
  strips the stale --hf-studio-rotation channel.

* feat(studio): GSAP runtime read layer + shared helpers

* fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.

* feat(studio): GSAP drag/commit/bridge editing infra

* fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore

BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
  so a failed commit leaves the dropped pose, not a cleared element.

* feat(studio): motion-path geometry + commit helpers

* docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics

* feat(studio): on-canvas motion-path overlay

* fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold

* feat(studio): keyframes flag, gesture recording + timeline/selection refinements

* fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens

- useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's
  empty parse isn't overridden by a stale selectedGsapAnimations cache.
- useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick).
- useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent).
- playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV).
- useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per
  group, so a mixed gesture no longer yields an untagged legacy tween.

* feat(studio): single-source manual offset + rotation via the GSAP timeline

Dragging or rotating an element writes into the GSAP timeline (the single source of
truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static
elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and
the live preview moves via gsap.set so what you see equals what is written and renders.
Removes the dual-channel CSS-var/transform reconciliation behind the
fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position
and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/
keyframe commit, dropped the handleDom*Commit CSS fallbacks).

Subcompositions edit the same single-source way, which surfaced and fixes:
- resolve a subcomp element's source file via the composition-id map (the runtime drops
  the source linkage when inlining the subcomposition);
- a selected element's selection box AND motion path use basic visibility, not the
  occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover);
- soft reload rebuilds ONLY the committed composition's timeline, leaving other
  compositions' timelines intact (no cross-composition revert);
- read keyframes from the element's OWN composition timeline (scan all timelines, not
  the first unstable key);
- delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.

* fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures

- DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only
  position/rotation channel; the false branch silently killed drag+rotate (and let GSAP
  elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests.
- gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller
  full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script.
- gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops
  partial reads (no phantom identity) and re-applies the drag override in finally.
- MotionPathOverlay: park-timer cleanup keyed on animId change.
- useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review).

* feat(studio): patchRuntimeTweenInPlace — update a tween's values in place

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.

* fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.

* feat(studio): instantPatch fast path in runCommit

A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.

* feat(studio): route static position/rotation set drags through instantPatch

Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).

* fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)

* feat(studio): no full iframe remount for soft-reloadable edits

A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.

* feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash

ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.

* fix(studio): don't re-save + reload when source editor syncs externally

The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.

* fix(core): inject MotionPathPlugin into preview when a composition uses motionPath

A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.

* fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact)

* fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard

BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
  in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).

* fix(core): align __clipTree and __clipManifest ids via stableClipId

Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.

* fix(core): strip baked runtime + tag comp root in preview assembly

Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).

* feat(studio): set motion-path destination from a toolbar toggle

Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.

* fix(studio): center timeline keyframe diamonds on their percentage

Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).

* fix(studio): resize static elements via tl.set, not a single-stop keyframes tween

Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.

* fix(studio): negative-cache failed media probes

Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.

* fix(studio): type window.setTimeout handle as number

ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.

* fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps

- Fix soft-reload clearProps destroying element inline styles — save cssText,
  clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
This commit is contained in:
Miguel Ángel
2026-06-22 01:21:18 -04:00
committed by GitHub
parent e0822c6e85
commit 091137e3c3
103 changed files with 8012 additions and 1217 deletions
+4 -1
View File
@@ -77,7 +77,10 @@ export function classifyTweenPropertyGroup(
): PropertyGroupName | undefined {
const groups = new Set<PropertyGroupName>();
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);
}
@@ -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);
+384 -4
View File
@@ -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<string, number | string> = {};
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<string, number | string>,
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<ReturnType<typeof locateAnimation>>,
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,
@@ -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,
@@ -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%"');
@@ -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<string, number | string>,
ease?: string,
backfillDefaults?: Record<string, number | string>,
) {
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<string, number | string>,
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,
+126 -3
View File
@@ -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<string, number | string>,
ease?: string,
): string {
const elements = ((arrayNode.elements ?? []) as Array<Node | null>).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<string, number | string> = {
...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<Node | null>).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);