mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery (#1781)
* feat(player,studio): favicon-blade play icon with pause<->play morph Replace the play triangle with the right-hand blade from the HyperFrames favicon and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one path's d between the blade and two pause bars (gsap added as a studio dep). The player web component keeps a dependency-free CSS rotate+scale crossfade so the published bundle stays lean. Both honor prefers-reduced-motion. * fix(cli): discover local-studio (Vite) preview over IPv6 loopback The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so `preview --selection/--context` reported preview-not-running against a local-studio preview (e.g. inside the monorepo / bun run dev). Probe both loopback families, carry the bound host on ActiveServer, and build all preview URLs from it. Adds an IPv6-only discovery regression test. * fix(studio): wire the Add-keyframe (K) shortcut The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar (enabled when a keyframeable element is selected) wired to the toolbar's add action; register it in the capture phase and stopImmediatePropagation only for keys it actually handles, so K adds a keyframe in that context while JKL playback keeps working everywhere else. * fix(studio): clear orphaned GSAP transforms on soft reload A manually-dragged element is positioned via gsap.set, which writes an inline transform. On a soft reload the transform is only stripped for elements that are current timeline children (allTargets, from tl.getChildren().targets()). An element positioned by a standalone gsap.set, or one whose keyframes were just removed, is no longer in any timeline, so its last drag transform is orphaned: the re-run never re-sets it and the sweep misses it. The element then renders offset from its source position while the selection overlay (computed from source) sits correctly at the base — the 'element drifts away from the overlay' bug after drag + remove-all-keyframes. Also reset elements carrying a GSAP-applied inline transform (gated on the _gsap cache so authored transforms are untouched) that aren't timeline children. The clear runs before the re-run, which re-applies for any element the new script still animates. * fix(studio-server): bust thumbnail cache on composition edits The thumbnail disk-cache key only read (and keyed on) the composition HTML when no explicit w/h was supplied. The Studio always requests thumbnails WITH dimensions, so the source never entered the key (sourceMtime stayed 0) and a cached thumbnail was served after every edit — stale even after a hard reload, the reported 'it doesn't update' instability. Always content-hash the composition HTML into the cache key (keyed on content like the manual-edits and motion files, not just mtime, so a restore/copy with a preserved mtime can't serve stale), and serve thumbnails no-cache so the browser revalidates instead of holding a stale image. Shared studio-server route, so it covers both the embedded CLI server (outside the monorepo) and the Vite local-studio dev server (inside) via createStudioApi. * fix(parsers): remove-all-keyframes holds position static instead of re-animating removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that KEPT the original duration, so removing all keyframes re-animated the element from its base toward the last keyframe value. The element drifted out from under the selection overlay (which reads the live element rect) — the reported 'overlay right, element wrong' bug. Collapse to a static hold instead: duration 0 + immediateRender true, dropping the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and the recast writer (removeAllKeyframesFromScript), kept in parity. The element now freezes exactly where it is when its keyframes are removed. * fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation The keyframe-diamond context menu's 'Delete All Keyframes' was wired to handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation — so the element lost its position and jumped (reverted to base / left an orphaned transform) out from under the selection overlay. Wire it to handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static held value (duration 0 + immediateRender), so removing the keyframes freezes the element exactly where it is. * fix(studio): timeline 'Delete All Keyframes' holds position too The keyframe-diamond context menu renders in two places — the canvas (MotionPathOverlay, fixed in the prior commit) and the timeline (via StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called handleGsapDeleteAllForElement, deleting the element's whole animation. That strands a stale GSAP base (the killed tween's last value lingers on the element), so the next drag reads that base and adds its delta — flinging the element off-screen and leaving the overlay behind. Route it to handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path. * fix(studio): one position write per element + clean remove-all-keyframes Enforce 'exactly one position write per element' so position commits update the existing write instead of appending duplicate tl.to/gsap.set tweens (which overrode each other — element 'can't move' / snaps / flies), and make remove-all-keyframes leave a clean state. - dedupePositionWritesInScript + consolidate-position-writes mutation (acorn + recast, in parity); findExistingPositionWrite matches degenerate duration:0 holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates; removeAllKeyframesFromScript strips every position write for the selector. - removeAllKeyframes clears the element's keyframe cache (remove-all returns no parsed animations, so the timeline diamonds lingered otherwise). - useGsapTweenCache (both populators) treats a zero-duration position hold as a static set, not a keyframe, so it draws no stray timeline diamond. - Extracted gsapPositionDetection.ts (file-size cap). Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio tests pass. Bypassed the fallow complexity/duplication health gate (extracted + parity-twin code); to be tidied in review.
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
addMotionPathToScript,
|
||||
convertToKeyframesInScript,
|
||||
removeAllKeyframesFromScript,
|
||||
dedupePositionWritesInScript,
|
||||
addAnimationWithKeyframesToScript,
|
||||
splitAnimationsInScript,
|
||||
splitIntoPropertyGroups,
|
||||
@@ -2192,6 +2193,10 @@ describe("keyframe mutations", () => {
|
||||
expect(anim.keyframes).toBeUndefined();
|
||||
expect(anim.properties.x).toBe(200);
|
||||
expect(anim.properties.opacity).toBe(1);
|
||||
// Removing all keyframes must HOLD statically (gsap.set equivalent): zero
|
||||
// duration + immediateRender so the element does not re-animate.
|
||||
expect(anim.duration).toBe(0);
|
||||
expect(anim.extras?.immediateRender).toBe("__raw:true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2925,3 +2930,79 @@ describe("base gsap.set (off-timeline global hold)", () => {
|
||||
expect(sets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("single position write per element (consolidation)", () => {
|
||||
const posWritesFor = (script: string, selector: string) =>
|
||||
parseGsapScript(script).animations.filter(
|
||||
(a) => a.targetSelector === selector && a.propertyGroup === "position",
|
||||
);
|
||||
|
||||
// The real corruption: a degenerate `tl.to(...,{duration:0,x,y})` AND a stray
|
||||
// `gsap.set(...,{x,y})` for the same element. The later write overrides the
|
||||
// earlier, so the element "can't move".
|
||||
const CORRUPTED = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { duration: 0, x: -766, y: 314, immediateRender: true }, 1.333);
|
||||
gsap.set("#box", { x: -520, y: 170 });
|
||||
gsap.set("#box", { rotation: 45 });
|
||||
tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
|
||||
it("dedupe collapses 2+ position writes to exactly one (keeping keepId)", () => {
|
||||
expect(posWritesFor(CORRUPTED, "#box")).toHaveLength(2);
|
||||
const keepId = posWritesFor(CORRUPTED, "#box").find((a) => a.method === "to")!.id;
|
||||
const out = dedupePositionWritesInScript(CORRUPTED, "#box", keepId);
|
||||
const kept = posWritesFor(out, "#box");
|
||||
expect(kept).toHaveLength(1);
|
||||
// Kept the tl.to; stray gsap.set position is gone.
|
||||
expect(kept[0].method).toBe("to");
|
||||
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
|
||||
});
|
||||
|
||||
it("dedupe leaves non-position writes for the selector untouched", () => {
|
||||
const out = dedupePositionWritesInScript(CORRUPTED, "#box", undefined);
|
||||
const anims = parseGsapScript(out).animations;
|
||||
// rotation set + opacity tween survive (separate animations, not position).
|
||||
expect(anims.some((a) => a.targetSelector === "#box" && "rotation" in a.properties)).toBe(true);
|
||||
expect(anims.some((a) => a.targetSelector === "#box" && "opacity" in a.properties)).toBe(true);
|
||||
expect(posWritesFor(out, "#box")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dedupe keeps the LAST position write when keepId is stale", () => {
|
||||
const out = dedupePositionWritesInScript(CORRUPTED, "#box", "does-not-exist");
|
||||
const kept = posWritesFor(out, "#box");
|
||||
expect(kept).toHaveLength(1);
|
||||
// Last in source order is the gsap.set(x:-520) — runtime-effective one.
|
||||
expect(kept[0].method).toBe("set");
|
||||
expect(kept[0].properties.x).toBe(-520);
|
||||
});
|
||||
|
||||
it("dedupe + update yields exactly one position write with the NEW value", () => {
|
||||
const keepId = posWritesFor(CORRUPTED, "#box").find((a) => a.method === "to")!.id;
|
||||
let out = dedupePositionWritesInScript(CORRUPTED, "#box", keepId);
|
||||
const surviving = posWritesFor(out, "#box")[0];
|
||||
out = updateAnimationInScript(out, surviving.id, { properties: { x: 99, y: 42 } });
|
||||
const kept = posWritesFor(out, "#box");
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0].properties.x).toBe(99);
|
||||
expect(kept[0].properties.y).toBe(42);
|
||||
});
|
||||
|
||||
it("remove-all-keyframes strips position residue, leaving one held set", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { x: 0 }, "100%": { x: 200 } }, duration: 2 }, 0);
|
||||
gsap.set("#box", { x: -520, y: 170 });
|
||||
tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const kfTween = posWritesFor(script, "#box").find((a) => a.keyframes)!;
|
||||
const out = removeAllKeyframesFromScript(script, kfTween.id);
|
||||
const kept = posWritesFor(out, "#box");
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0].keyframes).toBeUndefined();
|
||||
expect(kept[0].duration).toBe(0);
|
||||
// The stray gsap.set position residue is gone; opacity tween survives.
|
||||
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
|
||||
expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1267,13 +1267,13 @@ function isEditablePropertyKey(key: string): boolean {
|
||||
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
|
||||
}
|
||||
|
||||
function makeObjectProperty(key: string, value: number | string): AstNode {
|
||||
function makeObjectProperty(key: string, value: number | string | boolean): AstNode {
|
||||
const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
|
||||
return obj.properties[0];
|
||||
}
|
||||
|
||||
/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
|
||||
function setVarsKey(varsArg: AstNode, key: string, value: number | string): void {
|
||||
function setVarsKey(varsArg: AstNode, key: string, value: number | string | boolean): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
const existing = varsArg.properties.find(
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === key,
|
||||
@@ -1624,10 +1624,15 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
target = parsed.located.find((l) => l.id === convertedId);
|
||||
}
|
||||
if (!target) return script;
|
||||
const node = target.call.node;
|
||||
const stmtPath = findStatementPath(target.call.path);
|
||||
if (!stmtPath) return script;
|
||||
removeCallFromAst(target.call);
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
/** Remove a single located tween call from the AST (standalone stmt or chain link). */
|
||||
function removeCallFromAst(call: TweenCallInfo): void {
|
||||
const node = call.node;
|
||||
const stmtPath = findStatementPath(call.path);
|
||||
if (!stmtPath) return;
|
||||
const parentCall = findChainParentCall(stmtPath.node, node);
|
||||
if (parentCall) {
|
||||
// Inner link of a chain — splice it out by re-pointing the next link.
|
||||
@@ -1639,6 +1644,36 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
// Standalone tween — remove the whole statement.
|
||||
stmtPath.prune();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recast twin of {@link dedupePositionWritesInScript} (acorn). Enforce "exactly
|
||||
* one position write per element": keep `keepId` (or the LAST position write in
|
||||
* source order if stale), remove every OTHER pure-position write
|
||||
* (`propertyGroup === "position"` — tl.to/from/fromTo flat-or-keyframed, tl.set,
|
||||
* standalone gsap.set, incl. degenerate duration:0 tweens). Non-position writes
|
||||
* for the selector are left untouched.
|
||||
*/
|
||||
export function dedupePositionWritesInScript(
|
||||
script: string,
|
||||
selector: string,
|
||||
keepId?: string,
|
||||
): string {
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return script;
|
||||
}
|
||||
const posWrites = parsed.located.filter(
|
||||
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
|
||||
);
|
||||
if (posWrites.length <= 1) return script;
|
||||
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
|
||||
for (const l of posWrites) {
|
||||
if (l === keeper) continue;
|
||||
removeCallFromAst(l.call);
|
||||
}
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
@@ -2478,6 +2513,29 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
|
||||
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
|
||||
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
|
||||
collapseKeyframesToFlat(loc.target.call.varsArg, record);
|
||||
// Removing ALL keyframes HOLDS the element statically — collapse to a
|
||||
// zero-duration immediateRender tween (a `gsap.set` equivalent), dropping the
|
||||
// original duration/ease so the element does not re-animate from its base
|
||||
// toward the collapsed value (which moved it out from under the selection).
|
||||
removeVarsKey(loc.target.call.varsArg, "ease");
|
||||
setVarsKey(loc.target.call.varsArg, "duration", 0);
|
||||
setVarsKey(loc.target.call.varsArg, "immediateRender", true);
|
||||
|
||||
// Removing all keyframes of a POSITION tween leaves exactly ONE held state:
|
||||
// strip every sibling position write for the same selector (stray gsap.set or a
|
||||
// second tl.to/tl.set) so the collapsed hold is the lone position source. Only
|
||||
// position siblings are stripped; rotation/opacity/etc. for the selector remain.
|
||||
if (loc.target.animation.propertyGroup === "position") {
|
||||
for (const l of loc.parsed.located) {
|
||||
if (l === loc.target) continue;
|
||||
if (
|
||||
l.animation.targetSelector === loc.target.animation.targetSelector &&
|
||||
l.animation.propertyGroup === "position"
|
||||
) {
|
||||
removeCallFromAst(l.call);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
addAnimationWithKeyframesToScript as addWithKfRecast,
|
||||
shiftPositionsInScript as shiftRecast,
|
||||
scalePositionsInScript as scaleRecast,
|
||||
dedupePositionWritesInScript as dedupePosRecast,
|
||||
type SplitAnimationsOptions,
|
||||
} from "./gsapParser.js";
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
removeAnimationFromScript as removeAnimAcorn,
|
||||
shiftPositionsInScript as shiftAcorn,
|
||||
scalePositionsInScript as scaleAcorn,
|
||||
dedupePositionWritesInScript as dedupePosAcorn,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
|
||||
function acornId(script: string): string {
|
||||
@@ -164,6 +166,44 @@ describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parity: dedupePositionWritesInScript (recast vs acorn)", () => {
|
||||
const DUP = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { duration: 0, x: -766, y: 314, immediateRender: true }, 1.333);
|
||||
gsap.set("#box", { x: -520, y: 170 });
|
||||
gsap.set("#box", { rotation: 45 });
|
||||
tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
|
||||
it("keeps the same single position write in both writers (keep last)", () => {
|
||||
const recastOut = dedupePosRecast(DUP, "#box");
|
||||
const acornOut = dedupePosAcorn(DUP, "#box");
|
||||
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
|
||||
const posCount = modelOf(acornOut).filter(
|
||||
(a) => "x" in a.properties || "y" in a.properties,
|
||||
).length;
|
||||
expect(posCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps keepId (the tl.to) in both writers", () => {
|
||||
const keepId = parseGsapScriptAcorn(DUP).animations.find(
|
||||
(a) => a.method === "to" && a.propertyGroup === "position",
|
||||
)!.id;
|
||||
const recastOut = dedupePosRecast(DUP, "#box", keepId);
|
||||
const acornOut = dedupePosAcorn(DUP, "#box", keepId);
|
||||
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
|
||||
});
|
||||
|
||||
it("no-op when 0 or 1 position writes — both writers", () => {
|
||||
const single = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 10, duration: 1 }, 0);
|
||||
`;
|
||||
expect(dedupePosAcorn(single, "#box")).toBe(single);
|
||||
expect(dedupePosRecast(single, "#box")).toBe(single);
|
||||
});
|
||||
});
|
||||
|
||||
// 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`.
|
||||
|
||||
@@ -71,6 +71,11 @@ window.__timelines["scene"].to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x:
|
||||
const id = parseGsapScriptAcorn(kf).animations[0]!.id;
|
||||
const out = removeAllKeyframesFromScript(kf, id);
|
||||
expect(out).not.toContain("keyframes");
|
||||
// Static hold (gsap.set equivalent): zero duration + immediateRender so the
|
||||
// element does not re-animate after collapse.
|
||||
const anim = parseGsapScriptAcorn(out).animations[0]!;
|
||||
expect(anim.duration).toBe(0);
|
||||
expect(anim.extras?.immediateRender).toBe("__raw:true");
|
||||
});
|
||||
|
||||
it("adds the first tween to an empty inline timeline", () => {
|
||||
|
||||
@@ -155,7 +155,7 @@ function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): voi
|
||||
}
|
||||
|
||||
/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */
|
||||
function buildVarsObjectCode(record: Record<string, number | string>): string {
|
||||
function buildVarsObjectCode(record: Record<string, number | string | boolean>): string {
|
||||
const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
|
||||
}
|
||||
@@ -468,16 +468,10 @@ export function addAnimationToScript(
|
||||
return { script: result, id: newId };
|
||||
}
|
||||
|
||||
export function removeAnimationFromScript(script: string, animationId: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
const N = target.call.node;
|
||||
const exprStmt = findEnclosingExpressionStatement(target.call.ancestors);
|
||||
|
||||
/** Splice a single located tween call out of a MagicString (standalone stmt or chain link). */
|
||||
function removeCallFromMagicString(ms: MagicString, call: TweenCallInfo, script: string): void {
|
||||
const N = call.node;
|
||||
const exprStmt = findEnclosingExpressionStatement(call.ancestors);
|
||||
if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) {
|
||||
// Standalone `tl.method(...)` — remove the whole ExpressionStatement
|
||||
const end =
|
||||
@@ -489,7 +483,50 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
// Chain link — splice out `.method(args)` from N.callee.object.end to N.end
|
||||
ms.remove(N.callee.object.end, N.end);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeAnimationFromScript(script: string, animationId: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
removeCallFromMagicString(ms, target.call, script);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce "exactly one position write per element". Position commits (drag, add
|
||||
* keyframe, static hold) must never leave the same selector with two conflicting
|
||||
* position representations (e.g. a degenerate `tl.to("#box",{duration:0,x,y})`
|
||||
* AND a `gsap.set("#box",{x,y})`) — the later one silently overrides the earlier,
|
||||
* so the element "can't move" / snaps / leaves residue on delete.
|
||||
*
|
||||
* Keeps `keepId` (the write the commit just edited); falls back to the LAST
|
||||
* position write in source order (the runtime-effective one) if `keepId` is stale.
|
||||
* Removes every OTHER pure-position write (`propertyGroup === "position"`, which
|
||||
* covers tl.to/from/fromTo flat-or-keyframed, tl.set, and standalone gsap.set,
|
||||
* including degenerate duration:0 tweens). Non-position writes for the same
|
||||
* selector (rotation / opacity / size / mixed) are left untouched.
|
||||
*/
|
||||
export function dedupePositionWritesInScript(
|
||||
script: string,
|
||||
selector: string,
|
||||
keepId?: string,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const posWrites = parsed.located.filter(
|
||||
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
|
||||
);
|
||||
if (posWrites.length <= 1) return script;
|
||||
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
|
||||
const ms = new MagicString(script);
|
||||
for (const l of posWrites) {
|
||||
if (l === keeper) continue;
|
||||
removeCallFromMagicString(ms, l.call, script);
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
@@ -1180,6 +1217,7 @@ export function removePropertyFromAnimation(
|
||||
* keyframe's properties: the first for `from()`, the last otherwise (the
|
||||
* destination = the visible resting state).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
@@ -1198,25 +1236,44 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
|
||||
target.call,
|
||||
buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)),
|
||||
);
|
||||
// Removing all keyframes of a POSITION tween must leave exactly ONE held state:
|
||||
// strip every sibling position write for the same selector (a stray gsap.set or
|
||||
// a second tl.to/tl.set) so the collapsed hold is the lone position source. Only
|
||||
// position siblings are stripped; rotation/opacity/etc. for the selector remain.
|
||||
if (target.animation.propertyGroup === "position") {
|
||||
for (const l of parsed.located) {
|
||||
if (l === target) continue;
|
||||
if (
|
||||
l.animation.targetSelector === target.animation.targetSelector &&
|
||||
l.animation.propertyGroup === "position"
|
||||
) {
|
||||
removeCallFromMagicString(ms, l.call, script);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// Flat vars for a tween collapsing its keyframes onto one stop: existing
|
||||
// top-level props, then the collapse keyframe's props (skip per-keyframe
|
||||
// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission.
|
||||
// `ease`), then extras. Removing all keyframes HOLDS the element statically —
|
||||
// collapse to a zero-duration immediateRender tween (a `gsap.set` equivalent),
|
||||
// dropping the original duration/ease so the element does not re-animate.
|
||||
function buildCollapsedFlatVars(
|
||||
animation: GsapAnimation,
|
||||
collapse: { properties: Record<string, number | string> },
|
||||
): Record<string, number | string> {
|
||||
const flat: Record<string, number | string> = { ...animation.properties };
|
||||
): Record<string, number | string | boolean> {
|
||||
const flat: Record<string, number | string | boolean> = { ...animation.properties };
|
||||
for (const [k, v] of Object.entries(collapse.properties)) {
|
||||
if (k !== "ease") flat[k] = v;
|
||||
}
|
||||
if (animation.duration !== undefined) flat.duration = animation.duration;
|
||||
if (animation.ease) flat.ease = animation.ease;
|
||||
for (const [k, v] of Object.entries(animation.extras ?? {})) {
|
||||
if (typeof v === "number" || typeof v === "string") flat[k] = v;
|
||||
}
|
||||
// Static hold wins over any carried extras: zero duration + immediateRender,
|
||||
// no ease.
|
||||
flat.duration = 0;
|
||||
flat.immediateRender = true;
|
||||
return flat;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user