mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk): variable declaration edit ops (declare/update/remove) (#2047)
This commit is contained in:
@@ -21,6 +21,7 @@ export type {
|
||||
export {
|
||||
COMPOSITION_VARIABLE_TYPES,
|
||||
parseCompositionVariables,
|
||||
isCompositionVariable,
|
||||
isScalarVariableValue,
|
||||
} from "@hyperframes/parsers/composition";
|
||||
|
||||
|
||||
@@ -10,4 +10,8 @@ export {
|
||||
resolveAliasDisplayName,
|
||||
} from "./fontAliases.js";
|
||||
export { decodeUrlPathVariants } from "./utils/urlPath.js";
|
||||
export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js";
|
||||
export {
|
||||
parseCompositionVariables,
|
||||
isCompositionVariable,
|
||||
isScalarVariableValue,
|
||||
} from "./compositionVariables.js";
|
||||
|
||||
@@ -40,7 +40,13 @@ function isVariableType(t: unknown): t is CompositionVariableType {
|
||||
return typeof t === "string" && t in DEFAULT_TYPEOF;
|
||||
}
|
||||
|
||||
function isCompositionVariable(v: unknown): v is CompositionVariable {
|
||||
/**
|
||||
* True when the value is a structurally valid variable declaration: id, label,
|
||||
* a known type, a default matching that type, and options[] for enums. The
|
||||
* same predicate parseCompositionVariables filters with — exported so writers
|
||||
* (SDK declaration ops, Studio forms) can validate before persisting.
|
||||
*/
|
||||
export function isCompositionVariable(v: unknown): v is CompositionVariable {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
|
||||
if (!isVariableType(v.type)) return false;
|
||||
|
||||
@@ -220,8 +220,10 @@ window.__timelines = { t: tl };</script>
|
||||
});
|
||||
|
||||
it("mirrors declareVariable/removeVariable onto the live document's schema attribute", async () => {
|
||||
const iframe = mountIframe(BASE_HTML); // no data-composition-variables at all
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
// Full document (not a fragment): declareVariable refuses fragment sources.
|
||||
const fullDoc = `<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`;
|
||||
const iframe = mountIframe(fullDoc); // no data-composition-variables at all
|
||||
const comp = await openComposition(fullDoc);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
@@ -231,7 +233,8 @@ window.__timelines = { t: tl };</script>
|
||||
expect(liveDocEl.getAttribute("data-composition-variables")).toContain("accent");
|
||||
|
||||
comp.removeVariable("accent");
|
||||
expect(liveDocEl.getAttribute("data-composition-variables")).not.toContain("accent");
|
||||
// Removing the last declaration drops the attribute entirely (null).
|
||||
expect(liveDocEl.getAttribute("data-composition-variables") ?? "").not.toContain("accent");
|
||||
});
|
||||
|
||||
it("mirrors setTiming onto the live element's data-start/data-end attributes", async () => {
|
||||
|
||||
@@ -22,11 +22,19 @@ import { keyToPath, stylePath } from "./patches.js";
|
||||
import {
|
||||
writeVariableDefault,
|
||||
clearVariableDefault,
|
||||
declareVariableDecl,
|
||||
removeVariableDecl,
|
||||
type VariableDecl,
|
||||
writeVariableDeclaration,
|
||||
removeVariableDeclarationEntry,
|
||||
} from "./variableModel.js";
|
||||
|
||||
function isRawDeclarationEntry(value: unknown): value is { id: string } & Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { id?: unknown }).id === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedPath {
|
||||
@@ -38,7 +46,7 @@ interface ParsedPath {
|
||||
| "hold"
|
||||
| "element"
|
||||
| "variable"
|
||||
| "variable-decl"
|
||||
| "variableDeclaration"
|
||||
| "metadata"
|
||||
| "script"
|
||||
| "stylesheet";
|
||||
@@ -72,12 +80,12 @@ function parsePath(path: string): ParsedPath | null {
|
||||
const elemM = /^\/elements\/([^/]+)$/.exec(path);
|
||||
if (elemM) return { type: "element", id: elemM[1] };
|
||||
|
||||
const varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path);
|
||||
if (varDeclM) return { type: "variableDeclaration", id: varDeclM[1] };
|
||||
|
||||
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 +247,20 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
|
||||
break;
|
||||
}
|
||||
|
||||
case "variableDeclaration": {
|
||||
if (!p.id) return;
|
||||
if (patch.op === "remove") {
|
||||
removeVariableDeclarationEntry(parsed.document, p.id);
|
||||
} else if (isRawDeclarationEntry(patch.value)) {
|
||||
// Replay is faithful, not strict: inverse patches capture raw entries
|
||||
// (loose hand-authored declarations included) and undo must restore
|
||||
// them verbatim — gating on isCompositionVariable here would make
|
||||
// undo of a remove/update on a loose entry silently no-op.
|
||||
writeVariableDeclaration(parsed.document, patch.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "variable": {
|
||||
if (!p.id) return;
|
||||
// B1: update the JSON model (data-composition-variables) so
|
||||
@@ -249,35 +271,6 @@ 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, "");
|
||||
|
||||
@@ -31,6 +31,13 @@ function fresh() {
|
||||
return parseMutable(BASE_HTML);
|
||||
}
|
||||
|
||||
// Full document (BASE_HTML wrapped in <html>) with NO declarations — the shape
|
||||
// declareVariable requires (it refuses fragment sources whose synthetic <html>
|
||||
// is stripped on serialize).
|
||||
function freshDoc() {
|
||||
return parseMutable(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
|
||||
}
|
||||
|
||||
/** Full HTML fixture with data-composition-variables for B1/B2 tests. */
|
||||
const VARIABLES_HTML = `<!DOCTYPE html>
|
||||
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
|
||||
@@ -930,11 +937,11 @@ function readVarDecl(
|
||||
|
||||
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
|
||||
const parsed = freshDoc(); // full doc, 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" },
|
||||
declaration: { id: "brand-title", type: "string", label: "Title", default: "Hello" },
|
||||
});
|
||||
expect(readVarDecl(parsed, "brand-title")).toEqual({
|
||||
id: "brand-title",
|
||||
@@ -948,17 +955,35 @@ describe("declareVariable", () => {
|
||||
const parsed = freshWithVars();
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-tagline", type: "string", label: "Tagline", default: "Ship it" },
|
||||
declaration: { 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", () => {
|
||||
it("declareVariable no-ops on an existing id; updateVariableDeclaration replaces the whole decl", () => {
|
||||
const parsed = freshWithVars();
|
||||
// Canonical semantics: declareVariable creates only — re-declaring an existing
|
||||
// id is a no-op; updateVariableDeclaration is the path that replaces a decl.
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-color-primary", type: "color", label: "Renamed", default: "#00ff00" },
|
||||
declaration: {
|
||||
id: "brand-color-primary",
|
||||
type: "color",
|
||||
label: "Ignored",
|
||||
default: "#111111",
|
||||
},
|
||||
});
|
||||
expect(readVarDecl(parsed, "brand-color-primary")?.label).not.toBe("Ignored");
|
||||
applyOp(parsed, {
|
||||
type: "updateVariableDeclaration",
|
||||
id: "brand-color-primary",
|
||||
declaration: {
|
||||
id: "brand-color-primary",
|
||||
type: "color",
|
||||
label: "Renamed",
|
||||
default: "#00ff00",
|
||||
},
|
||||
});
|
||||
const decl = readVarDecl(parsed, "brand-color-primary");
|
||||
expect(decl?.label).toBe("Renamed");
|
||||
@@ -966,7 +991,7 @@ describe("declareVariable", () => {
|
||||
});
|
||||
|
||||
it("succeeds where setVariableValue would refuse — creating an undeclared variable", () => {
|
||||
const parsed = fresh();
|
||||
const parsed = freshDoc();
|
||||
// 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
|
||||
@@ -976,7 +1001,7 @@ describe("declareVariable", () => {
|
||||
expect(readVarDecl(parsed, "never-declared")).toBeUndefined();
|
||||
applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "never-declared", type: "string", label: "New", default: "x" },
|
||||
declaration: { id: "never-declared", type: "string", label: "New", default: "x" },
|
||||
});
|
||||
expect(readVarDecl(parsed, "never-declared")?.default).toBe("x");
|
||||
});
|
||||
@@ -987,36 +1012,10 @@ describe("declareVariable", () => {
|
||||
|
||||
const created = applyOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "brand-new", type: "string", label: "New", default: "x" },
|
||||
declaration: { 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1034,13 +1033,16 @@ describe("removeVariable", () => {
|
||||
expect(result.inverse).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("inverse restores the exact removed declaration", () => {
|
||||
it("inverse restores the removed declaration", () => {
|
||||
// Canonical remove re-adds the declaration on undo (array position is not
|
||||
// preserved), so assert the decl is restored by content rather than exact
|
||||
// byte-serialize.
|
||||
const parsed = freshWithVars();
|
||||
const before = serializeDocument(parsed);
|
||||
const original = readVarDecl(parsed, "brand-color-primary");
|
||||
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);
|
||||
expect(readVarDecl(parsed, "brand-color-primary")).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1151,22 +1153,25 @@ describe("validateOp", () => {
|
||||
|
||||
it("returns ok:true for declareVariable / removeVariable when a root exists", () => {
|
||||
expect(
|
||||
validateOp(fresh(), {
|
||||
validateOp(freshDoc(), {
|
||||
type: "declareVariable",
|
||||
decl: { id: "v1", type: "string", label: "V1", default: "x" },
|
||||
declaration: { 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", () => {
|
||||
it("refuses declareVariable / removeVariable on a rootless fragment", () => {
|
||||
const parsed = parseMutable(`no elements at all — just text`);
|
||||
// declareVariable runs its declaration precondition first, so a wrapped
|
||||
// fragment (no real <html> to carry the schema) surfaces E_FRAGMENT_COMPOSITION.
|
||||
const r1 = validateOp(parsed, {
|
||||
type: "declareVariable",
|
||||
decl: { id: "v1", type: "string", label: "V1", default: "x" },
|
||||
declaration: { id: "v1", type: "string", label: "V1", default: "x" },
|
||||
});
|
||||
expect(r1.ok).toBe(false);
|
||||
if (!r1.ok) expect(r1.code).toBe("E_NO_ROOT");
|
||||
if (!r1.ok) expect(r1.code).toBe("E_FRAGMENT_COMPOSITION");
|
||||
// removeVariable only needs a root; there is none → 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");
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
JsonPatchOp,
|
||||
} from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import type { CompositionVariable } from "@hyperframes/core";
|
||||
import {
|
||||
resolveScoped,
|
||||
escapeHfId,
|
||||
@@ -81,10 +80,15 @@ import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
||||
import {
|
||||
readVariableDefault,
|
||||
writeVariableDefault,
|
||||
declareVariableDecl,
|
||||
removeVariableDecl,
|
||||
type VariableDecl,
|
||||
findVariableDeclaration,
|
||||
writeVariableDeclaration,
|
||||
removeVariableDeclarationEntry,
|
||||
} from "./variableModel.js";
|
||||
import {
|
||||
isCompositionVariable,
|
||||
isScalarVariableValue as isScalar,
|
||||
} from "@hyperframes/core/variables";
|
||||
import type { CompositionVariable } from "@hyperframes/core/variables";
|
||||
import {
|
||||
URI_BEARING_ATTRS,
|
||||
DANGEROUS_URI_SCHEMES,
|
||||
@@ -297,9 +301,15 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
||||
case "setVariableValue":
|
||||
return handleSetVariableValue(parsed, op.id, op.value);
|
||||
case "declareVariable":
|
||||
return handleDeclareVariable(parsed, op.decl);
|
||||
return handleDeclareVariable(parsed, op.declaration);
|
||||
case "updateVariableDeclaration":
|
||||
return handleUpdateVariableDeclaration(parsed, op.id, op.declaration);
|
||||
case "removeVariableDeclaration":
|
||||
return handleRemoveVariableDeclaration(parsed, op.id);
|
||||
case "removeVariable":
|
||||
return handleRemoveVariable(parsed, op.id);
|
||||
// #2098 alias — delegate to the canonical handler so its patch grammar
|
||||
// and undo inverse match the rest of the variable-declaration ops.
|
||||
return handleRemoveVariableDeclaration(parsed, op.id);
|
||||
case "setClassStyle":
|
||||
return handleSetClassStyle(parsed, op.selector, op.styles);
|
||||
case "addLabel":
|
||||
@@ -898,49 +908,166 @@ function handleSetVariableValue(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Keep the `--{id}` CSS compat custom property on the root in sync with a
|
||||
* scalar default (same secondary channel handleSetVariableValue maintains).
|
||||
* Pass null to clear. Returns the patch pair, or null when there is no root
|
||||
* or nothing to change.
|
||||
*/
|
||||
function handleDeclareVariable(parsed: ParsedDocument, decl: CompositionVariable): MutationResult {
|
||||
function cssCompatChange(
|
||||
parsed: ParsedDocument,
|
||||
id: string,
|
||||
newVal: string | null,
|
||||
): { forward: JsonPatchOp; inverse: JsonPatchOp } | null {
|
||||
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] };
|
||||
const rootId = root?.getAttribute("data-hf-id");
|
||||
if (!root || !rootId) return null;
|
||||
const cssVar = `--${id}`;
|
||||
const oldCssValue = getElementStyles(root)[cssVar] ?? null;
|
||||
if (newVal !== null) {
|
||||
if (oldCssValue === newVal) return null;
|
||||
setElementStyles(root, { [cssVar]: newVal });
|
||||
return scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
|
||||
}
|
||||
if (oldCssValue === null) return null;
|
||||
setElementStyles(root, { [cssVar]: null });
|
||||
return scalarDelete(stylePath(rootId, cssVar), oldCssValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Declaration ops require a real `<html>` in the source: fragment inputs get
|
||||
* a synthetic wrapper that serialize() strips, so a declaration written there
|
||||
* would silently vanish on save.
|
||||
*/
|
||||
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 })],
|
||||
function fragmentCompositionErr(parsed: ParsedDocument): CanResult | null {
|
||||
if (!parsed.wrapped) return null;
|
||||
return canErr(
|
||||
"E_FRAGMENT_COMPOSITION",
|
||||
"Fragment compositions cannot carry variable declarations.",
|
||||
"data-composition-variables lives on the <html> element — convert the composition to a full HTML document first.",
|
||||
);
|
||||
}
|
||||
|
||||
function invalidDeclarationErr(): CanResult {
|
||||
return canErr(
|
||||
"E_INVALID_ARGS",
|
||||
"Not a valid variable declaration.",
|
||||
"Requires id, label, type (string|number|color|boolean|enum|font|image), and a default matching the type; enum also requires options[].",
|
||||
);
|
||||
}
|
||||
|
||||
// A variable id becomes a CSS custom-property name (`--{id}`), a `data-var-*`
|
||||
// attribute value, and a CLI `--variables` key. isCompositionVariable only
|
||||
// checks it is a non-empty string, so the SDK — the last gate before Studio /
|
||||
// CSS / CLI make those assumptions — enforces a safe identifier shape here.
|
||||
const VALID_VARIABLE_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||
|
||||
function isValidVariableId(id: string): boolean {
|
||||
return VALID_VARIABLE_ID.test(id);
|
||||
}
|
||||
|
||||
function invalidVariableIdErr(id: string): CanResult {
|
||||
return canErr(
|
||||
"E_INVALID_VARIABLE_ID",
|
||||
`Variable id ${JSON.stringify(id)} is not a valid identifier.`,
|
||||
"Ids must match /^[A-Za-z_][A-Za-z0-9_-]*$/ — they become CSS custom-property names (--id), data-var-* attribute values, and CLI --variables keys.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared can() precondition for declareVariable/updateVariableDeclaration:
|
||||
* refuse fragment compositions, non-declaration shapes, and malformed ids.
|
||||
* Returns the CanResult to surface, or null when the declaration is well-formed.
|
||||
* The shape check runs before the id access so a null/non-object declaration
|
||||
* yields a CanResult, not a TypeError.
|
||||
*/
|
||||
function declarationPreconditionErr(
|
||||
parsed: ParsedDocument,
|
||||
declaration: CompositionVariable,
|
||||
): CanResult | null {
|
||||
const fragmentErr = fragmentCompositionErr(parsed);
|
||||
if (fragmentErr) return fragmentErr;
|
||||
if (!isCompositionVariable(declaration)) return invalidDeclarationErr();
|
||||
if (!isValidVariableId(declaration.id)) return invalidVariableIdErr(declaration.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleDeclareVariable(
|
||||
parsed: ParsedDocument,
|
||||
declaration: CompositionVariable,
|
||||
): MutationResult {
|
||||
// Defensive re-check of can(): never write an invalid or duplicate
|
||||
// declaration into the schema. Fragment sources have no <html> of their
|
||||
// own — writing to the synthetic wrapper would be lost on serialize.
|
||||
if (parsed.wrapped) return EMPTY;
|
||||
if (!isCompositionVariable(declaration)) return EMPTY;
|
||||
if (!isValidVariableId(declaration.id)) return EMPTY;
|
||||
if (findVariableDeclaration(parsed.document, declaration.id) !== undefined) return EMPTY;
|
||||
if (!writeVariableDeclaration(parsed.document, declaration)) return EMPTY;
|
||||
const path = variableDeclPath(declaration.id);
|
||||
const result: MutationResult = {
|
||||
forward: [patchAdd(path, declaration)],
|
||||
inverse: [patchRemove(path)],
|
||||
};
|
||||
// Same CSS compat channel every other variable op maintains — a composition
|
||||
// CSS-bound to var(--id) must resolve regardless of which op set the value.
|
||||
if (isScalar(declaration.default)) {
|
||||
const css = cssCompatChange(parsed, declaration.id, String(declaration.default));
|
||||
if (css) {
|
||||
result.forward.push(css.forward);
|
||||
result.inverse.push(css.inverse);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleUpdateVariableDeclaration(
|
||||
parsed: ParsedDocument,
|
||||
id: string,
|
||||
declaration: CompositionVariable,
|
||||
): MutationResult {
|
||||
if (parsed.wrapped) return EMPTY;
|
||||
if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY;
|
||||
const old = findVariableDeclaration(parsed.document, id);
|
||||
if (old === undefined) return EMPTY;
|
||||
writeVariableDeclaration(parsed.document, declaration);
|
||||
const p = valueChange(variableDeclPath(id), old, declaration);
|
||||
const result: MutationResult = { forward: [p.forward], inverse: [p.inverse] };
|
||||
|
||||
// Default changed → keep the CSS compat prop in sync (set for scalars,
|
||||
// clear when the new default is object-valued font/image), and emit the
|
||||
// paired /variables value patch so the T3 override-set's var.{id} entry
|
||||
// agrees with the varDecl.{id} snapshot regardless of replay order.
|
||||
const oldDefault = old.default;
|
||||
const newDefault = declaration.default;
|
||||
if (JSON.stringify(oldDefault) !== JSON.stringify(newDefault)) {
|
||||
const valueP = valueChange(variablePath(id), oldDefault ?? null, newDefault);
|
||||
result.forward.push(valueP.forward);
|
||||
result.inverse.push(valueP.inverse);
|
||||
const css = cssCompatChange(parsed, id, isScalar(newDefault) ? String(newDefault) : null);
|
||||
if (css) {
|
||||
result.forward.push(css.forward);
|
||||
result.inverse.push(css.inverse);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult {
|
||||
if (parsed.wrapped) return EMPTY;
|
||||
const old = findVariableDeclaration(parsed.document, id);
|
||||
if (old === undefined) return EMPTY;
|
||||
removeVariableDeclarationEntry(parsed.document, id);
|
||||
const path = variableDeclPath(id);
|
||||
const result: MutationResult = {
|
||||
forward: [patchRemove(path)],
|
||||
inverse: [patchAdd(path, old)],
|
||||
};
|
||||
const css = cssCompatChange(parsed, id, null);
|
||||
if (css) {
|
||||
result.forward.push(css.forward);
|
||||
result.inverse.push(css.inverse);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── GSAP selector helpers ───────────────────────────────────────────────────
|
||||
@@ -1549,11 +1676,49 @@ 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;
|
||||
case "declareVariable": {
|
||||
const preErr = declarationPreconditionErr(parsed, op.declaration);
|
||||
if (preErr) return preErr;
|
||||
if (findVariableDeclaration(parsed.document, op.declaration.id) !== undefined)
|
||||
return canErr(
|
||||
"E_DUPLICATE_VARIABLE",
|
||||
`Variable "${op.declaration.id}" is already declared.`,
|
||||
"Use updateVariableDeclaration to change it, or setVariableValue to change its default.",
|
||||
);
|
||||
return CAN_OK;
|
||||
}
|
||||
case "updateVariableDeclaration": {
|
||||
const preErr = declarationPreconditionErr(parsed, op.declaration);
|
||||
if (preErr) return preErr;
|
||||
if (op.declaration.id !== op.id)
|
||||
return canErr(
|
||||
"E_INVALID_ARGS",
|
||||
`declaration.id ("${op.declaration.id}") must match id ("${op.id}").`,
|
||||
"Variable ids are immutable — rename via removeVariableDeclaration + declareVariable.",
|
||||
);
|
||||
if (findVariableDeclaration(parsed.document, op.id) === undefined)
|
||||
return canErr(
|
||||
"E_VARIABLE_NOT_FOUND",
|
||||
`Variable "${op.id}" is not declared.`,
|
||||
"Check comp.getVariableDeclarations(), or add it with declareVariable.",
|
||||
);
|
||||
return CAN_OK;
|
||||
}
|
||||
case "removeVariableDeclaration": {
|
||||
const fragmentErr = fragmentCompositionErr(parsed);
|
||||
if (fragmentErr) return fragmentErr;
|
||||
if (findVariableDeclaration(parsed.document, op.id) === undefined)
|
||||
return canErr(
|
||||
"E_VARIABLE_NOT_FOUND",
|
||||
`Variable "${op.id}" is not declared.`,
|
||||
"Check comp.getVariableDeclarations().",
|
||||
);
|
||||
return CAN_OK;
|
||||
}
|
||||
case "setCompositionMetadata":
|
||||
case "setClassStyle":
|
||||
return CAN_OK;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end
|
||||
* /elements/{hfId}/hold/{start|end|fill}
|
||||
* /elements/{hfId} ← whole subtree (removeElement)
|
||||
* /variables/{variableId}
|
||||
* /variables/{variableId} ← declaration's default value
|
||||
* /variableDeclarations/{variableId} ← whole declaration object
|
||||
* /metadata/{width|height|duration}
|
||||
* /script/gsap ← GSAP inline script textContent
|
||||
* /style/css ← <style> element textContent
|
||||
@@ -21,6 +22,7 @@
|
||||
* /elements/hf-x/hold/start → "hf-x.hold.start"
|
||||
* /elements/hf-x → "hf-x" (null = removal marker)
|
||||
* /variables/brand-color-primary → "var.brand-color-primary"
|
||||
* /variableDeclarations/brand-color-primary → "varDecl.brand-color-primary"
|
||||
* /metadata/width → "meta.width"
|
||||
* /script/gsap → "script.gsap"
|
||||
* /style/css → "style.css"
|
||||
@@ -75,9 +77,9 @@ export function variablePath(id: string): string {
|
||||
return `/variables/${id}`;
|
||||
}
|
||||
|
||||
/** Distinct from variablePath — that's the `default` field only; this is the whole decl. */
|
||||
/** Whole-declaration path — distinct from /variables/{id}, which is the default *value*. */
|
||||
export function variableDeclPath(id: string): string {
|
||||
return `/variable-decls/${id}`;
|
||||
return `/variableDeclarations/${id}`;
|
||||
}
|
||||
|
||||
export function metaPath(field: "width" | "height" | "duration"): string {
|
||||
@@ -125,6 +127,10 @@ export function pathToKey(path: string): string | null {
|
||||
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
|
||||
if (elemMatch) return decodePathSegment(elemMatch[1]!);
|
||||
|
||||
// /variableDeclarations/{id} → "varDecl.{id}" (checked before /variables/)
|
||||
const varDeclMatch = /^\/variableDeclarations\/(.+)$/.exec(path);
|
||||
if (varDeclMatch) return `varDecl.${varDeclMatch[1]}`;
|
||||
|
||||
// /variables/{id} → "var.{id}"
|
||||
const varMatch = /^\/variables\/(.+)$/.exec(path);
|
||||
if (varMatch) return `var.${varMatch[1]}`;
|
||||
@@ -146,6 +152,8 @@ export function pathToKey(path: string): string | null {
|
||||
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
|
||||
* Used to replay a stored override-set onto a fresh base document (T3 init).
|
||||
*/
|
||||
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function keyToPath(key: string): string | null {
|
||||
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
|
||||
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
|
||||
@@ -166,6 +174,9 @@ export function keyToPath(key: string): string | null {
|
||||
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
|
||||
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
|
||||
|
||||
const varDecl = /^varDecl\.(.+)$/.exec(key);
|
||||
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
|
||||
|
||||
const variable = /^var\.(.+)$/.exec(key);
|
||||
if (variable?.[1]) return variablePath(variable[1]);
|
||||
|
||||
|
||||
@@ -51,6 +51,68 @@ export function readVariableDeclarations(document: Document): CompositionVariabl
|
||||
return parseCompositionVariables(htmlEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the raw declaration entry for a variable id, verbatim (unvalidated).
|
||||
* Returns undefined when the attribute is absent, the JSON is invalid, or no
|
||||
* entry matches the id.
|
||||
*/
|
||||
export function findVariableDeclaration(document: Document, id: string): VariableDecl | undefined {
|
||||
const decls = readDecls(document);
|
||||
if (!decls) return undefined;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
return idx < 0 ? undefined : decls.arr[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a whole variable declaration by its id. Creates the
|
||||
* `data-composition-variables` attribute when absent; replaces an unparseable
|
||||
* attribute with a fresh single-entry array (the prior content was invisible
|
||||
* to every reader anyway). Returns false only when the document has no root
|
||||
* element to carry the attribute.
|
||||
*
|
||||
* Accepts raw (unvalidated) entries as well as typed declarations: the patch
|
||||
* REPLAY path must faithfully restore whatever entry the inverse patch
|
||||
* captured — including loose hand-authored declarations the strict parser
|
||||
* would drop — or undo silently diverges from history.
|
||||
*/
|
||||
export function writeVariableDeclaration(
|
||||
document: Document,
|
||||
declaration: CompositionVariable | ({ id: string } & Record<string, unknown>),
|
||||
): boolean {
|
||||
const htmlEl = getHtmlEl(document);
|
||||
if (!htmlEl) return false;
|
||||
const decls = readDecls(document);
|
||||
const arr = decls?.arr ?? [];
|
||||
const idx = indexOfId(arr, declaration.id);
|
||||
const entry: VariableDecl = { ...declaration };
|
||||
if (idx < 0) {
|
||||
arr.push(entry);
|
||||
} else {
|
||||
arr[idx] = entry;
|
||||
}
|
||||
(decls?.htmlEl ?? htmlEl).setAttribute("data-composition-variables", JSON.stringify(arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable declaration by id. Drops the whole attribute when the
|
||||
* last declaration is removed (an empty `[]` is noise in authored HTML).
|
||||
* No-ops (returns false) when the attribute or the entry is absent.
|
||||
*/
|
||||
export function removeVariableDeclarationEntry(document: Document, id: string): boolean {
|
||||
const decls = readDecls(document);
|
||||
if (!decls) return false;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0) return false;
|
||||
decls.arr.splice(idx, 1);
|
||||
if (decls.arr.length === 0) {
|
||||
decls.htmlEl.removeAttribute("data-composition-variables");
|
||||
} else {
|
||||
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current `default` value for a variable id. Returns undefined when
|
||||
* the attribute is absent, the JSON is invalid, or no entry matches the id.
|
||||
@@ -101,60 +163,6 @@ 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;
|
||||
}
|
||||
// #2098's declareVariableDecl / removeVariableDecl removed in the #2098
|
||||
// reconciliation — the canonical writeVariableDeclaration / removeVariableDeclarationEntry
|
||||
// above are the single source; the edit ops route through those.
|
||||
|
||||
@@ -597,7 +597,9 @@ describe("variable declarations (Composition API)", () => {
|
||||
});
|
||||
|
||||
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
|
||||
// Full document (not a fragment): declareVariable refuses fragment sources,
|
||||
// whose synthetic <html> is stripped on serialize. No declarations yet.
|
||||
const comp = await openComposition(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
|
||||
comp.setVariableValue("never-declared", "x");
|
||||
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
||||
comp.declareVariable({ id: "never-declared", type: "string", label: "New", default: "x" });
|
||||
|
||||
@@ -157,7 +157,7 @@ class CompositionImpl implements Composition {
|
||||
this.dispatch({ type: "setVariableValue", id, value });
|
||||
}
|
||||
|
||||
// ── #2098 CRUD surface (kept; superseded by the richer API below in #2047+) ──
|
||||
// ── #2098 CRUD conveniences (coexist with the canonical surface below) ──
|
||||
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
|
||||
@@ -178,15 +178,25 @@ class CompositionImpl implements Composition {
|
||||
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 });
|
||||
}
|
||||
|
||||
// ── Canonical read surface (this stack) ──
|
||||
// ── Canonical declaration edit ops (this stack) ──
|
||||
// declareVariable unified onto the {declaration} op payload (#2098 used
|
||||
// {decl}); the method signature is identical, so #2098 callers are unaffected.
|
||||
declareVariable(declaration: CompositionVariable): void {
|
||||
this.dispatch({ type: "declareVariable", declaration });
|
||||
}
|
||||
|
||||
updateVariableDeclaration(id: string, declaration: CompositionVariable): void {
|
||||
this.dispatch({ type: "updateVariableDeclaration", id, declaration });
|
||||
}
|
||||
|
||||
removeVariableDeclaration(id: string): void {
|
||||
this.dispatch({ type: "removeVariableDeclaration", id });
|
||||
}
|
||||
|
||||
getVariableDeclarations(): CompositionVariable[] {
|
||||
return readVariableDeclarations(this.parsed.document);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Variable declaration edit ops: declareVariable / updateVariableDeclaration /
|
||||
* removeVariableDeclaration. Covers dispatch semantics, can() validation,
|
||||
* CSS compat sync, patch grammar, and undo round-trips.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
import { variableDeclPath, pathToKey, keyToPath } from "./engine/patches.js";
|
||||
import type { CompositionVariable } from "@hyperframes/core/variables";
|
||||
|
||||
const TITLE_DECL: CompositionVariable = {
|
||||
id: "title",
|
||||
type: "string",
|
||||
label: "Title",
|
||||
default: "Hello",
|
||||
};
|
||||
|
||||
const COUNT_DECL: CompositionVariable = {
|
||||
id: "count",
|
||||
type: "number",
|
||||
label: "Count",
|
||||
default: 3,
|
||||
min: 0,
|
||||
max: 10,
|
||||
};
|
||||
|
||||
const BARE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const DECLARED_HTML = `<!DOCTYPE html>
|
||||
<html data-composition-variables='${JSON.stringify([TITLE_DECL, COUNT_DECL])}'>
|
||||
<body>${BARE_HTML}</body>
|
||||
</html>`;
|
||||
|
||||
const UNDECLARED_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>${BARE_HTML}</body>
|
||||
</html>`;
|
||||
|
||||
describe("declareVariable", () => {
|
||||
it("creates the attribute on a composition with no declarations", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
expect(comp.serialize()).toContain("data-composition-variables");
|
||||
});
|
||||
|
||||
it("appends to existing declarations and survives serialize round-trip", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.declareVariable({ id: "dark", type: "boolean", label: "Dark", default: false });
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
|
||||
|
||||
const reopened = await openComposition(comp.serialize());
|
||||
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
|
||||
});
|
||||
|
||||
it("no-ops on duplicate ids and can() reports E_DUPLICATE_VARIABLE", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
const dup = comp.can({ type: "declareVariable", declaration: TITLE_DECL });
|
||||
expect(dup).toMatchObject({ ok: false, code: "E_DUPLICATE_VARIABLE" });
|
||||
comp.declareVariable({ ...TITLE_DECL, default: "clobbered" });
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Hello");
|
||||
});
|
||||
|
||||
it("rejects structurally invalid declarations", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
const invalid = {
|
||||
id: "broken",
|
||||
type: "number",
|
||||
label: "Broken",
|
||||
default: "not-a-number",
|
||||
} as unknown as CompositionVariable;
|
||||
expect(comp.can({ type: "declareVariable", declaration: invalid })).toMatchObject({
|
||||
ok: false,
|
||||
code: "E_INVALID_ARGS",
|
||||
});
|
||||
comp.declareVariable(invalid);
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects ids that are not valid CSS/attribute identifiers", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
for (const id of ["", " ", "has space", "1leading", "dot.id", "sla/sh", 'quo"te']) {
|
||||
expect(
|
||||
comp.can({ type: "declareVariable", declaration: { ...TITLE_DECL, id } }),
|
||||
).toMatchObject({ ok: false, code: "E_INVALID_VARIABLE_ID" });
|
||||
comp.declareVariable({ ...TITLE_DECL, id });
|
||||
}
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
// Sanity: a valid id with the allowed charset still declares.
|
||||
comp.declareVariable({ ...TITLE_DECL, id: "brand_color-2" });
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["brand_color-2"]);
|
||||
});
|
||||
|
||||
it("undo removes the declaration again", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
comp.undo();
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.serialize()).not.toContain("data-composition-variables");
|
||||
comp.redo();
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
});
|
||||
|
||||
it("refuses fragment compositions (no <html> to carry the schema)", async () => {
|
||||
const comp = await openComposition(BARE_HTML);
|
||||
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
|
||||
ok: false,
|
||||
code: "E_FRAGMENT_COMPOSITION",
|
||||
});
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
// Nothing written, nothing lost on serialize.
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.serialize()).not.toContain("data-composition-variables");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVariableDeclaration", () => {
|
||||
it("replaces the declaration wholesale", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Item count", max: 20 });
|
||||
const decl = comp.getVariableDeclarations().find((d) => d.id === "count");
|
||||
expect(decl).toMatchObject({ label: "Item count", max: 20, default: 3 });
|
||||
});
|
||||
|
||||
it("syncs the CSS compat prop when a scalar default changes", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("count", { ...COUNT_DECL, default: 7 });
|
||||
const root = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(root?.inlineStyles["--count"]).toBe("7");
|
||||
});
|
||||
|
||||
it("keeps CSS untouched when the default is unchanged", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Renamed only" });
|
||||
const root = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(root?.inlineStyles["--count"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates id immutability and existence via can()", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
expect(
|
||||
comp.can({ type: "updateVariableDeclaration", id: "count", declaration: TITLE_DECL }),
|
||||
).toMatchObject({ ok: false, code: "E_INVALID_ARGS" });
|
||||
expect(
|
||||
comp.can({
|
||||
type: "updateVariableDeclaration",
|
||||
id: "ghost",
|
||||
declaration: { ...TITLE_DECL, id: "ghost" },
|
||||
}),
|
||||
).toMatchObject({ ok: false, code: "E_VARIABLE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("undo restores the previous declaration", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("title", { ...TITLE_DECL, label: "Headline" });
|
||||
comp.undo();
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.label).toBe("Title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVariableDeclaration", () => {
|
||||
it("removes the entry and drops the attribute with the last one", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.removeVariableDeclaration("count");
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
|
||||
comp.removeVariableDeclaration("title");
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.serialize()).not.toContain("data-composition-variables");
|
||||
});
|
||||
|
||||
it("clears the CSS compat prop and undo restores declaration + CSS", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.setVariableValue("count", 5);
|
||||
const rootBefore = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(rootBefore?.inlineStyles["--count"]).toBe("5");
|
||||
|
||||
comp.removeVariableDeclaration("count");
|
||||
const rootAfter = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(rootAfter?.inlineStyles["--count"]).toBeUndefined();
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
|
||||
|
||||
comp.undo();
|
||||
const rootRestored = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(rootRestored?.inlineStyles["--count"]).toBe("5");
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "count")?.default).toBe(5);
|
||||
});
|
||||
|
||||
it("no-ops on unknown ids and can() reports E_VARIABLE_NOT_FOUND", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
expect(comp.can({ type: "removeVariableDeclaration", id: "ghost" })).toMatchObject({
|
||||
ok: false,
|
||||
code: "E_VARIABLE_NOT_FOUND",
|
||||
});
|
||||
comp.removeVariableDeclaration("ghost");
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch grammar", () => {
|
||||
it("maps /variableDeclarations/{id} ↔ varDecl.{id} without colliding with /variables/", () => {
|
||||
const path = variableDeclPath("brand-color");
|
||||
expect(path).toBe("/variableDeclarations/brand-color");
|
||||
expect(pathToKey(path)).toBe("varDecl.brand-color");
|
||||
expect(keyToPath("varDecl.brand-color")).toBe(path);
|
||||
// The value path family must stay untouched.
|
||||
expect(pathToKey("/variables/brand-color")).toBe("var.brand-color");
|
||||
expect(keyToPath("var.brand-color")).toBe("/variables/brand-color");
|
||||
});
|
||||
|
||||
it("emits declaration patches on dispatch", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
const events: string[] = [];
|
||||
comp.on("patch", (e) => {
|
||||
for (const p of e.patches) events.push(`${p.op} ${p.path}`);
|
||||
});
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
comp.removeVariableDeclaration("title");
|
||||
// Declaration ops also maintain the --{id} CSS compat prop (scalar defaults).
|
||||
expect(events).toEqual([
|
||||
"add /variableDeclarations/title",
|
||||
"add /elements/hf-stage/inlineStyles/--title",
|
||||
"remove /variableDeclarations/title",
|
||||
"remove /elements/hf-stage/inlineStyles/--title",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+31
-11
@@ -112,12 +112,15 @@ export type EditOp =
|
||||
}
|
||||
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
|
||||
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
|
||||
| { type: "declareVariable"; declaration: CompositionVariable }
|
||||
| { type: "updateVariableDeclaration"; id: string; declaration: CompositionVariable }
|
||||
| { type: "removeVariableDeclaration"; id: string }
|
||||
| {
|
||||
type: "setVariableValue";
|
||||
id: string;
|
||||
value: string | number | boolean | FontValue | ImageValue;
|
||||
}
|
||||
| { type: "declareVariable"; decl: CompositionVariable }
|
||||
// #2098 alias op — remove-by-id, kept for its shipped session.removeVariable().
|
||||
| { type: "removeVariable"; id: string }
|
||||
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
|
||||
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
@@ -409,19 +412,36 @@ 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.
|
||||
* Current `default` value for a declared variable, or undefined if
|
||||
* undeclared/unset. Convenience over getVariableValues() (kept from #2098).
|
||||
*/
|
||||
declareVariable(decl: CompositionVariable): void;
|
||||
/** Remove a variable's declaration. Live `var.{id}` overrides are untouched. */
|
||||
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined;
|
||||
/**
|
||||
* Every declared variable's full schema (id/type/label/default/…), or [] when
|
||||
* none. Alias of getVariableDeclarations() (kept from #2098's surface).
|
||||
*/
|
||||
listVariables(): CompositionVariable[];
|
||||
/** Remove a variable's declaration — alias of removeVariableDeclaration(). */
|
||||
removeVariable(id: string): void;
|
||||
/**
|
||||
* Declare a new variable in `data-composition-variables`. No-ops when the
|
||||
* id is already declared (see can() for the E_DUPLICATE_VARIABLE check);
|
||||
* creates the attribute when the composition has none yet.
|
||||
*/
|
||||
declareVariable(declaration: CompositionVariable): void;
|
||||
/**
|
||||
* Replace an existing declaration wholesale (label, type, constraints,
|
||||
* default — the id itself is immutable; rename = remove + declare). When
|
||||
* the default changes to/from a scalar, the `--{id}` CSS compat custom
|
||||
* property on the root is kept in sync, mirroring setVariableValue.
|
||||
*/
|
||||
updateVariableDeclaration(id: string, declaration: CompositionVariable): void;
|
||||
/**
|
||||
* Remove a declaration (and the last one removes the whole attribute).
|
||||
* Also clears the `--{id}` CSS compat custom property if present.
|
||||
*/
|
||||
removeVariableDeclaration(id: string): void;
|
||||
/**
|
||||
* Read the typed variable declarations from `data-composition-variables`
|
||||
* (the canonical schema — same filter the render pipeline uses; malformed
|
||||
|
||||
Reference in New Issue
Block a user