mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
106 lines
4.0 KiB
TypeScript
106 lines
4.0 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|