Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
58 lines
5.0 KiB
JavaScript
58 lines
5.0 KiB
JavaScript
// The window that makes or edits one overlay. Nothing changes in the project until Save / Add.
|
||
import { composer as c } from "../../composer/composer.js";
|
||
import { FONTS, TEXT_DEFAULTS } from "../../shell/overlays.js";
|
||
|
||
const field = (label, control, hint) => c.field.default({ label, hint, control });
|
||
const options = (list) => list.map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) }));
|
||
|
||
function textForm(s) {
|
||
const num = (label, key, min, max, step) => field(label, c.number.md({ label, value: s[key], min, max, step, onChange: (v) => { s[key] = v; } }));
|
||
const tog = (label, key) => c.toggle.default({ label, checked: Boolean(s[key]), onChange: (v) => { s[key] = v; } });
|
||
const col = (label, key) => field(label, c.color.swatch({ label, value: s[key], onChange: (v) => { s[key] = v; } }));
|
||
return [
|
||
field("Text", c.textarea.md({ label: "Text", value: s.text, rows: 3, onInput: (v) => { s.text = v; } })),
|
||
num("Font size (px)", "font_size", 8, 400, 1),
|
||
field("Font", c.select.md({ label: "Font", value: s.font_family, options: options(FONTS), onChange: (v) => { s.font_family = v; } })),
|
||
col("Colour", "color"),
|
||
field("Opacity", c.slider.readout({ label: "Opacity", value: s.opacity, min: 0, max: 1, step: 0.05, precision: 2, onCommit: (v) => { s.opacity = v; } })),
|
||
tog("Bold", "bold"), tog("Italic", "italic"),
|
||
field("Alignment", c.segmented.md({ label: "Alignment", value: s.text_align, options: options(["left", "center", "right"]), onChange: (v) => { s.text_align = v; } })),
|
||
num("Line spacing", "line_spacing", 0.8, 3, 0.1),
|
||
num("Outline width (px)", "stroke_width", 0, 40, 1), col("Outline colour", "stroke_color"),
|
||
tog("Shadow", "shadow"), col("Shadow colour", "shadow_color"),
|
||
tog("Background box", "bg_enabled"), col("Box colour", "bg_color"),
|
||
field("Box opacity", c.slider.readout({ label: "Box opacity", value: s.bg_opacity, min: 0, max: 1, step: 0.05, precision: 2, onCommit: (v) => { s.bg_opacity = v; } })),
|
||
];
|
||
}
|
||
|
||
function imageForm(s, bin) {
|
||
return [
|
||
field("Picture", c.select.md({ label: "Picture", value: s.media_ref || "", options: [{ value: "", label: "Choose a picture…" }, ...(s.media_ref && !bin.some((m) => m.id === s.media_ref) ? [{ value: s.media_ref, label: "(no longer in the media bin)" }] : []), ...bin.map((m) => ({ value: m.id, label: m.name }))],
|
||
onChange: (v) => { s.media_ref = v; s.label = (bin.find((m) => m.id === v) || {}).name || "Image"; } }), bin.length ? undefined : "The media bin has no pictures yet."),
|
||
field("Width (px)", c.number.md({ label: "Width", value: s.width_px, min: 8, step: 1, precision: 0, onChange: (v) => { s.width_px = v; } })),
|
||
c.toggle.default({ label: "Keep the picture's proportions", checked: s.keep_aspect !== false, onChange: (v) => { s.keep_aspect = v; if (!v && s.height_px == null) s.height_px = s.width_px; } }),
|
||
field("Height (px)", c.number.md({ label: "Height", value: s.height_px ?? s.width_px, min: 8, step: 1, precision: 0, onChange: (v) => { s.height_px = v; } }), "Used only when the proportions are not kept."),
|
||
field("Opacity", c.slider.readout({ label: "Opacity", value: s.opacity ?? 1, min: 0, max: 1, step: 0.05, precision: 2, onCommit: (v) => { s.opacity = v; } })),
|
||
c.toggle.default({ label: "Flip left–right", checked: Boolean(s.flip_h), onChange: (v) => { s.flip_h = v; } }),
|
||
c.toggle.default({ label: "Flip upside down", checked: Boolean(s.flip_v), onChange: (v) => { s.flip_v = v; } }),
|
||
];
|
||
}
|
||
|
||
/** kind "text"|"image", `existing` an overlay (edit) or null (new), `bin` the pictures to choose from.
|
||
* `onSave(patch)` runs once with the chosen values; the window closes after it. */
|
||
export function openDialog({ kind, existing, bin = [], projectWidth = 768, onSave }) {
|
||
const s = kind === "text" ? { ...TEXT_DEFAULTS, text: "Title", ...(existing || {}) } : { width_px: Math.round(projectWidth * 0.35), keep_aspect: true, opacity: 1, ...(existing || {}) };
|
||
const where = field("Starts at (s)", c.number.md({ label: "Starts at", value: existing ? existing.start_sec : undefined, min: 0, step: 0.1, onChange: (v) => { s.start_sec = v; } }));
|
||
const dur = field("Shown for (s)", c.number.md({ label: "Shown for", value: s.duration_sec ?? 3, min: 0.1, step: 0.1, onChange: (v) => { s.duration_sec = v; } }));
|
||
const body = c.region.stack({ gap: "sm", children: [...(kind === "text" ? textForm(s) : imageForm(s, bin)), ...(existing ? [where, dur] : [])] });
|
||
const save = () => {
|
||
if (kind === "image" && !s.media_ref) return c.toast.warn({ text: "Choose a picture first." });
|
||
if (kind === "text") s.text = String(s.text || "").trim() || "Title"; // what the render would draw anyway
|
||
onSave(s);
|
||
win.close("done");
|
||
};
|
||
const win = c.modal.generic({ title: `${existing ? "Edit" : "Add"} ${kind} overlay`, size: "md", body });
|
||
win.setFooter({ actions: [c.button.sm({ label: "Cancel", tone: "ghost", onClick: () => win.close("cancel") }), c.button.sm({ label: existing ? "Save" : "Add", tone: "primary", onClick: save })] });
|
||
return win;
|
||
}
|