mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): promote to variable from the design panel
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Wraps a Design-panel property control with the promote-to-variable gesture.
|
||||
* When a control can be promoted it shows a visible "◇ var" button; clicking it
|
||||
* declares a variable (default = current value, so the render is unchanged) and
|
||||
* binds this property to it. Once bound, the button is replaced by a "◆ {id}"
|
||||
* chip and edits route to the variable's default (edit-in-place). Controls that
|
||||
* aren't eligible (or render outside a promote context, or are disabled by the
|
||||
* caller) pass through untouched. Uses a render-prop so each control keeps its
|
||||
* own value/onCommit shape.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useVariablePromoteChannel,
|
||||
type PromoteChannel,
|
||||
} from "../../contexts/VariablePromoteContext";
|
||||
|
||||
interface RenderArgs {
|
||||
/** When bound, the variable's default to display; otherwise undefined. */
|
||||
value?: string;
|
||||
/** When bound, routes commits to the variable default; otherwise undefined. */
|
||||
onCommit?: (value: string) => void;
|
||||
bound: boolean;
|
||||
}
|
||||
|
||||
export function PromotableControl({
|
||||
channel,
|
||||
enabled = true,
|
||||
children,
|
||||
}: {
|
||||
channel: PromoteChannel;
|
||||
/**
|
||||
* Caller-side gate. Text-section controls only promote when the edited field
|
||||
* is the selected element's OWN text (source "self"); binding a child/text-
|
||||
* node field would target a different element than the control edits.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
children: (args: RenderArgs) => React.ReactNode;
|
||||
}) {
|
||||
const promote = useVariablePromoteChannel(channel);
|
||||
|
||||
// A binding attribute (`data-var-*` / `var(--id)`) pointing at a declaration
|
||||
// that no longer exists renders as a plain unbound control — a silent
|
||||
// fallback that leaves a dev wondering why "their binding isn't showing".
|
||||
// Surface it in the console so the dangling reference is discoverable.
|
||||
const danglingId =
|
||||
enabled && promote && promote.boundId != null && promote.declaration == null
|
||||
? promote.boundId
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (danglingId != null) {
|
||||
console.warn(
|
||||
`[hyperframes] Control is bound to variable "${danglingId}", but no such declaration exists. The element still carries the binding on disk — re-declare the variable or unbind the element.`,
|
||||
);
|
||||
}
|
||||
}, [danglingId]);
|
||||
|
||||
if (!promote || !enabled) return <>{children({ bound: false })}</>;
|
||||
|
||||
// A binding whose declaration was removed elsewhere is dangling: don't show
|
||||
// it as an editable bound control (setDefault would silently no-op) — let it
|
||||
// fall back to a plain, re-promotable control.
|
||||
const bound = promote.boundId != null && promote.declaration != null;
|
||||
const canPromote = promote.action != null && !bound;
|
||||
const defaultValue = promote.declaration?.default;
|
||||
|
||||
const rendered = children(
|
||||
bound
|
||||
? {
|
||||
// Only string defaults render inline; a FontValue/ImageValue object
|
||||
// falls back to the element's real value instead of "[object Object]".
|
||||
value: typeof defaultValue === "string" ? defaultValue : undefined,
|
||||
onCommit: promote.setDefault,
|
||||
bound: true,
|
||||
}
|
||||
: { bound: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`relative ${bound ? "rounded-lg ring-1 ring-studio-accent/40" : ""}`}>
|
||||
{rendered}
|
||||
{bound && (
|
||||
<span
|
||||
className="pointer-events-none absolute right-1.5 top-0 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
|
||||
title={`Bound to variable "${promote.boundId}"`}
|
||||
>
|
||||
◆ {promote.boundId}
|
||||
</span>
|
||||
)}
|
||||
{canPromote && (
|
||||
<button
|
||||
type="button"
|
||||
title="Make this a variable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
promote.promote();
|
||||
}}
|
||||
className="absolute right-1.5 top-0 z-10 inline-flex items-center gap-1 rounded bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
|
||||
>
|
||||
◇ var
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { FIELD, LABEL, normalizeTextMetricValue, RESPONSIVE_GRID } from "./prope
|
||||
import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { FontFamilyField } from "./propertyPanelFont";
|
||||
import { PromotableControl } from "./PromotableControl";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Text helpers (used only by text section components) */
|
||||
@@ -280,20 +281,31 @@ function TextFieldEditor({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TextAreaField
|
||||
key={field.key}
|
||||
label="Content"
|
||||
value={field.value}
|
||||
disabled={false}
|
||||
autoFocus={showRemove}
|
||||
onCommit={(next) => onSetText(next, field.key)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text color"
|
||||
value={getTextFieldColor(field, styles)}
|
||||
disabled={false}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
|
||||
/>
|
||||
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
|
||||
{({ value, onCommit }) => (
|
||||
<TextAreaField
|
||||
key={field.key}
|
||||
label="Content"
|
||||
value={value ?? field.value}
|
||||
disabled={false}
|
||||
autoFocus={showRemove}
|
||||
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<PromotableControl
|
||||
channel={{ kind: "style", prop: "color" }}
|
||||
enabled={field.source === "self"}
|
||||
>
|
||||
{({ value, onCommit }) => (
|
||||
<ColorField
|
||||
label="Text color"
|
||||
value={value ?? getTextFieldColor(field, styles)}
|
||||
disabled={false}
|
||||
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<MetricField
|
||||
label="Size"
|
||||
@@ -309,13 +321,22 @@ function TextFieldEditor({
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-weight", next)}
|
||||
/>
|
||||
</div>
|
||||
<FontFamilyField
|
||||
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
|
||||
disabled={false}
|
||||
importedFonts={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
|
||||
/>
|
||||
<PromotableControl
|
||||
channel={{ kind: "style", prop: "font-family" }}
|
||||
enabled={field.source === "self"}
|
||||
>
|
||||
{({ value, onCommit }) => (
|
||||
<FontFamilyField
|
||||
value={
|
||||
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
|
||||
}
|
||||
disabled={false}
|
||||
importedFonts={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<AdvancedTextControls
|
||||
field={field}
|
||||
inheritedStyles={styles}
|
||||
|
||||
Reference in New Issue
Block a user