mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties
This commit is contained in:
@@ -13,6 +13,7 @@ import type { ParsedDocument } from "./model.js";
|
||||
import {
|
||||
findById,
|
||||
findRoot,
|
||||
declarationElement,
|
||||
setElementStyles,
|
||||
setOwnText,
|
||||
setGsapScript,
|
||||
@@ -105,11 +106,11 @@ function parsePath(path: string): ParsedPath | null {
|
||||
* the matching declaration's `default`. No-ops when the attr/decl is absent.
|
||||
* Shares the model logic with mutate.ts via ./variableModel.ts.
|
||||
*/
|
||||
function applyVariableDefault(document: Document, id: string, newDefault: unknown): void {
|
||||
function applyVariableDefault(declEl: Element | null, id: string, newDefault: unknown): void {
|
||||
if (newDefault === null) {
|
||||
clearVariableDefault(document, id);
|
||||
clearVariableDefault(declEl, id);
|
||||
} else {
|
||||
writeVariableDefault(document, id, newDefault);
|
||||
writeVariableDefault(declEl, id, newDefault);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,13 +264,13 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
|
||||
case "variableDeclaration": {
|
||||
if (!p.id) return;
|
||||
if (patch.op === "remove") {
|
||||
removeVariableDeclarationEntry(parsed.document, p.id);
|
||||
removeVariableDeclarationEntry(declarationElement(parsed.document, parsed.wrapped), 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);
|
||||
writeVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), patch.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -280,7 +281,11 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
|
||||
// getVariables() returns the correct value in both preview and render.
|
||||
// CSS compat is handled by explicit style-path patches emitted by mutate.ts,
|
||||
// so we do NOT write CSS here — the style case above handles those patches.
|
||||
applyVariableDefault(parsed.document, p.id, patch.op === "remove" ? null : patch.value);
|
||||
applyVariableDefault(
|
||||
declarationElement(parsed.document, parsed.wrapped),
|
||||
p.id,
|
||||
patch.op === "remove" ? null : patch.value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/core/hf-ids";
|
||||
|
||||
export interface ParsedDocument {
|
||||
document: Document;
|
||||
@@ -62,7 +62,7 @@ export function querySelectorAllDeep(root: Document | Element, selector: string)
|
||||
const walk = (parent: Element): void => {
|
||||
for (const child of Array.from(parent.children)) {
|
||||
if (child.tagName.toLowerCase() === "template") {
|
||||
if (child.getAttribute("data-composition-id") !== null) walk(child);
|
||||
if (isCompositionTemplate(child)) walk(child);
|
||||
continue;
|
||||
}
|
||||
if (child.matches(selector)) out.push(child);
|
||||
@@ -164,10 +164,25 @@ export function isNewHostBoundary(el: Element): boolean {
|
||||
return dcf !== parentDcf;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that carries composition-level declarations
|
||||
* (`data-composition-variables`). Full-document comps use `<html>`; a wrapped
|
||||
* template/fragment comp has a synthetic `<html>` that serialize() strips, so
|
||||
* its declarations must live on the composition root div (where values/metadata
|
||||
* already live) to survive save.
|
||||
*/
|
||||
export function declarationElement(document: Document, wrapped: boolean): Element | null {
|
||||
if (wrapped) return findRoot(document);
|
||||
return (document as Document & { documentElement?: Element }).documentElement ?? null;
|
||||
}
|
||||
|
||||
export function findRoot(document: Document): Element | null {
|
||||
return (
|
||||
document.querySelector("[data-hf-root]") ??
|
||||
document.getElementById("stage") ??
|
||||
// Descend into a composition <template> so a wrapped template sub-comp
|
||||
// resolves to its inner [data-composition-id] root, not the <template> shell.
|
||||
querySelectorAllDeep(document, "[data-composition-id]")[0] ??
|
||||
document.body?.firstElementChild ??
|
||||
null
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveScoped,
|
||||
escapeHfId,
|
||||
findRoot,
|
||||
declarationElement,
|
||||
getElementStyles,
|
||||
setElementStyles,
|
||||
toCamel,
|
||||
@@ -869,9 +870,10 @@ function handleSetVariableValue(
|
||||
): MutationResult {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root) return EMPTY;
|
||||
const declEl = declarationElement(parsed.document, parsed.wrapped);
|
||||
|
||||
const modelPath = variablePath(id);
|
||||
const oldVarDefault = readVariableDefault(parsed.document, id);
|
||||
const oldVarDefault = readVariableDefault(declEl, id);
|
||||
|
||||
// Update the JSON model (B1 — drives the runtime) and keep the CSS custom
|
||||
// prop as secondary / compat for compositions that CSS-bind directly to
|
||||
@@ -879,7 +881,7 @@ function handleSetVariableValue(
|
||||
// values (LOCKED §7) — cssCompatChange clears any stale scalar prop instead.
|
||||
// Emitting separate model + style patches keeps apply-patches.ts pure per
|
||||
// path type, so inverse patches restore the exact pre-call state.
|
||||
writeVariableDefault(parsed.document, id, value);
|
||||
writeVariableDefault(declEl, id, value);
|
||||
const modelP = valueChange(modelPath, oldVarDefault ?? null, value);
|
||||
const forward: JsonPatchOp[] = [modelP.forward];
|
||||
const inverse: JsonPatchOp[] = [modelP.inverse];
|
||||
@@ -920,16 +922,18 @@ function cssCompatChange(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Declaration ops need an element that survives serialize() to carry
|
||||
* `data-composition-variables`. Full-document comps use `<html>`; wrapped
|
||||
* template/fragment comps use their composition root div (the synthetic
|
||||
* `<html>` is stripped on save). Only a wrapped input with no root element at
|
||||
* all (an empty body) has nowhere durable to write.
|
||||
*/
|
||||
function fragmentCompositionErr(parsed: ParsedDocument): CanResult | null {
|
||||
if (!parsed.wrapped) return null;
|
||||
if (declarationElement(parsed.document, 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.",
|
||||
"The composition has no root element to hold data-composition-variables — add a composition root or convert to a full HTML document.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -982,13 +986,15 @@ function handleDeclareVariable(
|
||||
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;
|
||||
// declaration into the schema. Resolve the element that survives serialize
|
||||
// (root div for wrapped template comps, <html> otherwise); no element = a
|
||||
// bare fragment where a declaration would be lost on save.
|
||||
const declEl = declarationElement(parsed.document, parsed.wrapped);
|
||||
if (!declEl) 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;
|
||||
if (findVariableDeclaration(declEl, declaration.id) !== undefined) return EMPTY;
|
||||
if (!writeVariableDeclaration(declEl, declaration)) return EMPTY;
|
||||
const path = variableDeclPath(declaration.id);
|
||||
const result: MutationResult = {
|
||||
forward: [patchAdd(path, declaration)],
|
||||
@@ -1011,11 +1017,12 @@ function handleUpdateVariableDeclaration(
|
||||
id: string,
|
||||
declaration: CompositionVariable,
|
||||
): MutationResult {
|
||||
if (parsed.wrapped) return EMPTY;
|
||||
const declEl = declarationElement(parsed.document, parsed.wrapped);
|
||||
if (!declEl) return EMPTY;
|
||||
if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY;
|
||||
const old = findVariableDeclaration(parsed.document, id);
|
||||
const old = findVariableDeclaration(declEl, id);
|
||||
if (old === undefined) return EMPTY;
|
||||
writeVariableDeclaration(parsed.document, declaration);
|
||||
writeVariableDeclaration(declEl, declaration);
|
||||
const p = valueChange(variableDeclPath(id), old, declaration);
|
||||
const result: MutationResult = { forward: [p.forward], inverse: [p.inverse] };
|
||||
|
||||
@@ -1039,10 +1046,11 @@ function handleUpdateVariableDeclaration(
|
||||
}
|
||||
|
||||
function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult {
|
||||
if (parsed.wrapped) return EMPTY;
|
||||
const old = findVariableDeclaration(parsed.document, id);
|
||||
const declEl = declarationElement(parsed.document, parsed.wrapped);
|
||||
if (!declEl) return EMPTY;
|
||||
const old = findVariableDeclaration(declEl, id);
|
||||
if (old === undefined) return EMPTY;
|
||||
removeVariableDeclarationEntry(parsed.document, id);
|
||||
removeVariableDeclarationEntry(declEl, id);
|
||||
const path = variableDeclPath(id);
|
||||
const result: MutationResult = {
|
||||
forward: [patchRemove(path)],
|
||||
@@ -1671,7 +1679,12 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "declareVariable": {
|
||||
const preErr = declarationPreconditionErr(parsed, op.declaration);
|
||||
if (preErr) return preErr;
|
||||
if (findVariableDeclaration(parsed.document, op.declaration.id) !== undefined)
|
||||
if (
|
||||
findVariableDeclaration(
|
||||
declarationElement(parsed.document, parsed.wrapped),
|
||||
op.declaration.id,
|
||||
) !== undefined
|
||||
)
|
||||
return canErr(
|
||||
"E_DUPLICATE_VARIABLE",
|
||||
`Variable "${op.declaration.id}" is already declared.`,
|
||||
@@ -1688,7 +1701,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
`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)
|
||||
if (
|
||||
findVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), op.id) ===
|
||||
undefined
|
||||
)
|
||||
return canErr(
|
||||
"E_VARIABLE_NOT_FOUND",
|
||||
`Variable "${op.id}" is not declared.`,
|
||||
@@ -1699,7 +1715,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "removeVariableDeclaration": {
|
||||
const fragmentErr = fragmentCompositionErr(parsed);
|
||||
if (fragmentErr) return fragmentErr;
|
||||
if (findVariableDeclaration(parsed.document, op.id) === undefined)
|
||||
if (
|
||||
findVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), op.id) ===
|
||||
undefined
|
||||
)
|
||||
return canErr(
|
||||
"E_VARIABLE_NOT_FOUND",
|
||||
`Variable "${op.id}" is not declared.`,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Shared helpers for the composition variable JSON model
|
||||
* (`data-composition-variables` on `document.documentElement`).
|
||||
* (`data-composition-variables`). The declaration-carrying element is resolved
|
||||
* by the caller (`declarationElement` in model.ts): `<html>` for full-document
|
||||
* comps, the composition root div for wrapped template/fragment comps.
|
||||
*
|
||||
* Single source for the parse → find-by-id → read/write/clear logic so the
|
||||
* forward-mutation path (engine/mutate.ts) and the patch-replay path
|
||||
@@ -15,15 +17,10 @@ import type { CompositionVariable } from "@hyperframes/core/variables";
|
||||
// Exported so the SDK index can re-export it (kept from #2098's surface).
|
||||
export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
|
||||
|
||||
function getHtmlEl(document: Document): Element | null {
|
||||
return (document as Document & { documentElement?: Element }).documentElement ?? null;
|
||||
}
|
||||
|
||||
/** Parse the variable declaration array, or null when absent/invalid. */
|
||||
function readDecls(document: Document): { htmlEl: Element; arr: VariableDecl[] } | null {
|
||||
const htmlEl = getHtmlEl(document);
|
||||
if (!htmlEl) return null;
|
||||
const raw = htmlEl.getAttribute("data-composition-variables");
|
||||
function readDecls(declEl: Element | null): { declEl: Element; arr: VariableDecl[] } | null {
|
||||
if (!declEl) return null;
|
||||
const raw = declEl.getAttribute("data-composition-variables");
|
||||
if (!raw) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -32,7 +29,7 @@ function readDecls(document: Document): { htmlEl: Element; arr: VariableDecl[] }
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return { htmlEl, arr: parsed as VariableDecl[] };
|
||||
return { declEl, arr: parsed as VariableDecl[] };
|
||||
}
|
||||
|
||||
function indexOfId(arr: VariableDecl[], id: string): number {
|
||||
@@ -43,12 +40,11 @@ function indexOfId(arr: VariableDecl[], id: string): number {
|
||||
* Read the typed variable declarations from `data-composition-variables`.
|
||||
* Delegates to the canonical parser (same filter the render pipeline uses),
|
||||
* so malformed entries are dropped rather than surfaced. Returns `[]` when
|
||||
* the document has no root element or no declarations.
|
||||
* the declaration element is absent or has no declarations.
|
||||
*/
|
||||
export function readVariableDeclarations(document: Document): CompositionVariable[] {
|
||||
const htmlEl = getHtmlEl(document);
|
||||
if (!htmlEl) return [];
|
||||
return parseCompositionVariables(htmlEl);
|
||||
export function readVariableDeclarations(declEl: Element | null): CompositionVariable[] {
|
||||
if (!declEl) return [];
|
||||
return parseCompositionVariables(declEl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,8 +52,11 @@ export function readVariableDeclarations(document: Document): CompositionVariabl
|
||||
* 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);
|
||||
export function findVariableDeclaration(
|
||||
declEl: Element | null,
|
||||
id: string,
|
||||
): VariableDecl | undefined {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return undefined;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
return idx < 0 ? undefined : decls.arr[idx];
|
||||
@@ -67,7 +66,7 @@ export function findVariableDeclaration(document: Document, id: string): Variabl
|
||||
* 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
|
||||
* to every reader anyway). Returns false only when there is no declaration
|
||||
* element to carry the attribute.
|
||||
*
|
||||
* Accepts raw (unvalidated) entries as well as typed declarations: the patch
|
||||
@@ -76,12 +75,11 @@ export function findVariableDeclaration(document: Document, id: string): Variabl
|
||||
* would drop — or undo silently diverges from history.
|
||||
*/
|
||||
export function writeVariableDeclaration(
|
||||
document: Document,
|
||||
declEl: Element | null,
|
||||
declaration: CompositionVariable | ({ id: string } & Record<string, unknown>),
|
||||
): boolean {
|
||||
const htmlEl = getHtmlEl(document);
|
||||
if (!htmlEl) return false;
|
||||
const decls = readDecls(document);
|
||||
if (!declEl) return false;
|
||||
const decls = readDecls(declEl);
|
||||
const arr = decls?.arr ?? [];
|
||||
const idx = indexOfId(arr, declaration.id);
|
||||
const entry: VariableDecl = { ...declaration };
|
||||
@@ -90,7 +88,7 @@ export function writeVariableDeclaration(
|
||||
} else {
|
||||
arr[idx] = entry;
|
||||
}
|
||||
(decls?.htmlEl ?? htmlEl).setAttribute("data-composition-variables", JSON.stringify(arr));
|
||||
declEl.setAttribute("data-composition-variables", JSON.stringify(arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -99,16 +97,16 @@ export function writeVariableDeclaration(
|
||||
* 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);
|
||||
export function removeVariableDeclarationEntry(declEl: Element | null, id: string): boolean {
|
||||
const decls = readDecls(declEl);
|
||||
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");
|
||||
decls.declEl.removeAttribute("data-composition-variables");
|
||||
} else {
|
||||
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -117,8 +115,8 @@ export function removeVariableDeclarationEntry(document: Document, id: string):
|
||||
* 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.
|
||||
*/
|
||||
export function readVariableDefault(document: Document, id: string): unknown {
|
||||
const decls = readDecls(document);
|
||||
export function readVariableDefault(declEl: Element | null, id: string): unknown {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return undefined;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
return idx < 0 ? undefined : decls.arr[idx]?.default;
|
||||
@@ -130,13 +128,17 @@ export function readVariableDefault(document: Document, id: string): unknown {
|
||||
* for undeclared variables, keeping the schema authoritative. Returns true when
|
||||
* the attribute was updated.
|
||||
*/
|
||||
export function writeVariableDefault(document: Document, id: string, newDefault: unknown): boolean {
|
||||
const decls = readDecls(document);
|
||||
export function writeVariableDefault(
|
||||
declEl: Element | null,
|
||||
id: string,
|
||||
newDefault: unknown,
|
||||
): boolean {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return false;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0) return false; // variable not declared — don't auto-add
|
||||
decls.arr[idx] = { ...decls.arr[idx]!, default: newDefault };
|
||||
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -147,22 +149,20 @@ export function writeVariableDefault(document: Document, id: string, newDefault:
|
||||
* default-less variable round-trips. No-ops when the decl or key is absent.
|
||||
* Returns true when the attribute was updated.
|
||||
*/
|
||||
export function clearVariableDefault(document: Document, id: string): boolean {
|
||||
const decls = readDecls(document);
|
||||
export function clearVariableDefault(declEl: Element | null, id: string): boolean {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return false;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0 || !(decls.arr[idx]! && "default" in decls.arr[idx]!)) return false;
|
||||
const { default: _drop, ...rest } = decls.arr[idx]!;
|
||||
decls.arr[idx] = rest as VariableDecl;
|
||||
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
decls.declEl.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 ?? [];
|
||||
}
|
||||
|
||||
// #2098's declareVariableDecl / removeVariableDecl removed in the #2098
|
||||
// reconciliation — the canonical writeVariableDeclaration / removeVariableDeclarationEntry
|
||||
// above are the single source; the edit ops route through those.
|
||||
// NOTE: #2098's Document-based helpers (listVariableDecls / declareVariableDecl /
|
||||
// removeVariableDecl) were removed in the #2098-reconciliation — they duplicated
|
||||
// the canonical declEl-based readVariableDeclarations / writeVariableDeclaration /
|
||||
// removeVariableDeclarationEntry below and predate template/fragment declaration
|
||||
// scope. The session conveniences (listVariables / removeVariable) delegate to
|
||||
// the canonical methods instead.
|
||||
|
||||
Reference in New Issue
Block a user