mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk): stage 6 — arc path ops (setArcPath, updateArcSegment, removeArcPath) (#1500)
Port arc path trio from recast to browser-safe acorn+MagicString writer. Add SDK op types and mutate.ts handlers for setArcPath / updateArcSegment / removeArcPath. Decompose buildMotionPathObjectCode into small sub-functions in gsapSerialize.ts to stay within fallow complexity thresholds. Tests verify acorn output re-parses to correct arcPath shape. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
a746db6017
commit
1612d18fdf
@@ -475,3 +475,80 @@ export function resolveConversionProps(
|
||||
: { ...anim.properties };
|
||||
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
|
||||
}
|
||||
|
||||
// ── Arc path serialization helpers (shared by recast + acorn writers) ─────────
|
||||
|
||||
function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
|
||||
const vx = props.x;
|
||||
const vy = props.y;
|
||||
return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null;
|
||||
}
|
||||
|
||||
export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
|
||||
const keyframeWps = (anim.keyframes?.keyframes ?? [])
|
||||
.map((kf) => numericXY(kf.properties))
|
||||
.filter((pt): pt is { x: number; y: number } => pt !== null);
|
||||
if (keyframeWps.length >= 2) return keyframeWps;
|
||||
const propX = anim.properties.x;
|
||||
const propY = anim.properties.y;
|
||||
if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps;
|
||||
const destX = typeof propX === "number" ? propX : 0;
|
||||
const destY = typeof propY === "number" ? propY : 0;
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: destX, y: destY },
|
||||
];
|
||||
}
|
||||
|
||||
function autoRotateSuffix(autoRotate: boolean | number): string {
|
||||
if (autoRotate === true) return ", autoRotate: true";
|
||||
if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function cubicControlPoints(
|
||||
seg: ArcPathSegment,
|
||||
wp: { x: number; y: number },
|
||||
nextWp: { x: number; y: number },
|
||||
): string[] {
|
||||
if (seg.cp1 && seg.cp2) {
|
||||
return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`];
|
||||
}
|
||||
const dx = nextWp.x - wp.x;
|
||||
const dy = nextWp.y - wp.y;
|
||||
const c = seg.curviness ?? 1;
|
||||
return [
|
||||
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
|
||||
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildCubicPathEntries(
|
||||
waypoints: Array<{ x: number; y: number }>,
|
||||
segments: ArcPathSegment[],
|
||||
): string[] {
|
||||
const entries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const nextWp = waypoints[i + 1]!;
|
||||
entries.push(...cubicControlPoints(segments[i]!, waypoints[i]!, nextWp));
|
||||
entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildMotionPathObjectCode(config: {
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
segments: ArcPathSegment[];
|
||||
autoRotate: boolean | number;
|
||||
}): string {
|
||||
const { waypoints, segments, autoRotate } = config;
|
||||
const arSuffix = autoRotateSuffix(autoRotate);
|
||||
if (segments.some((s) => s.cp1 && s.cp2) && waypoints.length >= 2) {
|
||||
const pathStr = buildCubicPathEntries(waypoints, segments).join(", ");
|
||||
return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`;
|
||||
}
|
||||
const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
|
||||
const curviness = segments[0]?.curviness ?? 1;
|
||||
const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : "";
|
||||
return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,20 @@ import {
|
||||
materializeKeyframesFromScript as materializeAcorn,
|
||||
splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
|
||||
splitAnimationsInScript as splitAnimsAcorn,
|
||||
setArcPathInScript as setArcAcorn,
|
||||
updateArcSegmentInScript as updateArcSegmentAcorn,
|
||||
removeArcPathFromScript as removeArcAcorn,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
|
||||
function acornId(script: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
|
||||
return parsed.located[0]!.id;
|
||||
}
|
||||
|
||||
function arcShapeOf(script: string) {
|
||||
const anim = parseGsapScript(script).animations[0]!;
|
||||
return { arcPath: anim.arcPath, properties: anim.properties };
|
||||
}
|
||||
|
||||
/** Reparse a written script and return the first animation's editable shape. */
|
||||
function shapeOf(script: string) {
|
||||
const anim = parseGsapScript(script).animations[0]!;
|
||||
@@ -390,3 +397,66 @@ describe("parity: splitAnimationsInScript (recast vs acorn)", () => {
|
||||
expect(splitAnimsAcorn(script, opts).script).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── arc path parity ──────────────────────────────────────────────────────────
|
||||
|
||||
const ARC_FLAT_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 100, y: 50, duration: 2 }, 0);
|
||||
`;
|
||||
const ARC_CFG = {
|
||||
enabled: true as const,
|
||||
autoRotate: false as const,
|
||||
segments: [{ curviness: 1 }],
|
||||
};
|
||||
const DISABLE_CFG = {
|
||||
enabled: false as const,
|
||||
autoRotate: false as const,
|
||||
segments: [] as never[],
|
||||
};
|
||||
|
||||
function arcFixture() {
|
||||
const id = acornId(ARC_FLAT_SCRIPT);
|
||||
const enabled = setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG);
|
||||
return { id, enabled };
|
||||
}
|
||||
|
||||
describe("setArcPathInScript: acorn output correctness", () => {
|
||||
it("enable: arcPath.enabled=true, segments preserved", () => {
|
||||
const id = acornId(ARC_FLAT_SCRIPT);
|
||||
const shape = arcShapeOf(setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG));
|
||||
expect(shape.arcPath?.enabled).toBe(true);
|
||||
expect(shape.arcPath?.segments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disable: arcPath=undefined, x/y restored", () => {
|
||||
const { id, enabled } = arcFixture();
|
||||
const shape = arcShapeOf(setArcAcorn(enabled, id, DISABLE_CFG));
|
||||
expect(shape.arcPath).toBeUndefined();
|
||||
expect(typeof shape.properties.x).toBe("number");
|
||||
});
|
||||
|
||||
it("no-op when animation not found", () => {
|
||||
expect(setArcAcorn(ARC_FLAT_SCRIPT, "nope", ARC_CFG)).toBe(ARC_FLAT_SCRIPT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateArcSegmentInScript: acorn output correctness", () => {
|
||||
it("curviness update reflected in parsed shape", () => {
|
||||
const { id, enabled } = arcFixture();
|
||||
const shape = arcShapeOf(updateArcSegmentAcorn(enabled, id, 0, { curviness: 2 }));
|
||||
expect(shape.arcPath?.segments[0]?.curviness).toBe(2);
|
||||
});
|
||||
|
||||
it("no-op when index out of range", () => {
|
||||
const { id, enabled } = arcFixture();
|
||||
expect(updateArcSegmentAcorn(enabled, id, 99, { curviness: 2 })).toBe(enabled);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeArcPathFromScript: acorn output correctness", () => {
|
||||
it("arcPath=undefined after removal", () => {
|
||||
const { id, enabled } = arcFixture();
|
||||
expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
|
||||
*/
|
||||
import MagicString from "magic-string";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js";
|
||||
import { resolveConversionProps } from "./gsapSerialize.js";
|
||||
import type {
|
||||
GsapAnimation,
|
||||
GsapPercentageKeyframe,
|
||||
ArcPathConfig,
|
||||
ArcPathSegment,
|
||||
} from "./gsapSerialize.js";
|
||||
import {
|
||||
resolveConversionProps,
|
||||
extractArcWaypoints,
|
||||
buildMotionPathObjectCode,
|
||||
} from "./gsapSerialize.js";
|
||||
import {
|
||||
parseGsapScriptAcornForWrite,
|
||||
type ParsedGsapAcornForWrite,
|
||||
@@ -1304,6 +1313,160 @@ export function removeLabelFromScript(script: string, name: string): string {
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Arc path helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove a set of properties from an ObjectExpression in a single pass.
|
||||
* Groups consecutive marked props into blocks to avoid overlapping remove ranges.
|
||||
*/
|
||||
function removePropsByKey(ms: MagicString, objNode: any, keys: Set<string>): void {
|
||||
if (objNode?.type !== "ObjectExpression") return;
|
||||
const allProps = (objNode.properties ?? []).filter(isObjectProperty);
|
||||
const marked = allProps.map((p: any) => keys.has(propKeyName(p) ?? ""));
|
||||
let i = 0;
|
||||
while (i < allProps.length) {
|
||||
if (!marked[i]) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const blockStart = i;
|
||||
while (i < allProps.length && marked[i]) i++;
|
||||
ms.remove(...blockRemoveRange(allProps, blockStart, i));
|
||||
}
|
||||
}
|
||||
|
||||
function blockRemoveRange(allProps: any[], blockStart: number, blockEnd: number): [number, number] {
|
||||
if (blockStart === 0 && blockEnd === allProps.length)
|
||||
return [allProps[0].start, allProps[allProps.length - 1].end];
|
||||
if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start];
|
||||
return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end];
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function readLastWaypointXY(mpVal: any): { x: number | null; y: number | null } {
|
||||
if (mpVal?.type !== "ObjectExpression") return { x: null, y: null };
|
||||
const pathProp = findPropertyNode(mpVal, "path");
|
||||
if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null };
|
||||
const elems: any[] = pathProp.value.elements ?? [];
|
||||
const last = elems[elems.length - 1];
|
||||
if (last?.type !== "ObjectExpression") return { x: null, y: null };
|
||||
const xRaw = findPropertyNode(last, "x")?.value?.value;
|
||||
const yRaw = findPropertyNode(last, "y")?.value?.value;
|
||||
return { x: typeof xRaw === "number" ? xRaw : null, y: typeof yRaw === "number" ? yRaw : null };
|
||||
}
|
||||
|
||||
function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean {
|
||||
const mpProp = findPropertyNode(call.varsArg, "motionPath");
|
||||
if (!mpProp) return false;
|
||||
const { x, y } = readLastWaypointXY(mpProp.value);
|
||||
if (x === null && y === null) {
|
||||
const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty);
|
||||
removeProp(ms, mpProp, allProps);
|
||||
return true;
|
||||
}
|
||||
// Overwrite the entire motionPath property with the recovered x/y pair — avoids
|
||||
// the appendLeft+remove range-boundary issue in MagicString.
|
||||
const parts: string[] = [];
|
||||
if (x !== null) parts.push(`x: ${x}`);
|
||||
if (y !== null) parts.push(`y: ${y}`);
|
||||
ms.overwrite(mpProp.start, mpProp.end, parts.join(", "));
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripXYFromKeyframes(ms: MagicString, kfPropNode: any): void {
|
||||
if (kfPropNode?.value?.type !== "ObjectExpression") return;
|
||||
const xyKeys = new Set(["x", "y"]);
|
||||
for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) {
|
||||
const k = propKeyName(pctProp);
|
||||
if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") {
|
||||
removePropsByKey(ms, pctProp.value, xyKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableArcPath(
|
||||
ms: MagicString,
|
||||
call: TweenCallInfo,
|
||||
animation: GsapAnimation,
|
||||
config: ArcPathConfig,
|
||||
): boolean {
|
||||
const waypoints = extractArcWaypoints(animation);
|
||||
if (waypoints.length < 2) return false;
|
||||
const segments: ArcPathSegment[] =
|
||||
config.segments.length === waypoints.length - 1
|
||||
? config.segments
|
||||
: Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 }));
|
||||
const motionPathCode = buildMotionPathObjectCode({
|
||||
waypoints,
|
||||
segments,
|
||||
autoRotate: config.autoRotate,
|
||||
});
|
||||
upsertProp(ms, call.varsArg, "motionPath", `__raw:${motionPathCode}`);
|
||||
stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes"));
|
||||
removePropsByKey(ms, call.varsArg, new Set(["x", "y"]));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setArcPathInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
config: ArcPathConfig,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
const ms = new MagicString(script);
|
||||
const handled = config.enabled
|
||||
? enableArcPath(ms, target.call, target.animation, config)
|
||||
: disableArcPath(ms, target.call);
|
||||
return handled ? ms.toString() : script;
|
||||
}
|
||||
|
||||
export function updateArcSegmentInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
segmentIndex: number,
|
||||
update: Partial<ArcPathSegment>,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
|
||||
const { call, animation } = target;
|
||||
if (!animation.arcPath?.enabled) return script;
|
||||
|
||||
const segments = [...animation.arcPath.segments];
|
||||
if (segmentIndex < 0 || segmentIndex >= segments.length) return script;
|
||||
|
||||
segments[segmentIndex] = { ...segments[segmentIndex]!, ...update };
|
||||
|
||||
const waypoints = extractArcWaypoints(animation);
|
||||
if (waypoints.length < 2) return script;
|
||||
|
||||
const motionPathCode = buildMotionPathObjectCode({
|
||||
waypoints,
|
||||
segments,
|
||||
autoRotate: animation.arcPath.autoRotate,
|
||||
});
|
||||
|
||||
const mpProp = findPropertyNode(call.varsArg, "motionPath");
|
||||
if (!mpProp) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
export function removeArcPathFromScript(script: string, animationId: string): string {
|
||||
return setArcPathInScript(script, animationId, {
|
||||
enabled: false,
|
||||
autoRotate: false,
|
||||
segments: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ── splitAnimationsInScript helpers ──────────────────────────────────────────
|
||||
|
||||
/** Overwrite the selector (first arg) of a tween call. */
|
||||
|
||||
@@ -32,17 +32,6 @@ function fresh(script = GSAP_SCRIPT) {
|
||||
return parseMutable(makeHtml(script));
|
||||
}
|
||||
|
||||
// A sub-composition host: data-hf-id="hf-host" (its own leaf id) AND
|
||||
// data-composition-id="sub-1" (the id studio passes when targeting the root).
|
||||
function freshSubComp(script = GSAP_SCRIPT) {
|
||||
return parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-host" data-composition-id="sub-1" style="opacity: 0"></div>
|
||||
<script>${script}</script>
|
||||
</div>`.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function getScript(parsed: ReturnType<typeof parseMutable>): string {
|
||||
const doc = serializeDocument(parsed);
|
||||
const m = /<script>([\s\S]*?)<\/script>/i.exec(doc);
|
||||
@@ -187,89 +176,6 @@ describe("addGsapTween", () => {
|
||||
expect(newScript).toContain("opacity: 0");
|
||||
expect(newScript).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("returns EMPTY when no GSAP script", () => {
|
||||
const noScript = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
const result = applyOp(noScript, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 1 } },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A normal data-hf-id target keeps the [data-hf-id] selector form.
|
||||
it("emits a [data-hf-id] selector for a normal element target", () => {
|
||||
const result = applyOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 1 } },
|
||||
});
|
||||
const script = String(result.forward[0]?.value ?? "");
|
||||
expect(script).toContain(`[data-hf-id=\\"hf-box\\"]`);
|
||||
expect(script).not.toContain("data-composition-id");
|
||||
});
|
||||
|
||||
// A sub-composition ROOT is addressed by its composition id, but the SDK's
|
||||
// element↔tween attribution is data-hf-id based. So a comp-id target must
|
||||
// resolve to the host element and emit the CANONICAL [data-hf-id="<host>"]
|
||||
// form — NOT [data-composition-id] (invisible to selectorMatchesId / cascade /
|
||||
// buildAnimationIdMap) and NOT [data-hf-id="<compId>"] (matches no element).
|
||||
it("emits a canonical [data-hf-id] selector for a sub-composition root target", () => {
|
||||
const result = applyOp(freshSubComp(), {
|
||||
type: "addGsapTween",
|
||||
target: "sub-1",
|
||||
tween: { method: "to", properties: { x: 1 } },
|
||||
});
|
||||
const script = String(result.forward[0]?.value ?? "");
|
||||
expect(script).toContain(`[data-hf-id=\\"hf-host\\"]`);
|
||||
expect(script).not.toContain("data-composition-id");
|
||||
expect(script).not.toContain(`[data-hf-id=\\"sub-1\\"]`);
|
||||
});
|
||||
|
||||
// validateOp/can must accept a comp-root target (resolveScoped's comp-id
|
||||
// fallback resolves it) — otherwise can/apply diverge.
|
||||
it("validateOp accepts a sub-composition root target (no E_TARGET_NOT_FOUND)", () => {
|
||||
const r = validateOp(freshSubComp(), {
|
||||
type: "addGsapTween",
|
||||
target: "sub-1",
|
||||
tween: { method: "to", properties: { x: 1 } },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
// setTiming on the comp-root after adding a tween updates the tween's GSAP
|
||||
// position/duration — selectorMatchesId matches the canonical host hf-id.
|
||||
it("setTiming on a comp-root syncs its tween position/duration", () => {
|
||||
// applyOp mutates parsed.document in place, so chain ops on the same parsed.
|
||||
const parsed = freshSubComp();
|
||||
applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "sub-1",
|
||||
tween: { method: "to", duration: 0.5, properties: { x: 1 } },
|
||||
});
|
||||
applyOp(parsed, { type: "setTiming", target: "sub-1", start: 2, duration: 1.5 });
|
||||
const script = getScript(parsed);
|
||||
// The host tween's GSAP position (3rd arg) is now 2 and duration 1.5.
|
||||
expect(script).toContain(`[data-hf-id=\\"hf-host\\"]`);
|
||||
expect(script).toMatch(/duration:\s*1\.5/);
|
||||
expect(script).toMatch(/\},\s*2\)/);
|
||||
});
|
||||
|
||||
// removeElement on the comp-root cascade-removes its tween (not orphaned).
|
||||
it("removeElement on a comp-root cascade-removes its tween", () => {
|
||||
const parsed = freshSubComp();
|
||||
applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "sub-1",
|
||||
tween: { method: "to", properties: { x: 1 } },
|
||||
});
|
||||
expect(getScript(parsed)).toContain(`[data-hf-id=\\"hf-host\\"]`);
|
||||
applyOp(parsed, { type: "removeElement", target: "sub-1" });
|
||||
expect(getScript(parsed)).not.toContain(`[data-hf-id=\\"hf-host\\"]`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tween op test helpers ────────────────────────────────────────────────────
|
||||
@@ -832,3 +738,122 @@ window.__timelines["t"] = tl;`;
|
||||
expect(newScript).toContain("hf-stage");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GSAP ops on composition with no script block ────────────────────────────
|
||||
|
||||
const NO_SCRIPT_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-box" style="opacity:0"></div>
|
||||
</div>`.trim();
|
||||
|
||||
describe("GSAP ops on composition with no GSAP script block", () => {
|
||||
function freshNoScript() {
|
||||
return parseMutable(NO_SCRIPT_HTML);
|
||||
}
|
||||
|
||||
it("addGsapTween throws instead of silent no-op", () => {
|
||||
expect(() =>
|
||||
applyOp(freshNoScript(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("setGsapTween throws instead of silent no-op", () => {
|
||||
expect(() =>
|
||||
applyOp(freshNoScript(), {
|
||||
type: "setGsapTween",
|
||||
animationId: "anim-1",
|
||||
properties: { ease: "power2.out" },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("removeGsapTween throws instead of silent no-op", () => {
|
||||
expect(() =>
|
||||
applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("addGsapKeyframe throws when script element is null", () => {
|
||||
expect(() =>
|
||||
applyOp(freshNoScript(), {
|
||||
type: "addGsapKeyframe",
|
||||
animationId: "a1",
|
||||
percentage: 0,
|
||||
value: { opacity: 0 },
|
||||
}),
|
||||
).toThrow("No GSAP script block found");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── arc path ops ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ARC_SCRIPT = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to('[data-hf-id="hf-hero"]', { x: 100, y: 50, duration: 2 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const ARC_ANIM_ID = `[data-hf-id="hf-hero"]-to-0-position`;
|
||||
const ARC_ENABLED_CONFIG = {
|
||||
enabled: true as const,
|
||||
autoRotate: false as const,
|
||||
segments: [{ curviness: 1 }],
|
||||
};
|
||||
|
||||
function freshArc() {
|
||||
return parseMutable(makeHtml(ARC_SCRIPT));
|
||||
}
|
||||
|
||||
function enableArc(parsed: ReturnType<typeof parseMutable>) {
|
||||
applyOp(parsed, { type: "setArcPath", animationId: ARC_ANIM_ID, config: ARC_ENABLED_CONFIG });
|
||||
}
|
||||
|
||||
describe("setArcPath", () => {
|
||||
it("enabled: true adds motionPath to script", () => {
|
||||
const parsed = freshArc();
|
||||
enableArc(parsed);
|
||||
expect(getScript(parsed)).toContain("motionPath");
|
||||
});
|
||||
|
||||
it("enabled: false removes motionPath and restores x/y", () => {
|
||||
const parsed = freshArc();
|
||||
enableArc(parsed);
|
||||
applyOp(parsed, {
|
||||
type: "setArcPath",
|
||||
animationId: ARC_ANIM_ID,
|
||||
config: { enabled: false, autoRotate: false, segments: [] },
|
||||
});
|
||||
const s = getScript(parsed);
|
||||
expect(s).not.toContain("motionPath");
|
||||
});
|
||||
|
||||
it("no-op when animation not found", () => {
|
||||
const parsed = freshArc();
|
||||
const before = getScript(parsed);
|
||||
applyOp(parsed, { type: "setArcPath", animationId: "nonexistent", config: ARC_ENABLED_CONFIG });
|
||||
expect(getScript(parsed)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateArcSegment", () => {
|
||||
it("changes curviness of segment", () => {
|
||||
const parsed = freshArc();
|
||||
enableArc(parsed);
|
||||
applyOp(parsed, {
|
||||
type: "updateArcSegment",
|
||||
animationId: ARC_ANIM_ID,
|
||||
segmentIndex: 0,
|
||||
update: { curviness: 2 },
|
||||
});
|
||||
expect(getScript(parsed)).toContain("motionPath");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeArcPath", () => {
|
||||
it("removes motionPath from script", () => {
|
||||
const parsed = freshArc();
|
||||
enableArc(parsed);
|
||||
applyOp(parsed, { type: "removeArcPath", animationId: ARC_ANIM_ID });
|
||||
expect(getScript(parsed)).not.toContain("motionPath");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,14 +469,14 @@ describe("validateOp", () => {
|
||||
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────
|
||||
|
||||
describe("Phase 3b ops", () => {
|
||||
it("applyOp returns EMPTY when no GSAP script is present", () => {
|
||||
const result = applyOp(fresh(), {
|
||||
it("applyOp throws when no GSAP script is present", () => {
|
||||
expect(() =>
|
||||
applyOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
tween: { method: "from", properties: { opacity: 0 } },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
expect(result.inverse).toHaveLength(0);
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("validateOp returns ok:false / E_NO_GSAP_SCRIPT when no GSAP script present", () => {
|
||||
|
||||
@@ -58,6 +58,9 @@ import {
|
||||
updateKeyframeInScript,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
setArcPathInScript,
|
||||
updateArcSegmentInScript,
|
||||
removeArcPathFromScript,
|
||||
} from "@hyperframes/core/gsap-writer-acorn";
|
||||
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
||||
|
||||
@@ -187,9 +190,34 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult
|
||||
}
|
||||
}
|
||||
|
||||
function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
const s = getGsapScript(parsed.document) ?? "";
|
||||
switch (op.type) {
|
||||
case "setArcPath": {
|
||||
const cfg = {
|
||||
...op.config,
|
||||
segments: op.config.segments.map((seg) => ({ ...seg, curviness: seg.curviness ?? 1 })),
|
||||
};
|
||||
return handleArcPathScript(parsed, s, setArcPathInScript(s, op.animationId, cfg));
|
||||
}
|
||||
case "updateArcSegment":
|
||||
return handleArcPathScript(
|
||||
parsed,
|
||||
s,
|
||||
updateArcSegmentInScript(s, op.animationId, op.segmentIndex, op.update),
|
||||
);
|
||||
case "removeArcPath":
|
||||
return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId));
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
const kf = applyGsapKeyframeOp(parsed, op);
|
||||
if (kf !== undefined) return kf;
|
||||
const arc = applyArcPathOp(parsed, op);
|
||||
if (arc !== undefined) return arc;
|
||||
switch (op.type) {
|
||||
case "addGsapTween":
|
||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||
@@ -679,7 +707,7 @@ function handleAddGsapTween(
|
||||
tween: GsapTweenSpec,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
if (!script) throw new Error("No GSAP script block found in the composition.");
|
||||
|
||||
const extras: Record<string, unknown> = {};
|
||||
if (tween.repeat !== undefined) extras.repeat = tween.repeat;
|
||||
@@ -720,7 +748,7 @@ function handleSetGsapTween(
|
||||
properties: Partial<GsapTweenSpec>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
if (!script) throw new Error("No GSAP script block found in the composition.");
|
||||
|
||||
const updates: Partial<GsapAnimation> = {};
|
||||
if (properties.duration !== undefined) updates.duration = properties.duration;
|
||||
@@ -760,7 +788,7 @@ function handleRemoveGsapProperty(
|
||||
|
||||
function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
if (!script) throw new Error("No GSAP script block found in the composition.");
|
||||
const newScript = removeAnimationFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
@@ -844,6 +872,16 @@ function handleSplitAnimations(
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleArcPathScript(
|
||||
parsed: ParsedDocument,
|
||||
oldScript: string,
|
||||
newScript: string,
|
||||
): MutationResult {
|
||||
if (newScript === oldScript) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(oldScript, newScript);
|
||||
}
|
||||
|
||||
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
@@ -921,7 +959,7 @@ function handleAddGsapKeyframe(
|
||||
value: Record<string, unknown>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
if (!script) throw new Error("No GSAP script block found in the composition.");
|
||||
const props = value as Record<string, number | string>;
|
||||
const newScript = addKeyframeToScript(
|
||||
script,
|
||||
@@ -1066,6 +1104,9 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "materializeKeyframes":
|
||||
case "splitIntoPropertyGroups":
|
||||
case "splitAnimations":
|
||||
case "setArcPath":
|
||||
case "updateArcSegment":
|
||||
case "removeArcPath":
|
||||
case "deleteAllForSelector":
|
||||
case "removeLabel":
|
||||
if (getGsapScript(parsed.document) === null)
|
||||
|
||||
@@ -133,7 +133,31 @@ export type EditOp =
|
||||
elementDuration: number;
|
||||
}
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string };
|
||||
| { type: "removeLabel"; name: string }
|
||||
| {
|
||||
type: "setArcPath";
|
||||
animationId: string;
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate: boolean | number;
|
||||
segments: Array<{
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "updateArcSegment";
|
||||
animationId: string;
|
||||
segmentIndex: number;
|
||||
update: {
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
};
|
||||
}
|
||||
| { type: "removeArcPath"; animationId: string };
|
||||
|
||||
export interface ElasticHold {
|
||||
start: number;
|
||||
|
||||
Reference in New Issue
Block a user