mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups acorn ports + SDK ops
- acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript - acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers - parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites - SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants - mutate.ts: handlers + can() gates for both new ops - mutate.gsap.test.ts: 6 new tests (53 total passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
761221e583
commit
dc60436505
@@ -12,11 +12,15 @@ import {
|
||||
parseGsapScript,
|
||||
removeAllKeyframesFromScript as removeAllRecast,
|
||||
convertToKeyframesInScript as convertRecast,
|
||||
materializeKeyframesInScript as materializeRecast,
|
||||
splitIntoPropertyGroups as splitGroupsRecast,
|
||||
} from "./gsapParser.js";
|
||||
import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js";
|
||||
import {
|
||||
removeAllKeyframesFromScript as removeAllAcorn,
|
||||
convertToKeyframesFromScript as convertAcorn,
|
||||
materializeKeyframesFromScript as materializeAcorn,
|
||||
splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
|
||||
function acornId(script: string): string {
|
||||
@@ -176,3 +180,124 @@ describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => {
|
||||
expect(convertAcorn(script, id)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
// ── materializeKeyframes parity ───────────────────────────────────────────────
|
||||
|
||||
const MATERIALIZE_KFS = [
|
||||
{ percentage: 0, properties: { x: 0, opacity: 1 } },
|
||||
{ percentage: 50, properties: { x: 150, opacity: 0.5 } },
|
||||
{ percentage: 100, properties: { x: 300, opacity: 0 } },
|
||||
];
|
||||
|
||||
const MATERIALIZE_FIXTURES: Array<{
|
||||
name: string;
|
||||
script: string;
|
||||
kfs: typeof MATERIALIZE_KFS;
|
||||
easeEach?: string;
|
||||
}> = [
|
||||
{
|
||||
name: "flat tween — adds keyframes property",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 300, duration: 2 }, 0);
|
||||
`,
|
||||
kfs: MATERIALIZE_KFS,
|
||||
},
|
||||
{
|
||||
name: "with easeEach",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { opacity: 0, duration: 1 }, 0);
|
||||
`,
|
||||
kfs: [
|
||||
{ percentage: 0, properties: { opacity: 1 } },
|
||||
{ percentage: 100, properties: { opacity: 0 } },
|
||||
],
|
||||
easeEach: "power2.inOut",
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: materializeKeyframesFromScript (recast vs acorn)", () => {
|
||||
for (const { name, script, kfs, easeEach } of MATERIALIZE_FIXTURES) {
|
||||
it(name, () => {
|
||||
const id = acornId(script);
|
||||
const recastOut = materializeRecast(script, id, kfs, easeEach);
|
||||
const acornOut = materializeAcorn(script, id, kfs, easeEach);
|
||||
const recastShape = shapeOf(recastOut);
|
||||
const acornShape = shapeOf(acornOut);
|
||||
expect(acornShape.keyframes).toBeDefined();
|
||||
expect(acornShape).toEqual(recastShape);
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when id not found", () => {
|
||||
const script = MATERIALIZE_FIXTURES[0]!.script;
|
||||
expect(materializeAcorn(script, "nope", MATERIALIZE_KFS)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
// ── splitIntoPropertyGroups parity ────────────────────────────────────────────
|
||||
|
||||
function shapesOf(script: string) {
|
||||
return parseGsapScript(script).animations.map((a) => ({
|
||||
method: a.method,
|
||||
properties: a.properties,
|
||||
keyframes: a.keyframes,
|
||||
duration: a.duration,
|
||||
ease: a.ease,
|
||||
selector: a.targetSelector,
|
||||
propertyGroup: a.propertyGroup,
|
||||
}));
|
||||
}
|
||||
|
||||
const SPLIT_FIXTURES: Array<{ name: string; script: string }> = [
|
||||
{
|
||||
name: "flat mixed tween — splits into position + visual groups",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 100, y: 50, opacity: 0.5, duration: 1 }, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "keyframed mixed tween — splits per group",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { x: 0, opacity: 1 }, "100%": { x: 200, opacity: 0 } }, duration: 1 }, 0);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => {
|
||||
for (const { name, script } of SPLIT_FIXTURES) {
|
||||
it(name, () => {
|
||||
const id = acornId(script);
|
||||
const { script: recastOut } = splitGroupsRecast(script, id);
|
||||
const { script: acornOut } = splitGroupsAcorn(script, id);
|
||||
const recastShapes = shapesOf(recastOut);
|
||||
const acornShapes = shapesOf(acornOut);
|
||||
expect(acornShapes).toHaveLength(recastShapes.length);
|
||||
expect(acornShapes.length).toBeGreaterThan(1);
|
||||
// Each produced group should match its counterpart by propertyGroup
|
||||
const sortByGroup = (arr: typeof recastShapes) =>
|
||||
arr.slice().sort((a, b) => (a.propertyGroup ?? "").localeCompare(b.propertyGroup ?? ""));
|
||||
expect(sortByGroup(acornShapes)).toEqual(sortByGroup(recastShapes));
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when id not found", () => {
|
||||
const script = SPLIT_FIXTURES[0]!.script;
|
||||
const { script: out, ids } = splitGroupsAcorn(script, "nope");
|
||||
expect(out).toBe(script);
|
||||
expect(ids).toEqual(["nope"]);
|
||||
});
|
||||
|
||||
it("no-op when single-group tween", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const id = acornId(script);
|
||||
const { script: out } = splitGroupsAcorn(script, id);
|
||||
expect(out).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
|
||||
*/
|
||||
import MagicString from "magic-string";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js";
|
||||
import { resolveConversionProps } from "./gsapSerialize.js";
|
||||
import {
|
||||
parseGsapScriptAcornForWrite,
|
||||
type ParsedGsapAcornForWrite,
|
||||
type TweenCallInfo,
|
||||
} from "./gsapParserAcorn.js";
|
||||
import { classifyPropertyGroup } from "./gsapConstants.js";
|
||||
import type { PropertyGroupName } from "./gsapConstants.js";
|
||||
import * as acornWalk from "acorn-walk";
|
||||
|
||||
// ── Code generation helpers ──────────────────────────────────────────────────
|
||||
@@ -997,6 +999,262 @@ export function convertToKeyframesFromScript(
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Keyframe-object code builder ─────────────────────────────────────────────
|
||||
|
||||
/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */
|
||||
function buildKeyframeObjectCode(
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
): string {
|
||||
const entries = keyframes.map((kf) => {
|
||||
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
|
||||
});
|
||||
if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
// ── Materialize keyframes ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace a dynamic or static keyframes expression with a fully-resolved
|
||||
* percentage-keyframes object. Called when a user first edits a dynamically-
|
||||
* generated keyframe in the studio so it becomes statically editable.
|
||||
*/
|
||||
export function materializeKeyframesFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
resolvedSelector?: string,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
|
||||
const { call } = target;
|
||||
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
|
||||
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
|
||||
const ms = new MagicString(script);
|
||||
|
||||
if (resolvedSelector) {
|
||||
const selectorArg = call.node.arguments[0];
|
||||
if (selectorArg)
|
||||
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector));
|
||||
}
|
||||
|
||||
const kfProp = findPropertyNode(call.varsArg, "keyframes");
|
||||
if (kfProp) {
|
||||
ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode);
|
||||
} else if (call.varsArg?.type === "ObjectExpression") {
|
||||
const vars = call.varsArg;
|
||||
if (vars.properties.length > 0) {
|
||||
ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `);
|
||||
} else {
|
||||
ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
const eachProp = findPropertyNode(call.varsArg, "easeEach");
|
||||
if (eachProp) {
|
||||
const allProps = (call.varsArg.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||
removeProp(ms, eachProp, allProps);
|
||||
}
|
||||
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Add animation with keyframes ──────────────────────────────────────────────
|
||||
|
||||
/** Insert a new keyframed `to()` call and return the new animation ID. */
|
||||
export function addAnimationWithKeyframesToScript(
|
||||
script: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
ease?: string,
|
||||
): { script: string; id: string } {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return { script, id: "" };
|
||||
const insertionPoint = findInsertionPoint(parsed);
|
||||
if (insertionPoint === null) return { script, id: "" };
|
||||
|
||||
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
|
||||
const kfObjCode = buildKeyframeObjectCode(sorted);
|
||||
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
|
||||
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
|
||||
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
ms.appendLeft(insertionPoint, "\n" + stmtCode);
|
||||
|
||||
const result = ms.toString();
|
||||
const reParsed = parseGsapScriptAcornForWrite(result);
|
||||
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
|
||||
return { script: result, id: newId };
|
||||
}
|
||||
|
||||
// ── Split into property groups ────────────────────────────────────────────────
|
||||
|
||||
function collectPropertyKeys(anim: GsapAnimation): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (anim.keyframes) {
|
||||
for (const kf of anim.keyframes.keyframes) {
|
||||
for (const k of Object.keys(kf.properties)) keys.add(k);
|
||||
}
|
||||
} else {
|
||||
for (const k of Object.keys(anim.properties)) keys.add(k);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function partitionPropertyGroups(keys: Set<string>): Map<PropertyGroupName, string[]> {
|
||||
const groups = new Map<PropertyGroupName, string[]>();
|
||||
for (const key of keys) {
|
||||
if (key === "transformOrigin") continue;
|
||||
const group = classifyPropertyGroup(key);
|
||||
let arr = groups.get(group);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
groups.set(group, arr);
|
||||
}
|
||||
arr.push(key);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function assignTransformOrigin(groupProps: Map<PropertyGroupName, string[]>): void {
|
||||
let largestGroup: PropertyGroupName | undefined;
|
||||
let largestCount = 0;
|
||||
for (const [group, props] of groupProps) {
|
||||
if (props.length > largestCount) {
|
||||
largestCount = props.length;
|
||||
largestGroup = group;
|
||||
}
|
||||
}
|
||||
if (largestGroup) groupProps.get(largestGroup)!.push("transformOrigin");
|
||||
}
|
||||
|
||||
function filterGroupKeyframes(
|
||||
kfs: GsapPercentageKeyframe[],
|
||||
propSet: Set<string>,
|
||||
): Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }> {
|
||||
const result: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}> = [];
|
||||
for (const kf of kfs) {
|
||||
const filtered: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(kf.properties)) {
|
||||
if (propSet.has(k)) filtered[k] = v;
|
||||
}
|
||||
if (Object.keys(filtered).length > 0) {
|
||||
result.push({
|
||||
percentage: kf.percentage,
|
||||
properties: filtered,
|
||||
...(kf.ease ? { ease: kf.ease } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function filterGroupProperties(
|
||||
properties: Record<string, number | string>,
|
||||
propSet: Set<string>,
|
||||
): Record<string, number | string> {
|
||||
const result: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(properties)) {
|
||||
if (propSet.has(k)) result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addGroupAnimToScript(
|
||||
script: string,
|
||||
anim: GsapAnimation,
|
||||
propSet: Set<string>,
|
||||
): { script: string; id: string } {
|
||||
if (anim.keyframes) {
|
||||
const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet);
|
||||
if (groupKeyframes.length === 0) return { script, id: "" };
|
||||
const pos = typeof anim.position === "number" ? anim.position : 0;
|
||||
return addAnimationWithKeyframesToScript(
|
||||
script,
|
||||
anim.targetSelector,
|
||||
pos,
|
||||
anim.duration ?? 0.5,
|
||||
groupKeyframes,
|
||||
anim.keyframes.easeEach ?? anim.ease,
|
||||
);
|
||||
}
|
||||
const groupProperties = filterGroupProperties(anim.properties, propSet);
|
||||
if (Object.keys(groupProperties).length === 0) return { script, id: "" };
|
||||
const fromProperties =
|
||||
anim.method === "fromTo" && anim.fromProperties
|
||||
? filterGroupProperties(anim.fromProperties, propSet)
|
||||
: undefined;
|
||||
return addAnimationToScript(script, {
|
||||
targetSelector: anim.targetSelector,
|
||||
method: anim.method,
|
||||
position: anim.position,
|
||||
duration: anim.duration,
|
||||
ease: anim.ease,
|
||||
properties: groupProperties,
|
||||
fromProperties,
|
||||
extras: anim.extras,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a mixed-property tween into one tween per property group (position,
|
||||
* scale, visual, etc.) so each group can be edited independently.
|
||||
* Returns the updated script and the IDs of the newly-created tweens.
|
||||
*/
|
||||
export function splitIntoPropertyGroupsFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): { script: string; ids: string[] } {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return { script, ids: [animationId] };
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return { script, ids: [animationId] };
|
||||
const { animation } = target;
|
||||
|
||||
const allPropKeys = collectPropertyKeys(animation);
|
||||
const groupProps = partitionPropertyGroups(allPropKeys);
|
||||
if (groupProps.size <= 1) return { script, ids: [animationId] };
|
||||
if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps);
|
||||
|
||||
let result = removeAnimationFromScript(script, animationId);
|
||||
for (const [, props] of groupProps) {
|
||||
const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props));
|
||||
if (id) result = next;
|
||||
}
|
||||
|
||||
const reParsed = parseGsapScriptAcornForWrite(result);
|
||||
const newIds = (reParsed?.located ?? [])
|
||||
.filter((l) => l.animation.targetSelector === animation.targetSelector)
|
||||
.map((l) => l.id);
|
||||
return { script: result, ids: newIds };
|
||||
}
|
||||
|
||||
// ── Label write ops ───────────────────────────────────────────────────────────
|
||||
|
||||
export function addLabelToScript(script: string, name: string, position: number): string {
|
||||
|
||||
@@ -583,6 +583,89 @@ window.__timelines["t"] = tl;`;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── materializeKeyframes ─────────────────────────────────────────────────────
|
||||
|
||||
describe("materializeKeyframes", () => {
|
||||
it("adds keyframes property to flat tween", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: TWEEN_ANIM_ID,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("keyframes");
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain('"100%"');
|
||||
});
|
||||
|
||||
it("injects easeEach into keyframes object", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: TWEEN_ANIM_ID,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
easeEach: "power2.out",
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("easeEach");
|
||||
expect(newScript).toContain("power2.out");
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: "nope",
|
||||
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitIntoPropertyGroups ──────────────────────────────────────────────────
|
||||
|
||||
describe("splitIntoPropertyGroups", () => {
|
||||
it("splits mixed tween into multiple group tweens", () => {
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
// mixed tween has no propertyGroup → no group suffix in id
|
||||
const animId = `[data-hf-id="hf-box"]-to-0`;
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
// x is position group, opacity is visual group — expect 2 tweens
|
||||
const toCount = (newScript.match(/\.to\(/g) ?? []).length;
|
||||
expect(toCount).toBe(2);
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when tween has only one property group", () => {
|
||||
// x + y = same "position" group → nothing to split
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-position`;
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Label ops ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addLabel", () => {
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
removeKeyframeFromScript,
|
||||
removeAllKeyframesFromScript,
|
||||
convertToKeyframesFromScript,
|
||||
materializeKeyframesFromScript,
|
||||
splitIntoPropertyGroupsFromScript,
|
||||
updateKeyframeInScript,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
@@ -167,6 +169,16 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult
|
||||
return handleRemoveAllKeyframes(parsed, op.animationId);
|
||||
case "convertToKeyframes":
|
||||
return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues);
|
||||
case "materializeKeyframes":
|
||||
return handleMaterializeKeyframes(
|
||||
parsed,
|
||||
op.animationId,
|
||||
op.keyframes,
|
||||
op.easeEach,
|
||||
op.resolvedSelector,
|
||||
);
|
||||
case "splitIntoPropertyGroups":
|
||||
return handleSplitIntoPropertyGroups(parsed, op.animationId);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -774,6 +786,43 @@ function handleConvertToKeyframes(
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleMaterializeKeyframes(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
resolvedSelector?: string,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = materializeKeyframesFromScript(
|
||||
script,
|
||||
animationId,
|
||||
keyframes,
|
||||
easeEach,
|
||||
resolvedSelector,
|
||||
);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleSplitIntoPropertyGroups(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
@@ -993,6 +1042,8 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "removeGsapTween":
|
||||
case "removeAllKeyframes":
|
||||
case "convertToKeyframes":
|
||||
case "materializeKeyframes":
|
||||
case "splitIntoPropertyGroups":
|
||||
case "deleteAllForSelector":
|
||||
case "removeLabel":
|
||||
if (getGsapScript(parsed.document) === null)
|
||||
|
||||
@@ -112,6 +112,18 @@ export type EditOp =
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "deleteAllForSelector"; selector: string }
|
||||
| {
|
||||
type: "materializeKeyframes";
|
||||
animationId: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
easeEach?: string;
|
||||
resolvedSelector?: string;
|
||||
}
|
||||
| { type: "splitIntoPropertyGroups"; animationId: string }
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user