Properties panel
Forms for the selected clip, generated from the command schemas.
A properties panel reads the selected clip and dispatches clip/set-property when a field changes. The field types, ranges and enums come from the command catalog, so the form doesn't hard-code validation.
The selected clip
import type { Clip, ProjectState } from "@miraiclip/core";
const selectClip = (s: ProjectState) =>
s.selection.length === 1 ? s.doc.clips[s.selection[0]!] : undefined;
project.subscribe(selectClip, (clip) => {
if (clip) renderPanel(clip);
else clearPanel();
});
declare function renderPanel(clip: Clip): void;
declare function clearPanel(): void;The selector returns the clip object itself. It's a new object only when that clip changed, so other edits don't re-render the panel. It returns undefined for a removed clip, because removing a clip leaves its id in selection.
Fields per clip kind
clip/set-property has one schema for every kind. A field that doesn't apply to the clip's kind is rejected (CommandRejectedError), so pick fields by kind:
| Kind | Fields |
|---|---|
| All | transform (x, y, scale, rotation, opacity); nested fields merge |
video, audio | volume, fadeInUs, fadeOutUs |
image | transform only |
text | text, fontFamily, fontSizePx, color, fontWeight, fontStyle, lineHeight, letterSpacing, textAlign |
caption | style (nested fields merge), words |
html | template, params, unsetParams, widthPx, heightPx, animated |
Rejection code | Cause |
|---|---|
no-audio | volume or fades on a clip without sound |
not-text | A text field on a non-text clip |
not-caption | style or words on a non-caption clip |
not-html | An html field on a non-html clip |
Custom clip kinds
clip/set-property has no props field. Unknown fields are stripped by validation, so { props: … } changes nothing. Edit a custom kind's props with a custom command.
Fields from the schema
project.commandCatalog() returns a JSON Schema per command. Read the clip/set-property properties to build inputs. Optional fields that can be cleared are anyOf: [<type>, { type: "null" }].
import { tryDispatch, type Clip } from "@miraiclip/core";
interface JsonSchema {
type?: string;
enum?: string[];
anyOf?: JsonSchema[];
minimum?: number;
maximum?: number;
exclusiveMinimum?: number;
multipleOf?: number;
properties?: Record<string, JsonSchema>;
}
const setProperty = project.commandCatalog()["clip/set-property"] as JsonSchema;
const FIELDS: Record<string, string[]> = {
video: ["volume", "fadeInUs", "fadeOutUs"],
audio: ["volume", "fadeInUs", "fadeOutUs"],
text: ["text", "fontFamily", "fontSizePx", "color", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "textAlign"],
};
function field(clip: Clip, key: string, onError: (message: string) => void): HTMLElement {
const schema = setProperty.properties![key]!;
const nullable = schema.anyOf?.some((s) => s.type === "null") ?? false;
const base = schema.anyOf?.find((s) => s.type !== "null") ?? schema;
const current = (clip as unknown as Record<string, unknown>)[key];
let input: HTMLInputElement | HTMLSelectElement;
if (base.enum) {
input = document.createElement("select");
const options = nullable ? ["", ...base.enum] : base.enum; // "" = default
input.append(...options.map((value) => new Option(value || "Default", value)));
} else {
input = document.createElement("input");
if (base.type === "number" || base.type === "integer") {
input.type = "number";
if (base.minimum !== undefined) input.min = String(base.minimum);
if (base.maximum !== undefined) input.max = String(base.maximum);
input.step = String(base.multipleOf ?? (base.type === "integer" ? 1 : "any"));
}
}
input.value = current === undefined ? "" : String(current);
input.addEventListener("change", () => {
const raw = input.value;
const isNumber = base.type === "number" || base.type === "integer";
const value = raw === "" && nullable ? null : isNumber ? Number(raw) : raw;
const result = tryDispatch(project, { type: "clip/set-property", payload: { clipId: clip.id, [key]: value } });
if (!result.ok) onError(result.error.message);
});
return input;
}
function fieldsFor(clip: Clip, onError: (message: string) => void): HTMLElement[] {
return (FIELDS[clip.kind] ?? []).map((key) => field(clip, key, onError));
}| Schema | Input |
|---|---|
enum | <select> |
type: "number" / "integer" | Number input or slider; minimum, maximum, exclusiveMinimum, multipleOf give the limits |
type: "string" | Text input. Colors are plain strings (#rrggbb); use a color input for color |
anyOf with null | Optional: offer a "Default" or reset control that sends null |
type: "object" | Nested group (transform, caption style); send only the changed key |
tryDispatch returns { ok: false, error } instead of throwing. error.kind is invalid-payload (with error.issues) or rejected (with error.code). Show error.message next to the field.
The same schemas are exported as Zod objects in builtinPayloadSchemas["clip/set-property"] if you'd rather validate in your form library.
Clearing optional fields
null removes the key, which brings back the default.
import { TYPOGRAPHY_DEFAULTS } from "@miraiclip/core";
project.dispatch({ type: "clip/set-property", payload: { clipId: "title", fontWeight: null, letterSpacing: null } });
// Show the default as a placeholder when the key is absent.
const weightPlaceholder = String(TYPOGRAPHY_DEFAULTS.fontWeight); // "400"Clearable with null | Kinds |
|---|---|
fadeInUs, fadeOutUs | video, audio |
fontWeight, fontStyle, lineHeight, letterSpacing, textAlign | text |
widthPx, heightPx | html |
Optional style fields (backgroundColor, strokeColor, shadowColor, …) | caption |
Sliders and undo
Every dispatch is one undo step. A slider fires input many times per drag, and transaction(fn) is synchronous, so it can't span pointer events. Two ways to get one step per drag:
Commit on release. Dispatch on change only. The preview updates when the drag ends.
const slider = document.querySelector<HTMLInputElement>("#opacity")!;
slider.addEventListener("change", () => {
project.dispatch({ type: "clip/set-property", payload: { clipId: "intro", transform: { opacity: slider.valueAsNumber } } });
});Live, one step. Dispatch on every input, and undo the previous step of the same drag first. The preview follows the slider, and the drag leaves one undo step.
function bindLiveSlider(slider: HTMLInputElement, clipId: string) {
let stepOpen = false; // this drag already added an undo step
let mine = false;
// Another edit landed mid-drag: don't undo it.
project.events.on("history", ({ kind }) => {
if (kind === "commit" && !mine) stepOpen = false;
});
slider.addEventListener("input", () => {
mine = true;
try {
if (stepOpen) project.undo();
project.dispatch({ type: "clip/set-property", payload: { clipId, transform: { opacity: slider.valueAsNumber } } });
stepOpen = true;
} finally {
mine = false;
}
});
slider.addEventListener("change", () => {
stepOpen = false;
});
}The live variant emits patches and history events for every intermediate value, including the undos. If you sync edits to other users, use commit on release; see Collaboration.
A dispatch that changes nothing (the same value again) adds no undo step and emits no events.
Animated properties
A property with keyframes ignores its static value. Editing transform.opacity on a clip with opacity keyframes changes nothing on screen. Show the evaluated value at the playhead and write a keyframe instead:
import { evaluateClipAt } from "@miraiclip/core";
const { doc, playheadUs } = project.getState();
const clip = doc.clips["intro"]!;
const localUs = playheadUs - clip.startUs;
if (clip.animations?.opacity?.length && localUs >= 0 && localUs <= clip.durationUs) {
const shown = evaluateClipAt(clip, localUs).opacity;
project.dispatch({ type: "keyframe/set", payload: { clipId: clip.id, property: "opacity", timeUs: localUs, value: shown } });
}More in Keyframes.
Effects
getEffectInfo(kind) gives labels, ranges, steps and defaults for each built-in effect param. Build the effect section from it and dispatch effect/update, which merges params.
import { getEffectInfo, type EffectInstance } from "@miraiclip/core";
function effectInputs(clipId: string, effect: EffectInstance): HTMLInputElement[] {
const info = getEffectInfo(effect.kind);
if (!info) return []; // a custom kind: use its own schema
return info.params.map((param) => {
const input = document.createElement("input");
input.title = param.label;
if (param.type === "number") {
Object.assign(input, { type: "range", min: String(param.min), max: String(param.max), step: String(param.step) });
} else {
input.type = "color";
}
input.value = String(effect.params[param.key] ?? param.default);
input.addEventListener("change", () => {
const value = param.type === "number" ? input.valueAsNumber : input.value;
project.dispatch({ type: "effect/update", payload: { clipId, effectId: effect.id, params: { [param.key]: value } } });
});
return input;
});
}Effect kinds, categories and the picker: Effects. Typography fields: Typography. Caption styles: Captions.