diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9213793f7a..7e716c10b9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -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"; diff --git a/packages/studio/src/components/PanelTabButton.tsx b/packages/studio/src/components/PanelTabButton.tsx new file mode 100644 index 0000000000..1f87cf8ad8 --- /dev/null +++ b/packages/studio/src/components/PanelTabButton.tsx @@ -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 ( + + + + ); +} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 31f504208b..4901106d9e 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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"; @@ -467,64 +468,38 @@ export function StudioRightPanel({
{STUDIO_INSPECTOR_PANELS_ENABLED && ( <> - - - - - - + handleInspectorPaneButtonClick("design")} + /> + handleInspectorPaneButtonClick("layers")} + /> )} - - - - - - + 0 ? `Renders (${renderJobs.length})` : "Renders"} + tooltip="Render queue and exports" + active={rightPanelTab === "renders"} + onClick={() => setRightPanelTab("renders")} + /> + setRightPanelTab("slideshow")} + /> + setRightPanelTab("variables")} + />
{rightPanelTab === "block-params" && activeBlockParams ? ( @@ -541,6 +516,13 @@ export function StudioRightPanel({ onPersist={onPersistSlideshow} onPersistNotes={onPersistSlideshowNotes} /> + ) : rightPanelTab === "variables" ? ( + ) : layersPaneOpen && designPaneOpen ? (
`${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 = {}; + 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 +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 ( +
+ + {children} +
+ ); +} + +function DefaultField({ + draft, + onChange, +}: { + draft: DeclarationDraft; + onChange: (defaultRaw: string) => void; +}) { + if (draft.type === "boolean") { + return ( + + ); + } + return ( + 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(initial); + const [error, setError] = useState(null); + const editingExisting = initial.id.length > 0; + const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + + const submit = () => { + const result = declarationFromDraft(draft); + if (typeof result === "string") { + setError(result); + return; + } + setError(null); + onSubmit(result); + }; + + return ( +
+
+ + set({ id: e.target.value })} + placeholder="title" + className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`} + /> + + + set({ label: e.target.value })} + placeholder="Title" + className={VARIABLES_INPUT_CLASS} + /> + +
+
+ + + + + set({ defaultRaw })} /> + +
+ {draft.type === "number" && ( +
+ {(["min", "max", "step"] as const).map((key) => ( + + set({ [key]: e.target.value })} + className={`${VARIABLES_INPUT_CLASS} tabular-nums`} + /> + + ))} +
+ )} + {draft.type === "enum" && ( + +