mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle (#1379)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// fallow-ignore-file duplication
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* T6b — acorn vs golden differential harness.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// fallow-ignore-file duplication
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios
|
||||
* against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live
|
||||
@@ -912,3 +912,140 @@ describe("native GSAP keyframes parsing", () => {
|
||||
expect(Object.keys(anim.properties)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── motionPath parsing ────────────────────────────────────────────────────────
|
||||
|
||||
describe("motionPath parsing", () => {
|
||||
it("parses motionPath with waypoint array and curviness", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", {
|
||||
motionPath: {
|
||||
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
|
||||
curviness: 1.5
|
||||
},
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
|
||||
expect(anim.arcPath).toBeDefined();
|
||||
expect(anim.arcPath!.enabled).toBe(true);
|
||||
expect(anim.arcPath!.segments).toHaveLength(2);
|
||||
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
|
||||
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);
|
||||
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(3);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
|
||||
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
|
||||
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
|
||||
});
|
||||
|
||||
it("parses motionPath with type cubic and explicit control points", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", {
|
||||
motionPath: {
|
||||
path: [
|
||||
{x: 0, y: 0},
|
||||
{x: 50, y: -80}, {x: 150, y: -120},
|
||||
{x: 200, y: -100},
|
||||
{x: 250, y: -80}, {x: 350, y: 30},
|
||||
{x: 400, y: 50}
|
||||
],
|
||||
type: "cubic"
|
||||
},
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
const anim = result.animations[0];
|
||||
|
||||
expect(anim.arcPath).toBeDefined();
|
||||
expect(anim.arcPath!.segments).toHaveLength(2);
|
||||
|
||||
expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
|
||||
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });
|
||||
|
||||
expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
|
||||
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });
|
||||
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(3);
|
||||
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
|
||||
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
|
||||
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
|
||||
});
|
||||
|
||||
it("parses motionPath with autoRotate", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", {
|
||||
motionPath: {
|
||||
path: [{x: 0, y: 0}, {x: 200, y: 100}],
|
||||
autoRotate: true
|
||||
},
|
||||
duration: 1
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.arcPath!.autoRotate).toBe(true);
|
||||
});
|
||||
|
||||
it("merges motionPath waypoints into existing keyframes", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", {
|
||||
motionPath: {
|
||||
path: [{x: 0, y: 0}, {x: 200, y: 100}],
|
||||
curviness: 2
|
||||
},
|
||||
keyframes: {
|
||||
"0%": { opacity: 1 },
|
||||
"100%": { opacity: 0 }
|
||||
},
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
const anim = result.animations[0];
|
||||
|
||||
expect(anim.arcPath).toBeDefined();
|
||||
expect(anim.arcPath!.segments).toHaveLength(1);
|
||||
expect(anim.arcPath!.segments[0].curviness).toBe(2);
|
||||
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(2);
|
||||
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
|
||||
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
|
||||
});
|
||||
|
||||
it("skips motionPath with fewer than 2 waypoints", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", {
|
||||
motionPath: { path: [{x: 0, y: 0}] },
|
||||
duration: 1
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].arcPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tween without motionPath parses identically to before", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.arcPath).toBeUndefined();
|
||||
expect(anim.properties.x).toBe(100);
|
||||
expect(anim.properties.y).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// fallow-ignore-file duplication
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Browser-safe GSAP read path — acorn + acorn-walk.
|
||||
*
|
||||
@@ -1046,6 +1046,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
|
||||
export interface ParsedGsapAcornForWrite {
|
||||
ast: any;
|
||||
timelineVar: string;
|
||||
hasTimeline: boolean;
|
||||
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
|
||||
}
|
||||
|
||||
@@ -1075,7 +1076,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
|
||||
call,
|
||||
animation: animations[i]!,
|
||||
}));
|
||||
return { ast, timelineVar, located };
|
||||
return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function serializeGsapAnimations(
|
||||
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
|
||||
return aNum - bNum;
|
||||
});
|
||||
// fallow-ignore-next-line complexity
|
||||
const lines = sorted.map((anim) => {
|
||||
const selector = `"${anim.targetSelector}"`;
|
||||
const props: Record<string, number | string> = { ...anim.properties };
|
||||
@@ -200,7 +201,6 @@ export function getAnimationsForElementId(
|
||||
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
||||
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
|
||||
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
|
||||
{ pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" },
|
||||
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
|
||||
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
|
||||
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
|
||||
@@ -247,6 +247,7 @@ export function keyframesToGsapAnimations(
|
||||
const baseY = base?.y ?? 0;
|
||||
const baseScale = base?.scale ?? 1;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
sorted.forEach((kf, i) => {
|
||||
const absoluteTime = elementStartTime + kf.time;
|
||||
const isFirst = i === 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// fallow-ignore-file duplication
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* T6c — acorn write path with magic-string offset-splice.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// fallow-ignore-file duplication
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Browser-safe GSAP write path — magic-string offset-splice.
|
||||
*
|
||||
@@ -8,15 +8,20 @@
|
||||
*/
|
||||
import MagicString from "magic-string";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
import { parseGsapScriptAcornForWrite, type TweenCallInfo } from "./gsapParserAcorn.js";
|
||||
import {
|
||||
parseGsapScriptAcornForWrite,
|
||||
type ParsedGsapAcornForWrite,
|
||||
type TweenCallInfo,
|
||||
} from "./gsapParserAcorn.js";
|
||||
import * as acornWalk from "acorn-walk";
|
||||
|
||||
// ── Code generation helpers ──────────────────────────────────────────────────
|
||||
|
||||
function valueToCode(value: number | string): string {
|
||||
function valueToCode(value: unknown): string {
|
||||
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
return String(value);
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function safeKey(key: string): string {
|
||||
@@ -32,7 +37,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
|
||||
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (anim.extras) {
|
||||
for (const [k, v] of Object.entries(anim.extras)) {
|
||||
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
|
||||
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
}
|
||||
const objCode = `{ ${entries.join(", ")} }`;
|
||||
@@ -121,7 +126,7 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void
|
||||
* Update a property value if it exists, or append a new key: val before the
|
||||
* closing `}`. Call with the full ObjectExpression node.
|
||||
*/
|
||||
function upsertProp(ms: MagicString, objNode: any, key: string, value: number | string): void {
|
||||
function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown): void {
|
||||
if (objNode?.type !== "ObjectExpression") return;
|
||||
const existing = findPropertyNode(objNode, key);
|
||||
if (existing) {
|
||||
@@ -132,6 +137,31 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: number |
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insertion helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
|
||||
function isTimelineRooted(node: any, timelineVar: string): boolean {
|
||||
if (node?.type === "Identifier") return node.name === timelineVar;
|
||||
if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the byte offset after which to insert a new statement (tween or label).
|
||||
* Returns null when no timeline declaration exists in the script — callers must
|
||||
* not emit `tl.xxx()` calls in that case as `tl` would be undefined at render.
|
||||
*/
|
||||
function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
|
||||
if (parsed.located.length > 0) {
|
||||
const lastCall = parsed.located[parsed.located.length - 1]!.call;
|
||||
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
|
||||
return exprStmt?.end ?? lastCall.node.end;
|
||||
}
|
||||
if (!parsed.hasTimeline) return null;
|
||||
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
|
||||
return tlDecl?.end ?? (parsed.ast.end as number);
|
||||
}
|
||||
|
||||
// ── Public write API ─────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -179,6 +209,12 @@ export function updateAnimationInScript(
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.extras) {
|
||||
for (const [key, value] of Object.entries(updates.extras)) {
|
||||
upsertProp(ms, call.varsArg, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
@@ -189,19 +225,11 @@ export function addAnimationToScript(
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return { script, id: "" };
|
||||
|
||||
const insertionPoint = findInsertionPoint(parsed);
|
||||
if (insertionPoint === null) return { script, id: "" };
|
||||
|
||||
const ms = new MagicString(script);
|
||||
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
|
||||
|
||||
let insertionPoint: number;
|
||||
if (parsed.located.length > 0) {
|
||||
const lastCall = parsed.located[parsed.located.length - 1]!.call;
|
||||
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
|
||||
insertionPoint = exprStmt?.end ?? lastCall.node.end;
|
||||
} else {
|
||||
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
|
||||
insertionPoint = tlDecl?.end ?? script.length;
|
||||
}
|
||||
|
||||
ms.appendLeft(insertionPoint, "\n" + statementCode);
|
||||
|
||||
const result = ms.toString();
|
||||
@@ -366,3 +394,51 @@ export function removeKeyframeFromScript(
|
||||
removeProp(ms, match.prop, allProps);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Label write ops ───────────────────────────────────────────────────────────
|
||||
|
||||
export function addLabelToScript(script: string, name: string, position: number): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
|
||||
const insertionPoint = findInsertionPoint(parsed);
|
||||
if (insertionPoint === null) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
const labelCode = `${parsed.timelineVar}.addLabel(${JSON.stringify(name)}, ${valueToCode(position)});`;
|
||||
ms.appendLeft(insertionPoint, "\n" + labelCode);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
export function removeLabelFromScript(script: string, name: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
|
||||
const targets: any[] = [];
|
||||
acornWalk.simple(parsed.ast, {
|
||||
// fallow-ignore-next-line complexity
|
||||
ExpressionStatement(node: any) {
|
||||
const expr = node.expression;
|
||||
if (
|
||||
expr?.type === "CallExpression" &&
|
||||
expr.callee?.type === "MemberExpression" &&
|
||||
isTimelineRooted(expr.callee.object, parsed.timelineVar) &&
|
||||
expr.callee.property?.name === "addLabel" &&
|
||||
expr.arguments?.[0]?.type === "Literal" &&
|
||||
expr.arguments[0].value === name
|
||||
) {
|
||||
targets.push(node);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!targets.length) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
for (const target of targets) {
|
||||
const end =
|
||||
target.end < script.length && script[target.end] === "\n" ? target.end + 1 : target.end;
|
||||
ms.remove(target.start, end);
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user