|
| 1 | +/** |
| 2 | + * Declarative variable bindings — the no-script consumption channel for |
| 3 | + * composition variables (values are fixed for the page's lifetime, so this is |
| 4 | + * seek-safe and deterministic): |
| 5 | + * |
| 6 | + * - `data-var-src="id"` — sets the element's `src` from the variable value |
| 7 | + * (a URL string or an image value `{url}`). The authored src stays as the |
| 8 | + * fallback when the variable resolves to nothing. |
| 9 | + * - `data-var-text="id"` — sets the element's OWN text from a scalar variable |
| 10 | + * value. Elements with element children keep them: only the direct text |
| 11 | + * node is replaced, mirroring the SDK's setOwnText semantics — a text |
| 12 | + * binding must never delete nested clips or animation targets. |
| 13 | + * - Every scalar variable (and a font value's family name) is applied as a |
| 14 | + * `--{id}` CSS custom property on its composition root, so CSS bindings |
| 15 | + * like `color: var(--accent)` respond to render/preview overrides instead |
| 16 | + * of only the persisted default. |
| 17 | + * |
| 18 | + * Values resolve against the element's owning composition — the same scope |
| 19 | + * chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for |
| 20 | + * inlined sub-compositions, then the top-level merged `getVariables()`. |
| 21 | + * |
| 22 | + * Applied at init AND re-applied after the composition loader inlines |
| 23 | + * external / template sub-compositions (their DOM and per-instance scoped |
| 24 | + * values don't exist at init). Idempotent: re-applying writes the same |
| 25 | + * values. |
| 26 | + */ |
| 27 | + |
| 28 | +import { readVariablesForElement } from "./variableScope"; |
| 29 | +import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition"; |
| 30 | + |
| 31 | +function resolveUrl(value: unknown): string | null { |
| 32 | + if (typeof value === "string" && value.length > 0) return value; |
| 33 | + if (value !== null && typeof value === "object") { |
| 34 | + const url = (value as { url?: unknown }).url; |
| 35 | + if (typeof url === "string" && url.length > 0) return url; |
| 36 | + } |
| 37 | + return null; |
| 38 | +} |
| 39 | + |
| 40 | +/** CSS custom-property value for a variable, or null when not CSS-applicable. */ |
| 41 | +function cssValueFor(value: unknown): string | null { |
| 42 | + if (isScalar(value)) return String(value); |
| 43 | + if (value !== null && typeof value === "object") { |
| 44 | + // Font values apply their family name; the face itself must be loaded by |
| 45 | + // the composition (or the media pipeline). |
| 46 | + const name = (value as { name?: unknown }).name; |
| 47 | + if (typeof name === "string" && name.length > 0) return name; |
| 48 | + } |
| 49 | + return null; |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Per-run memo of scope element → resolved values, so N bound elements in |
| 54 | + * one scope pay for one resolution (the top-level path re-parses the |
| 55 | + * declarations attribute on every getVariables() call). |
| 56 | + */ |
| 57 | +type ScopeValuesCache = Map<Element | null, Record<string, unknown>>; |
| 58 | + |
| 59 | +function valuesForElement(el: Element, cache: ScopeValuesCache): Record<string, unknown> { |
| 60 | + const scope = el.closest("[data-composition-id]"); |
| 61 | + const cached = cache.get(scope); |
| 62 | + if (cached) return cached; |
| 63 | + const values = readVariablesForElement(el); |
| 64 | + cache.set(scope, values); |
| 65 | + return values; |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * Replace the element's own text while preserving element children (nested |
| 70 | + * clips, animation-target spans). Mirrors the SDK's setOwnText: write the |
| 71 | + * first direct text node, clear the others; append when none exists. |
| 72 | + */ |
| 73 | +function setOwnTextPreservingChildren(el: Element, text: string): void { |
| 74 | + if (el.childElementCount === 0) { |
| 75 | + el.textContent = text; |
| 76 | + return; |
| 77 | + } |
| 78 | + let written = false; |
| 79 | + for (const node of Array.from(el.childNodes)) { |
| 80 | + if (node.nodeType !== Node.TEXT_NODE) continue; |
| 81 | + node.nodeValue = written ? "" : text; |
| 82 | + written = true; |
| 83 | + } |
| 84 | + if (!written) { |
| 85 | + el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild); |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +/** |
| 90 | + * Composition root, matching the SDK's findRoot chain exactly — the SDK |
| 91 | + * persists `--{id}` defaults on this element, so the runtime must write |
| 92 | + * overrides to the SAME element or an inline default on a descendant would |
| 93 | + * shadow an override applied higher up. |
| 94 | + */ |
| 95 | +function findTopRoot(doc: Document): Element | null { |
| 96 | + return ( |
| 97 | + doc.querySelector("[data-hf-root]") ?? |
| 98 | + doc.getElementById("stage") ?? |
| 99 | + doc.body?.firstElementChild ?? |
| 100 | + doc.body |
| 101 | + ); |
| 102 | +} |
| 103 | + |
| 104 | +function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void { |
| 105 | + // Top-level root plus every inlined sub-composition root; custom props |
| 106 | + // inherit, so descendants of each root see its scope's values. |
| 107 | + const roots = new Set<Element>(); |
| 108 | + const topRoot = findTopRoot(doc); |
| 109 | + if (topRoot) roots.add(topRoot); |
| 110 | + for (const el of Array.from(doc.querySelectorAll("[data-composition-id]"))) { |
| 111 | + roots.add(el); |
| 112 | + } |
| 113 | + for (const root of roots) { |
| 114 | + const values = valuesForElement(root, cache); |
| 115 | + for (const [id, value] of Object.entries(values)) { |
| 116 | + const css = cssValueFor(value); |
| 117 | + if (css !== null && root instanceof HTMLElement) { |
| 118 | + root.style.setProperty(`--${id}`, css); |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +export function applyVariableBindings(doc: Document): void { |
| 125 | + const cache: ScopeValuesCache = new Map(); |
| 126 | + applyCssCustomProperties(doc, cache); |
| 127 | + |
| 128 | + for (const el of Array.from(doc.querySelectorAll("[data-var-src]"))) { |
| 129 | + const id = el.getAttribute("data-var-src")?.trim(); |
| 130 | + if (!id) continue; |
| 131 | + const url = resolveUrl(valuesForElement(el, cache)[id]); |
| 132 | + if (url !== null) el.setAttribute("src", url); |
| 133 | + } |
| 134 | + |
| 135 | + for (const el of Array.from(doc.querySelectorAll("[data-var-text]"))) { |
| 136 | + const id = el.getAttribute("data-var-text")?.trim(); |
| 137 | + if (!id) continue; |
| 138 | + const value = valuesForElement(el, cache)[id]; |
| 139 | + if (isScalar(value)) setOwnTextPreservingChildren(el, String(value)); |
| 140 | + } |
| 141 | +} |
0 commit comments