mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): bind selected element properties to variables
Ninth PR of the template-variables stack: the promote-a-property gesture.
Select an element on the canvas/timeline, open the Variables tab, and the
panel offers per-property bind actions.
- "Bind selected" card in the Variables panel, built from the selection:
image/media source (img/video/audio), text, text color, background, and
font. Each action declares a variable whose default is the element's
CURRENT value (promoting never changes the render — computed rgb colors
convert to hex, the first computed font family becomes the font default)
and writes the declarative binding the runtime resolves: data-var-src /
data-var-text attributes or `<prop>: var(--id)` styles. Declare + bind
run as one batched schema edit (one undo step); binding to an
already-declared id skips the declare and just binds.
- guarded to selections from the composition the session models — a
selection in another source file never writes bindings into this one.
- core: extract readVariablesForElement into runtime/variableScope.ts,
shared by color grading and the declarative bindings (was duplicated).
- fix(studio-server): buildSubCompositionHtml's extractElementAttrs
rebuilt html/body attributes without HTML-escaping values, shredding
quote-bearing attributes — data-composition-variables (a JSON array)
came out as mangled bogus attributes, so getVariables() silently
returned {} on every /preview/comp/* page (no declared defaults, no
runtime bindings). Pre-existing bug surfaced by live-testing this
feature; regression test added.
Verified end-to-end in a live session: select headline → Bind text color
→ declaration + var(--headline-color) written to disk → override in the
panel → runtime applies the custom prop and the element renders the
override.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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} → 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 */}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user