mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
Merge pull request #2673 from heygen-com/codex/studio-timeline-a-parser-intent-v2
fix(parsers): preserve authored keyframe intent
This commit is contained in:
@@ -3057,6 +3057,29 @@ describe("single position write per element (consolidation)", () => {
|
||||
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
|
||||
expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true);
|
||||
});
|
||||
|
||||
it("remove-all-keyframes collapses synthetic motionPath keyframes to a held position", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", {
|
||||
motionPath: { path: [{ x: 0, y: 10 }, { x: 200, y: 40 }] },
|
||||
duration: 2,
|
||||
ease: "power1.inOut",
|
||||
}, 1);
|
||||
`;
|
||||
const motionPathTween = parseGsapScript(script).animations.find(
|
||||
(animation) => animation.arcPath?.enabled,
|
||||
)!;
|
||||
|
||||
const out = removeAllKeyframesFromScript(script, motionPathTween.id);
|
||||
const kept = posWritesFor(out, "#box");
|
||||
|
||||
expect(out).not.toBe(script);
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0].arcPath).toBeUndefined();
|
||||
expect(kept[0].duration).toBe(0);
|
||||
expect(kept[0].properties).toMatchObject({ x: 200, y: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("recast writer never doubles vars keys", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
serializeValue as valueToCode,
|
||||
safeJsKey as safeKey,
|
||||
resolveConversionProps,
|
||||
mergePercentageKeyframes,
|
||||
} from "./gsapSerialize";
|
||||
|
||||
export type {
|
||||
@@ -1967,7 +1968,7 @@ function buildKeyframeObjectCode(
|
||||
}>,
|
||||
options?: { easeEach?: string },
|
||||
): string {
|
||||
const entries = keyframes.map((kf) => {
|
||||
const entries = mergePercentageKeyframes(keyframes).map((kf) => {
|
||||
const props = keyframePropsToCode(kf);
|
||||
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
if (kf.auto) props.push(`_auto: 1`);
|
||||
@@ -2361,10 +2362,9 @@ export function removeKeyframeFromScript(
|
||||
/**
|
||||
* Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`,
|
||||
* PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead"
|
||||
* gesture). Re-sorts keyframes by percentage. If a keyframe already exists at
|
||||
* `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when
|
||||
* the animation/keyframe isn't found, the tween has no object-form keyframes, or
|
||||
* the move resolves onto the same keyframe. Acorn twin: moveKeyframeInScript.
|
||||
* gesture). Re-sorts keyframes by percentage. No-op when the animation/keyframe
|
||||
* isn't found, the tween has no object-form keyframes, the move resolves onto the
|
||||
* same keyframe, or the destination is occupied. Acorn twin: moveKeyframeInScript.
|
||||
*/
|
||||
export function moveKeyframeInScript(
|
||||
script: string,
|
||||
@@ -2388,19 +2388,19 @@ export function moveKeyframeInScript(
|
||||
// retime, because findKeyframePropByPct resolves the destination back onto the
|
||||
// from-keyframe — so a deliberate 1% drag committed nothing. Acorn twin too.
|
||||
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script;
|
||||
// A destination keyframe is only a real collision (overwrite) when it's a
|
||||
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
|
||||
// Never overwrite another authored keyframe. Resolving the destination back
|
||||
// onto the source keyframe is not a collision (the tolerance is intentionally
|
||||
// wider than MOVE_NOOP_EPSILON_PCT).
|
||||
const dest = findKeyframePropByPct(kfNode, toPercentage);
|
||||
const collision = dest && dest.prop !== match.prop ? dest : null;
|
||||
if (dest && dest.prop !== match.prop) return script;
|
||||
|
||||
// Reuse each keyframe's value node verbatim (preserves properties +
|
||||
// per-keyframe ease + _auto). Drop the moved keyframe (and any destination
|
||||
// keyframe it overwrites), re-key the moved value to toPercentage, then re-sort.
|
||||
// per-keyframe ease + _auto). Drop the moved keyframe, re-key its value to
|
||||
// toPercentage, then re-sort.
|
||||
const movedValue = match.prop.value;
|
||||
const entries: Array<{ pct: number; value: AstNode }> = [];
|
||||
for (const prop of filterPercentageProps(kfNode)) {
|
||||
if (prop === match.prop) continue;
|
||||
if (collision && prop === collision.prop) continue;
|
||||
const pct = percentageFromKey(propKeyName(prop) ?? "");
|
||||
if (Number.isNaN(pct)) continue;
|
||||
entries.push({ pct, value: prop.value });
|
||||
@@ -2452,7 +2452,12 @@ export function resizeKeyframedTweenInScript(
|
||||
match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key;
|
||||
}
|
||||
|
||||
applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration });
|
||||
applyUpdatesToCall(loc.target.call, {
|
||||
position: newPosition,
|
||||
// Resizing is an explicit duration-authoring gesture. Promote GSAP's
|
||||
// implicit default so the dragged window is the window that plays.
|
||||
duration: newDuration,
|
||||
});
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
@@ -2501,6 +2506,36 @@ export function updateKeyframeInScript(
|
||||
return recast.print(arrLoc.parsed.ast).code;
|
||||
}
|
||||
|
||||
// motionPath waypoints are exposed as synthetic keyframes. Their positions
|
||||
// live in `motionPath.path`, while one tween-level ease owns every segment.
|
||||
// Commit both parts when a Studio gesture carries x/y + ease.
|
||||
if (arrLoc && !arrVal && arrLoc.target.animation.arcPath?.enabled) {
|
||||
const propertyKeys = Object.keys(properties);
|
||||
if (propertyKeys.some((key) => key !== "x" && key !== "y")) return script;
|
||||
let next = script;
|
||||
if (propertyKeys.length > 0) {
|
||||
const waypoints = extractArcWaypoints(arrLoc.target.animation);
|
||||
if (waypoints.length < 2) return script;
|
||||
const pointIndex = Math.max(
|
||||
0,
|
||||
Math.min(waypoints.length - 1, Math.round((percentage / 100) * (waypoints.length - 1))),
|
||||
);
|
||||
const current = waypoints[pointIndex];
|
||||
if (!current) return script;
|
||||
const x = properties.x ?? current.x;
|
||||
const y = properties.y ?? current.y;
|
||||
if (typeof x !== "number" || typeof y !== "number") return script;
|
||||
next = updateMotionPathPointInScript(next, animationId, pointIndex, { x, y });
|
||||
}
|
||||
if (ease !== undefined) {
|
||||
const updated = locateAnimationWithFallback(next, animationId);
|
||||
if (!updated) return script;
|
||||
applyUpdatesToCall(updated.target.call, { ease });
|
||||
next = recast.print(updated.parsed.ast).code;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const ctx = locateKeyframeCtx(script, animationId, percentage);
|
||||
if (!ctx) return script;
|
||||
const { loc, kfNode } = ctx;
|
||||
@@ -2640,19 +2675,29 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
|
||||
.filter((e) => !Number.isNaN(e.pct))
|
||||
.sort((a, b) => a.pct - b.pct);
|
||||
if (kfEntries.length === 0) return script;
|
||||
|
||||
// For to()/set(): collapse to last keyframe (the destination = visible state).
|
||||
// For from(): collapse to first keyframe (the starting state).
|
||||
const method = loc.target.call.method;
|
||||
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
|
||||
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
|
||||
let record: Record<string, unknown>;
|
||||
if (kfNode) {
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
|
||||
.filter((e) => !Number.isNaN(e.pct))
|
||||
.sort((a, b) => a.pct - b.pct);
|
||||
if (kfEntries.length === 0) return script;
|
||||
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
|
||||
record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
|
||||
} else {
|
||||
// motionPath waypoints are exposed to Studio as synthetic keyframes. They
|
||||
// have no literal `keyframes` node, so collapse the parsed endpoint and
|
||||
// remove the authored path instead of silently returning the original file.
|
||||
const synthetic = loc.target.animation.arcPath?.enabled
|
||||
? loc.target.animation.keyframes?.keyframes
|
||||
: undefined;
|
||||
if (!synthetic?.length) return script;
|
||||
const sorted = [...synthetic].sort((a, b) => a.percentage - b.percentage);
|
||||
record = (method === "from" ? sorted[0] : sorted[sorted.length - 1])!.properties;
|
||||
removeVarsKey(loc.target.call.varsArg, "motionPath");
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -90,6 +90,36 @@ export interface GsapPercentageKeyframe {
|
||||
ease?: string;
|
||||
}
|
||||
|
||||
export interface WritableGsapPercentageKeyframe extends GsapPercentageKeyframe {
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse duplicate percentage entries before serializing an object literal.
|
||||
* Matches addKeyframeToScript's merge contract: later properties/ease win while
|
||||
* unrelated authored properties and an earlier ease survive. An explicit later
|
||||
* `auto` value wins, and output is always sorted by percentage.
|
||||
*/
|
||||
export function mergePercentageKeyframes(
|
||||
keyframes: readonly WritableGsapPercentageKeyframe[],
|
||||
): WritableGsapPercentageKeyframe[] {
|
||||
const byPercentage = new Map<number, WritableGsapPercentageKeyframe>();
|
||||
for (const keyframe of keyframes) {
|
||||
const existing = byPercentage.get(keyframe.percentage);
|
||||
if (!existing) {
|
||||
byPercentage.set(keyframe.percentage, {
|
||||
...keyframe,
|
||||
properties: { ...keyframe.properties },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.properties = { ...existing.properties, ...keyframe.properties };
|
||||
if (keyframe.ease !== undefined) existing.ease = keyframe.ease;
|
||||
if (keyframe.auto !== undefined) existing.auto = keyframe.auto;
|
||||
}
|
||||
return [...byPercentage.values()].sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
|
||||
|
||||
export interface GsapKeyframesData {
|
||||
|
||||
@@ -1049,6 +1049,10 @@ 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);
|
||||
`;
|
||||
const UPD_MOTION_PATH_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#title", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 40 }] }, duration: 1, ease: "power1.inOut" }, 0.2);
|
||||
`;
|
||||
|
||||
describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
|
||||
function expectParity(
|
||||
@@ -1100,6 +1104,30 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the authored tween ease for synthetic motion-path keyframes", () => {
|
||||
const id = acornId(UPD_MOTION_PATH_SCRIPT);
|
||||
const ease = "spring(0.42)";
|
||||
const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
|
||||
const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
|
||||
|
||||
expect(modelOf(acorn)).toEqual(modelOf(recast));
|
||||
expect(parseGsapScript(acorn).animations[0]?.ease).toBe(ease);
|
||||
expect(parseGsapScript(recast).animations[0]?.ease).toBe(ease);
|
||||
});
|
||||
|
||||
it("updates a synthetic motion-path point and its tween ease atomically", () => {
|
||||
const id = acornId(UPD_MOTION_PATH_SCRIPT);
|
||||
const ease = "spring(0.42)";
|
||||
const properties = { x: 140, y: 60 };
|
||||
const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, properties, ease);
|
||||
const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, properties, ease);
|
||||
|
||||
expect(modelOf(acorn)).toEqual(modelOf(recast));
|
||||
const animation = parseGsapScript(acorn).animations[0];
|
||||
expect(animation?.ease).toBe(ease);
|
||||
expect(animation?.keyframes?.keyframes.at(-1)?.properties).toMatchObject(properties);
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -1124,8 +1152,9 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
|
||||
// ── moveKeyframeInScript (retime: preserve value + ease) ─────────────────────
|
||||
// "Move to Playhead" retimes a keyframe in time, keeping its properties and
|
||||
// per-keyframe ease. The moved keyframe must vanish from the source percentage
|
||||
// and reappear (with identical value + ease) at the destination; a destination
|
||||
// collision is overwritten, not duplicated. recast and acorn must agree.
|
||||
// and reappear (with identical value + ease) at the destination. An occupied
|
||||
// destination rejects the retime without mutating authored data. Both writers
|
||||
// must agree.
|
||||
const MOVE_KF_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 100, opacity: 0.5, ease: "power2.in" }, "100%": { x: 200 } }, duration: 1 }, 0.2);
|
||||
@@ -1145,16 +1174,10 @@ describe("moveKeyframeInScript: retime preserves value + ease (acorn) ", () => {
|
||||
expect(pcts).not.toContain(50);
|
||||
});
|
||||
|
||||
it("overwrites the destination keyframe on collision (no duplicate)", () => {
|
||||
it("rejects an occupied destination without mutating either writer", () => {
|
||||
const id = acornId(MOVE_KF_SCRIPT);
|
||||
const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100);
|
||||
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
|
||||
const pcts = kfs.map((k) => k.percentage);
|
||||
expect(pcts).toEqual([0, 100]);
|
||||
const dest = kfs.find((k) => k.percentage === 100)!;
|
||||
// The moved keyframe's value + ease replaced the old 100% { x: 200 }.
|
||||
expect(dest.properties).toEqual({ x: 100, opacity: 0.5 });
|
||||
expect(dest.ease).toBe("power2.in");
|
||||
expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100)).toBe(MOVE_KF_SCRIPT);
|
||||
expect(moveKeyframeRecast(MOVE_KF_SCRIPT, id, 50, 100)).toBe(MOVE_KF_SCRIPT);
|
||||
});
|
||||
|
||||
it("no-ops only for a negligible move (below the drag epsilon)", () => {
|
||||
@@ -1203,7 +1226,7 @@ describe("parity: moveKeyframeInScript (recast vs acorn)", () => {
|
||||
expectParity(MOVE_KF_SCRIPT, 50, 10);
|
||||
});
|
||||
|
||||
it("retime onto an existing percentage (collision overwrite)", () => {
|
||||
it("reject an occupied destination", () => {
|
||||
expectParity(MOVE_KF_SCRIPT, 50, 100);
|
||||
});
|
||||
|
||||
@@ -1242,6 +1265,12 @@ describe("moveKeyframeInScript: array-form keyframes (recast + acorn parity)", (
|
||||
modelOf(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 75)),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves array-form source untouched when the destination is occupied", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(moveKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
|
||||
@@ -1253,6 +1282,10 @@ const RESIZE_KF_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1, _auto: 1 }, easeEach: "power1.inOut" }, duration: 1, ease: "power3.out" }, 0.2);
|
||||
`;
|
||||
const RESIZE_KF_NO_DURATION_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1 } }, ease: "power3.out" }, 0.2);
|
||||
`;
|
||||
// Window [0.2, 1.2]; drag the last keyframe (abs 1.2) out to abs 2.2 → [0.2, 2.2].
|
||||
// abs 0.2/0.7/2.2 over the new 2.0s window → 0 / 25 / 100.
|
||||
const RESIZE_REMAP = [
|
||||
@@ -1290,6 +1323,17 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
|
||||
it(`${label}: no-ops on unknown id`, () => {
|
||||
expect(resize(RESIZE_KF_SCRIPT, "bad-id", 0.2, 2, RESIZE_REMAP)).toBe(RESIZE_KF_SCRIPT);
|
||||
});
|
||||
|
||||
it(`${label}: authors the duration set by the resize gesture`, () => {
|
||||
const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT);
|
||||
const out = resize(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP);
|
||||
const shape = shapeOf(out);
|
||||
expect(shape.duration).toBe(2);
|
||||
expect(parseGsapScript(out).animations[0]!.position).toBeCloseTo(0.4, 5);
|
||||
expect(shape.keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual([
|
||||
0, 25, 100,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
@@ -1298,6 +1342,15 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
|
||||
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP)),
|
||||
);
|
||||
});
|
||||
|
||||
it("parity: both writers promote an implicit duration to the resized value", () => {
|
||||
const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT);
|
||||
expect(
|
||||
modelOf(resizeKeyframedTweenAcorn(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP)),
|
||||
).toEqual(
|
||||
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: same array-form gap as moveKeyframeInScript above — boundary
|
||||
@@ -1375,6 +1428,44 @@ function lastModelOf(script: string) {
|
||||
return arr[arr.length - 1];
|
||||
}
|
||||
|
||||
describe("keyframe object builders: duplicate percentage merge", () => {
|
||||
const duplicateKeyframes = [
|
||||
{ percentage: 100, properties: { x: 200 } },
|
||||
{ percentage: 50, properties: { x: 100 }, ease: "power1.in" },
|
||||
{ percentage: 50, properties: { x: 120, opacity: 0.5 }, auto: true },
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
];
|
||||
|
||||
for (const [label, add] of [
|
||||
["acorn", addWithKfAcorn],
|
||||
["recast", addWithKfRecast],
|
||||
] as const) {
|
||||
it(`${label}: emits one merged key and preserves authored properties and ease`, () => {
|
||||
const out = add(ADD_WITH_KF_BASE, "#hero", 0, 1, duplicateKeyframes).script;
|
||||
expect(out.match(/["']50%["']\s*:/g)).toHaveLength(1);
|
||||
const atFifty = lastModelOf(out)?.keyframes?.keyframes.filter(
|
||||
(keyframe) => keyframe.percentage === 50,
|
||||
);
|
||||
expect(atFifty).toHaveLength(1);
|
||||
expect(atFifty?.[0]?.properties).toEqual({ x: 120, opacity: 0.5, _auto: 1 });
|
||||
expect(atFifty?.[0]?.ease).toBe("power1.in");
|
||||
expect(lastModelOf(out)?.keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual(
|
||||
[0, 50, 100],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("an explicit later auto=false clears an earlier synthetic marker", () => {
|
||||
const out = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, [
|
||||
{ percentage: 0, properties: { x: 0 }, auto: true },
|
||||
{ percentage: 0, properties: { opacity: 1 }, auto: false },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]).script;
|
||||
|
||||
expect(lastModelOf(out)?.keyframes?.keyframes[0]?.properties).toEqual({ x: 0, opacity: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE (WS-3.F): recast is retired, so `recast` here is an alias of the acorn
|
||||
// writer and the historical `toEqual(lastModelOf(recast))` comparisons are
|
||||
// tautologies. The WS-3.C ops below instead pin the acorn output as golden
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveConversionProps,
|
||||
extractArcWaypoints,
|
||||
buildMotionPathObjectCode,
|
||||
mergePercentageKeyframes,
|
||||
} from "./gsapSerialize.js";
|
||||
import {
|
||||
parseGsapScriptAcornForWrite,
|
||||
@@ -865,6 +866,77 @@ function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: n
|
||||
return best;
|
||||
}
|
||||
|
||||
function updateMotionPathPosition(
|
||||
script: string,
|
||||
target: ParsedGsapAcornForWrite["located"][number],
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
): string | undefined {
|
||||
const waypoints = extractArcWaypoints(target.animation);
|
||||
if (waypoints.length < 2) return undefined;
|
||||
const pointIndex = Math.max(
|
||||
0,
|
||||
Math.min(waypoints.length - 1, Math.round((percentage / 100) * (waypoints.length - 1))),
|
||||
);
|
||||
const current = waypoints[pointIndex];
|
||||
if (!current) return undefined;
|
||||
const x = properties.x ?? current.x;
|
||||
const y = properties.y ?? current.y;
|
||||
if (typeof x !== "number" || typeof y !== "number") return undefined;
|
||||
return updateMotionPathPointInScript(script, target.id, pointIndex, { x, y });
|
||||
}
|
||||
|
||||
function updateTweenEase(script: string, animationId: string, ease: string): string | undefined {
|
||||
const reparsed = parseGsapScriptAcornForWrite(script);
|
||||
const target = reparsed?.located.find((entry) => entry.id === animationId);
|
||||
if (!target) return undefined;
|
||||
const ms = new MagicString(script);
|
||||
upsertProp(ms, target.call.varsArg, "ease", ease);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
function updateMotionPathKeyframe(
|
||||
script: string,
|
||||
target: ParsedGsapAcornForWrite["located"][number],
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): string | undefined {
|
||||
if (!target.animation.arcPath?.enabled || !findPropertyNode(target.call.varsArg, "motionPath")) {
|
||||
return undefined;
|
||||
}
|
||||
const propertyKeys = Object.keys(properties);
|
||||
if (propertyKeys.some((key) => key !== "x" && key !== "y")) return undefined;
|
||||
const next =
|
||||
propertyKeys.length === 0
|
||||
? script
|
||||
: updateMotionPathPosition(script, target, percentage, properties);
|
||||
if (next === undefined || ease === undefined) return next;
|
||||
return updateTweenEase(next, target.id, ease);
|
||||
}
|
||||
|
||||
function updateObjectKeyframe(
|
||||
script: string,
|
||||
prop: Node,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const ms = new MagicString(script);
|
||||
// Merge into the authored object so an edit cannot discard unrelated
|
||||
// keyframed properties at the same percentage.
|
||||
if (prop.value?.type === "ObjectExpression") {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
upsertProp(ms, prop.value, key, value);
|
||||
}
|
||||
if (ease !== undefined) upsertProp(ms, prop.value, "ease", ease);
|
||||
} else {
|
||||
const record: Record<string, number | string> = { ...properties };
|
||||
if (ease) record.ease = ease;
|
||||
ms.overwrite(prop.value.start, prop.value.end, recordToCode(record));
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
export function updateKeyframeInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
@@ -878,7 +950,12 @@ export function updateKeyframeInScript(
|
||||
if (!target) return script;
|
||||
|
||||
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
|
||||
if (!kfPropNode) return script;
|
||||
if (!kfPropNode) {
|
||||
// motionPath waypoints are exposed to Studio as synthetic keyframes, but
|
||||
// GSAP authors their positions in `motionPath.path` and one ease for the
|
||||
// whole tween. Commit both parts when a Studio gesture carries x/y + ease.
|
||||
return updateMotionPathKeyframe(script, target, percentage, properties, ease) ?? script;
|
||||
}
|
||||
|
||||
// Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages
|
||||
// — GSAP distributes them evenly, and the runtime read assigns even percentages
|
||||
@@ -894,23 +971,7 @@ export function updateKeyframeInScript(
|
||||
const match = findKfPropByPct(kfPropNode.value, percentage);
|
||||
if (!match) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
// MERGE the edited props into the existing keyframe, preserving properties already
|
||||
// keyframed at this percentage (z, transformPerspective, rotation, …). A whole-value
|
||||
// overwrite DROPS every prop not in this edit — e.g. editing rotationY at the 0%
|
||||
// keyframe would strip z / transformPerspective, so the lens then animates from 0 and
|
||||
// the element pops. Mirrors addKeyframeToScript's merge-into-existing branch.
|
||||
if (match.prop.value?.type === "ObjectExpression") {
|
||||
for (const [k, v] of Object.entries(properties)) {
|
||||
upsertProp(ms, match.prop.value, k, v);
|
||||
}
|
||||
if (ease !== undefined) upsertProp(ms, match.prop.value, "ease", ease);
|
||||
} else {
|
||||
const record: Record<string, number | string> = { ...properties };
|
||||
if (ease) record.ease = ease;
|
||||
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
|
||||
}
|
||||
return ms.toString();
|
||||
return updateObjectKeyframe(script, match.prop, properties, ease);
|
||||
}
|
||||
|
||||
// ponytail: even-spacing index map; if array keyframes ever carry per-element
|
||||
@@ -1271,10 +1332,9 @@ export function removeKeyframeFromScript(
|
||||
/**
|
||||
* Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`,
|
||||
* PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead"
|
||||
* gesture). Re-sorts keyframes by percentage. If a keyframe already exists at
|
||||
* `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when
|
||||
* the animation/keyframe isn't found, the tween has no object-form keyframes, or
|
||||
* the move resolves onto the same keyframe.
|
||||
* gesture). Re-sorts keyframes by percentage. No-op when the animation/keyframe
|
||||
* isn't found, the tween has no object-form keyframes, the move resolves onto the
|
||||
* same keyframe, or the destination is occupied.
|
||||
*/
|
||||
export function moveKeyframeInScript(
|
||||
script: string,
|
||||
@@ -1296,18 +1356,18 @@ export function moveKeyframeInScript(
|
||||
// retime, because findKfPropByPct resolves the destination back onto the
|
||||
// from-keyframe — so a deliberate 1% drag committed nothing.
|
||||
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return src;
|
||||
// A destination keyframe is only a real collision (overwrite) when it's a
|
||||
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
|
||||
// Never overwrite another authored keyframe. Resolving the destination back
|
||||
// onto the source keyframe is not a collision (the tolerance is intentionally
|
||||
// wider than MOVE_NOOP_EPSILON_PCT).
|
||||
const dest = findKfPropByPct(kfNode, toPercentage);
|
||||
const collision = dest && dest.prop !== match.prop ? dest : null;
|
||||
if (dest && dest.prop !== match.prop) return script;
|
||||
|
||||
// Rebuild the keyframes object: drop the moved keyframe (and any keyframe at
|
||||
// the destination it overwrites), re-key the moved record to toPercentage,
|
||||
// then re-sort. recordToCode round-trips properties + per-keyframe ease + _auto.
|
||||
// Rebuild the keyframes object: drop the moved keyframe, re-key the moved
|
||||
// record to toPercentage, then re-sort. recordToCode round-trips properties +
|
||||
// per-keyframe ease + _auto.
|
||||
const entries: Array<{ pct: number; record: Record<string, number | string> }> = [];
|
||||
for (const prop of percentagePropsOf(kfNode)) {
|
||||
if (prop === match.prop) continue;
|
||||
if (collision && prop === collision.prop) continue;
|
||||
const pct = percentageFromKey(propKeyName(prop) ?? "");
|
||||
if (Number.isNaN(pct)) continue;
|
||||
entries.push({ pct, record: valueNodeToRecord(prop.value, src) });
|
||||
@@ -1365,6 +1425,8 @@ export function resizeKeyframedTweenInScript(
|
||||
ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`));
|
||||
}
|
||||
overwritePosition(ms, target.call, newPosition);
|
||||
// Resizing is an explicit duration-authoring gesture. Promote GSAP's implicit
|
||||
// default to source so the dragged window is the window that plays.
|
||||
upsertProp(ms, target.call.varsArg, "duration", newDuration);
|
||||
return ms.toString();
|
||||
}
|
||||
@@ -1555,7 +1617,7 @@ function buildKeyframeObjectCode(
|
||||
}>,
|
||||
easeEach?: string,
|
||||
): string {
|
||||
const entries = keyframes.map((kf) => {
|
||||
const entries = mergePercentageKeyframes(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)}`);
|
||||
if (kf.auto) props.push(`_auto: 1`);
|
||||
|
||||
@@ -283,6 +283,21 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
||||
}
|
||||
|
||||
describe("runCommit — instantPatch wiring", () => {
|
||||
it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
|
||||
mockFetchResult({ changed: false });
|
||||
const deps = renderCommitHook();
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 100 },
|
||||
{ label: "Move keyframe" },
|
||||
);
|
||||
});
|
||||
|
||||
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
|
||||
});
|
||||
|
||||
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
||||
patchRuntimeTweenInPlace.mockReturnValue(true);
|
||||
mockFetchResult({ changed: false });
|
||||
|
||||
@@ -77,6 +77,17 @@ async function mutateGsapScriptBatch(
|
||||
|
||||
type ShowToast = (message: string, tone?: "error" | "info") => void;
|
||||
|
||||
function showUnchangedMutationFeedback(
|
||||
mutations: Record<string, unknown>[],
|
||||
result: MutationResult,
|
||||
showToast: ShowToast | undefined,
|
||||
): void {
|
||||
if (result.changed !== false || mutations.length !== 1) return;
|
||||
if (mutations[0]?.type === "move-keyframe") {
|
||||
showToast?.("A keyframe already exists at that time", "info");
|
||||
}
|
||||
}
|
||||
|
||||
async function runMutationRequest(
|
||||
mutations: Record<string, unknown>[],
|
||||
options: CommitMutationOptions,
|
||||
@@ -94,7 +105,9 @@ async function runMutationRequest(
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await request();
|
||||
const result = await request();
|
||||
showUnchangedMutationFeedback(mutations, result, showToast);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof GsapMutationHttpError) {
|
||||
showToast?.(formatGsapMutationRejectionToast(error), "error");
|
||||
|
||||
Reference in New Issue
Block a user