mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): variables inspector panel with live preview values
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Add/edit form for a variable declaration. Builds a typed
|
||||
* CompositionVariable from free-text drafts; structural validation beyond
|
||||
* the field-level checks here is the SDK's job (can() on dispatch).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { CompositionVariable, CompositionVariableType } from "@hyperframes/sdk";
|
||||
import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
|
||||
|
||||
const VARIABLE_TYPES: CompositionVariableType[] = [
|
||||
"string",
|
||||
"number",
|
||||
"color",
|
||||
"boolean",
|
||||
"enum",
|
||||
"font",
|
||||
"image",
|
||||
];
|
||||
|
||||
export interface DeclarationDraft {
|
||||
id: string;
|
||||
label: string;
|
||||
type: CompositionVariableType;
|
||||
defaultRaw: string;
|
||||
description: string;
|
||||
min: string;
|
||||
max: string;
|
||||
step: string;
|
||||
optionsRaw: string;
|
||||
}
|
||||
|
||||
export const EMPTY_DRAFT: DeclarationDraft = {
|
||||
id: "",
|
||||
label: "",
|
||||
type: "string",
|
||||
defaultRaw: "",
|
||||
description: "",
|
||||
min: "",
|
||||
max: "",
|
||||
step: "",
|
||||
optionsRaw: "",
|
||||
};
|
||||
|
||||
// Per-type field mapping — one ternary per optional field.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function draftFromDeclaration(decl: CompositionVariable): DeclarationDraft {
|
||||
const numeric = decl.type === "number" ? decl : null;
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
id: decl.id,
|
||||
label: decl.label,
|
||||
type: decl.type,
|
||||
defaultRaw: String(decl.default),
|
||||
description: decl.description ?? "",
|
||||
min: numeric?.min !== undefined ? String(numeric.min) : "",
|
||||
max: numeric?.max !== undefined ? String(numeric.max) : "",
|
||||
step: numeric?.step !== undefined ? String(numeric.step) : "",
|
||||
optionsRaw:
|
||||
decl.type === "enum" ? decl.options.map((o) => `${o.value}:${o.label}`).join("\n") : "",
|
||||
};
|
||||
}
|
||||
|
||||
function numberDeclFromDraft(
|
||||
base: { id: string; label: string; description?: string },
|
||||
draft: DeclarationDraft,
|
||||
): CompositionVariable | string {
|
||||
const value = Number(draft.defaultRaw);
|
||||
if (!Number.isFinite(value)) return "Default must be a number.";
|
||||
const constraint = (key: "min" | "max" | "step") => {
|
||||
const raw = draft[key].trim();
|
||||
if (!raw) return {};
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? { [key]: parsed } : {};
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
type: "number",
|
||||
default: value,
|
||||
...constraint("min"),
|
||||
...constraint("max"),
|
||||
...constraint("step"),
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function enumDeclFromDraft(
|
||||
base: { id: string; label: string; description?: string },
|
||||
draft: DeclarationDraft,
|
||||
): CompositionVariable | string {
|
||||
const options = draft.optionsRaw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [value, ...rest] = line.split(":");
|
||||
const v = (value ?? "").trim();
|
||||
return { value: v, label: rest.join(":").trim() || v };
|
||||
})
|
||||
.filter((o) => o.value.length > 0);
|
||||
if (options.length === 0) return "Enum needs at least one option (one per line, value:Label).";
|
||||
const value = draft.defaultRaw.trim() || (options[0]?.value ?? "");
|
||||
if (!options.some((o) => o.value === value)) return "Default must be one of the options.";
|
||||
return { ...base, type: "enum", default: value, options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the form actually models. On an unchanged-type edit, every OTHER key
|
||||
* of the original declaration (font source/default_name/default_source,
|
||||
* brandRole, placeholder, maxLength, unit, …) must ride through untouched —
|
||||
* updateVariableDeclaration replaces wholesale, so dropping them here would
|
||||
* silently strip schema metadata on every Edit + Save.
|
||||
*/
|
||||
const FORM_OWNED_KEYS = new Set([
|
||||
"id",
|
||||
"label",
|
||||
"type",
|
||||
"default",
|
||||
"description",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"options",
|
||||
]);
|
||||
|
||||
export function mergeDeclarationEdit(
|
||||
original: CompositionVariable,
|
||||
edited: CompositionVariable,
|
||||
): CompositionVariable {
|
||||
// Type changed → old type-specific metadata no longer applies.
|
||||
if (original.type !== edited.type) return edited;
|
||||
const passthrough: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (!FORM_OWNED_KEYS.has(key)) passthrough[key] = value;
|
||||
}
|
||||
// Both sides are same-type declarations and edited owns every form key, so
|
||||
// the merge preserves the declared shape.
|
||||
return { ...passthrough, ...edited };
|
||||
}
|
||||
|
||||
/** Build a typed declaration from the form draft; string on validation error. */
|
||||
// fallow-ignore-next-line complexity
|
||||
export function declarationFromDraft(draft: DeclarationDraft): CompositionVariable | string {
|
||||
const id = draft.id.trim();
|
||||
if (!id) return "Variable id is required.";
|
||||
const label = draft.label.trim() || id;
|
||||
const description = draft.description.trim() || undefined;
|
||||
const base = { id, label, ...(description ? { description } : {}) };
|
||||
switch (draft.type) {
|
||||
case "number":
|
||||
return numberDeclFromDraft(base, draft);
|
||||
case "boolean":
|
||||
return { ...base, type: "boolean", default: draft.defaultRaw.trim() === "true" };
|
||||
case "enum":
|
||||
return enumDeclFromDraft(base, draft);
|
||||
default:
|
||||
// string / color / font / image — string default, verbatim.
|
||||
return { ...base, type: draft.type, default: draft.defaultRaw };
|
||||
}
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-medium text-neutral-500">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultField({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: DeclarationDraft;
|
||||
onChange: (defaultRaw: string) => void;
|
||||
}) {
|
||||
if (draft.type === "boolean") {
|
||||
return (
|
||||
<select
|
||||
value={draft.defaultRaw === "true" ? "true" : "false"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={VARIABLES_INPUT_CLASS}
|
||||
>
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={draft.defaultRaw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={VARIABLES_INPUT_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeclarationForm({
|
||||
initial,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: DeclarationDraft;
|
||||
submitLabel: string;
|
||||
onSubmit: (decl: CompositionVariable) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<DeclarationDraft>(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const editingExisting = initial.id.length > 0;
|
||||
const set = (patch: Partial<DeclarationDraft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const submit = () => {
|
||||
const result = declarationFromDraft(draft);
|
||||
if (typeof result === "string") {
|
||||
setError(result);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onSubmit(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-neutral-800 bg-neutral-900/60 p-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="ID">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.id}
|
||||
disabled={editingExisting}
|
||||
onChange={(e) => set({ id: e.target.value })}
|
||||
placeholder="title"
|
||||
className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Label">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.label}
|
||||
onChange={(e) => set({ label: e.target.value })}
|
||||
placeholder="Title"
|
||||
className={VARIABLES_INPUT_CLASS}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Type">
|
||||
<select
|
||||
value={draft.type}
|
||||
onChange={(e) => {
|
||||
const type = VARIABLE_TYPES.find((t) => t === e.target.value);
|
||||
if (type) set({ type });
|
||||
}}
|
||||
className={VARIABLES_INPUT_CLASS}
|
||||
>
|
||||
{VARIABLE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Default">
|
||||
<DefaultField draft={draft} onChange={(defaultRaw) => set({ defaultRaw })} />
|
||||
</Field>
|
||||
</div>
|
||||
{draft.type === "number" && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["min", "max", "step"] as const).map((key) => (
|
||||
<Field key={key} label={key}>
|
||||
<input
|
||||
type="text"
|
||||
value={draft[key]}
|
||||
onChange={(e) => set({ [key]: e.target.value })}
|
||||
className={`${VARIABLES_INPUT_CLASS} tabular-nums`}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{draft.type === "enum" && (
|
||||
<Field label="Options (one per line, value:Label)">
|
||||
<textarea
|
||||
value={draft.optionsRaw}
|
||||
onChange={(e) => set({ optionsRaw: e.target.value })}
|
||||
rows={3}
|
||||
className={`${VARIABLES_INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Description (optional)">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.description}
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
className={VARIABLES_INPUT_CLASS}
|
||||
/>
|
||||
</Field>
|
||||
{error && <p className="text-[10px] text-red-400">{error}</p>}
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user