Files
hyperframes/packages/studio/src/components/editor/PromotableControl.tsx
T

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>
);
}