mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op (#1501)
* feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * test(core): recast-vs-acorn parity + acorn fixes for arc/unroll/keyframe-add/%-removeKeyframe/add-with-keyframes (WS-3.F gate) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(core): port shiftPositions/scalePositions to acorn writer (WS-3.F) shiftPositionsInScript + scalePositionsInScript were recast-only GSAP-script writers reachable from executeGsapMutation (shift-positions/scale-positions), called by Studio timeline clip move/resize — the last write ops blocking recast retirement. Ported to gsapWriterAcorn.ts mirroring recast's arithmetic (shift: max(0,pos+delta); scale: remap pos by duration ratio + scale duration), reusing a shared overwritePosition helper (also adopted by updateAnimationInScript). Adds 10 recast-vs-acorn parity tests. Closes the WS-3.F op-coverage gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Miguel Ángel
parent
1612d18fdf
commit
09cefc1bb7
@@ -15,9 +15,22 @@ import {
|
|||||||
materializeKeyframesInScript as materializeRecast,
|
materializeKeyframesInScript as materializeRecast,
|
||||||
splitIntoPropertyGroups as splitGroupsRecast,
|
splitIntoPropertyGroups as splitGroupsRecast,
|
||||||
splitAnimationsInScript as splitAnimsRecast,
|
splitAnimationsInScript as splitAnimsRecast,
|
||||||
|
setArcPathInScript as setArcRecast,
|
||||||
|
updateArcSegmentInScript as updateArcSegmentRecast,
|
||||||
|
removeArcPathFromScript as removeArcRecast,
|
||||||
|
unrollDynamicAnimations as unrollRecast,
|
||||||
|
addKeyframeToScript as addKeyframeRecast,
|
||||||
|
removeKeyframeFromScript as removeKeyframeRecast,
|
||||||
|
addAnimationWithKeyframesToScript as addWithKfRecast,
|
||||||
|
shiftPositionsInScript as shiftRecast,
|
||||||
|
scalePositionsInScript as scaleRecast,
|
||||||
type SplitAnimationsOptions,
|
type SplitAnimationsOptions,
|
||||||
} from "./gsapParser.js";
|
} from "./gsapParser.js";
|
||||||
import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js";
|
import {
|
||||||
|
parseGsapScriptAcorn,
|
||||||
|
parseGsapScriptAcornForWrite,
|
||||||
|
type ParsedGsapAcornForWrite,
|
||||||
|
} from "./gsapParserAcorn.js";
|
||||||
import {
|
import {
|
||||||
removeAllKeyframesFromScript as removeAllAcorn,
|
removeAllKeyframesFromScript as removeAllAcorn,
|
||||||
convertToKeyframesFromScript as convertAcorn,
|
convertToKeyframesFromScript as convertAcorn,
|
||||||
@@ -27,12 +40,39 @@ import {
|
|||||||
setArcPathInScript as setArcAcorn,
|
setArcPathInScript as setArcAcorn,
|
||||||
updateArcSegmentInScript as updateArcSegmentAcorn,
|
updateArcSegmentInScript as updateArcSegmentAcorn,
|
||||||
removeArcPathFromScript as removeArcAcorn,
|
removeArcPathFromScript as removeArcAcorn,
|
||||||
|
unrollDynamicAnimations as unrollAcorn,
|
||||||
|
addKeyframeToScript as addKeyframeAcorn,
|
||||||
|
removeKeyframeFromScript as removeKeyframeAcorn,
|
||||||
|
addAnimationWithKeyframesToScript as addWithKfAcorn,
|
||||||
|
shiftPositionsInScript as shiftAcorn,
|
||||||
|
scalePositionsInScript as scaleAcorn,
|
||||||
} from "./gsapWriterAcorn.js";
|
} from "./gsapWriterAcorn.js";
|
||||||
function acornId(script: string): string {
|
function acornId(script: string): string {
|
||||||
const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
|
const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
|
||||||
return parsed.located[0]!.id;
|
return parsed.located[0]!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True recast-vs-acorn differential: parse a written script with the acorn
|
||||||
|
* parser and strip per-parse metadata, leaving only the AUTHORED animation
|
||||||
|
* shape. Both writers must produce scripts that reparse to the same model
|
||||||
|
* (raw text differs — recast pretty-prints, acorn splices in place).
|
||||||
|
*/
|
||||||
|
function modelOf(script: string) {
|
||||||
|
return parseGsapScriptAcorn(script).animations.map((a) => {
|
||||||
|
// Drop per-parse metadata; compare AUTHORED shape only.
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
resolvedStart: _resolvedStart,
|
||||||
|
implicitPosition: _implicitPosition,
|
||||||
|
propertyGroup: _propertyGroup,
|
||||||
|
provenance: _provenance,
|
||||||
|
...rest
|
||||||
|
} = a;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function arcShapeOf(script: string) {
|
function arcShapeOf(script: string) {
|
||||||
const anim = parseGsapScript(script).animations[0]!;
|
const anim = parseGsapScript(script).animations[0]!;
|
||||||
return { arcPath: anim.arcPath, properties: anim.properties };
|
return { arcPath: anim.arcPath, properties: anim.properties };
|
||||||
@@ -421,6 +461,21 @@ function arcFixture() {
|
|||||||
return { id, enabled };
|
return { id, enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-waypoint fixture: keyframes drive >2 path waypoints and >1 segment, and
|
||||||
|
// autoRotate is on — exercises the multi-segment branch of buildMotionPathObjectCode.
|
||||||
|
const ARC_KEYFRAME_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#hero", {
|
||||||
|
keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 50, y: 100 }, "100%": { x: 200, y: 0 } },
|
||||||
|
duration: 2
|
||||||
|
}, 0);
|
||||||
|
`;
|
||||||
|
const ARC_KEYFRAME_CFG = {
|
||||||
|
enabled: true as const,
|
||||||
|
autoRotate: true as const,
|
||||||
|
segments: [{ curviness: 1.5 }, { curviness: 0.5 }],
|
||||||
|
};
|
||||||
|
|
||||||
describe("setArcPathInScript: acorn output correctness", () => {
|
describe("setArcPathInScript: acorn output correctness", () => {
|
||||||
it("enable: arcPath.enabled=true, segments preserved", () => {
|
it("enable: arcPath.enabled=true, segments preserved", () => {
|
||||||
const id = acornId(ARC_FLAT_SCRIPT);
|
const id = acornId(ARC_FLAT_SCRIPT);
|
||||||
@@ -460,3 +515,477 @@ describe("removeArcPathFromScript: acorn output correctness", () => {
|
|||||||
expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined();
|
expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── unrollDynamicAnimations correctness ──────────────────────────────────────
|
||||||
|
|
||||||
|
const UNROLL_LOOP_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
const items = ["#a", "#b"];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UNROLL_FOREACH_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
["#a", "#b"].forEach(function(sel) {
|
||||||
|
tl.to(sel, { opacity: 1, duration: 2 }, 1);
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UNROLL_ELEMENTS = [
|
||||||
|
{
|
||||||
|
selector: "#hero",
|
||||||
|
keyframes: [
|
||||||
|
{ percentage: 0, properties: { opacity: 0 } },
|
||||||
|
{ percentage: 100, properties: { opacity: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "#sub",
|
||||||
|
keyframes: [
|
||||||
|
{ percentage: 0, properties: { x: 0 } },
|
||||||
|
{ percentage: 100, properties: { x: 200 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function unrollId(script: string): string {
|
||||||
|
const p = acornId(script);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("unrollDynamicAnimations: acorn output correctness", () => {
|
||||||
|
it("for-loop: loop replaced with individual tl.to() calls", () => {
|
||||||
|
const id = unrollId(UNROLL_LOOP_SCRIPT);
|
||||||
|
const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS);
|
||||||
|
expect(out).not.toBe(UNROLL_LOOP_SCRIPT);
|
||||||
|
expect(out).toContain('tl.to("#hero"');
|
||||||
|
expect(out).toContain('tl.to("#sub"');
|
||||||
|
expect(out).not.toContain("for (");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forEach: loop replaced with individual tl.to() calls", () => {
|
||||||
|
const id = unrollId(UNROLL_FOREACH_SCRIPT);
|
||||||
|
const out = unrollAcorn(UNROLL_FOREACH_SCRIPT, id, UNROLL_ELEMENTS);
|
||||||
|
expect(out).toContain('tl.to("#hero"');
|
||||||
|
expect(out).not.toContain("forEach");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves duration and position from original tween", () => {
|
||||||
|
const id = unrollId(UNROLL_LOOP_SCRIPT);
|
||||||
|
const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS);
|
||||||
|
expect(out).toContain("duration: 1");
|
||||||
|
expect(out).toContain("}, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op when animationId not found", () => {
|
||||||
|
expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── True recast-vs-acorn differential for the arc trio ──────────────────────
|
||||||
|
// For each representative input, apply the op via BOTH the recast writer
|
||||||
|
// (gsapParser.ts) and the acorn writer (gsapWriterAcorn.ts), then assert the
|
||||||
|
// reparsed authored model is identical. This is the WS-3.F parity safety net:
|
||||||
|
// acorn cannot drop or mis-serialize a path/segment/restored-xy that recast keeps.
|
||||||
|
describe("parity: arc path trio (recast vs acorn)", () => {
|
||||||
|
for (const { name, script, cfg } of [
|
||||||
|
{ name: "flat x/y — single segment", script: ARC_FLAT_SCRIPT, cfg: ARC_CFG },
|
||||||
|
{
|
||||||
|
name: "keyframes — multi-segment + autoRotate",
|
||||||
|
script: ARC_KEYFRAME_SCRIPT,
|
||||||
|
cfg: ARC_KEYFRAME_CFG,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
describe(name, () => {
|
||||||
|
it("setArcPath enable: models match", () => {
|
||||||
|
const id = acornId(script);
|
||||||
|
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
|
||||||
|
expect(modelOf(setArcAcorn(script, id, cfg))).toEqual(
|
||||||
|
modelOf(setArcRecast(script, id, cfg)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateArcSegment: models match", () => {
|
||||||
|
const recastEnabled = setArcRecast(script, acornId(script), cfg);
|
||||||
|
const acornEnabled = setArcAcorn(script, acornId(script), cfg);
|
||||||
|
const idx = cfg.segments.length - 1;
|
||||||
|
expect(
|
||||||
|
modelOf(
|
||||||
|
updateArcSegmentAcorn(acornEnabled, acornId(acornEnabled), idx, { curviness: 3 }),
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
modelOf(
|
||||||
|
updateArcSegmentRecast(recastEnabled, acornId(recastEnabled), idx, { curviness: 3 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeArcPath: models match (x/y restored, motionPath gone)", () => {
|
||||||
|
const recastEnabled = setArcRecast(script, acornId(script), cfg);
|
||||||
|
const acornEnabled = setArcAcorn(script, acornId(script), cfg);
|
||||||
|
expect(modelOf(removeArcAcorn(acornEnabled, acornId(acornEnabled)))).toEqual(
|
||||||
|
modelOf(removeArcRecast(recastEnabled, acornId(recastEnabled))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── forEach with explicit ease + nonzero position — the acorn writer reads
|
||||||
|
// duration/ease/position from the parsed animation model, recast reads them
|
||||||
|
// straight from the original tween's AST var/position args. ────────────────────
|
||||||
|
const UNROLL_FOREACH_EASE_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
["#a", "#b"].forEach(function(sel) {
|
||||||
|
tl.to(sel, { opacity: 1, duration: 2, ease: "power2.out" }, 1);
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Dynamic tween NOT inside a loop — both writers fall back to replacing the
|
||||||
|
// enclosing expression statement. String (label) position, no duration/ease,
|
||||||
|
// so the default duration: 8 / ease: "none" path is exercised on both sides.
|
||||||
|
const UNROLL_FALLBACK_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#dyn", { opacity: 1 }, "intro");
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UNROLL_ELEMENTS_EASE = [
|
||||||
|
{
|
||||||
|
selector: "#hero",
|
||||||
|
keyframes: [
|
||||||
|
{ percentage: 0, properties: { opacity: 0 } },
|
||||||
|
{ percentage: 100, properties: { opacity: 1 } },
|
||||||
|
],
|
||||||
|
easeEach: "power1.in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "#sub",
|
||||||
|
keyframes: [
|
||||||
|
{ percentage: 0, properties: { x: 0 } },
|
||||||
|
{ percentage: 100, properties: { x: 200 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// True recast-vs-acorn differential: apply unroll via BOTH writers, then assert
|
||||||
|
// the reparsed (authored-shape) models are identical. Covers a for-loop, a
|
||||||
|
// forEach with explicit ease + nonzero position, and a non-loop (fallback)
|
||||||
|
// tween with a label position + defaulted duration/ease.
|
||||||
|
const UNROLL_PARITY_CASES: Array<{
|
||||||
|
name: string;
|
||||||
|
script: string;
|
||||||
|
elements: typeof UNROLL_ELEMENTS;
|
||||||
|
}> = [
|
||||||
|
{ name: "for-loop", script: UNROLL_LOOP_SCRIPT, elements: UNROLL_ELEMENTS },
|
||||||
|
{ name: "forEach", script: UNROLL_FOREACH_SCRIPT, elements: UNROLL_ELEMENTS },
|
||||||
|
{
|
||||||
|
name: "forEach with ease + nonzero position",
|
||||||
|
script: UNROLL_FOREACH_EASE_SCRIPT,
|
||||||
|
elements: UNROLL_ELEMENTS_EASE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-loop fallback — label position, defaulted duration/ease",
|
||||||
|
script: UNROLL_FALLBACK_SCRIPT,
|
||||||
|
elements: UNROLL_ELEMENTS_EASE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("parity: unrollDynamicAnimations (recast vs acorn)", () => {
|
||||||
|
for (const { name, script, elements } of UNROLL_PARITY_CASES) {
|
||||||
|
it(name, () => {
|
||||||
|
const id = unrollId(script);
|
||||||
|
// Sanity: recast and acorn agree on the id for the dynamic tween.
|
||||||
|
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
|
||||||
|
|
||||||
|
const recastOut = unrollRecast(script, id, elements);
|
||||||
|
const acornOut = unrollAcorn(script, id, elements);
|
||||||
|
|
||||||
|
// Both writers must actually unroll (not no-op).
|
||||||
|
expect(recastOut).not.toBe(script);
|
||||||
|
expect(acornOut).not.toBe(script);
|
||||||
|
|
||||||
|
// Reparsed authored models must be identical.
|
||||||
|
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("no-op parity when animationId not found", () => {
|
||||||
|
expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
|
||||||
|
expect(unrollRecast(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── addKeyframeToScript parity (recast vs acorn) ────────────────────────────
|
||||||
|
// PR #1470 routes Studio's GSAP keyframe-add through the acorn writer. Each
|
||||||
|
// case applies the op via BOTH writers and asserts the parsed authored models
|
||||||
|
// are equal — closing the parity gap the keyframe-add fix enforces (the acorn
|
||||||
|
// whole-value overwrite branch must emit recordToCode, not a stale valueCode).
|
||||||
|
|
||||||
|
// Two distinct percentages so adding/merging exercises the insert + merge paths.
|
||||||
|
const KF_ADD_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// `_auto`-marked endpoints exercise the adjacent-endpoint sync branch.
|
||||||
|
const KF_ADD_AUTO_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "100%": { opacity: 1, _auto: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Flat tween — adding a keyframe runs the convert-to-keyframes path first.
|
||||||
|
const KF_ADD_FLAT_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// A percentage entry whose value is NOT an object literal — exercises the
|
||||||
|
// whole-value overwrite branch (the acorn path here once referenced an
|
||||||
|
// undefined `valueCode`; recast emits the new value node).
|
||||||
|
const KF_ADD_NON_OBJECT_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": 0.7, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("parity: addKeyframeToScript (recast vs acorn)", () => {
|
||||||
|
function expectParity(
|
||||||
|
script: string,
|
||||||
|
percentage: number,
|
||||||
|
properties: Record<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("inserts a new percentage in sorted order", () => {
|
||||||
|
expectParity(KF_ADD_SCRIPT, 25, { opacity: 0.3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the value when the percentage already exists", () => {
|
||||||
|
expectParity(KF_ADD_SCRIPT, 100, { opacity: 0.99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges a new property into an existing keyframe, preserving siblings", () => {
|
||||||
|
expectParity(KF_ADD_SCRIPT, 100, { x: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries an ease onto the new keyframe", () => {
|
||||||
|
expectParity(KF_ADD_SCRIPT, 30, { opacity: 0.4 }, "power2.out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfills a new property across sibling keyframes", () => {
|
||||||
|
expectParity(KF_ADD_SCRIPT, 25, { x: 50 }, undefined, { x: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs an adjacent _auto 0% endpoint", () => {
|
||||||
|
expectParity(KF_ADD_AUTO_SCRIPT, 10, { opacity: 0.2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs an adjacent _auto 100% endpoint", () => {
|
||||||
|
expectParity(KF_ADD_AUTO_SCRIPT, 90, { opacity: 0.8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts a flat tween to keyframes before inserting", () => {
|
||||||
|
expectParity(KF_ADD_FLAT_SCRIPT, 50, { opacity: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites a non-object keyframe value with the new properties", () => {
|
||||||
|
expectParity(KF_ADD_NON_OBJECT_SCRIPT, 50, { opacity: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op on unknown id agrees between writers", () => {
|
||||||
|
expect(addKeyframeAcorn(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT);
|
||||||
|
expect(addKeyframeRecast(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── removeKeyframeFromScript parity (recast vs acorn) ───────────────────────
|
||||||
|
// When removal drops a keyframes block below two stops it must collapse back to
|
||||||
|
// a flat tween (recast via collapseKeyframesToFlat). The acorn writer must
|
||||||
|
// mirror this — folding the survivor (incl. `_auto`), dropping per-keyframe
|
||||||
|
// `ease` and the sibling `easeEach` — or the SDK/server paths diverge.
|
||||||
|
|
||||||
|
// Three plain keyframes — removing the interior one stays a keyframes block.
|
||||||
|
const RM_PLAIN_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Two plain keyframes — removing one drops below 2 → collapse to flat tween.
|
||||||
|
const RM_TWO_KF_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Two _auto endpoints — collapse must carry the surviving `_auto` marker.
|
||||||
|
const RM_TWO_KF_AUTO_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Survivor carries a per-keyframe `ease`, plus a sibling `easeEach`. Collapse
|
||||||
|
// must drop both `ease` (per-keyframe) and `easeEach` (keyframes-only).
|
||||||
|
const RM_TWO_KF_EASE_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1, ease: "power2.in" }, easeEach: "none" }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Survivor is empty — collapse yields a tween with NO authored props.
|
||||||
|
const RM_TWO_KF_EMPTY_SCRIPT = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#box", { keyframes: { "0%": {}, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("parity: removeKeyframeFromScript (recast vs acorn)", () => {
|
||||||
|
function expectParity(script: string, percentage: number) {
|
||||||
|
const id = acornId(script);
|
||||||
|
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
|
||||||
|
expect(modelOf(removeKeyframeAcorn(script, id, percentage))).toEqual(
|
||||||
|
modelOf(removeKeyframeRecast(script, id, percentage)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("removes an interior keyframe and stays a keyframes block (3 → 2)", () => {
|
||||||
|
expectParity(RM_PLAIN_SCRIPT, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets a near-coincident percentage (51 → the 50% keyframe) at parity", () => {
|
||||||
|
expectParity(RM_PLAIN_SCRIPT, 51);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses to a flat tween when only one keyframe would remain (2 → 1)", () => {
|
||||||
|
expectParity(RM_TWO_KF_SCRIPT, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses an _auto endpoint pair, carrying the surviving _auto marker", () => {
|
||||||
|
expectParity(RM_TWO_KF_AUTO_SCRIPT, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses and drops per-keyframe ease + easeEach", () => {
|
||||||
|
expectParity(RM_TWO_KF_EASE_SCRIPT, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses to a propless flat tween when the surviving keyframe is empty", () => {
|
||||||
|
expectParity(RM_TWO_KF_EMPTY_SCRIPT, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op on unknown id agrees between writers", () => {
|
||||||
|
expect(removeKeyframeAcorn(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT);
|
||||||
|
expect(removeKeyframeRecast(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ───────────────
|
||||||
|
// WS-3.C add path: both writers insert a new keyframed tl.to() call. The
|
||||||
|
// inserted statement's authored model (selector, keyframes, duration, ease,
|
||||||
|
// position) must match — comparing the LAST animation each writer produced.
|
||||||
|
const ADD_WITH_KF_BASE = `
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#existing", { opacity: 1, duration: 1 }, 0);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function lastModelOf(script: string) {
|
||||||
|
const arr = modelOf(script);
|
||||||
|
return arr[arr.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => {
|
||||||
|
it("minimal: two-keyframe insert, no ease", () => {
|
||||||
|
const kfs = [
|
||||||
|
{ percentage: 0, properties: { x: 0 } },
|
||||||
|
{ percentage: 100, properties: { x: 200 } },
|
||||||
|
];
|
||||||
|
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
|
||||||
|
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
|
||||||
|
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moderate: three keyframes, per-keyframe ease, easeEach, nonzero position", () => {
|
||||||
|
const kfs = [
|
||||||
|
{ percentage: 0, properties: { x: 0, opacity: 0 } },
|
||||||
|
{ percentage: 50, properties: { x: 100, opacity: 0.5 }, ease: "power2.out" },
|
||||||
|
{ percentage: 100, properties: { x: 300, opacity: 1 } },
|
||||||
|
];
|
||||||
|
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
|
||||||
|
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
|
||||||
|
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ──
|
||||||
|
|
||||||
|
const POSITIONS_MULTI = `const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
|
||||||
|
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
|
||||||
|
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
|
||||||
|
|
||||||
|
describe("parity: shiftPositionsInScript (recast vs acorn)", () => {
|
||||||
|
it("shifts only the target selector's numeric positions", () => {
|
||||||
|
const a = shiftAcorn(POSITIONS_MULTI, "#hero", 3);
|
||||||
|
const r = shiftRecast(POSITIONS_MULTI, "#hero", 3);
|
||||||
|
expect(modelOf(a)).toEqual(modelOf(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps negative-going positions to zero", () => {
|
||||||
|
const s = `const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#el", { x: 100, duration: 1 }, 0.3);
|
||||||
|
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
|
||||||
|
expect(modelOf(shiftAcorn(s, "#el", -1))).toEqual(modelOf(shiftRecast(s, "#el", -1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips string (relative) positions", () => {
|
||||||
|
const s = `const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#el", { x: 100, duration: 1 }, 2);
|
||||||
|
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
|
||||||
|
expect(modelOf(shiftAcorn(s, "#el", 1))).toEqual(modelOf(shiftRecast(s, "#el", 1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjacent positions do not collide", () => {
|
||||||
|
const s = `const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
|
||||||
|
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
|
||||||
|
expect(modelOf(shiftAcorn(s, "#burst", 0.5))).toEqual(modelOf(shiftRecast(s, "#burst", 0.5)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("implicit-position tween gains an explicit shifted position", () => {
|
||||||
|
const s = `const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#el", { x: 1, duration: 1 });`;
|
||||||
|
expect(modelOf(shiftAcorn(s, "#el", 2))).toEqual(modelOf(shiftRecast(s, "#el", 2)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no matching selector is a no-op", () => {
|
||||||
|
expect(shiftAcorn(POSITIONS_MULTI, "#nope", 3)).toBe(POSITIONS_MULTI);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parity: scalePositionsInScript (recast vs acorn)", () => {
|
||||||
|
it("scales positions and durations proportionally for the target", () => {
|
||||||
|
const a = scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 2);
|
||||||
|
const r = scaleRecast(POSITIONS_MULTI, "#hero", 0, 1, 2, 2);
|
||||||
|
expect(modelOf(a)).toEqual(modelOf(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips string (relative) positions", () => {
|
||||||
|
const s = `const tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("#el", { x: 100, duration: 1 }, 2);
|
||||||
|
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
|
||||||
|
expect(modelOf(scaleAcorn(s, "#el", 0, 1, 1, 2))).toEqual(
|
||||||
|
modelOf(scaleRecast(s, "#el", 0, 1, 1, 2)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op when oldDuration <= 0", () => {
|
||||||
|
expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 0, 2, 2)).toBe(POSITIONS_MULTI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op when newDuration <= 0", () => {
|
||||||
|
expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 0)).toBe(POSITIONS_MULTI);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -331,18 +331,87 @@ export function updateAnimationInScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updates.position !== undefined) {
|
if (updates.position !== undefined) {
|
||||||
const posIdx = call.method === "fromTo" ? 3 : 2;
|
overwritePosition(ms, call, updates.position);
|
||||||
const posArgNode = call.node.arguments?.[posIdx];
|
|
||||||
if (posArgNode) {
|
|
||||||
ms.overwrite(posArgNode.start, posArgNode.end, valueToCode(updates.position));
|
|
||||||
} else {
|
|
||||||
ms.appendLeft(call.node.end - 1, `, ${valueToCode(updates.position)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ms.toString();
|
return ms.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite a tween call's numeric position argument (the positionArg the parser
|
||||||
|
* located: 3rd arg for fromTo, else 2nd), or append one when the call has no
|
||||||
|
* explicit position. Shared by updateAnimationInScript and the
|
||||||
|
* shift/scalePositionsInScript timeline ops.
|
||||||
|
*/
|
||||||
|
function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void {
|
||||||
|
if (call.positionArg) {
|
||||||
|
ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position));
|
||||||
|
} else {
|
||||||
|
ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0),
|
||||||
|
* rewriting each call's position argument. Mirrors recast's shiftPositionsInScript
|
||||||
|
* (used by timeline clip-move to keep GSAP positions in sync with the clip start).
|
||||||
|
*/
|
||||||
|
export function shiftPositionsInScript(
|
||||||
|
script: string,
|
||||||
|
targetSelector: string,
|
||||||
|
delta: number,
|
||||||
|
): string {
|
||||||
|
const parsed = parseGsapScriptAcornForWrite(script);
|
||||||
|
if (!parsed) return script;
|
||||||
|
const ms = new MagicString(script);
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of parsed.located) {
|
||||||
|
if (entry.animation.targetSelector !== targetSelector) continue;
|
||||||
|
if (typeof entry.animation.position !== "number") continue;
|
||||||
|
const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
|
||||||
|
overwritePosition(ms, entry.call, newPos);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? ms.toString() : script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linearly remap every tween targeting `targetSelector` from the old clip
|
||||||
|
* [oldStart, oldDuration] onto the new [newStart, newDuration] (position and,
|
||||||
|
* when present, duration scaled by the duration ratio). Mirrors recast's
|
||||||
|
* scalePositionsInScript (used by timeline clip-resize).
|
||||||
|
*/
|
||||||
|
export function scalePositionsInScript(
|
||||||
|
script: string,
|
||||||
|
targetSelector: string,
|
||||||
|
oldStart: number,
|
||||||
|
oldDuration: number,
|
||||||
|
newStart: number,
|
||||||
|
newDuration: number,
|
||||||
|
): string {
|
||||||
|
if (oldDuration <= 0 || newDuration <= 0) return script;
|
||||||
|
const ratio = newDuration / oldDuration;
|
||||||
|
const parsed = parseGsapScriptAcornForWrite(script);
|
||||||
|
if (!parsed) return script;
|
||||||
|
const ms = new MagicString(script);
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of parsed.located) {
|
||||||
|
if (entry.animation.targetSelector !== targetSelector) continue;
|
||||||
|
if (typeof entry.animation.position !== "number") continue;
|
||||||
|
const newPos = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000,
|
||||||
|
);
|
||||||
|
overwritePosition(ms, entry.call, newPos);
|
||||||
|
if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
|
||||||
|
const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000);
|
||||||
|
upsertProp(ms, entry.call.varsArg, "duration", newDur);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? ms.toString() : script;
|
||||||
|
}
|
||||||
|
|
||||||
export function addAnimationToScript(
|
export function addAnimationToScript(
|
||||||
script: string,
|
script: string,
|
||||||
animation: Omit<GsapAnimation, "id">,
|
animation: Omit<GsapAnimation, "id">,
|
||||||
@@ -1632,3 +1701,88 @@ export function splitAnimationsInScript(
|
|||||||
|
|
||||||
return { script: result, skippedSelectors };
|
return { script: result, skippedSelectors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Unroll dynamic animations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isLoopNode(node: any): boolean {
|
||||||
|
const t = node?.type;
|
||||||
|
return (
|
||||||
|
t === "ForStatement" ||
|
||||||
|
t === "ForInStatement" ||
|
||||||
|
t === "ForOfStatement" ||
|
||||||
|
t === "WhileStatement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForEachStatement(node: any): boolean {
|
||||||
|
return (
|
||||||
|
node?.type === "ExpressionStatement" &&
|
||||||
|
node.expression?.type === "CallExpression" &&
|
||||||
|
node.expression.callee?.property?.name === "forEach"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEnclosingLoop(ancestors: any[]): { start: number; end: number } | null {
|
||||||
|
for (let i = ancestors.length - 2; i >= 0; i--) {
|
||||||
|
const node = ancestors[i];
|
||||||
|
if (isLoopNode(node) || isForEachStatement(node)) {
|
||||||
|
return { start: node.start as number, end: node.end as number };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnrollReplacement(
|
||||||
|
timelineVar: string,
|
||||||
|
animation: GsapAnimation,
|
||||||
|
elements: Array<{
|
||||||
|
selector: string;
|
||||||
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||||
|
easeEach?: string;
|
||||||
|
}>,
|
||||||
|
): string {
|
||||||
|
const duration = typeof animation.duration === "number" ? animation.duration : 8;
|
||||||
|
const ease = typeof animation.ease === "string" ? animation.ease : "none";
|
||||||
|
const pos = animation.position ?? 0;
|
||||||
|
const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos);
|
||||||
|
const calls = elements.map((el) => {
|
||||||
|
const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage);
|
||||||
|
const kfCode = buildKeyframeObjectCode(sorted, el.easeEach);
|
||||||
|
return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`;
|
||||||
|
});
|
||||||
|
return calls.join("\n ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnrollElement = {
|
||||||
|
selector: string;
|
||||||
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||||
|
easeEach?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a dynamic loop that generates multiple tween calls with individual
|
||||||
|
* static `tl.to()` calls — one per element. Finds the loop containing the
|
||||||
|
* animation and replaces the entire loop body with unrolled static calls.
|
||||||
|
*/
|
||||||
|
export function unrollDynamicAnimations(
|
||||||
|
script: string,
|
||||||
|
animationId: string,
|
||||||
|
elements: UnrollElement[],
|
||||||
|
): string {
|
||||||
|
const parsed = parseGsapScriptAcornForWrite(script);
|
||||||
|
if (!parsed) return script;
|
||||||
|
const target = parsed.located.find((l) => l.id === animationId);
|
||||||
|
if (!target) return script;
|
||||||
|
|
||||||
|
const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements);
|
||||||
|
const ms = new MagicString(script);
|
||||||
|
const loop = findEnclosingLoop(target.call.ancestors);
|
||||||
|
if (loop) {
|
||||||
|
ms.overwrite(loop.start, loop.end, replacement);
|
||||||
|
} else {
|
||||||
|
const stmt = findEnclosingExpressionStatement(target.call.ancestors);
|
||||||
|
if (!stmt) return script;
|
||||||
|
ms.overwrite(stmt.start as number, stmt.end as number, replacement);
|
||||||
|
}
|
||||||
|
return ms.toString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
setArcPathInScript,
|
setArcPathInScript,
|
||||||
updateArcSegmentInScript,
|
updateArcSegmentInScript,
|
||||||
removeArcPathFromScript,
|
removeArcPathFromScript,
|
||||||
|
unrollDynamicAnimations,
|
||||||
} from "@hyperframes/core/gsap-writer-acorn";
|
} from "@hyperframes/core/gsap-writer-acorn";
|
||||||
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
||||||
|
|
||||||
@@ -208,6 +209,12 @@ function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | un
|
|||||||
);
|
);
|
||||||
case "removeArcPath":
|
case "removeArcPath":
|
||||||
return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId));
|
return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId));
|
||||||
|
case "unrollDynamicAnimations":
|
||||||
|
return handleArcPathScript(
|
||||||
|
parsed,
|
||||||
|
s,
|
||||||
|
unrollDynamicAnimations(s, op.animationId, op.elements),
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -1107,6 +1114,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
|||||||
case "setArcPath":
|
case "setArcPath":
|
||||||
case "updateArcSegment":
|
case "updateArcSegment":
|
||||||
case "removeArcPath":
|
case "removeArcPath":
|
||||||
|
case "unrollDynamicAnimations":
|
||||||
case "deleteAllForSelector":
|
case "deleteAllForSelector":
|
||||||
case "removeLabel":
|
case "removeLabel":
|
||||||
if (getGsapScript(parsed.document) === null)
|
if (getGsapScript(parsed.document) === null)
|
||||||
|
|||||||
@@ -157,7 +157,16 @@ export type EditOp =
|
|||||||
cp2?: { x: number; y: number };
|
cp2?: { x: number; y: number };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "removeArcPath"; animationId: string };
|
| { type: "removeArcPath"; animationId: string }
|
||||||
|
| {
|
||||||
|
type: "unrollDynamicAnimations";
|
||||||
|
animationId: string;
|
||||||
|
elements: Array<{
|
||||||
|
selector: string;
|
||||||
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||||
|
easeEach?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ElasticHold {
|
export interface ElasticHold {
|
||||||
start: number;
|
start: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user