mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #2098 from heygen-com/feat/sdk-variable-crud-and-exports
feat(sdk): variable CRUD (declare/remove/get/list) + export gaps
This commit is contained in:
@@ -19,7 +19,13 @@ import {
|
||||
setStyleSheet,
|
||||
} from "./model.js";
|
||||
import { keyToPath, stylePath } from "./patches.js";
|
||||
import { writeVariableDefault, clearVariableDefault } from "./variableModel.js";
|
||||
import {
|
||||
writeVariableDefault,
|
||||
clearVariableDefault,
|
||||
declareVariableDecl,
|
||||
removeVariableDecl,
|
||||
type VariableDecl,
|
||||
} from "./variableModel.js";
|
||||
|
||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +38,7 @@ interface ParsedPath {
|
||||
| "hold"
|
||||
| "element"
|
||||
| "variable"
|
||||
| "variable-decl"
|
||||
| "metadata"
|
||||
| "script"
|
||||
| "stylesheet";
|
||||
@@ -68,6 +75,9 @@ function parsePath(path: string): ParsedPath | null {
|
||||
const varM = /^\/variables\/(.+)$/.exec(path);
|
||||
if (varM) return { type: "variable", id: varM[1] };
|
||||
|
||||
const varDeclM = /^\/variable-decls\/(.+)$/.exec(path);
|
||||
if (varDeclM) return { type: "variable-decl", id: varDeclM[1] };
|
||||
|
||||
const metaM = /^\/metadata\/(.+)$/.exec(path);
|
||||
if (metaM) return { type: "metadata", field: metaM[1] };
|
||||
|
||||
@@ -239,6 +249,35 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
|
||||
break;
|
||||
}
|
||||
|
||||
case "variable-decl": {
|
||||
if (!p.id) return;
|
||||
// Distinct from "variable" above: this replays the WHOLE declaration
|
||||
// (declareVariable/removeVariable), not just its default value.
|
||||
if (patch.op === "remove") {
|
||||
removeVariableDecl(parsed.document, p.id);
|
||||
} else {
|
||||
// Undo of removeVariable bundles {__kind: "reinsert", decl, index} to
|
||||
// reinsert at the original array position; a plain declareVariable
|
||||
// forward/replace patch carries the bare decl. Disambiguate on the
|
||||
// __kind tag, not structural "decl"/"index" key presence — VariableDecl
|
||||
// has an open index signature, so a genuine variable schema could
|
||||
// legally declare its own "decl"/"index" fields, and "in" narrowing
|
||||
// alone can't rule that out.
|
||||
const value = patch.value;
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { __kind?: unknown }).__kind === "reinsert"
|
||||
) {
|
||||
const wrapped = value as { decl: VariableDecl; index: number };
|
||||
declareVariableDecl(parsed.document, wrapped.decl, { atIndex: wrapped.index });
|
||||
} else {
|
||||
declareVariableDecl(parsed.document, value as VariableDecl);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "script": {
|
||||
if (patch.op === "remove") {
|
||||
setGsapScript(parsed.document, "");
|
||||
|
||||
@@ -915,6 +915,135 @@ describe("setVariableValue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── declareVariable / removeVariable ─────────────────────────────────────────
|
||||
|
||||
/** Read a full variable decl (not just its default) for id, or undefined. */
|
||||
function readVarDecl(
|
||||
parsed: ReturnType<typeof parseMutable>,
|
||||
id: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const raw = parsed.document.documentElement?.getAttribute("data-composition-variables");
|
||||
if (!raw) return undefined;
|
||||
const arr = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
return arr.find((v) => v.id === id);
|
||||
}
|
||||
|
||||
describe("declareVariable", () => {
|
||||
it("creates the data-composition-variables attribute from scratch when absent", () => {
|
||||
const parsed = fresh(); // BASE_HTML has no data-composition-variables at all
|
||||
expect(parsed.document.documentElement?.getAttribute("data-composition-variables")).toBeNull();
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-title", type: "string", label: "Title", default: "Hello" },
|
||||
});
|
||||
expect(readVarDecl(parsed, "brand-title")).toEqual({
|
||||
id: "brand-title",
|
||||
type: "string",
|
||||
label: "Title",
|
||||
default: "Hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends a new declaration when the composition already has others", () => {
|
||||
const parsed = freshWithVars();
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-tagline", type: "string", label: "Tagline", default: "Ship it" },
|
||||
});
|
||||
expect(readVarDecl(parsed, "brand-color-primary")).toBeDefined(); // untouched
|
||||
expect(readVarDecl(parsed, "brand-tagline")?.default).toBe("Ship it");
|
||||
});
|
||||
|
||||
it("replaces the WHOLE existing decl (not just default) when the id already exists", () => {
|
||||
const parsed = freshWithVars();
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-color-primary", type: "color", label: "Renamed", default: "#00ff00" },
|
||||
});
|
||||
const decl = readVarDecl(parsed, "brand-color-primary");
|
||||
expect(decl?.label).toBe("Renamed");
|
||||
expect(decl?.default).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("succeeds where setVariableValue would refuse — creating an undeclared variable", () => {
|
||||
const parsed = fresh();
|
||||
// setVariableValue on an undeclared id still writes the --{id} CSS compat
|
||||
// prop unconditionally (for CSS-only compositions with no JSON schema at
|
||||
// all) — but the JSON model write itself no-ops, per writeVariableDefault's
|
||||
// "don't auto-add declarations" contract. declareVariable is the only path
|
||||
// that actually creates the schema entry.
|
||||
applyOp(parsed, { type: "setVariableValue", id: "never-declared", value: "x" });
|
||||
expect(readVarDecl(parsed, "never-declared")).toBeUndefined();
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "never-declared", type: "string", label: "New", default: "x" },
|
||||
});
|
||||
expect(readVarDecl(parsed, "never-declared")?.default).toBe("x");
|
||||
});
|
||||
|
||||
it("inverse restores the pre-declare state (remove on a fresh create, replace on an edit)", () => {
|
||||
const parsed = freshWithVars();
|
||||
const before = serializeDocument(parsed);
|
||||
|
||||
const created = applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-new", type: "string", label: "New", default: "x" },
|
||||
});
|
||||
applyPatchesToDocument(parsed, created.inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
|
||||
const edited = applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-color-primary", type: "color", label: "Edited", default: "#000000" },
|
||||
});
|
||||
applyPatchesToDocument(parsed, edited.inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
|
||||
it("a decl whose own extension data includes 'decl'/'index' keys isn't mistaken for a wrapped reinsert", () => {
|
||||
// VariableDecl has an open index signature, so a real, weird schema can
|
||||
// legally carry fields named "decl"/"index" as ordinary extension data —
|
||||
// this must NOT be confused with removeVariable's {__kind: "reinsert",
|
||||
// decl, index} inverse-patch wrapper, which is disambiguated by __kind,
|
||||
// not by structural key presence.
|
||||
const parsed = fresh();
|
||||
const pathologicalDecl = {
|
||||
id: "weird-var",
|
||||
type: "string",
|
||||
label: "Weird",
|
||||
default: "x",
|
||||
decl: "not-a-real-decl",
|
||||
index: 999,
|
||||
};
|
||||
applyOp(parsed, { type: "declareVariable", decl: pathologicalDecl });
|
||||
expect(readVarDecl(parsed, "weird-var")).toEqual(pathologicalDecl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVariable", () => {
|
||||
it("removes the declaration entirely (not just the default)", () => {
|
||||
const parsed = freshWithVars();
|
||||
applyOp(parsed, { type: "removeVariable", id: "brand-color-primary" });
|
||||
expect(readVarDecl(parsed, "brand-color-primary")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops (empty forward/inverse) when the id isn't declared", () => {
|
||||
const parsed = freshWithVars();
|
||||
const result = applyOp(parsed, { type: "removeVariable", id: "hf-nonexistent" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
expect(result.inverse).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("inverse restores the exact removed declaration", () => {
|
||||
const parsed = freshWithVars();
|
||||
const before = serializeDocument(parsed);
|
||||
const result = applyOp(parsed, { type: "removeVariable", id: "brand-color-primary" });
|
||||
expect(readVarDecl(parsed, "brand-color-primary")).toBeUndefined();
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setCompositionMetadata ───────────────────────────────────────────────────
|
||||
|
||||
describe("setCompositionMetadata", () => {
|
||||
@@ -1019,6 +1148,29 @@ describe("validateOp", () => {
|
||||
it("returns ok:true for setCompositionMetadata (no target)", () => {
|
||||
expect(validateOp(fresh(), { type: "setCompositionMetadata", width: 100 }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok:true for declareVariable / removeVariable when a root exists", () => {
|
||||
expect(
|
||||
validateOp(fresh(), {
|
||||
type: "declareVariable",
|
||||
decl: { id: "v1", type: "string", label: "V1", default: "x" },
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(validateOp(fresh(), { type: "removeVariable", id: "v1" }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok:false / E_NO_ROOT for declareVariable / removeVariable with no root", () => {
|
||||
const parsed = parseMutable(`no elements at all — just text`);
|
||||
const r1 = validateOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "v1", type: "string", label: "V1", default: "x" },
|
||||
});
|
||||
expect(r1.ok).toBe(false);
|
||||
if (!r1.ok) expect(r1.code).toBe("E_NO_ROOT");
|
||||
const r2 = validateOp(parsed, { type: "removeVariable", id: "v1" });
|
||||
expect(r2.ok).toBe(false);
|
||||
if (!r2.ok) expect(r2.code).toBe("E_NO_ROOT");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
JsonPatchOp,
|
||||
} from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import type { CompositionVariable } from "@hyperframes/core";
|
||||
import {
|
||||
resolveScoped,
|
||||
escapeHfId,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
holdPath,
|
||||
elementPath,
|
||||
variablePath,
|
||||
variableDeclPath,
|
||||
metaPath,
|
||||
gsapScriptPath,
|
||||
styleSheetPath,
|
||||
@@ -76,7 +78,13 @@ import {
|
||||
unrollDynamicAnimations,
|
||||
} from "@hyperframes/core/gsap-writer-acorn";
|
||||
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
||||
import { readVariableDefault, writeVariableDefault } from "./variableModel.js";
|
||||
import {
|
||||
readVariableDefault,
|
||||
writeVariableDefault,
|
||||
declareVariableDecl,
|
||||
removeVariableDecl,
|
||||
type VariableDecl,
|
||||
} from "./variableModel.js";
|
||||
import {
|
||||
URI_BEARING_ATTRS,
|
||||
DANGEROUS_URI_SCHEMES,
|
||||
@@ -288,6 +296,10 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
||||
return handleSetCompositionMetadata(parsed, op);
|
||||
case "setVariableValue":
|
||||
return handleSetVariableValue(parsed, op.id, op.value);
|
||||
case "declareVariable":
|
||||
return handleDeclareVariable(parsed, op.decl);
|
||||
case "removeVariable":
|
||||
return handleRemoveVariable(parsed, op.id);
|
||||
case "setClassStyle":
|
||||
return handleSetClassStyle(parsed, op.selector, op.styles);
|
||||
case "addLabel":
|
||||
@@ -885,6 +897,52 @@ function handleSetVariableValue(
|
||||
return { forward, inverse };
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare (create or fully replace) a variable's schema entry — id/type/label/
|
||||
* default/etc. Unlike setVariableValue, this creates the `data-composition-
|
||||
* variables` attribute from scratch when the composition has none yet, and
|
||||
* replaces the whole decl (not just `default`) when the id already exists —
|
||||
* the path a variables panel needs to add or edit a declaration, since
|
||||
* setVariableValue intentionally refuses to create undeclared variables.
|
||||
*/
|
||||
function handleDeclareVariable(parsed: ParsedDocument, decl: CompositionVariable): MutationResult {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root) return EMPTY;
|
||||
// The storage layer treats a decl as an untyped JSON bag (VariableDecl has an
|
||||
// index signature so it can round-trip arbitrary extra keys); CompositionVariable
|
||||
// is a closed union with no index signature, so TS won't structurally widen it
|
||||
// automatically — this is the exact boundary readDecls' own `as VariableDecl[]`
|
||||
// cast already crosses for the read side.
|
||||
const storageDecl = decl as unknown as VariableDecl;
|
||||
const previous = declareVariableDecl(parsed.document, storageDecl);
|
||||
const path = variableDeclPath(decl.id);
|
||||
const p = valueChange(path, previous, storageDecl);
|
||||
return { forward: [p.forward], inverse: [p.inverse] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable's declaration entirely. Live `var.{id}` overrides and any
|
||||
* data-var-* DOM references are left untouched — this only removes the schema
|
||||
* entry, mirroring removeVariableDecl's contract.
|
||||
*/
|
||||
function handleRemoveVariable(parsed: ParsedDocument, id: string): MutationResult {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root) return EMPTY;
|
||||
const removed = removeVariableDecl(parsed.document, id);
|
||||
if (!removed) return EMPTY;
|
||||
const path = variableDeclPath(id);
|
||||
// Bundle the original array index so undo reinserts at the same position
|
||||
// instead of appending — mirrors handleRemoveElement's {html, parentId,
|
||||
// siblingIndex} inverse value. Tagged with __kind (rather than relying on
|
||||
// structural "decl"/"index" key presence) because VariableDecl has an open
|
||||
// index signature — a genuine variable schema could legally declare its own
|
||||
// "decl"/"index" fields, which a structural check alone can't rule out.
|
||||
return {
|
||||
forward: [patchRemove(path)],
|
||||
inverse: [patchAdd(path, { __kind: "reinsert", decl: removed.decl, index: removed.index })],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GSAP selector helpers ───────────────────────────────────────────────────
|
||||
|
||||
function selectorMatchesId(selector: string, id: HfId): boolean {
|
||||
@@ -1491,6 +1549,8 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
return CAN_OK;
|
||||
}
|
||||
case "setVariableValue":
|
||||
case "declareVariable":
|
||||
case "removeVariable":
|
||||
if (findRoot(parsed.document) === null)
|
||||
return canErr("E_NO_ROOT", "Composition root element not found.");
|
||||
return CAN_OK;
|
||||
|
||||
@@ -75,6 +75,11 @@ export function variablePath(id: string): string {
|
||||
return `/variables/${id}`;
|
||||
}
|
||||
|
||||
/** Distinct from variablePath — that's the `default` field only; this is the whole decl. */
|
||||
export function variableDeclPath(id: string): string {
|
||||
return `/variable-decls/${id}`;
|
||||
}
|
||||
|
||||
export function metaPath(field: "width" | "height" | "duration"): string {
|
||||
return `/metadata/${field}`;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* (engine/apply-patches.ts) can never disagree on the model's shape.
|
||||
*/
|
||||
|
||||
type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
|
||||
export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
|
||||
|
||||
function getHtmlEl(document: Document): Element | null {
|
||||
return (document as Document & { documentElement?: Element }).documentElement ?? null;
|
||||
@@ -77,3 +77,66 @@ export function clearVariableDefault(document: Document, id: string): boolean {
|
||||
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** All declared variables, or [] when the attribute is absent/invalid. */
|
||||
export function listVariableDecls(document: Document): VariableDecl[] {
|
||||
return readDecls(document)?.arr ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a full variable declaration (id/type/label/default/…), unlike
|
||||
* writeVariableDefault which only ever touches the `default` field of an
|
||||
* ALREADY-declared variable and refuses to create new ones. This is the
|
||||
* "let someone add a variable" path a declarations panel needs — creates the
|
||||
* `data-composition-variables` attribute from scratch when absent.
|
||||
*
|
||||
* Replaces the whole existing decl when `decl.id` is already declared (so
|
||||
* editing a variable's type/label/options goes through the same call as
|
||||
* creating one). Returns the previous decl (for inverse-patch capture) or
|
||||
* null when this was a fresh create.
|
||||
*/
|
||||
export function declareVariableDecl(
|
||||
document: Document,
|
||||
decl: VariableDecl,
|
||||
opts?: { atIndex?: number },
|
||||
): VariableDecl | null {
|
||||
const htmlEl = getHtmlEl(document);
|
||||
if (!htmlEl) return null;
|
||||
const existing = readDecls(document);
|
||||
const arr = existing?.arr ?? [];
|
||||
const idx = indexOfId(arr, decl.id);
|
||||
const previous = idx < 0 ? null : arr[idx]!;
|
||||
if (idx >= 0) {
|
||||
arr[idx] = decl; // edit in place — position is already preserved
|
||||
} else if (opts?.atIndex !== undefined) {
|
||||
// Undo of removeVariable: reinsert at the exact index it was removed
|
||||
// from, so a remove-then-undo round-trips the array order, not just
|
||||
// set-membership (mirrors handleRemoveElement's siblingIndex).
|
||||
arr.splice(opts.atIndex, 0, decl);
|
||||
} else {
|
||||
arr.push(decl); // a genuinely new declaration goes to the end of the list
|
||||
}
|
||||
htmlEl.setAttribute("data-composition-variables", JSON.stringify(arr));
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable's declaration entirely (not just its default — the whole
|
||||
* schema entry). Live `var.{id}` overrides and any data-var-* DOM references
|
||||
* are left untouched; removing the declaration doesn't reach into either.
|
||||
* Returns the removed decl AND its array index (for inverse-patch capture, so
|
||||
* undo can reinsert at the original position — mirrors handleRemoveElement's
|
||||
* siblingIndex), or null when the attribute/decl was absent.
|
||||
*/
|
||||
export function removeVariableDecl(
|
||||
document: Document,
|
||||
id: string,
|
||||
): { decl: VariableDecl; index: number } | null {
|
||||
const decls = readDecls(document);
|
||||
if (!decls) return null;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0) return null;
|
||||
const [removed] = decls.arr.splice(idx, 1);
|
||||
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
return removed ? { decl: removed, index: idx } : null;
|
||||
}
|
||||
|
||||
@@ -20,13 +20,17 @@ export type {
|
||||
CanResult,
|
||||
} from "./types.js";
|
||||
|
||||
export type { CompositionVariable } from "@hyperframes/core";
|
||||
|
||||
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
|
||||
export { UnsupportedOpError } from "./engine/mutate.js";
|
||||
|
||||
export { buildDocument, buildRoots, flatElements } from "./document.js";
|
||||
|
||||
export { isNewHostBoundary, bareId } from "./engine/model.js";
|
||||
export { isNewHostBoundary, bareId, resolveScoped, findById, escapeHfId } from "./engine/model.js";
|
||||
|
||||
export { readVariableDefault } from "./engine/variableModel.js";
|
||||
|
||||
export { openComposition } from "./session.js";
|
||||
export type { OpenCompositionOptions } from "./session.js";
|
||||
|
||||
@@ -556,3 +556,92 @@ describe("getAllAnimationIds", () => {
|
||||
expect(comp.getAllAnimationIds().has(realId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getVariableValue / listVariables / declareVariable / removeVariable ──────
|
||||
|
||||
const VARIABLES_HTML = `<!DOCTYPE html>
|
||||
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
|
||||
[{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" }],
|
||||
)}'>
|
||||
<body>${BASE_HTML}</body>
|
||||
</html>`;
|
||||
|
||||
describe("variable declarations (Composition API)", () => {
|
||||
it("getVariableValue reads a declared variable's current default", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
expect(comp.getVariableValue("brand-color")).toBe("#0066cc");
|
||||
});
|
||||
|
||||
it("getVariableValue returns undefined for an undeclared id", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listVariables returns every declared variable's full schema", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("listVariables returns [] when the composition declares none", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.listVariables()).toEqual([]);
|
||||
});
|
||||
|
||||
it("declareVariable creates a new declaration a variables panel can list immediately", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
||||
expect(comp.getVariableValue("brand-title")).toBe("Hi");
|
||||
expect(comp.listVariables()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("declareVariable can create where setVariableValue's model write silently no-ops", async () => {
|
||||
const comp = await openComposition(BASE_HTML); // no data-composition-variables at all
|
||||
comp.setVariableValue("never-declared", "x");
|
||||
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
||||
comp.declareVariable({ id: "never-declared", type: "string", label: "New", default: "x" });
|
||||
expect(comp.getVariableValue("never-declared")).toBe("x");
|
||||
});
|
||||
|
||||
it("removeVariable removes the declaration; listVariables reflects it immediately", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
comp.removeVariable("brand-color");
|
||||
expect(comp.listVariables()).toEqual([]);
|
||||
expect(comp.getVariableValue("brand-color")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("declareVariable / removeVariable both support undo", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
||||
expect(comp.listVariables()).toHaveLength(2);
|
||||
comp.undo();
|
||||
expect(comp.listVariables()).toHaveLength(1);
|
||||
|
||||
comp.removeVariable("brand-color");
|
||||
expect(comp.listVariables()).toEqual([]);
|
||||
comp.undo();
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("declareVariable / removeVariable both support redo after undo", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
|
||||
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
||||
comp.undo();
|
||||
comp.redo();
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
||||
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
|
||||
]);
|
||||
|
||||
comp.removeVariable("brand-color");
|
||||
comp.undo();
|
||||
comp.redo();
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import { parseMutable } from "./engine/model.js";
|
||||
import type { ParsedDocument } from "./engine/model.js";
|
||||
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
|
||||
import { getGsapScript, resolveScoped } from "./engine/model.js";
|
||||
import { readVariableDefault, listVariableDecls } from "./engine/variableModel.js";
|
||||
import type { CompositionVariable } from "@hyperframes/core";
|
||||
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
|
||||
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
|
||||
@@ -153,6 +155,34 @@ class CompositionImpl implements Composition {
|
||||
this.dispatch({ type: "setVariableValue", id, value });
|
||||
}
|
||||
|
||||
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined {
|
||||
// readVariableDefault genuinely can't narrow beyond unknown — the schema
|
||||
// isn't validated at read time — so the cast lives here at the SDK
|
||||
// boundary rather than pushing it onto every caller of getVariableValue.
|
||||
return readVariableDefault(this.parsed.document, id) as
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| FontValue
|
||||
| ImageValue
|
||||
| undefined;
|
||||
}
|
||||
|
||||
listVariables(): CompositionVariable[] {
|
||||
// Same VariableDecl (index-signature) -> CompositionVariable (closed union)
|
||||
// boundary cast as handleDeclareVariable — the model trusts the schema is
|
||||
// well-formed rather than validating each decl's shape at read time.
|
||||
return listVariableDecls(this.parsed.document) as unknown as CompositionVariable[];
|
||||
}
|
||||
|
||||
declareVariable(decl: CompositionVariable): void {
|
||||
this.dispatch({ type: "declareVariable", decl });
|
||||
}
|
||||
|
||||
removeVariable(id: string): void {
|
||||
this.dispatch({ type: "removeVariable", id });
|
||||
}
|
||||
|
||||
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "./index.js";
|
||||
import {
|
||||
openComposition,
|
||||
ORIGIN_APPLY_PATCHES,
|
||||
resolveScoped,
|
||||
findById,
|
||||
escapeHfId,
|
||||
readVariableDefault,
|
||||
} from "./index.js";
|
||||
import { createMemoryAdapter } from "./adapters/memory.js";
|
||||
|
||||
// ─── Fixture ─────────────────────────────────────────────────────────────────
|
||||
@@ -313,3 +320,15 @@ describe("T3 embedded mode", () => {
|
||||
expect(comp2.getElement("hf-body")?.text).toContain("Override text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("engine helper exports (resolveScoped, findById, escapeHfId, readVariableDefault)", () => {
|
||||
it("are importable from the public index and work against a live document", async () => {
|
||||
// These operate on a Document directly, not the Composition — exercise them
|
||||
// against a document parsed the same way the SDK parses internally.
|
||||
const { document } = await import("linkedom").then((m) => m.parseHTML(BASE_HTML));
|
||||
expect(findById(document as unknown as Document, "hf-title")).not.toBeNull();
|
||||
expect(resolveScoped(document as unknown as Document, "hf-title")).not.toBeNull();
|
||||
expect(escapeHfId('hf-"quoted"')).toBe('hf-\\"quoted\\"');
|
||||
expect(readVariableDefault(document as unknown as Document, "never-declared")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CompositionVariable } from "@hyperframes/core";
|
||||
|
||||
// ─── Document model ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
|
||||
@@ -115,6 +117,8 @@ export type EditOp =
|
||||
id: string;
|
||||
value: string | number | boolean | FontValue | ImageValue;
|
||||
}
|
||||
| { type: "declareVariable"; decl: CompositionVariable }
|
||||
| { type: "removeVariable"; id: string }
|
||||
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
|
||||
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| {
|
||||
@@ -405,6 +409,19 @@ export interface Composition {
|
||||
*/
|
||||
addElement(parent: HfId | null, index: number, html: string): HfId;
|
||||
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void;
|
||||
/** Current `default` value for a declared variable, or undefined if undeclared/unset. */
|
||||
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined;
|
||||
/** Every declared variable's full schema (id/type/label/default/…), or [] when none. */
|
||||
listVariables(): CompositionVariable[];
|
||||
/**
|
||||
* Create a new variable declaration, or fully replace an existing one (type/
|
||||
* label/default/etc, not just the value — use setVariableValue for that).
|
||||
* The path setVariableValue can't take: it refuses to create undeclared
|
||||
* variables by design, keeping the schema authoritative.
|
||||
*/
|
||||
declareVariable(decl: CompositionVariable): void;
|
||||
/** Remove a variable's declaration. Live `var.{id}` overrides are untouched. */
|
||||
removeVariable(id: string): void;
|
||||
/**
|
||||
* Read enter/exit times and GSAP labels for every timed element (WS-C).
|
||||
* Derives enterAt/exitAt using the same data-duration vs data-end preference
|
||||
|
||||
Reference in New Issue
Block a user