mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle (#1379)
This commit is contained in:
@@ -83,6 +83,14 @@
|
||||
"import": "./src/parsers/gsapParser.ts",
|
||||
"types": "./src/parsers/gsapParser.ts"
|
||||
},
|
||||
"./gsap-parser-acorn": {
|
||||
"import": "./src/parsers/gsapParserAcorn.ts",
|
||||
"types": "./src/parsers/gsapParserAcorn.ts"
|
||||
},
|
||||
"./gsap-writer-acorn": {
|
||||
"import": "./src/parsers/gsapWriterAcorn.ts",
|
||||
"types": "./src/parsers/gsapWriterAcorn.ts"
|
||||
},
|
||||
"./gsap-constants": {
|
||||
"import": "./src/parsers/gsapConstants.ts",
|
||||
"types": "./src/parsers/gsapConstants.ts"
|
||||
@@ -166,6 +174,14 @@
|
||||
"import": "./dist/parsers/gsapParser.js",
|
||||
"types": "./dist/parsers/gsapParser.d.ts"
|
||||
},
|
||||
"./gsap-parser-acorn": {
|
||||
"import": "./dist/parsers/gsapParserAcorn.js",
|
||||
"types": "./dist/parsers/gsapParserAcorn.d.ts"
|
||||
},
|
||||
"./gsap-writer-acorn": {
|
||||
"import": "./dist/parsers/gsapWriterAcorn.js",
|
||||
"types": "./dist/parsers/gsapWriterAcorn.d.ts"
|
||||
},
|
||||
"./gsap-constants": {
|
||||
"import": "./dist/parsers/gsapConstants.js",
|
||||
"types": "./dist/parsers/gsapConstants.d.ts"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -10,18 +10,36 @@
|
||||
|
||||
import type { JsonPatchOp, OverrideSet } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
|
||||
import {
|
||||
findById,
|
||||
findRoot,
|
||||
setElementStyles,
|
||||
setOwnText,
|
||||
setGsapScript,
|
||||
setStyleSheet,
|
||||
} from "./model.js";
|
||||
import { keyToPath } from "./patches.js";
|
||||
|
||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedPath {
|
||||
type: "style" | "text" | "attribute" | "timing" | "hold" | "element" | "variable" | "metadata";
|
||||
type:
|
||||
| "style"
|
||||
| "text"
|
||||
| "attribute"
|
||||
| "timing"
|
||||
| "hold"
|
||||
| "element"
|
||||
| "variable"
|
||||
| "metadata"
|
||||
| "script"
|
||||
| "stylesheet";
|
||||
id?: string;
|
||||
prop?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parsePath(path: string): ParsedPath | null {
|
||||
const styleM = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
|
||||
if (styleM) return { type: "style", id: styleM[1], prop: styleM[2] };
|
||||
@@ -52,6 +70,9 @@ function parsePath(path: string): ParsedPath | null {
|
||||
const metaM = /^\/metadata\/(.+)$/.exec(path);
|
||||
if (metaM) return { type: "metadata", field: metaM[1] };
|
||||
|
||||
if (path === "/script/gsap") return { type: "script" };
|
||||
if (path === "/style/css") return { type: "stylesheet" };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -185,6 +206,22 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
|
||||
break;
|
||||
}
|
||||
|
||||
case "script": {
|
||||
if (patch.op !== "remove") {
|
||||
setGsapScript(parsed.document, String(patch.value ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stylesheet": {
|
||||
if (patch.op === "remove") {
|
||||
setStyleSheet(parsed.document, "");
|
||||
} else {
|
||||
setStyleSheet(parsed.document, String(patch.value ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "metadata": {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root || !p.field) return;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Hand-rolled flat-CSS rule editor.
|
||||
*
|
||||
* Handles only simple flat rules (`selector { declarations }`) — no nesting,
|
||||
* no @-rules, no CSS comments. Sufficient for composition <style> blocks
|
||||
* generated by hyperframes, which never contain those constructs.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CssRule {
|
||||
selector: string;
|
||||
body: string;
|
||||
/** Byte offset of the first char of the selector. */
|
||||
start: number;
|
||||
/** Byte offset after the closing `}`. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseCssRules(css: string): CssRule[] {
|
||||
const rules: CssRule[] = [];
|
||||
let i = 0;
|
||||
const len = css.length;
|
||||
while (i < len) {
|
||||
const braceOpen = css.indexOf("{", i);
|
||||
if (braceOpen === -1) break;
|
||||
const selector = css.slice(i, braceOpen).trim();
|
||||
if (!selector) {
|
||||
i = braceOpen + 1;
|
||||
continue;
|
||||
}
|
||||
// skip leading whitespace to get the true selector start
|
||||
let selStart = i;
|
||||
while (selStart < braceOpen && " \t\r\n".includes(css[selStart]!)) selStart++;
|
||||
// find closing }, respecting quoted strings so `content: "}"` doesn't end the rule
|
||||
let j = braceOpen + 1;
|
||||
let quote: string | null = null;
|
||||
while (j < len) {
|
||||
const ch = css[j]!;
|
||||
if (quote) {
|
||||
if (ch === "\\" && j + 1 < len)
|
||||
j++; // skip escaped char
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === "}") {
|
||||
break;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
rules.push({ selector, body: css.slice(braceOpen + 1, j), start: selStart, end: j + 1 });
|
||||
i = j + 1;
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseDeclarations(body: string): Record<string, string> {
|
||||
const decls: Record<string, string> = {};
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= body.length; i++) {
|
||||
const ch = i < body.length ? body[i]! : ";"; // sentinel flush
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === ";" && depth === 0) {
|
||||
const part = body.slice(start, i);
|
||||
const colon = part.indexOf(":");
|
||||
if (colon !== -1) {
|
||||
const prop = part.slice(0, colon).trim();
|
||||
const value = part.slice(colon + 1).trim();
|
||||
if (prop && value) decls[prop] = value;
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return decls;
|
||||
}
|
||||
|
||||
function serializeDeclarations(decls: Record<string, string>): string {
|
||||
const entries = Object.entries(decls);
|
||||
if (!entries.length) return "";
|
||||
return " " + entries.map(([k, v]) => `${k}: ${v}`).join("; ") + "; ";
|
||||
}
|
||||
|
||||
function normalizeSelector(sel: string): string {
|
||||
return sel.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update or insert a CSS rule.
|
||||
*
|
||||
* Finds the first rule whose selector matches (after whitespace normalization)
|
||||
* and merges the given declarations into it. A null value removes the property.
|
||||
* If no matching rule exists, appends a new one.
|
||||
*
|
||||
* Returns the modified CSS string. Returns `css` unchanged when nothing changed.
|
||||
*/
|
||||
export function upsertCssRule(
|
||||
css: string,
|
||||
selector: string,
|
||||
styles: Record<string, string | null>,
|
||||
): string {
|
||||
const normalized = normalizeSelector(selector);
|
||||
const rules = parseCssRules(css);
|
||||
const idx = rules.findIndex((r) => normalizeSelector(r.selector) === normalized);
|
||||
|
||||
if (idx !== -1) {
|
||||
const rule = rules[idx]!;
|
||||
const decls = parseDeclarations(rule.body);
|
||||
for (const [prop, value] of Object.entries(styles)) {
|
||||
if (value === null) {
|
||||
delete decls[prop];
|
||||
} else {
|
||||
decls[prop] = value;
|
||||
}
|
||||
}
|
||||
const newRuleText = `${rule.selector} {${serializeDeclarations(decls)}}`;
|
||||
return css.slice(0, rule.start) + newRuleText + css.slice(rule.end);
|
||||
}
|
||||
|
||||
// Append new rule — skip if all values are null (nothing to write).
|
||||
const newDecls: Record<string, string> = {};
|
||||
for (const [prop, value] of Object.entries(styles)) {
|
||||
if (value !== null) newDecls[prop] = value;
|
||||
}
|
||||
if (!Object.keys(newDecls).length) return css;
|
||||
|
||||
const newRuleText = `${selector} {${serializeDeclarations(newDecls)}}`;
|
||||
const sep = css.length > 0 && !css.endsWith("\n") ? "\n" : "";
|
||||
return css + sep + newRuleText + "\n";
|
||||
}
|
||||
@@ -132,6 +132,62 @@ export function setOwnText(el: Element, text: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CSS style helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function findStyleElement(document: Document): Element | null {
|
||||
return document.querySelector("style") as unknown as Element | null;
|
||||
}
|
||||
|
||||
export function getStyleSheet(document: Document): string {
|
||||
return findStyleElement(document)?.textContent ?? "";
|
||||
}
|
||||
|
||||
export function setStyleSheet(document: Document, css: string): void {
|
||||
const existing = findStyleElement(document);
|
||||
if (!css) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
let el = existing;
|
||||
if (!el) {
|
||||
el = document.createElement("style") as unknown as Element;
|
||||
const head =
|
||||
(document.querySelector("head") as unknown as Element | null) ??
|
||||
(document.body as unknown as Element);
|
||||
(head as any).appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
}
|
||||
|
||||
// ─── GSAP script helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function findGsapScriptElement(document: Document): Element | null {
|
||||
const scripts = document.querySelectorAll("script");
|
||||
for (const script of Array.from(scripts)) {
|
||||
const text = script.textContent ?? "";
|
||||
if (text.includes("gsap") || text.includes("ScrollTrigger"))
|
||||
return script as unknown as Element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGsapScript(document: Document): string | null {
|
||||
const el = findGsapScriptElement(document);
|
||||
return el ? (el.textContent ?? "") : null;
|
||||
}
|
||||
|
||||
export function setGsapScript(document: Document, newScript: string): void {
|
||||
let el = findGsapScriptElement(document);
|
||||
if (!el) {
|
||||
el = document.createElement("script") as unknown as Element;
|
||||
const head =
|
||||
(document.querySelector("head") as unknown as Element | null) ??
|
||||
(document.body as unknown as Element);
|
||||
(head as any).appendChild(el);
|
||||
}
|
||||
el.textContent = newScript;
|
||||
}
|
||||
|
||||
// ─── Sibling index ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getSiblingIndex(el: Element): number {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* setClassStyle handler tests — flat CSS rule upsert via <style> element.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMutable } from "./model.js";
|
||||
import { applyOp, validateOp } from "./mutate.js";
|
||||
import { applyPatchesToDocument } from "./apply-patches.js";
|
||||
import { serializeDocument } from "./serialize.js";
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CSS = `.box { opacity: 0; transform: translateX(-50px); }
|
||||
.title { color: #fff; font-size: 64px; }
|
||||
`;
|
||||
|
||||
function makeHtml(style = CSS) {
|
||||
return `<!DOCTYPE html><html><head><style>${style}</style></head><body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-box" class="box"></div>
|
||||
<h1 data-hf-id="hf-title" class="title">Hello</h1>
|
||||
</div></body></html>`.trim();
|
||||
}
|
||||
|
||||
function fresh(style = CSS) {
|
||||
return parseMutable(makeHtml(style));
|
||||
}
|
||||
|
||||
function getStyleText(parsed: ReturnType<typeof parseMutable>): string {
|
||||
const doc = serializeDocument(parsed);
|
||||
const m = /<style>([\s\S]*?)<\/style>/i.exec(doc);
|
||||
return m ? m[1]! : "";
|
||||
}
|
||||
|
||||
// ─── validateOp ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateOp setClassStyle", () => {
|
||||
it("returns true (always valid — creates <style> if absent)", () => {
|
||||
expect(
|
||||
validateOp(fresh(), { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true even when no <style> element present", () => {
|
||||
const noStyle = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
expect(
|
||||
validateOp(noStyle, { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setClassStyle: update existing rule ──────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — update existing rule", () => {
|
||||
it("adds a new property to an existing rule", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { color: "red" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
expect(result.forward[0]?.path).toBe("/style/css");
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain("color: red");
|
||||
expect(newCss).toContain("opacity: 0");
|
||||
});
|
||||
|
||||
it("overwrites an existing property value", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain("opacity: 1");
|
||||
expect(newCss).not.toContain("opacity: 0");
|
||||
expect(newCss).toContain("translateX(-50px)");
|
||||
});
|
||||
|
||||
it("removes a property when value is null", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: null },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).not.toContain("opacity");
|
||||
expect(newCss).toContain("translateX(-50px)");
|
||||
});
|
||||
|
||||
it("leaves other rules untouched", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain(".title");
|
||||
expect(newCss).toContain("color: #fff");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setClassStyle: insert new rule ──────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — insert new rule", () => {
|
||||
it("appends a new rule when selector not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".new",
|
||||
styles: { display: "flex", gap: "8px" },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain(".new");
|
||||
expect(newCss).toContain("display: flex");
|
||||
expect(newCss).toContain("gap: 8px");
|
||||
expect(newCss).toContain(".box");
|
||||
});
|
||||
|
||||
it("creates <style> element when none exists", () => {
|
||||
const noStyle = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
const result = applyOp(noStyle, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain(".box");
|
||||
expect(newCss).toContain("opacity: 1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── no-op cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — no-ops", () => {
|
||||
it("returns EMPTY when all values are null and selector not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".nonexistent",
|
||||
styles: { opacity: null },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── inverse restores original ────────────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — inverse patches", () => {
|
||||
it("inverse restores original CSS", () => {
|
||||
const parsed = fresh();
|
||||
const original = getStyleText(parsed);
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1", color: "blue" },
|
||||
});
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(getStyleText(parsed)).toBe(original);
|
||||
});
|
||||
|
||||
it("undo on style-less composition does not create spurious <style> element", () => {
|
||||
const noStyle = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
const result = applyOp(noStyle, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
applyPatchesToDocument(noStyle, result.inverse);
|
||||
const html = serializeDocument(noStyle);
|
||||
expect(html).not.toContain("<style");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── semicolon-containing CSS values ─────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — CSS values with semicolons (data URIs)", () => {
|
||||
it("preserves data URI value when updating another property in same rule", () => {
|
||||
const dataUriCss = ".hero { background: url(data:image/png;base64,abc=); color: red; }\n";
|
||||
const parsed = fresh(dataUriCss);
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".hero",
|
||||
styles: { color: "blue" },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain("url(data:image/png;base64,abc=)");
|
||||
expect(newCss).toContain("color: blue");
|
||||
expect(newCss).not.toContain("color: red");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DOM side-effect ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — DOM mutation", () => {
|
||||
it("mutates the live <style> element in the document", () => {
|
||||
const parsed = fresh();
|
||||
applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
expect(getStyleText(parsed)).toContain("opacity: 1");
|
||||
expect(getStyleText(parsed)).not.toContain("opacity: 0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Phase 3b — GSAP mutation handler tests.
|
||||
*
|
||||
* Verifies the 8 parser-backed ops: addGsapTween, setGsapTween, removeGsapTween,
|
||||
* setGsapKeyframe, addGsapKeyframe, removeGsapKeyframe, addLabel, removeLabel.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMutable } from "./model.js";
|
||||
import { applyOp, validateOp } from "./mutate.js";
|
||||
import { applyPatchesToDocument } from "./apply-patches.js";
|
||||
import { serializeDocument } from "./serialize.js";
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const GSAP_SCRIPT = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5, ease: "power2.out" }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
const KF_SCRIPT = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
function makeHtml(script: string) {
|
||||
return `<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-box" style="opacity: 0"></div>
|
||||
<script>${script}</script>
|
||||
</div>`.trim();
|
||||
}
|
||||
|
||||
function fresh(script = GSAP_SCRIPT) {
|
||||
return parseMutable(makeHtml(script));
|
||||
}
|
||||
|
||||
function getScript(parsed: ReturnType<typeof parseMutable>): string {
|
||||
const doc = serializeDocument(parsed);
|
||||
const m = /<script>([\s\S]*?)<\/script>/i.exec(doc);
|
||||
return m ? m[1]!.trim() : "";
|
||||
}
|
||||
|
||||
// ─── validateOp gating on timeline existence ──────────────────────────────────
|
||||
|
||||
const NO_TIMELINE_SCRIPT = `gsap.defaults({ ease: "power1.out" });
|
||||
window.__timelines = {};`;
|
||||
|
||||
describe("validateOp — no gsap.timeline() declaration", () => {
|
||||
function freshNoTimeline() {
|
||||
return parseMutable(makeHtml(NO_TIMELINE_SCRIPT));
|
||||
}
|
||||
|
||||
it("addGsapTween → false when script has no timeline", () => {
|
||||
expect(
|
||||
validateOp(freshNoTimeline(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("addLabel → false when script has no timeline", () => {
|
||||
expect(validateOp(freshNoTimeline(), { type: "addLabel", name: "start", position: 0 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("addGsapTween dispatch returns EMPTY when no timeline — no dangling tl call emitted", () => {
|
||||
const parsed = freshNoTimeline();
|
||||
const scriptBefore = getScript(parsed);
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 } },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
expect(getScript(parsed)).toBe(scriptBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateOp returns true when GSAP script present ─────────────────────────
|
||||
|
||||
describe("validateOp with GSAP script", () => {
|
||||
it("addGsapTween → true", () => {
|
||||
expect(
|
||||
validateOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", duration: 0.3, properties: { x: 100 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("removeGsapTween → true", () => {
|
||||
expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "some-id" })).toBe(true);
|
||||
});
|
||||
|
||||
it("addLabel → true", () => {
|
||||
expect(validateOp(fresh(), { type: "addLabel", name: "start", position: 0 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGsapTween ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addGsapTween", () => {
|
||||
it("inserts new tween and returns animationId in meta", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", duration: 0.3, properties: { x: 100 } },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
expect(result.forward[0]?.path).toBe("/script/gsap");
|
||||
expect(result.meta?.animationId).toBeTruthy();
|
||||
expect(typeof result.meta?.animationId).toBe("string");
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("x: 100");
|
||||
expect(newScript).toContain("duration: 0.3");
|
||||
});
|
||||
|
||||
it("inverse patch restores original script", () => {
|
||||
const parsed = fresh();
|
||||
const original = getScript(parsed);
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", duration: 0.3, properties: { x: 100 } },
|
||||
});
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(getScript(parsed)).toBe(original);
|
||||
});
|
||||
|
||||
it("adds repeat/yoyo as extras", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", duration: 1, properties: { y: 50 }, repeat: -1, yoyo: true },
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("repeat: -1");
|
||||
expect(newScript).toContain("yoyo: true");
|
||||
});
|
||||
|
||||
it("serializes stagger object as JSON, not [object Object]", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: {
|
||||
method: "to",
|
||||
duration: 1,
|
||||
properties: { opacity: 1 },
|
||||
stagger: { amount: 0.5, from: "center" } as any,
|
||||
},
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).not.toContain("[object Object]");
|
||||
expect(newScript).toContain("amount");
|
||||
});
|
||||
|
||||
it("adds fromTo tween with fromProperties and toProperties", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: {
|
||||
method: "fromTo",
|
||||
duration: 0.5,
|
||||
fromProperties: { opacity: 0 },
|
||||
toProperties: { opacity: 1 },
|
||||
},
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("fromTo(");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setGsapTween ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setGsapTween", () => {
|
||||
it("updates ease in existing tween", () => {
|
||||
const parsed = fresh();
|
||||
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapTween",
|
||||
animationId: animId,
|
||||
properties: { ease: "power3.in" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain('"power3.in"');
|
||||
expect(newScript).not.toContain('"power2.out"');
|
||||
});
|
||||
|
||||
it("updates duration in existing tween", () => {
|
||||
const parsed = fresh();
|
||||
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapTween",
|
||||
animationId: animId,
|
||||
properties: { duration: 1.5 },
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("duration: 1.5");
|
||||
expect(newScript).not.toContain("duration: 0.5");
|
||||
});
|
||||
|
||||
it("returns EMPTY for unknown animationId", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapTween",
|
||||
animationId: "nonexistent-id",
|
||||
properties: { ease: "power1.in" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("inverse restores original script", () => {
|
||||
const parsed = fresh();
|
||||
const original = getScript(parsed);
|
||||
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapTween",
|
||||
animationId: animId,
|
||||
properties: { ease: "power3.in" },
|
||||
});
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(getScript(parsed)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── removeGsapTween ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("removeGsapTween", () => {
|
||||
it("removes tween by animationId", () => {
|
||||
const parsed = fresh();
|
||||
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
|
||||
const result = applyOp(parsed, { type: "removeGsapTween", animationId: animId });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).not.toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("returns EMPTY for unknown animationId", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "removeGsapTween", animationId: "no-such-id" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("inverse restores original script", () => {
|
||||
const parsed = fresh();
|
||||
const original = getScript(parsed);
|
||||
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
|
||||
const result = applyOp(parsed, { type: "removeGsapTween", animationId: animId });
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(getScript(parsed)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyframe ops ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addGsapKeyframe", () => {
|
||||
it("inserts new keyframe at given percentage", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapKeyframe",
|
||||
animationId: animId,
|
||||
position: 25,
|
||||
value: { opacity: 0.3 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain('"25%"');
|
||||
expect(newScript).toContain("opacity: 0.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setGsapKeyframe", () => {
|
||||
it("updates keyframe value at index 1 (50%)", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapKeyframe",
|
||||
animationId: animId,
|
||||
keyframeIndex: 1,
|
||||
value: { opacity: 0.5 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("opacity: 0.5");
|
||||
expect(newScript).not.toContain("opacity: 0.7");
|
||||
});
|
||||
|
||||
it("returns EMPTY for out-of-range keyframeIndex", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapKeyframe",
|
||||
animationId: `[data-hf-id="hf-box"]-to-0-visual`,
|
||||
keyframeIndex: 99,
|
||||
value: { opacity: 0 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("position-only move preserves existing properties — does not delete keyframe", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapKeyframe",
|
||||
animationId: animId,
|
||||
keyframeIndex: 1,
|
||||
position: 60,
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain('"60%"');
|
||||
expect(newScript).not.toContain('"50%"');
|
||||
expect(newScript).toContain("opacity: 0.7");
|
||||
});
|
||||
|
||||
it("ease-only update (same position, no value) does not corrupt keyframe", () => {
|
||||
const kfWithEase = KF_SCRIPT.replace(
|
||||
'"0%": { opacity: 0 }',
|
||||
'"0%": { opacity: 0, ease: "power1.in" }',
|
||||
);
|
||||
const parsed = fresh(kfWithEase);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapKeyframe",
|
||||
animationId: animId,
|
||||
keyframeIndex: 0,
|
||||
ease: "power2.out",
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain("opacity: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeGsapKeyframe", () => {
|
||||
it("removes keyframe at index 1 (50%)", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "removeGsapKeyframe",
|
||||
animationId: animId,
|
||||
keyframeIndex: 1,
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).not.toContain('"50%"');
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain('"100%"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Label ops ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addLabel", () => {
|
||||
it("inserts addLabel call into script", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "addLabel", name: "intro", position: 0.5 });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain('addLabel("intro"');
|
||||
expect(newScript).toContain("0.5");
|
||||
});
|
||||
|
||||
it("addLabel output is not blocked by GSAP validator", async () => {
|
||||
const { validateCompositionGsap } = await import("@hyperframes/core/gsap-parser");
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "addLabel", name: "scene1", position: 1.0 });
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
const { errors } = validateCompositionGsap(newScript);
|
||||
const labelError = errors.find((e) => /addLabel/i.test(e));
|
||||
expect(labelError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("inverse restores original script", () => {
|
||||
const parsed = fresh();
|
||||
const original = getScript(parsed);
|
||||
const result = applyOp(parsed, { type: "addLabel", name: "intro", position: 0.5 });
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(getScript(parsed)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeLabel", () => {
|
||||
it("removes addLabel call from script", () => {
|
||||
const withLabel = GSAP_SCRIPT.replace(
|
||||
'window.__timelines["t"] = tl;',
|
||||
'tl.addLabel("intro", 0.5);\nwindow.__timelines["t"] = tl;',
|
||||
);
|
||||
const parsed = fresh(withLabel);
|
||||
const result = applyOp(parsed, { type: "removeLabel", name: "intro" });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).not.toContain("addLabel");
|
||||
});
|
||||
|
||||
it("returns EMPTY when label not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "removeLabel", name: "nonexistent" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -384,31 +384,39 @@ describe("validateOp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 3b ops — fail loudly, feature-detectable ───────────────────────────
|
||||
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────
|
||||
|
||||
describe("Phase 3b ops", () => {
|
||||
it("applyOp throws UnsupportedOpError instead of silently no-opping", () => {
|
||||
expect(() =>
|
||||
applyOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
id: "tw-1",
|
||||
tween: { method: "from", fromProperties: { opacity: 0 } },
|
||||
}),
|
||||
).toThrowError(/Phase 3b/);
|
||||
it("applyOp returns EMPTY when no GSAP script is present", () => {
|
||||
const result = applyOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
tween: { method: "from", properties: { opacity: 0 } },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
expect(result.inverse).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validateOp returns false so can() feature-detects", () => {
|
||||
it("validateOp returns false when no GSAP script present", () => {
|
||||
expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "tw-1" })).toBe(false);
|
||||
expect(
|
||||
validateOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
id: "tw-1",
|
||||
tween: { method: "from", fromProperties: { opacity: 0 } },
|
||||
tween: { method: "from", properties: { opacity: 0 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("setClassStyle no longer throws — implemented in Phase 3b", () => {
|
||||
expect(() =>
|
||||
applyOp(fresh(), {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { color: "red" },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setCompositionMetadata — data-width/data-height forced override ─────────
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers.
|
||||
*/
|
||||
|
||||
import type { EditOp, HfId, JsonPatchOp } from "../types.js";
|
||||
import type { EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import {
|
||||
findById,
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
getOwnText,
|
||||
setOwnText,
|
||||
getSiblingIndex,
|
||||
getGsapScript,
|
||||
setGsapScript,
|
||||
getStyleSheet,
|
||||
setStyleSheet,
|
||||
} from "./model.js";
|
||||
import {
|
||||
stylePath,
|
||||
@@ -27,15 +31,31 @@ import {
|
||||
elementPath,
|
||||
variablePath,
|
||||
metaPath,
|
||||
gsapScriptPath,
|
||||
styleSheetPath,
|
||||
scalarChange,
|
||||
scalarDelete,
|
||||
patchAdd,
|
||||
patchRemove,
|
||||
} from "./patches.js";
|
||||
import { upsertCssRule } from "./cssWriter.js";
|
||||
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import {
|
||||
addAnimationToScript,
|
||||
updateAnimationInScript,
|
||||
removeAnimationFromScript,
|
||||
addKeyframeToScript,
|
||||
removeKeyframeFromScript,
|
||||
updateKeyframeInScript,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
} from "@hyperframes/core/gsap-writer-acorn";
|
||||
|
||||
export interface MutationResult {
|
||||
forward: JsonPatchOp[];
|
||||
inverse: JsonPatchOp[];
|
||||
meta?: { animationId?: string };
|
||||
}
|
||||
|
||||
const EMPTY: MutationResult = { forward: [], inverse: [] };
|
||||
@@ -137,19 +157,31 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
||||
return handleSetCompositionMetadata(parsed, op);
|
||||
case "setVariableValue":
|
||||
return handleSetVariableValue(parsed, op.id, op.value);
|
||||
// Phase 3b parser-backed ops — fail loudly rather than silently no-op:
|
||||
// a caller must never believe an animation edit succeeded when nothing
|
||||
// was mutated and no patch was emitted.
|
||||
case "setClassStyle":
|
||||
case "addGsapTween":
|
||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||
case "setGsapTween":
|
||||
case "setGsapKeyframe":
|
||||
case "addGsapKeyframe":
|
||||
case "removeGsapKeyframe":
|
||||
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
||||
case "removeGsapTween":
|
||||
return handleRemoveGsapTween(parsed, op.animationId);
|
||||
case "setGsapKeyframe":
|
||||
return handleSetGsapKeyframe(
|
||||
parsed,
|
||||
op.animationId,
|
||||
op.keyframeIndex,
|
||||
op.position,
|
||||
op.value,
|
||||
op.ease,
|
||||
);
|
||||
case "addGsapKeyframe":
|
||||
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
|
||||
case "removeGsapKeyframe":
|
||||
return handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
|
||||
case "addLabel":
|
||||
return handleAddLabel(parsed, op.name, op.position);
|
||||
case "removeLabel":
|
||||
throw new UnsupportedOpError(op.type);
|
||||
return handleRemoveLabel(parsed, op.name);
|
||||
case "setClassStyle":
|
||||
return handleSetClassStyle(parsed, op.selector, op.styles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,9 +456,212 @@ function handleSetVariableValue(
|
||||
return { forward: [p.forward], inverse: [p.inverse] };
|
||||
}
|
||||
|
||||
// ─── setClassStyle handler ────────────────────────────────────────────────────
|
||||
|
||||
function handleSetClassStyle(
|
||||
parsed: ParsedDocument,
|
||||
selector: string,
|
||||
styles: Record<string, string | null>,
|
||||
): MutationResult {
|
||||
const oldCss = getStyleSheet(parsed.document);
|
||||
const newCss = upsertCssRule(oldCss, selector, styles);
|
||||
if (newCss === oldCss) return EMPTY;
|
||||
setStyleSheet(parsed.document, newCss);
|
||||
const path = styleSheetPath();
|
||||
return {
|
||||
forward: [{ op: "replace", path, value: newCss }],
|
||||
inverse: [oldCss === "" ? { op: "remove", path } : { op: "replace", path, value: oldCss }],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GSAP script patch helpers ───────────────────────────────────────────────
|
||||
|
||||
function gsapScriptChange(oldScript: string, newScript: string): MutationResult {
|
||||
const path = gsapScriptPath();
|
||||
return {
|
||||
forward: [{ op: "replace", path, value: newScript }],
|
||||
inverse: [{ op: "replace", path, value: oldScript }],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Phase 3b handlers ───────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleAddGsapTween(
|
||||
parsed: ParsedDocument,
|
||||
target: HfId,
|
||||
tween: GsapTweenSpec,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
|
||||
const extras: Record<string, unknown> = {};
|
||||
if (tween.repeat !== undefined) extras.repeat = tween.repeat;
|
||||
if (tween.yoyo !== undefined) extras.yoyo = tween.yoyo;
|
||||
if (tween.stagger !== undefined) extras.stagger = tween.stagger;
|
||||
|
||||
const toProps =
|
||||
tween.method === "fromTo"
|
||||
? ((tween.toProperties ?? {}) as Record<string, number | string>)
|
||||
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
|
||||
|
||||
const animation: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: `[data-hf-id="${target}"]`,
|
||||
method: tween.method,
|
||||
position: tween.position ?? 0,
|
||||
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
|
||||
...(tween.ease ? { ease: tween.ease } : {}),
|
||||
properties: toProps,
|
||||
...(tween.fromProperties
|
||||
? { fromProperties: tween.fromProperties as Record<string, number | string> }
|
||||
: {}),
|
||||
...(Object.keys(extras).length > 0 ? { extras } : {}),
|
||||
};
|
||||
|
||||
const { script: newScript, id: animationId } = addAnimationToScript(script, animation);
|
||||
if (!animationId) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return { ...gsapScriptChange(script, newScript), meta: { animationId } };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleSetGsapTween(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
properties: Partial<GsapTweenSpec>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
|
||||
const updates: Partial<GsapAnimation> = {};
|
||||
if (properties.duration !== undefined) updates.duration = properties.duration;
|
||||
if (properties.ease !== undefined) updates.ease = properties.ease;
|
||||
if (properties.position !== undefined) updates.position = properties.position;
|
||||
|
||||
const toProps = properties.toProperties ?? properties.properties;
|
||||
if (toProps) updates.properties = toProps as Record<string, number | string>;
|
||||
if (properties.fromProperties)
|
||||
updates.fromProperties = properties.fromProperties as Record<string, number | string>;
|
||||
|
||||
const extras: Record<string, unknown> = {};
|
||||
if (properties.repeat !== undefined) extras.repeat = properties.repeat;
|
||||
if (properties.yoyo !== undefined) extras.yoyo = properties.yoyo;
|
||||
if (Object.keys(extras).length > 0) updates.extras = extras;
|
||||
|
||||
const newScript = updateAnimationInScript(script, animationId, updates);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = removeAnimationFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleSetGsapKeyframe(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
keyframeIndex: number,
|
||||
position: number | undefined,
|
||||
value: Record<string, unknown> | undefined,
|
||||
ease: string | undefined,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
|
||||
const parsedForWrite = parseGsapScriptAcornForWrite(script);
|
||||
const located = parsedForWrite?.located.find((l) => l.id === animationId);
|
||||
const kfs = located?.animation.keyframes?.keyframes;
|
||||
if (!kfs || keyframeIndex < 0 || keyframeIndex >= kfs.length) return EMPTY;
|
||||
|
||||
const existingKf = kfs[keyframeIndex]!;
|
||||
const currentPct = existingKf.percentage;
|
||||
const targetPct = position ?? currentPct;
|
||||
const props: Record<string, number | string> = value
|
||||
? (value as Record<string, number | string>)
|
||||
: { ...existingKf.properties };
|
||||
const resolvedEase = ease ?? existingKf.ease;
|
||||
|
||||
let newScript = script;
|
||||
if (targetPct !== currentPct) {
|
||||
newScript = removeKeyframeFromScript(newScript, animationId, currentPct);
|
||||
newScript = addKeyframeToScript(newScript, animationId, targetPct, props, resolvedEase);
|
||||
} else {
|
||||
newScript = updateKeyframeInScript(newScript, animationId, currentPct, props, resolvedEase);
|
||||
}
|
||||
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleAddGsapKeyframe(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
value: Record<string, unknown>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = addKeyframeToScript(
|
||||
script,
|
||||
animationId,
|
||||
percentage,
|
||||
value as Record<string, number | string>,
|
||||
);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleRemoveGsapKeyframe(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
keyframeIndex: number,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
|
||||
const parsedForWrite = parseGsapScriptAcornForWrite(script);
|
||||
const located = parsedForWrite?.located.find((l) => l.id === animationId);
|
||||
const kfs = located?.animation.keyframes?.keyframes;
|
||||
if (!kfs || keyframeIndex < 0 || keyframeIndex >= kfs.length) return EMPTY;
|
||||
|
||||
const pct = kfs[keyframeIndex]!.percentage;
|
||||
const newScript = removeKeyframeFromScript(script, animationId, pct);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleAddLabel(parsed: ParsedDocument, name: string, position: number): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = addLabelToScript(script, name, position);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleRemoveLabel(parsed: ParsedDocument, name: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = removeLabelFromScript(script, name);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
// ─── Validation (can(op)) ────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if the op can be applied to the current document state. */
|
||||
// fallow-ignore-next-line complexity
|
||||
export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
|
||||
switch (op.type) {
|
||||
case "setStyle":
|
||||
@@ -442,10 +677,23 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
|
||||
case "setVariableValue":
|
||||
return findRoot(parsed.document) !== null;
|
||||
case "setCompositionMetadata":
|
||||
case "setClassStyle":
|
||||
return true;
|
||||
// Phase 3b and unknown ops — report false so callers can feature-detect.
|
||||
// An unknown op type must never silently pass validation only to no-op
|
||||
// or throw in applyOp (which would violate the can() contract).
|
||||
case "addGsapTween":
|
||||
case "addLabel": {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return false;
|
||||
const p = parseGsapScriptAcornForWrite(script);
|
||||
return p !== null && p.hasTimeline;
|
||||
}
|
||||
case "setGsapTween":
|
||||
case "setGsapKeyframe":
|
||||
case "addGsapKeyframe":
|
||||
case "removeGsapKeyframe":
|
||||
case "removeGsapTween":
|
||||
case "removeLabel":
|
||||
return getGsapScript(parsed.document) !== null;
|
||||
// Unknown ops — report false so callers can feature-detect.
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* /elements/{hfId} ← whole subtree (removeElement)
|
||||
* /variables/{variableId}
|
||||
* /metadata/{width|height|duration}
|
||||
* /script/gsap ← GSAP inline script textContent
|
||||
* /style/css ← <style> element textContent
|
||||
*
|
||||
* Override-set key mapping:
|
||||
* /elements/hf-x/inlineStyles/fontSize → "hf-x.style.fontSize"
|
||||
@@ -20,6 +22,8 @@
|
||||
* /elements/hf-x → "hf-x" (null = removal marker)
|
||||
* /variables/brand-color-primary → "var.brand-color-primary"
|
||||
* /metadata/width → "meta.width"
|
||||
* /script/gsap → "script.gsap"
|
||||
* /style/css → "style.css"
|
||||
*/
|
||||
|
||||
import type { JsonPatchOp, PatchEvent } from "../types.js";
|
||||
@@ -60,6 +64,14 @@ export function metaPath(field: "width" | "height" | "duration"): string {
|
||||
return `/metadata/${field}`;
|
||||
}
|
||||
|
||||
export function gsapScriptPath(): string {
|
||||
return "/script/gsap";
|
||||
}
|
||||
|
||||
export function styleSheetPath(): string {
|
||||
return "/style/css";
|
||||
}
|
||||
|
||||
// ─── Override-set key mapping ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -100,6 +112,12 @@ export function pathToKey(path: string): string | null {
|
||||
const metaMatch = /^\/metadata\/(.+)$/.exec(path);
|
||||
if (metaMatch) return `meta.${metaMatch[1]}`;
|
||||
|
||||
// /script/gsap → "script.gsap"
|
||||
if (path === "/script/gsap") return "script.gsap";
|
||||
|
||||
// /style/css → "style.css"
|
||||
if (path === "/style/css") return "style.css";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,6 +149,9 @@ export function keyToPath(key: string): string | null {
|
||||
const meta = /^meta\.(width|height|duration)$/.exec(key);
|
||||
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
|
||||
|
||||
if (key === "script.gsap") return gsapScriptPath();
|
||||
if (key === "style.css") return styleSheetPath();
|
||||
|
||||
// Bare element id — removal marker key.
|
||||
if (!key.includes(".")) return elementPath(key);
|
||||
|
||||
|
||||
+13
-10
@@ -27,7 +27,7 @@ import { buildRoots, flatElements } from "./document.js";
|
||||
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
||||
import { parseMutable } from "./engine/model.js";
|
||||
import type { ParsedDocument } from "./engine/model.js";
|
||||
import { applyOp, validateOp } from "./engine/mutate.js";
|
||||
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
|
||||
import { serializeDocument } from "./engine/serialize.js";
|
||||
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
|
||||
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
|
||||
@@ -129,10 +129,8 @@ class CompositionImpl implements Composition {
|
||||
}
|
||||
|
||||
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
|
||||
// Phase 3b: AST splice. For now, mint id and pass through.
|
||||
const tweenId = `tw-${crypto.randomUUID().slice(0, 8)}`;
|
||||
this.dispatch({ type: "addGsapTween", target, id: tweenId, tween });
|
||||
return tweenId;
|
||||
const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL);
|
||||
return result.meta?.animationId ?? "";
|
||||
}
|
||||
|
||||
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
|
||||
@@ -219,14 +217,13 @@ class CompositionImpl implements Composition {
|
||||
// ── Dispatch / batch ─────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
|
||||
const origin = opts?.origin ?? ORIGIN_LOCAL;
|
||||
const { forward, inverse } = applyOp(this.parsed, op);
|
||||
private _dispatch(op: EditOp, origin: unknown): MutationResult {
|
||||
const result = applyOp(this.parsed, op);
|
||||
const { forward, inverse } = result;
|
||||
|
||||
if (forward.length === 0 && inverse.length === 0) {
|
||||
// No-op (e.g. Phase 3b op with no implementation yet): still fire change
|
||||
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
this.elementsCache = null;
|
||||
@@ -249,6 +246,12 @@ class CompositionImpl implements Composition {
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
|
||||
this._dispatch(op, opts?.origin ?? ORIGIN_LOCAL);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,7 +66,7 @@ export type EditOp =
|
||||
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
|
||||
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
|
||||
| { type: "setVariableValue"; id: string; value: string | number | boolean }
|
||||
| { type: "addGsapTween"; target: HfId; id: string; tween: GsapTweenSpec }
|
||||
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
|
||||
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| {
|
||||
type: "setGsapKeyframe";
|
||||
@@ -104,6 +104,7 @@ export interface GsapTweenSpec {
|
||||
properties?: Record<string, unknown>;
|
||||
repeat?: number;
|
||||
yoyo?: boolean;
|
||||
stagger?: number | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Patch layer (F2: RFC 6902 frozen contract) ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user