mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties
This commit is contained in:
@@ -126,6 +126,37 @@ function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
|
||||
return declared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union declared variable ids from every element carrying
|
||||
* `data-composition-variables`: full-document comps hold it on `<html>`;
|
||||
* template/fragment sub-comps hold it on their composition root div. Returns
|
||||
* null if any occurrence has unparseable JSON.
|
||||
*/
|
||||
function collectAllDeclaredVariableIds(source: string): Set<string> | null {
|
||||
const all = new Set<string>();
|
||||
const tagRe = /<[a-zA-Z][^>]*\bdata-composition-variables\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(source)) !== null) {
|
||||
const ids = collectDeclaredVariableIds(match[0]);
|
||||
if (ids === null) return null;
|
||||
for (const id of ids) all.add(id);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared ids to validate `data-var-*` bindings against, or null to skip the
|
||||
* file: unparseable declarations (reported elsewhere), or a fragment with no
|
||||
* `<html>` and no declarations of its own (its values come from a host's
|
||||
* data-variable-values, which this file can't see).
|
||||
*/
|
||||
function declaredIdsForBindingCheck(source: string): Set<string> | null {
|
||||
const declared = collectAllDeclaredVariableIds(source);
|
||||
if (declared === null) return null;
|
||||
if (declared.size === 0 && !findHtmlTag(source)) return null;
|
||||
return declared;
|
||||
}
|
||||
|
||||
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
|
||||
// href, inline-style url(), and <style> url() asset references on
|
||||
@@ -623,12 +654,11 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// does nothing. Skipped for fragment files (no <html>): their values come
|
||||
// from a host's data-variable-values, which this file can't see.
|
||||
({ source, tags }) => {
|
||||
const htmlTag = findHtmlTag(source);
|
||||
if (!htmlTag) return [];
|
||||
const declared = collectDeclaredVariableIds(htmlTag.raw);
|
||||
// null = unparseable declarations; invalid_composition_variables_declaration
|
||||
// reports that failure, so this rule stays quiet.
|
||||
if (declared === null) return [];
|
||||
// Declarations live on <html> (full-document comps) OR the composition root
|
||||
// div (template/fragment sub-comps); declaredIdsForBindingCheck unions both
|
||||
// and returns null for files this rule should skip.
|
||||
const declared = declaredIdsForBindingCheck(source);
|
||||
if (!declared) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
for (const attr of ["data-var-src", "data-var-text"]) {
|
||||
@@ -638,7 +668,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
code: "unknown_variable_binding",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}> binds ${attr}="${id}" but no variable "${id}" is declared in data-composition-variables — the binding will silently keep the authored fallback.`,
|
||||
fixHint: `Declare the variable on <html>: data-composition-variables='[{"id":"${id}","type":"${attr === "data-var-src" ? "image" : "string"}","label":"${id}","default":"..."}]', or fix the binding id.`,
|
||||
fixHint: `Declare the variable on the composition root (<html>, or the [data-composition-id] root element for a template/fragment comp): data-composition-variables='[{"id":"${id}","type":"${attr === "data-var-src" ? "image" : "string"}","label":"${id}","default":"..."}]', or fix the binding id.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
|
||||
@@ -103,15 +103,24 @@ export function mintHfId(el: Element, assigned: Set<string>): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a `<template data-composition-id>` — the sub-composition authoring
|
||||
* pattern whose content the studio preview unwraps into the served body. Only
|
||||
* these templates are treated as transparent containers for hf-id purposes.
|
||||
* A plain `<template>` (runtime clone-source: list item, particle, etc.) must
|
||||
* NOT get inner ids: its content is cloned N times into the live DOM, so a
|
||||
* persisted inner id would be duplicated across every clone.
|
||||
* True for a sub-composition authoring template whose content the studio preview
|
||||
* unwraps into the served body. Two accepted forms:
|
||||
* A) `<template data-composition-id="X">…` — the id on the template itself.
|
||||
* B) `<template id="X-template"><div data-composition-id="X">…` — the id on the
|
||||
* wrapped root div (the form `hyperframes add` scaffolds and registry blocks use).
|
||||
* Only these are treated as transparent containers for hf-id purposes. A plain
|
||||
* `<template>` (runtime clone-source: list item, particle, etc.) must NOT get
|
||||
* inner ids — its content is cloned N times into the live DOM, so a persisted
|
||||
* inner id would be duplicated across every clone. Form B is distinguished from
|
||||
* a clone-source by the presence of a direct `[data-composition-id]` child.
|
||||
*/
|
||||
export function isCompositionTemplate(el: Element): boolean {
|
||||
return el.tagName.toLowerCase() === "template" && el.getAttribute("data-composition-id") !== null;
|
||||
if (el.tagName.toLowerCase() !== "template") return false;
|
||||
if (el.getAttribute("data-composition-id") !== null) return true;
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (child.getAttribute("data-composition-id") !== null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -79,3 +79,48 @@ describe("template-based sub-comp compositions", () => {
|
||||
expect(comp.getElement("hf-dup")?.text).toBe("tpl");
|
||||
});
|
||||
});
|
||||
|
||||
// The authored sub-comp form `hyperframes add` scaffolds: the composition id is
|
||||
// on the wrapped root div, and the <template> is keyed by `id="X-template"`.
|
||||
const AUTHORED_TEMPLATE_HTML = `
|
||||
<template id="card-template">
|
||||
<div data-composition-id="card" data-width="1280" data-height="720" data-duration="5">
|
||||
<h1 class="title" style="color: rgb(255, 0, 0)">Headline</h1>
|
||||
</div>
|
||||
</template>
|
||||
`.trim();
|
||||
|
||||
describe("authored template sub-comps (id on the wrapped root div)", () => {
|
||||
it("enumerates and resolves inner elements", async () => {
|
||||
const comp = await openComposition(AUTHORED_TEMPLATE_HTML);
|
||||
const title = comp.getElements().find((e) => e.classNames.includes("title"));
|
||||
expect(title).toBeTruthy();
|
||||
expect(comp.getElement(title!.id)?.text).toBe("Headline");
|
||||
});
|
||||
|
||||
it("declares a variable on the root div and round-trips through serialize", async () => {
|
||||
const comp = await openComposition(AUTHORED_TEMPLATE_HTML);
|
||||
comp.declareVariable({
|
||||
id: "title-color",
|
||||
type: "color",
|
||||
label: "Title color",
|
||||
default: "#ff0000",
|
||||
});
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title-color"]);
|
||||
const serialized = comp.serialize();
|
||||
expect(serialized).toContain("data-composition-variables");
|
||||
// Survives a re-open (declaration is on the root div, not a stripped <html>).
|
||||
const reopened = await openComposition(serialized);
|
||||
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title-color"]);
|
||||
});
|
||||
|
||||
it("does not treat a plain clone-source template as a composition", async () => {
|
||||
const comp = await openComposition(
|
||||
`<div data-composition-id="c" data-width="100" data-height="100" data-duration="1" data-start="0">` +
|
||||
`<template id="particle"><span class="dot">·</span></template></div>`,
|
||||
);
|
||||
// The particle template's inner <span> must NOT be enumerated (it is cloned
|
||||
// N times at runtime; a persisted inner id would duplicate across clones).
|
||||
expect(comp.getElements().some((e) => e.classNames.includes("dot"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-15
@@ -35,8 +35,7 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
||||
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 { getGsapScript, resolveScoped, declarationElement } from "./engine/model.js";
|
||||
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
|
||||
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
|
||||
@@ -167,12 +166,11 @@ class CompositionImpl implements Composition {
|
||||
this.dispatch({ type: "setVariableValue", id, value });
|
||||
}
|
||||
|
||||
// ── #2098 CRUD conveniences (coexist with the canonical surface below) ──
|
||||
// ── #2098 CRUD conveniences — thin aliases over the canonical surface below.
|
||||
// They delegate so the per-file declaration-element scope (template/fragment
|
||||
// sub-comps included) is resolved in exactly one place.
|
||||
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
|
||||
return this.getVariableValues()[id] as
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
@@ -182,10 +180,7 @@ class CompositionImpl implements Composition {
|
||||
}
|
||||
|
||||
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[];
|
||||
return this.getVariableDeclarations();
|
||||
}
|
||||
|
||||
removeVariable(id: string): void {
|
||||
@@ -208,7 +203,7 @@ class CompositionImpl implements Composition {
|
||||
}
|
||||
|
||||
getVariableDeclarations(): CompositionVariable[] {
|
||||
return readVariableDeclarations(this.parsed.document);
|
||||
return readVariableDeclarations(declarationElement(this.parsed.document, this.parsed.wrapped));
|
||||
}
|
||||
|
||||
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -220,9 +215,9 @@ class CompositionImpl implements Composition {
|
||||
// (core/runtime/getVariables.ts) additionally walks inlined sub-composition
|
||||
// declarers because it operates on the bundled multi-composition document;
|
||||
// the SDK models one composition file, so per-file scope is intended.
|
||||
const documentEl =
|
||||
(this.parsed.document as Document & { documentElement?: Element }).documentElement ?? null;
|
||||
const defaults = readDeclaredDefaults(documentEl);
|
||||
const defaults = readDeclaredDefaults(
|
||||
declarationElement(this.parsed.document, this.parsed.wrapped),
|
||||
);
|
||||
return { ...defaults, ...(overrides ?? {}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -107,14 +107,27 @@ describe("declareVariable", () => {
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
});
|
||||
|
||||
it("refuses fragment compositions (no <html> to carry the schema)", async () => {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("supports fragment compositions with a root element (schema on the root div)", async () => {
|
||||
// A fragment (no <html>) still has a composition root; declarations live on
|
||||
// that root div, which survives serialize — so template/sub-comp files are
|
||||
// first-class editable, not refused.
|
||||
const comp = await openComposition(BARE_HTML);
|
||||
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
expect(comp.serialize()).toContain("data-composition-variables");
|
||||
});
|
||||
|
||||
it("refuses a fragment with no root element (nowhere durable to write)", async () => {
|
||||
const comp = await openComposition("just text, no element");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -329,6 +329,8 @@ describe("engine helper exports (resolveScoped, findById, escapeHfId, readVariab
|
||||
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();
|
||||
// readVariableDefault takes the declaration element (the <html>/root), not the Document.
|
||||
const declEl = (document as unknown as Document).documentElement;
|
||||
expect(readVariableDefault(declEl, "never-declared")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,44 @@
|
||||
/**
|
||||
* Wires the Design panel's promote-to-variable context: instantiates the
|
||||
* single-writer variables persist path and feeds it to VariablePromoteProvider,
|
||||
* so schema edits from Design-panel controls (declare + bind, or edit a bound
|
||||
* variable's default) flow through the same path the Variables tab uses.
|
||||
* Wires the Design panel's promote-to-variable context. Promote/bind operates
|
||||
* on the file the selected element actually lives in — a sub-composition file
|
||||
* when you select an element inside an inlined sub-comp, not the host. So we
|
||||
* open (and persist to) an SDK session keyed on `selection.sourceFile`, not the
|
||||
* host `activeCompPath`. Declaring a variable therefore lands in the sub-comp's
|
||||
* own file, making it a knob on that reusable frame everywhere it is used. When
|
||||
* nothing is selected (or the element is top-level) the target is the active
|
||||
* composition, so behavior there is unchanged.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
import { useSdkSession } from "../hooks/useSdkSession";
|
||||
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
|
||||
import { VariablePromoteProvider } from "../contexts/VariablePromoteContext";
|
||||
|
||||
/** Persist wiring minus the target — this provider derives the target from the selection. */
|
||||
type PersistDeps = Omit<UseVariablesPersistParams, "sdkSession" | "activeCompPath">;
|
||||
|
||||
export function DesignPanelPromoteProvider({
|
||||
selection,
|
||||
projectId,
|
||||
activeCompPath,
|
||||
children,
|
||||
...persistParams
|
||||
}: UseVariablesPersistParams & {
|
||||
...persistDeps
|
||||
}: PersistDeps & {
|
||||
selection: DomEditSelection | null;
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const persist = useVariablesPersist(persistParams);
|
||||
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
|
||||
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
|
||||
const persist = useVariablesPersist({
|
||||
...persistDeps,
|
||||
sdkSession: handle.session,
|
||||
activeCompPath: targetPath,
|
||||
});
|
||||
return (
|
||||
<VariablePromoteProvider
|
||||
session={persistParams.sdkSession}
|
||||
selection={selection}
|
||||
persist={persist}
|
||||
>
|
||||
<VariablePromoteProvider session={handle.session} selection={selection} persist={persist}>
|
||||
{children}
|
||||
</VariablePromoteProvider>
|
||||
);
|
||||
|
||||
@@ -345,7 +345,7 @@ export function StudioRightPanel({
|
||||
const propertyPanel = (
|
||||
<DesignPanelPromoteProvider
|
||||
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
sdkSession={sdkSession}
|
||||
projectId={projectId}
|
||||
activeCompPath={activeCompPath}
|
||||
readProjectFile={readProjectFile}
|
||||
writeProjectFile={writeProjectFile}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Variables tab section for compositions OTHER than the active one. A variable
|
||||
* promoted into a sub-comp lives in that frame's file, not the active session —
|
||||
* this surfaces every such file's declarations grouped by path, with per-file
|
||||
* management (edit declaration / remove). Live-preview override for these is a
|
||||
* follow-up (values are per-composition-scope), so no preview control is shown.
|
||||
*/
|
||||
|
||||
import { useCallback, useState, type MutableRefObject } from "react";
|
||||
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
|
||||
import {
|
||||
useEditVariablesInFile,
|
||||
useProjectCompositionVariables,
|
||||
type CompositionVariableGroup,
|
||||
type RecordEditFn,
|
||||
} from "../../hooks/useProjectCompositionVariables";
|
||||
import {
|
||||
DeclarationForm,
|
||||
draftFromDeclaration,
|
||||
mergeDeclarationEdit,
|
||||
} from "./VariablesDeclarationForm";
|
||||
import { RowAction } from "./VariablesRowAction";
|
||||
|
||||
function CompositionSection({
|
||||
group,
|
||||
editingKey,
|
||||
onToggleEdit,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
group: CompositionVariableGroup;
|
||||
editingKey: string | null;
|
||||
onToggleEdit: (key: string | null) => void;
|
||||
onSave: (path: string, decl: CompositionVariable) => void;
|
||||
onRemove: (path: string, id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p
|
||||
className="truncate text-[9px] font-medium uppercase tracking-wider text-neutral-500"
|
||||
title={group.path}
|
||||
>
|
||||
{group.path}
|
||||
</p>
|
||||
{group.variables.map((decl) => {
|
||||
const key = `${group.path}::${decl.id}`;
|
||||
const editing = editingKey === key;
|
||||
return (
|
||||
<div key={key} className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[10px] font-medium text-neutral-300">
|
||||
{decl.label}
|
||||
</span>
|
||||
<span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
|
||||
{decl.type}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<RowAction
|
||||
label="Edit"
|
||||
title="Edit declaration"
|
||||
onClick={() => onToggleEdit(editing ? null : key)}
|
||||
/>
|
||||
<RowAction
|
||||
label="✕"
|
||||
title="Remove declaration"
|
||||
danger
|
||||
onClick={() => onRemove(group.path, decl.id)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
|
||||
{editing && (
|
||||
<DeclarationForm
|
||||
initial={draftFromDeclaration(decl)}
|
||||
submitLabel="Save"
|
||||
onSubmit={(edited) => onSave(group.path, mergeDeclarationEdit(decl, edited))}
|
||||
onCancel={() => onToggleEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VariablesOtherCompositions({
|
||||
fileTree,
|
||||
excludePath,
|
||||
refreshKey,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
}: {
|
||||
fileTree: string[];
|
||||
excludePath: string;
|
||||
refreshKey: unknown;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: RecordEditFn;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
}) {
|
||||
const [selfRefresh, setSelfRefresh] = useState(0);
|
||||
const groups = useProjectCompositionVariables(
|
||||
fileTree,
|
||||
excludePath,
|
||||
readProjectFile,
|
||||
`${refreshKey}:${selfRefresh}`,
|
||||
);
|
||||
const editInFile = useEditVariablesInFile({
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
});
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
|
||||
const onSave = useCallback(
|
||||
(path: string, decl: CompositionVariable) => {
|
||||
setEditingKey(null);
|
||||
void editInFile(path, `Update variable "${decl.id}"`, (s: Composition) =>
|
||||
s.updateVariableDeclaration(decl.id, decl),
|
||||
).then(() => setSelfRefresh((r) => r + 1));
|
||||
},
|
||||
[editInFile],
|
||||
);
|
||||
const onRemove = useCallback(
|
||||
(path: string, id: string) => {
|
||||
void editInFile(path, `Remove variable "${id}"`, (s: Composition) =>
|
||||
s.removeVariableDeclaration(id),
|
||||
).then(() => setSelfRefresh((r) => r + 1));
|
||||
},
|
||||
[editInFile],
|
||||
);
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t border-neutral-800 pt-3">
|
||||
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-600">
|
||||
Other compositions
|
||||
</p>
|
||||
{groups.map((group) => (
|
||||
<CompositionSection
|
||||
key={group.path}
|
||||
group={group}
|
||||
editingKey={editingKey}
|
||||
onToggleEdit={setEditingKey}
|
||||
onSave={onSave}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { useDomEditContext } from "../../contexts/DomEditContext";
|
||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||
import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
|
||||
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
|
||||
import { VariablesOtherCompositions } from "./VariablesOtherCompositions";
|
||||
import { RowAction } from "./VariablesRowAction";
|
||||
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
|
||||
import {
|
||||
DeclarationForm,
|
||||
@@ -63,31 +65,6 @@ function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
title,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
|
||||
danger ? "hover:text-red-400" : "hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function VariableRow({
|
||||
decl,
|
||||
@@ -561,6 +538,16 @@ export const VariablesPanel = memo(function VariablesPanel({
|
||||
+ Add variable
|
||||
</button>
|
||||
)}
|
||||
<VariablesOtherCompositions
|
||||
fileTree={fileTree}
|
||||
excludePath={activeCompPath ?? "index.html"}
|
||||
refreshKey={`${refreshKey}:${revision}`}
|
||||
readProjectFile={readProjectFile}
|
||||
writeProjectFile={writeProjectFile}
|
||||
recordEdit={recordEdit}
|
||||
reloadPreview={reloadPreview}
|
||||
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Small text-button used in the Variables tab rows (Edit / Remove / Set default / Declare). */
|
||||
export function RowAction({
|
||||
label,
|
||||
title,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
|
||||
danger ? "hover:text-red-400" : "hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useState, type MutableRefObject } from "react";
|
||||
import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
|
||||
import { persistSdkSerialize } from "../utils/sdkCutover";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
/** Records an edit into the studio's undo history (label + kind + per-file before/after). */
|
||||
export type RecordEditFn = (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
|
||||
export interface CompositionVariableGroup {
|
||||
/** Project-relative file path, e.g. "compositions/frames/02-problem.html". */
|
||||
path: string;
|
||||
/** The composition's variable declarations (empty groups are dropped by the hook). */
|
||||
variables: CompositionVariable[];
|
||||
}
|
||||
|
||||
/** Read one composition file's declarations, or null to skip (unreadable / none / unparseable). */
|
||||
// fallow-ignore-next-line complexity
|
||||
async function readGroup(
|
||||
path: string,
|
||||
readProjectFile: (path: string) => Promise<string>,
|
||||
): Promise<CompositionVariableGroup | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readProjectFile(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!content.includes("data-composition-variables")) return null;
|
||||
try {
|
||||
const comp = await openComposition(content, { history: false });
|
||||
try {
|
||||
const variables = comp.getVariableDeclarations();
|
||||
return variables.length > 0 ? { path, variables } : null;
|
||||
} finally {
|
||||
comp.dispose();
|
||||
}
|
||||
} catch {
|
||||
return null; // Unparseable composition — skip rather than break the whole panel.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read variable declarations from every composition file in the project except
|
||||
* `excludePath` (the active composition, which the panel renders with its full
|
||||
* preview/add controls). Powers the Variables tab's "other compositions"
|
||||
* sections so a variable promoted into a sub-comp file is visible alongside the
|
||||
* host's own. Re-reads whenever `refreshKey` changes (after an edit or preview
|
||||
* reload). A cheap substring guard skips files with no declarations before the
|
||||
* full parse, so large projects don't pay N openComposition calls.
|
||||
*/
|
||||
export function useProjectCompositionVariables(
|
||||
fileTree: string[],
|
||||
excludePath: string | null,
|
||||
readProjectFile: (path: string) => Promise<string>,
|
||||
refreshKey: unknown,
|
||||
): CompositionVariableGroup[] {
|
||||
const [groups, setGroups] = useState<CompositionVariableGroup[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const htmlFiles = fileTree.filter((p) => p.endsWith(".html") && p !== excludePath);
|
||||
|
||||
void (async () => {
|
||||
const out: CompositionVariableGroup[] = [];
|
||||
for (const path of htmlFiles) {
|
||||
const group = await readGroup(path, readProjectFile);
|
||||
if (group) out.push(group);
|
||||
}
|
||||
if (!cancelled) setGroups(out);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileTree, excludePath, readProjectFile, refreshKey]);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
interface EditVariablesDeps {
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: RecordEditFn;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a variable-schema mutation to an arbitrary composition file (a sub-comp
|
||||
* that isn't the active SDK session) and persist it through the standard
|
||||
* single-writer path. Opens a throwaway session on the file, runs `mutate`,
|
||||
* and writes the serialized result — the same contract as useVariablesPersist,
|
||||
* but keyed on `path` rather than a live session.
|
||||
*/
|
||||
export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
||||
const { readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef } =
|
||||
deps;
|
||||
return useCallback(
|
||||
async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
|
||||
const originalContent = await readProjectFile(path);
|
||||
const comp = await openComposition(originalContent, { history: false });
|
||||
let after: string;
|
||||
try {
|
||||
mutate(comp);
|
||||
after = comp.serialize();
|
||||
} finally {
|
||||
comp.dispose();
|
||||
}
|
||||
if (after === originalContent) return;
|
||||
await persistSdkSerialize(
|
||||
after,
|
||||
path,
|
||||
originalContent,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: path,
|
||||
},
|
||||
{ label },
|
||||
);
|
||||
},
|
||||
[readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user