mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user