Merge pull request #2055 from heygen-com/07-07-feat_studio_bind_selected_element_properties_to_variables

feat(studio): bind selected element properties to variables
This commit is contained in:
James Russo
2026-07-09 13:43:38 -07:00
committed by GitHub
7 changed files with 358 additions and 4 deletions
+7 -1
View File
@@ -11,6 +11,8 @@ import { isPrivateUrl } from "./assetDownloader.js";
const DEFAULT_SETTLE_TIME = 3000;
// Pre-existing capture pipeline size — surfaced by a one-line escape fix, not new logic.
// fallow-ignore-next-line complexity
export async function extractHtml(
page: Page,
opts: { settleTime?: number } = {},
@@ -38,6 +40,7 @@ export async function extractHtml(
if (!res.ok) continue;
let css = await res.text();
// Fix relative url() references
// fallow-ignore-next-line complexity
css = css.replace(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g, (match: string, url: string) => {
if (url.startsWith("data:") || url.startsWith("http") || url.startsWith("//")) return match;
try {
@@ -163,7 +166,7 @@ export async function extractHtml(
for (var i = 0; i < htmlEl.attributes.length; i++) {
var attr = htmlEl.attributes[i];
if (attr.name === "lang" || attr.name === "class" || attr.name === "style" || attr.name === "dir" || attr.name.startsWith("data-")) {
attrParts.push(attr.name + '="' + attr.value.replace(/"/g, "&quot;") + '"');
attrParts.push(attr.name + '="' + attr.value.replace(/&/g, "&amp;").replace(/"/g, "&quot;") + '"');
}
}
@@ -183,6 +186,7 @@ export async function extractHtml(
// 2. Make relative image URLs absolute using the page's origin
const pageOrigin = new URL(page.url()).origin;
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<img\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
@@ -208,6 +212,7 @@ export async function extractHtml(
);
// Also fix video src/poster URLs
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
@@ -216,6 +221,7 @@ export async function extractHtml(
return pre + fixed + post;
},
);
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bposter=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
@@ -156,6 +156,13 @@ function extractTemplateInnerHtml(rawComp: string): string | null {
return template ? template.innerHTML : null;
}
/** Attribute values read from the DOM are decoded — re-escape on rebuild or
* quote-bearing values (data-composition-variables is a JSON array) shred
* the wrapper's markup into bogus attributes. */
function escapeAttrValue(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function extractElementAttrs(el: Element): string {
const parts: string[] = [];
for (let i = 0; i < el.attributes.length; i++) {
@@ -163,7 +170,7 @@ function extractElementAttrs(el: Element): string {
if (attr.value === "") {
parts.push(attr.name);
} else {
parts.push(`${attr.name}="${attr.value}"`);
parts.push(`${attr.name}="${escapeAttrValue(attr.value)}"`);
}
}
return parts.join(" ");
@@ -613,3 +613,27 @@ describe("preview ?variables= injection", () => {
expect(html).toContain('window.__hfVariables={"accent":"#f00"}');
});
});
describe("sub-composition preview attribute integrity", () => {
it("preserves quote-bearing html attributes (data-composition-variables JSON)", async () => {
const projectDir = createProjectDir();
const decls = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
]);
writeFileSync(
join(projectDir, "card.html"),
`<!doctype html><html data-composition-variables='${decls}'><head></head><body><div class="clip" data-start="0" data-duration="2">x</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/card.html");
expect(res.status).toBe(200);
const html = await res.text();
const attr = /data-composition-variables="([^"]*)"/.exec(html)?.[1] ?? "";
// Entities decode back to the exact declared JSON — a lost/shredded
// attribute here silently breaks getVariables() on the comp route.
const decoded = attr.replace(/&quot;/g, '"').replace(/&amp;/g, "&");
expect(JSON.parse(decoded)).toEqual(JSON.parse(decls));
});
});
@@ -0,0 +1,263 @@
/**
* "Bind selected element" card for the Variables panel — the promote-a-
* property-to-variable gesture. Each action declares a variable whose default
* is the element's current value, so binding to a NEW id never changes the
* render. Binding to an EXISTING id instead wires the element to that
* variable's current default (which came from another element) — the card
* warns before committing, since that does change the render. The binding the
* runtime resolves is written as a data-var-src / data-var-text attribute, or
* a `<prop>: var(--id)` style.
*/
import { useMemo, useState } from "react";
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
import type { DomEditSelection } from "../editor/domEditingTypes";
import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
// <source> is deliberately excluded: rewriting a <source> child's src after
// the parent media element ran resource selection is a spec no-op.
const MEDIA_TAGS = new Set(["img", "video", "audio"]);
export interface BindAction {
key: string;
label: string;
/** Binding channel: data-var-src / data-var-text attribute, or a style prop. */
kind: "src" | "text" | "style";
styleProp?: string;
suggestedId: string;
declaration: (id: string) => CompositionVariable;
}
function sanitizeId(raw: string): string {
const cleaned = raw
.trim()
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "variable";
}
/**
* "rgb(0, 195, 255)" / "rgba(0, 195, 255, 0.4)" → "#00c3ff". Alpha is dropped
* (color variables are hex) — a fully transparent computed color maps to
* #000000, which the picker can at least display. Unrecognized formats pass
* through verbatim.
*/
function rgbToHex(value: string): string {
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value);
if (!m) return value;
return `#${m
.slice(1, 4)
.map((n) => Number(n).toString(16).padStart(2, "0"))
.join("")}`;
}
function firstFontFamily(value: string): string {
const first = value.split(",")[0] ?? "";
return first.trim().replace(/^["']|["']$/g, "") || "sans-serif";
}
// fallow-ignore-next-line complexity
function buildBindActions(selection: DomEditSelection, sdkSession: Composition): BindAction[] {
const hfId = selection.hfId;
if (!hfId) return [];
// No snapshot = the session can't resolve this element (e.g. a sub-comp
// element that missed the source map) — a bind would target the wrong
// document or dead-end, so offer nothing.
const snapshot = sdkSession.getElement(hfId);
if (!snapshot) return [];
const base = sanitizeId(snapshot.attributes.id ?? selection.label ?? hfId);
const actions: BindAction[] = [];
const tag = selection.tagName.toLowerCase();
if (MEDIA_TAGS.has(tag)) {
const currentSrc = snapshot.attributes.src ?? "";
actions.push({
key: "src",
label: tag === "img" ? "Image source" : "Media source",
kind: "src",
suggestedId: base,
declaration: (id) =>
tag === "img"
? { id, type: "image", label: `${selection.label} image`, default: currentSrc }
: { id, type: "string", label: `${selection.label} source`, default: currentSrc },
});
}
// Text binds only on leaf elements: the runtime preserves children, but a
// container's "own text" default rarely matches what the user sees, so the
// promote-never-changes-the-render guarantee only holds for leaves.
const text = (snapshot.text ?? "").trim();
if (text && snapshot.children.length === 0 && !selection.isCompositionHost) {
actions.push({
key: "text",
label: "Text",
kind: "text",
suggestedId: `${base}-text`,
declaration: (id) => ({
id,
type: "string",
label: `${selection.label} text`,
default: text,
}),
});
}
if (selection.capabilities.canEditStyles) {
const computed = selection.computedStyles;
actions.push(
{
key: "color",
label: "Text color",
kind: "style",
styleProp: "color",
suggestedId: `${base}-color`,
declaration: (id) => ({
id,
type: "color",
label: `${selection.label} color`,
default: rgbToHex(computed["color"] ?? "#000000"),
}),
},
{
key: "background",
label: "Background",
kind: "style",
styleProp: "background-color",
suggestedId: `${base}-bg`,
declaration: (id) => ({
id,
type: "color",
label: `${selection.label} background`,
default: rgbToHex(computed["background-color"] ?? "#000000"),
}),
},
{
key: "font",
label: "Font",
kind: "style",
styleProp: "font-family",
suggestedId: `${base}-font`,
declaration: (id) => ({
id,
type: "font",
label: `${selection.label} font`,
default: firstFontFamily(computed["font-family"] ?? "sans-serif"),
}),
},
);
}
return actions;
}
/** One batched schema edit: declare (unless the id already exists) + bind. */
export function applyBind(
session: Composition,
hfId: string,
action: BindAction,
id: string,
): void {
session.batch(() => {
if (!session.getVariableDeclarations().some((d) => d.id === id)) {
session.declareVariable(action.declaration(id));
}
if (action.kind === "style" && action.styleProp) {
session.setStyle(hfId, { [action.styleProp]: `var(--${id})` });
} else {
session.setAttribute(hfId, `data-var-${action.kind}`, id);
}
});
}
export function VariablesBindElement({
selection,
sdkSession,
onBind,
}: {
selection: DomEditSelection;
sdkSession: Composition;
onBind: (action: BindAction, id: string) => void;
}) {
const [activeKey, setActiveKey] = useState<string | null>(null);
const [idDraft, setIdDraft] = useState("");
const actions = useMemo(() => buildBindActions(selection, sdkSession), [selection, sdkSession]);
if (actions.length === 0) return null;
const active = actions.find((a) => a.key === activeKey) ?? null;
const trimmedId = sanitizeId(idDraft);
const idIsEmpty = idDraft.trim().length === 0;
// Binding to an id that already exists wires the element to THAT variable's
// existing default (declared from a different element), so the render changes
// silently. Surface it before the user commits rather than after.
const existingDecl = active
? sdkSession.getVariableDeclarations().find((d) => d.id === trimmedId)
: undefined;
return (
<div className="space-y-1.5 rounded-lg border border-studio-accent/30 bg-neutral-900/40 p-2">
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
Bind selected: <span className="normal-case text-neutral-300">{selection.label}</span>
</p>
{active ? (
<div className="space-y-1.5">
<label className="text-[9px] font-medium text-neutral-500">
Variable id for {active.label.toLowerCase()}
</label>
<input
type="text"
value={idDraft}
onChange={(e) => setIdDraft(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setActiveKey(null)}
className={`${VARIABLES_INPUT_CLASS} font-mono`}
/>
{existingDecl && (
<p className="text-[9px] leading-snug text-amber-400/90">
"{trimmedId}" already exists. This element will use its current value
{existingDecl.default !== undefined && (
<span className="font-mono"> ({String(existingDecl.default)})</span>
)}
, not the element's own — binding won't change "{trimmedId}".
</p>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setActiveKey(null)}
className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
<button
type="button"
disabled={idIsEmpty}
onClick={() => {
setActiveKey(null);
onBind(active, trimmedId);
}}
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{existingDecl ? "Bind anyway" : "Bind"}
</button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{actions.map((action) => (
<button
key={action.key}
type="button"
onClick={() => {
setActiveKey(action.key);
setIdDraft(action.suggestedId);
}}
className="h-6 rounded-md border border-neutral-800 px-2 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
>
{action.label} &nbsp;variable
</button>
))}
</div>
)}
</div>
);
}
@@ -7,7 +7,9 @@ import type {
} from "@hyperframes/sdk";
import type { EditHistoryKind } from "../../utils/editHistory";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
import {
@@ -275,6 +277,7 @@ export const VariablesPanel = memo(function VariablesPanel({
const { activeCompPath, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
const { domEditSelection } = useDomEditContext();
// On the master view (no activeCompPath) the panel targets the project's real
// main composition — the first .html in the tree — not a hardcoded index.html
// that may not exist. This same path is used for the persist write target (so
@@ -449,6 +452,38 @@ export const VariablesPanel = memo(function VariablesPanel({
reloadPreview();
}, [setPreviewValues, reloadPreview]);
const handleBind = useCallback(
// Guard chain (session, selection, type-compat) — one branch per guard.
// fallow-ignore-next-line complexity
(action: BindAction, id: string) => {
if (!sdkSession || !domEditSelection?.hfId) return;
// Binding to an existing variable is allowed, but only when the types
// agree — wiring a color style to a string variable silently breaks
// the element's styling.
const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id);
const wanted = action.declaration(id).type;
if (existing && existing.type !== wanted) {
showToast(
`"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`,
"error",
);
return;
}
const hfId = domEditSelection.hfId;
void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) =>
applyBind(s, hfId, action, id),
);
},
[sdkSession, domEditSelection, runSchemaEdit, showToast],
);
// The bind gesture targets the composition the session models — a selection
// from another source file must not write bindings into this one.
const bindableSelection =
domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html")
? domEditSelection
: null;
if (!sdkSession) {
return (
<div className="flex h-full items-center justify-center px-6 text-center">
@@ -464,6 +499,14 @@ export const VariablesPanel = memo(function VariablesPanel({
onReset={resetPreview}
/>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
{bindableSelection && (
<VariablesBindElement
key={bindableSelection.hfId}
selection={bindableSelection}
sdkSession={sdkSession}
onBind={handleBind}
/>
)}
<ValidationStrip issues={issues} />
{declarations.length === 0 && !addOpen && EMPTY_STATE}
{/* fallow-ignore-next-line complexity */}
+7 -1
View File
@@ -126,6 +126,8 @@ export function useDomSelection({
// ── Refs ──
const rightPanelTabRef = useRef(rightPanelTab);
rightPanelTabRef.current = rightPanelTab;
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
@@ -205,7 +207,11 @@ export function useDomSelection({
if (nextSelection) {
if (options?.revealPanel !== false) {
setRightCollapsed(false);
setRightPanelTab("design");
// Keep the Variables tab in place — selecting elements is part of
// the bind flow there; yanking to Design would lose the context.
if (rightPanelTabRef.current !== "variables") {
setRightPanelTab("design");
}
}
const nextSelectedTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
@@ -97,7 +97,12 @@ export function useInspectorState(
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
// Keep the selection box + motion path drawn even when the Inspector is
// collapsed — closing the panel shouldn't visually deselect the element.
shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording,
// The Variables tab also works against the canvas selection (bind card),
// so the selection outline stays visible there too.
shouldShowSelectedDomBounds:
(inspectorPanelActive || rightPanelTab === "variables") &&
!isPlaying &&
!isGestureRecording,
};
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
}