mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
Merge pull request #2050 from heygen-com/07-07-feat_studio_variables_inspector_panel_with_live_preview_values
feat(studio): variables inspector panel with live preview values
This commit is contained in:
@@ -27,6 +27,14 @@ export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
export type {
|
||||
CompositionVariable,
|
||||
CompositionVariableType,
|
||||
CompositionVariableBase,
|
||||
StringVariable,
|
||||
NumberVariable,
|
||||
ColorVariable,
|
||||
BooleanVariable,
|
||||
EnumVariable,
|
||||
FontVariable,
|
||||
ImageVariable,
|
||||
VariableValidationIssue,
|
||||
VariableUsageScan,
|
||||
} from "@hyperframes/core/variables";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Tooltip } from "./ui";
|
||||
|
||||
/** Tab-bar button for the right inspector panel header. */
|
||||
export function PanelTabButton({
|
||||
label,
|
||||
tooltip,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={tooltip} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
active
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type MutableRefObject,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { Tooltip } from "./ui";
|
||||
import { PropertyPanel } from "./editor/PropertyPanel";
|
||||
import { LayersPanel } from "./editor/LayersPanel";
|
||||
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
||||
@@ -15,6 +14,8 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
||||
import { RenderQueue } from "./renders/RenderQueue";
|
||||
import { SlideshowPanel } from "./panels/SlideshowPanel";
|
||||
import type { SceneInfo } from "./panels/SlideshowPanel";
|
||||
import { VariablesPanel } from "./panels/VariablesPanel";
|
||||
import { PanelTabButton } from "./PanelTabButton";
|
||||
import type { RenderJob } from "./renders/useRenderQueue";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
import type { IframeWindow } from "../player/lib/playbackTypes";
|
||||
@@ -461,64 +462,38 @@ export function StudioRightPanel({
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
|
||||
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
||||
<>
|
||||
<Tooltip label="Element styles and properties" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInspectorPaneButtonClick("design")}
|
||||
aria-pressed={designPaneOpen}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
designPaneOpen
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Design
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Composition layer stack" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInspectorPaneButtonClick("layers")}
|
||||
aria-pressed={layersPaneOpen}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
layersPaneOpen
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Layers
|
||||
</button>
|
||||
</Tooltip>
|
||||
<PanelTabButton
|
||||
label="Design"
|
||||
tooltip="Element styles and properties"
|
||||
active={designPaneOpen}
|
||||
onClick={() => handleInspectorPaneButtonClick("design")}
|
||||
/>
|
||||
<PanelTabButton
|
||||
label="Layers"
|
||||
tooltip="Composition layer stack"
|
||||
active={layersPaneOpen}
|
||||
onClick={() => handleInspectorPaneButtonClick("layers")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label="Render queue and exports" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("renders")}
|
||||
aria-pressed={rightPanelTab === "renders"}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
rightPanelTab === "renders"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Slideshow branching editor" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("slideshow")}
|
||||
aria-pressed={rightPanelTab === "slideshow"}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
rightPanelTab === "slideshow"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Slideshow
|
||||
</button>
|
||||
</Tooltip>
|
||||
<PanelTabButton
|
||||
label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
|
||||
tooltip="Render queue and exports"
|
||||
active={rightPanelTab === "renders"}
|
||||
onClick={() => setRightPanelTab("renders")}
|
||||
/>
|
||||
<PanelTabButton
|
||||
label="Slideshow"
|
||||
tooltip="Slideshow branching editor"
|
||||
active={rightPanelTab === "slideshow"}
|
||||
onClick={() => setRightPanelTab("slideshow")}
|
||||
/>
|
||||
<PanelTabButton
|
||||
label="Variables"
|
||||
tooltip="Template variables — declare, preview with values"
|
||||
active={rightPanelTab === "variables"}
|
||||
onClick={() => setRightPanelTab("variables")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{rightPanelTab === "block-params" && activeBlockParams ? (
|
||||
@@ -535,6 +510,13 @@ export function StudioRightPanel({
|
||||
onPersist={onPersistSlideshow}
|
||||
onPersistNotes={onPersistSlideshowNotes}
|
||||
/>
|
||||
) : rightPanelTab === "variables" ? (
|
||||
<VariablesPanel
|
||||
sdkSession={sdkSession}
|
||||
reloadPreview={reloadPreview}
|
||||
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
||||
recordEdit={recordEdit}
|
||||
/>
|
||||
) : layersPaneOpen && designPaneOpen ? (
|
||||
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Pure form-logic guards for the Variables declaration form:
|
||||
* mergeDeclarationEdit (unmodeled-key passthrough on same-type edits) and
|
||||
* declarationFromDraft (per-type parsing + validation).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mergeDeclarationEdit,
|
||||
declarationFromDraft,
|
||||
draftFromDeclaration,
|
||||
EMPTY_DRAFT,
|
||||
} from "./VariablesDeclarationForm.js";
|
||||
import type { CompositionVariable } from "@hyperframes/core/variables";
|
||||
|
||||
describe("mergeDeclarationEdit", () => {
|
||||
it("preserves unmodeled keys on a same-type edit", () => {
|
||||
// The form owns id/label/type/default/description/min/max/step/options; every
|
||||
// other key (source, brandRole, unit, maxLength, …) must ride through a Save.
|
||||
const original = {
|
||||
id: "brand",
|
||||
type: "font",
|
||||
label: "Brand font",
|
||||
default: "Inter",
|
||||
source: "brand-kit",
|
||||
default_name: "Inter",
|
||||
brandRole: "heading",
|
||||
} as unknown as CompositionVariable;
|
||||
const edited = {
|
||||
id: "brand",
|
||||
type: "font",
|
||||
label: "Brand heading font",
|
||||
default: "Inter",
|
||||
} as CompositionVariable;
|
||||
|
||||
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
|
||||
expect(merged.label).toBe("Brand heading font");
|
||||
expect(merged.source).toBe("brand-kit");
|
||||
expect(merged.default_name).toBe("Inter");
|
||||
expect(merged.brandRole).toBe("heading");
|
||||
});
|
||||
|
||||
it("drops old type-specific metadata when the type changes", () => {
|
||||
const original = {
|
||||
id: "count",
|
||||
type: "number",
|
||||
label: "Count",
|
||||
default: 3,
|
||||
min: 0,
|
||||
max: 10,
|
||||
} as CompositionVariable;
|
||||
const edited = {
|
||||
id: "count",
|
||||
type: "string",
|
||||
label: "Count",
|
||||
default: "3",
|
||||
} as CompositionVariable;
|
||||
|
||||
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
|
||||
expect(merged).toEqual(edited);
|
||||
expect(merged.min).toBeUndefined();
|
||||
expect(merged.max).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("declarationFromDraft", () => {
|
||||
it("requires a non-empty id", () => {
|
||||
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: " " })).toBe("Variable id is required.");
|
||||
});
|
||||
|
||||
it("parses a string variable verbatim and defaults label to id", () => {
|
||||
const decl = declarationFromDraft({ ...EMPTY_DRAFT, id: "title", defaultRaw: "Hello" });
|
||||
expect(decl).toEqual({ id: "title", label: "title", type: "string", default: "Hello" });
|
||||
});
|
||||
|
||||
it("rejects a non-numeric number default", () => {
|
||||
expect(
|
||||
declarationFromDraft({ ...EMPTY_DRAFT, id: "n", type: "number", defaultRaw: "abc" }),
|
||||
).toBe("Default must be a number.");
|
||||
});
|
||||
|
||||
it("parses a number variable with min/max/step and drops blank constraints", () => {
|
||||
const decl = declarationFromDraft({
|
||||
...EMPTY_DRAFT,
|
||||
id: "n",
|
||||
type: "number",
|
||||
defaultRaw: "5",
|
||||
min: "0",
|
||||
max: "",
|
||||
step: "0.5",
|
||||
});
|
||||
expect(decl).toEqual({ id: "n", label: "n", type: "number", default: 5, min: 0, step: 0.5 });
|
||||
});
|
||||
|
||||
it("rejects an enum with no options and an off-list default", () => {
|
||||
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: "e", type: "enum", optionsRaw: "" })).toBe(
|
||||
"Enum needs at least one option (one per line, value:Label).",
|
||||
);
|
||||
expect(
|
||||
declarationFromDraft({
|
||||
...EMPTY_DRAFT,
|
||||
id: "e",
|
||||
type: "enum",
|
||||
optionsRaw: "wide:Wide\ntall:Tall",
|
||||
defaultRaw: "square",
|
||||
}),
|
||||
).toBe("Default must be one of the options.");
|
||||
});
|
||||
|
||||
it("parses a boolean from the 'true' sentinel", () => {
|
||||
expect(
|
||||
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "true" }),
|
||||
).toMatchObject({
|
||||
type: "boolean",
|
||||
default: true,
|
||||
});
|
||||
expect(
|
||||
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "" }),
|
||||
).toMatchObject({
|
||||
default: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a declaration through draftFromDeclaration → declarationFromDraft", () => {
|
||||
const original: CompositionVariable = {
|
||||
id: "count",
|
||||
type: "number",
|
||||
label: "Count",
|
||||
default: 3,
|
||||
min: 0,
|
||||
max: 10,
|
||||
};
|
||||
expect(declarationFromDraft(draftFromDeclaration(original))).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
|
||||
import type {
|
||||
Composition,
|
||||
CompositionVariable,
|
||||
VariableUsageReport,
|
||||
VariableValidationIssue,
|
||||
} from "@hyperframes/sdk";
|
||||
import type { EditHistoryKind } from "../../utils/editHistory";
|
||||
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
|
||||
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
|
||||
import {
|
||||
DeclarationForm,
|
||||
draftFromDeclaration,
|
||||
mergeDeclarationEdit,
|
||||
EMPTY_DRAFT,
|
||||
} from "./VariablesDeclarationForm";
|
||||
import { PreviewValueControl } from "./VariablesValueControls";
|
||||
import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
|
||||
|
||||
interface VariablesPanelProps {
|
||||
sdkSession: Composition | null;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
function formatIssue(issue: VariableValidationIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "undeclared":
|
||||
return `"${issue.variableId}" is not declared.`;
|
||||
case "type-mismatch":
|
||||
return `"${issue.variableId}" expects ${issue.expected}, got ${issue.actual}.`;
|
||||
case "enum-out-of-range":
|
||||
return `"${issue.variableId}" must be one of: ${issue.allowed.join(", ")}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
|
||||
if (issues.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1 rounded-lg border border-red-900/60 bg-red-950/30 p-2">
|
||||
{issues.map((issue) => (
|
||||
<p key={`${issue.kind}:${issue.variableId}`} className="text-[10px] text-red-300">
|
||||
{formatIssue(issue)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
title,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
|
||||
danger ? "hover:text-red-400" : "hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function VariableRow({
|
||||
decl,
|
||||
value,
|
||||
overridden,
|
||||
unused,
|
||||
editing,
|
||||
onCommitPreview,
|
||||
onSetDefault,
|
||||
onToggleEdit,
|
||||
onSaveEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
decl: CompositionVariable;
|
||||
value: unknown;
|
||||
overridden: boolean;
|
||||
unused: boolean;
|
||||
editing: boolean;
|
||||
onCommitPreview: (value: unknown) => void;
|
||||
onSetDefault: (value: string | number | boolean) => void;
|
||||
onToggleEdit: () => void;
|
||||
onSaveEdit: (decl: CompositionVariable) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[10px] font-medium text-neutral-300">{decl.label}</span>
|
||||
<span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
|
||||
{decl.type}
|
||||
</span>
|
||||
{unused && (
|
||||
<span
|
||||
className="rounded bg-amber-900/40 px-1 py-px text-[8px] text-amber-400"
|
||||
title="No script reads this variable"
|
||||
>
|
||||
unused
|
||||
</span>
|
||||
)}
|
||||
{overridden && <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" />}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{overridden && isScalar(value) && (
|
||||
<RowAction
|
||||
label="Set default"
|
||||
title="Persist this value as the declared default"
|
||||
onClick={() => onSetDefault(value)}
|
||||
/>
|
||||
)}
|
||||
<RowAction label="Edit" title="Edit declaration" onClick={onToggleEdit} />
|
||||
<RowAction label="✕" title="Remove declaration" danger onClick={onRemove} />
|
||||
</span>
|
||||
</div>
|
||||
{decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
|
||||
{editing ? (
|
||||
<DeclarationForm
|
||||
initial={draftFromDeclaration(decl)}
|
||||
submitLabel="Save"
|
||||
onSubmit={(edited) => onSaveEdit(mergeDeclarationEdit(decl, edited))}
|
||||
onCancel={onToggleEdit}
|
||||
/>
|
||||
) : (
|
||||
<PreviewValueControl decl={decl} value={value} onCommit={onCommitPreview} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UndeclaredReads({
|
||||
usage,
|
||||
onDeclare,
|
||||
}: {
|
||||
usage: VariableUsageReport | null;
|
||||
onDeclare: (id: string) => void;
|
||||
}) {
|
||||
if (!usage || usage.undeclaredReads.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
|
||||
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
|
||||
Read by scripts, not declared
|
||||
</p>
|
||||
{usage.undeclaredReads.map((id) => (
|
||||
<div key={id} className="flex items-center gap-2">
|
||||
<code className="font-mono text-[10px] text-neutral-400">{id}</code>
|
||||
<RowAction
|
||||
label="Declare"
|
||||
title="Declare as a string variable"
|
||||
onClick={() => onDeclare(id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview-state pill + reset, shown in the panel header. */
|
||||
function PreviewModeHeader({
|
||||
overrideCount,
|
||||
onReset,
|
||||
}: {
|
||||
overrideCount: number;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const hasOverrides = overrideCount > 0;
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-neutral-200">Variables</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[9px] font-medium ${
|
||||
hasOverrides
|
||||
? "bg-studio-accent/20 text-studio-accent"
|
||||
: "bg-neutral-800 text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{hasOverrides ? `Previewing ${overrideCount} custom` : "Previewing defaults"}
|
||||
</span>
|
||||
</div>
|
||||
{hasOverrides && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="h-6 rounded px-2 text-[10px] text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_STATE = (
|
||||
<p className="text-[10px] leading-relaxed text-neutral-500">
|
||||
No variables declared. Variables make parts of this composition dynamic — declare them here (or
|
||||
in <code className="font-mono">data-composition-variables</code>), read them with{" "}
|
||||
<code className="font-mono">getVariables()</code>, and pass values at render time with{" "}
|
||||
<code className="font-mono">--variables</code>.
|
||||
</p>
|
||||
);
|
||||
|
||||
// Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
|
||||
// fallow-ignore-next-line complexity
|
||||
export const VariablesPanel = memo(function VariablesPanel({
|
||||
sdkSession,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
recordEdit,
|
||||
}: VariablesPanelProps) {
|
||||
const { activeCompPath, showToast } = useStudioShellContext();
|
||||
const { refreshKey } = useStudioPlaybackContext();
|
||||
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
||||
const previewValues = usePreviewVariablesStore((s) => s.values);
|
||||
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
|
||||
|
||||
// Bumped after each persisted schema edit so declarations re-derive without
|
||||
// waiting for the session reload round-trip.
|
||||
const [revision, setRevision] = useState(0);
|
||||
// Also bump on any session mutation (undo/redo, edits dispatched by other
|
||||
// panels or agents) — the memos below must never trust refreshKey alone.
|
||||
useEffect(() => {
|
||||
if (!sdkSession) return;
|
||||
return sdkSession.on("change", () => setRevision((r) => r + 1));
|
||||
}, [sdkSession]);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const persistVariables = useVariablesPersist({
|
||||
sdkSession,
|
||||
activeCompPath,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
});
|
||||
|
||||
const declarations = useMemo(
|
||||
() => sdkSession?.getVariableDeclarations() ?? [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sdkSession, refreshKey, revision],
|
||||
);
|
||||
const usage = useMemo(
|
||||
() => sdkSession?.getVariableUsage() ?? null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sdkSession, refreshKey, revision],
|
||||
);
|
||||
const issues = useMemo(
|
||||
() => (previewValues && sdkSession ? sdkSession.validateVariableValues(previewValues) : []),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sdkSession, previewValues, refreshKey, revision],
|
||||
);
|
||||
|
||||
const dropPreviewOverride = useCallback(
|
||||
(id: string) => {
|
||||
if (previewValues && id in previewValues) {
|
||||
const next = { ...previewValues };
|
||||
delete next[id];
|
||||
setPreviewValues(next);
|
||||
}
|
||||
},
|
||||
[previewValues, setPreviewValues],
|
||||
);
|
||||
|
||||
const commitPreviewValue = useCallback(
|
||||
(id: string, value: unknown, declDefault: unknown) => {
|
||||
const next = { ...(previewValues ?? {}) };
|
||||
if (JSON.stringify(value) === JSON.stringify(declDefault)) {
|
||||
delete next[id];
|
||||
} else {
|
||||
next[id] = value;
|
||||
}
|
||||
setPreviewValues(next);
|
||||
reloadPreview();
|
||||
},
|
||||
[previewValues, setPreviewValues, reloadPreview],
|
||||
);
|
||||
|
||||
const runSchemaEdit = useCallback(
|
||||
async (label: string, mutate: (session: Composition) => void): Promise<boolean> => {
|
||||
try {
|
||||
const changed = await persistVariables(label, mutate);
|
||||
if (changed) setRevision((r) => r + 1);
|
||||
else showToast(`${label}: no change applied`, "info");
|
||||
return changed;
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[persistVariables, showToast],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(decl: CompositionVariable) => {
|
||||
if (!sdkSession) return;
|
||||
const check = sdkSession.can({ type: "declareVariable", declaration: decl });
|
||||
if (!check.ok) {
|
||||
showToast(check.message, "error");
|
||||
return;
|
||||
}
|
||||
setAddOpen(false);
|
||||
void runSchemaEdit(`Declare variable "${decl.id}"`, (s) => s.declareVariable(decl));
|
||||
},
|
||||
[sdkSession, runSchemaEdit, showToast],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(decl: CompositionVariable) => {
|
||||
if (!sdkSession) return;
|
||||
const check = sdkSession.can({
|
||||
type: "updateVariableDeclaration",
|
||||
id: decl.id,
|
||||
declaration: decl,
|
||||
});
|
||||
if (!check.ok) {
|
||||
showToast(check.message, "error");
|
||||
return;
|
||||
}
|
||||
setEditingId(null);
|
||||
void runSchemaEdit(`Edit variable "${decl.id}"`, (s) =>
|
||||
s.updateVariableDeclaration(decl.id, decl),
|
||||
);
|
||||
},
|
||||
[sdkSession, runSchemaEdit, showToast],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
if (!sdkSession) return;
|
||||
const check = sdkSession.can({ type: "removeVariableDeclaration", id });
|
||||
if (!check.ok) {
|
||||
showToast(check.message, "error");
|
||||
return;
|
||||
}
|
||||
// Drop the preview override only if the declaration was actually removed —
|
||||
// otherwise a rejected/failed edit would leave the row on disk but silently
|
||||
// wipe the user's custom preview value.
|
||||
void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id)).then(
|
||||
(changed) => {
|
||||
if (changed) dropPreviewOverride(id);
|
||||
},
|
||||
);
|
||||
},
|
||||
[sdkSession, runSchemaEdit, dropPreviewOverride, showToast],
|
||||
);
|
||||
|
||||
const handleSetDefault = useCallback(
|
||||
(id: string, value: string | number | boolean) => {
|
||||
void runSchemaEdit(`Set default for "${id}"`, (s) => s.setVariableValue(id, value));
|
||||
// The override now equals the persisted default — drop it from preview state.
|
||||
dropPreviewOverride(id);
|
||||
},
|
||||
[runSchemaEdit, dropPreviewOverride],
|
||||
);
|
||||
|
||||
const resetPreview = useCallback(() => {
|
||||
setPreviewValues(null);
|
||||
reloadPreview();
|
||||
}, [setPreviewValues, reloadPreview]);
|
||||
|
||||
if (!sdkSession) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<p className="text-xs text-neutral-500">Open a composition to manage its variables.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PreviewModeHeader
|
||||
overrideCount={previewValues ? Object.keys(previewValues).length : 0}
|
||||
onReset={resetPreview}
|
||||
/>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
<ValidationStrip issues={issues} />
|
||||
{declarations.length === 0 && !addOpen && EMPTY_STATE}
|
||||
{/* fallow-ignore-next-line complexity */}
|
||||
{declarations.map((decl) => (
|
||||
<VariableRow
|
||||
key={decl.id}
|
||||
decl={decl}
|
||||
value={
|
||||
previewValues && decl.id in previewValues ? previewValues[decl.id] : decl.default
|
||||
}
|
||||
overridden={previewValues !== null && decl.id in previewValues}
|
||||
unused={
|
||||
usage !== null && !usage.scanIncomplete && usage.unusedDeclarations.includes(decl.id)
|
||||
}
|
||||
editing={editingId === decl.id}
|
||||
onCommitPreview={(v) => commitPreviewValue(decl.id, v, decl.default)}
|
||||
onSetDefault={(v) => handleSetDefault(decl.id, v)}
|
||||
onToggleEdit={() => setEditingId(editingId === decl.id ? null : decl.id)}
|
||||
onSaveEdit={handleUpdate}
|
||||
onRemove={() => handleRemove(decl.id)}
|
||||
/>
|
||||
))}
|
||||
<UndeclaredReads
|
||||
usage={usage}
|
||||
onDeclare={(id) => handleAdd({ id, type: "string", label: id, default: "" })}
|
||||
/>
|
||||
{usage?.scanIncomplete && (
|
||||
<p className="text-[9px] text-neutral-600">
|
||||
Scripts access variables dynamically — usage info may be incomplete.
|
||||
</p>
|
||||
)}
|
||||
{addOpen ? (
|
||||
<DeclarationForm
|
||||
initial={EMPTY_DRAFT}
|
||||
submitLabel="Add variable"
|
||||
onSubmit={handleAdd}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="h-7 w-full rounded-lg border border-dashed border-neutral-800 text-[10px] font-medium text-neutral-500 transition-colors hover:border-neutral-700 hover:text-neutral-300"
|
||||
>
|
||||
+ Add variable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Per-type preview-value inputs for the Variables panel. Text-like inputs
|
||||
* draft locally and commit on blur/Enter so the preview doesn't reload per
|
||||
* keystroke; discrete inputs (checkbox, select, color swatch, range) commit
|
||||
* immediately.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
CompositionVariable,
|
||||
ColorVariable,
|
||||
EnumVariable,
|
||||
NumberVariable,
|
||||
} from "@hyperframes/sdk";
|
||||
|
||||
export const VARIABLES_INPUT_CLASS =
|
||||
"w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700";
|
||||
|
||||
/** Text input that drafts locally and commits on blur/Enter. */
|
||||
function DraftTextInput({
|
||||
value,
|
||||
onCommit,
|
||||
type = "text",
|
||||
className = VARIABLES_INPUT_CLASS,
|
||||
maxLength,
|
||||
placeholder,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
value: string;
|
||||
onCommit: (raw: string) => void;
|
||||
type?: "text" | "number";
|
||||
className?: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={draft ?? value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (draft !== null && draft !== value) onCommit(draft);
|
||||
setDraft(null);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumControl({
|
||||
decl,
|
||||
current,
|
||||
onCommit,
|
||||
}: {
|
||||
decl: EnumVariable;
|
||||
current: unknown;
|
||||
onCommit: (value: unknown) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={String(current)}
|
||||
onChange={(e) => onCommit(e.target.value)}
|
||||
className={VARIABLES_INPUT_CLASS}
|
||||
>
|
||||
{decl.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorControl({
|
||||
current,
|
||||
onCommit,
|
||||
}: {
|
||||
decl: ColorVariable;
|
||||
current: unknown;
|
||||
onCommit: (value: unknown) => void;
|
||||
}) {
|
||||
const colorValue = typeof current === "string" ? current : "#000000";
|
||||
// The native picker fires change continuously while dragging the gradient;
|
||||
// draft locally and commit once on close (blur) — each commit reloads the
|
||||
// whole preview iframe.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={draft ?? (/^#[0-9a-fA-F]{6}$/.test(colorValue) ? colorValue : "#000000")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (draft !== null && draft !== colorValue) onCommit(draft);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="h-6 w-6 cursor-pointer rounded border border-neutral-700 bg-transparent"
|
||||
/>
|
||||
<DraftTextInput
|
||||
value={colorValue}
|
||||
onCommit={onCommit}
|
||||
className={`${VARIABLES_INPUT_CLASS} flex-1 font-mono`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function NumberControl({
|
||||
decl,
|
||||
current,
|
||||
onCommit,
|
||||
}: {
|
||||
decl: NumberVariable;
|
||||
current: unknown;
|
||||
onCommit: (value: unknown) => void;
|
||||
}) {
|
||||
const numberValue = typeof current === "number" ? current : Number(current) || 0;
|
||||
const hasRange = decl.min !== undefined && decl.max !== undefined;
|
||||
// Drag ticks stay local; commit once on release — each commit reloads the
|
||||
// whole preview iframe, so per-tick commits would thrash it.
|
||||
const [dragValue, setDragValue] = useState<number | null>(null);
|
||||
const commitDrag = () => {
|
||||
if (dragValue !== null && dragValue !== numberValue) onCommit(dragValue);
|
||||
setDragValue(null);
|
||||
};
|
||||
const commitRaw = (raw: string) => {
|
||||
const n = Number(raw);
|
||||
onCommit(Number.isFinite(n) ? n : raw);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{hasRange && (
|
||||
<input
|
||||
type="range"
|
||||
min={decl.min}
|
||||
max={decl.max}
|
||||
step={decl.step ?? 1}
|
||||
value={dragValue ?? numberValue}
|
||||
onChange={(e) => setDragValue(Number(e.target.value))}
|
||||
onPointerUp={commitDrag}
|
||||
onKeyUp={commitDrag}
|
||||
onBlur={commitDrag}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<DraftTextInput
|
||||
type="number"
|
||||
value={String(numberValue)}
|
||||
onCommit={commitRaw}
|
||||
min={decl.min}
|
||||
max={decl.max}
|
||||
step={decl.step}
|
||||
className={`${VARIABLES_INPUT_CLASS} ${hasRange ? "w-16" : "flex-1"} tabular-nums`}
|
||||
/>
|
||||
{decl.unit && <span className="text-[9px] text-neutral-500">{decl.unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-type dispatcher — one branch per variable type, same shape as BlockParamsPanel.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PreviewValueControl({
|
||||
decl,
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
decl: CompositionVariable;
|
||||
value: unknown;
|
||||
onCommit: (value: unknown) => void;
|
||||
}) {
|
||||
const current = value === undefined ? decl.default : value;
|
||||
|
||||
switch (decl.type) {
|
||||
case "boolean":
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current === true}
|
||||
onChange={(e) => onCommit(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-neutral-400"
|
||||
/>
|
||||
);
|
||||
case "enum":
|
||||
return <EnumControl decl={decl} current={current} onCommit={onCommit} />;
|
||||
case "color":
|
||||
return <ColorControl decl={decl} current={current} onCommit={onCommit} />;
|
||||
case "number":
|
||||
return <NumberControl decl={decl} current={current} onCommit={onCommit} />;
|
||||
default: {
|
||||
// string / font (family name) / image (URL) — plain text input for v1.
|
||||
const textValue = typeof current === "string" ? current : JSON.stringify(current);
|
||||
return (
|
||||
<DraftTextInput
|
||||
value={textValue}
|
||||
onCommit={onCommit}
|
||||
maxLength={decl.type === "string" ? decl.maxLength : undefined}
|
||||
placeholder={decl.type === "string" ? decl.placeholder : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Ephemeral composition-variable overrides for the preview iframe.
|
||||
*
|
||||
* Values here are NEVER persisted to the composition — they ride the preview
|
||||
* URL as `?variables=<json>` (see the studio-server preview routes), which the
|
||||
* server injects as `window.__hfVariables` exactly like render-time injection,
|
||||
* so what the user previews is what `hyperframes render --variables` produces.
|
||||
* `null` means "preview with declared defaults".
|
||||
*/
|
||||
interface PreviewVariablesState {
|
||||
values: Record<string, unknown> | null;
|
||||
setValues: (values: Record<string, unknown> | null) => void;
|
||||
}
|
||||
|
||||
export const usePreviewVariablesStore = create<PreviewVariablesState>((set) => ({
|
||||
values: null,
|
||||
setValues: (values) => set({ values: values && Object.keys(values).length > 0 ? values : null }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Apply the current preview-variable overrides to a preview URL (both the
|
||||
* Player's initial mount and refreshPlayer's soft reload route through this,
|
||||
* so a hard remount can't silently drop the active overrides).
|
||||
*/
|
||||
export function applyPreviewVariablesToUrl(url: URL): void {
|
||||
const values = usePreviewVariablesStore.getState().values;
|
||||
if (values) {
|
||||
url.searchParams.set("variables", JSON.stringify(values));
|
||||
} else {
|
||||
url.searchParams.delete("variables");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { persistSdkSerialize } from "../utils/sdkCutover";
|
||||
import type { UseSlideshowPersistParams } from "./useSlideshowPersist";
|
||||
|
||||
/** Same single-writer dependency set the slideshow persist path uses. */
|
||||
export type UseVariablesPersistParams = Omit<UseSlideshowPersistParams, "coalesceKey">;
|
||||
|
||||
/**
|
||||
* Persist a variable-schema edit: run `mutate` (SDK declaration/value ops)
|
||||
* against the session, then write the serialized composition through the
|
||||
* standard single-writer path (undo history + self-write echo suppression +
|
||||
* preview reload). Mutations that end up changing nothing are skipped, so a
|
||||
* no-op dispatch (e.g. declaring a duplicate id) never pollutes undo history.
|
||||
*/
|
||||
export function useVariablesPersist({
|
||||
sdkSession,
|
||||
activeCompPath,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
}: UseVariablesPersistParams): (
|
||||
label: string,
|
||||
mutate: (session: Composition) => void,
|
||||
) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (label: string, mutate: (session: Composition) => void) => {
|
||||
if (!sdkSession) return false;
|
||||
const path = activeCompPath ?? "index.html";
|
||||
const originalContent = await readProjectFile(path);
|
||||
mutate(sdkSession);
|
||||
const after = sdkSession.serialize();
|
||||
if (after === originalContent) return false;
|
||||
await persistSdkSerialize(
|
||||
after,
|
||||
path,
|
||||
originalContent,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
},
|
||||
{ label },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
sdkSession,
|
||||
activeCompPath,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||
import { HyperframesLoader } from "../../components/ui";
|
||||
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
||||
// at module load, which throws under SSR. Defer the import to the mount effect
|
||||
@@ -154,7 +155,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
|
||||
// Create the web component imperatively to avoid JSX custom-element typing.
|
||||
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
||||
const src = directUrl || `/api/projects/${projectId}/preview`;
|
||||
const srcUrl = new URL(
|
||||
directUrl || `/api/projects/${projectId}/preview`,
|
||||
window.location.origin,
|
||||
);
|
||||
applyPreviewVariablesToUrl(srcUrl);
|
||||
const src = srcUrl.pathname + srcUrl.search;
|
||||
player.setAttribute("shader-capture-scale", "1");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("src", src);
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||
|
||||
/**
|
||||
* Whether the derived elements differ from the current ones in any field that
|
||||
@@ -127,6 +128,8 @@ export function useTimelinePlayer() {
|
||||
[setElements, setTimelineReady, setDuration],
|
||||
);
|
||||
|
||||
// Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
|
||||
// fallow-ignore-next-line complexity
|
||||
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -209,6 +212,7 @@ export function useTimelinePlayer() {
|
||||
}, []);
|
||||
|
||||
const startRAFLoop = useCallback(() => {
|
||||
// fallow-ignore-next-line complexity
|
||||
const tick = () => {
|
||||
const adapter = getAdapter();
|
||||
if (adapter) {
|
||||
@@ -475,6 +479,7 @@ export function useTimelinePlayer() {
|
||||
const src = iframe.src;
|
||||
const url = new URL(src, window.location.origin);
|
||||
url.searchParams.set("_t", String(Date.now()));
|
||||
applyPreviewVariablesToUrl(url);
|
||||
iframe.src = url.toString();
|
||||
}, [saveSeekPosition]);
|
||||
const getAdapterRef = useRef(getAdapter);
|
||||
@@ -484,6 +489,8 @@ export function useTimelinePlayer() {
|
||||
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
||||
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
||||
|
||||
// Pre-existing message-router complexity — surfaced by line shifts, not new logic.
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
const ourIframe = iframeRef.current;
|
||||
|
||||
@@ -13,7 +13,13 @@ export interface AppToast {
|
||||
tone: "error" | "info";
|
||||
}
|
||||
|
||||
export type RightPanelTab = "layers" | "design" | "renders" | "block-params" | "slideshow";
|
||||
export type RightPanelTab =
|
||||
| "layers"
|
||||
| "design"
|
||||
| "renders"
|
||||
| "block-params"
|
||||
| "slideshow"
|
||||
| "variables";
|
||||
export type RightInspectorPane = "layers" | "design";
|
||||
|
||||
export interface RightInspectorPanes {
|
||||
|
||||
Reference in New Issue
Block a user