);
}
/** Slider with a typed-in number beside it. The slider moves in `step`s; the
* field accepts any whole number and is clamped to min..max when it commits. */
/** A rectangle whose top or bottom corners are rounded: the corner-radius slider icons. */
/** a pair of corners on one side, or a single corner */
export function CornerIcon({ side, size = 20 }: { side: "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br"; size?: number }) {
const PATHS: Record = {
top: "M4 17 V9 a5 5 0 0 1 5 -5 h6 a5 5 0 0 1 5 5 v8",
bottom: "M4 3 v8 a5 5 0 0 0 5 5 h6 a5 5 0 0 0 5 -5 v-8",
left: "M17 4 H9 a5 5 0 0 0 -5 5 v6 a5 5 0 0 0 5 5 h8",
right: "M7 4 h8 a5 5 0 0 1 5 5 v6 a5 5 0 0 1 -5 5 h-8",
tl: "M5 20 V11 a6 6 0 0 1 6 -6 H20",
tr: "M4 5 H13 a6 6 0 0 1 6 6 V20",
bl: "M5 4 V13 a6 6 0 0 0 6 6 H20",
br: "M20 4 V13 a6 6 0 0 1 -6 6 H4",
};
const d = PATHS[side];
return (
);
}
export function Slider({
icon = "tune",
iconNode,
value,
min,
max,
step,
onChange,
p,
unit = "",
title,
}: {
icon?: string;
/** custom glyph shown instead of the Material Symbol */
iconNode?: React.ReactNode;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
p: Palette;
unit?: string;
title?: string;
}) {
const [text, setText] = useState(String(value));
const [editing, setEditing] = useState(false);
useEffect(() => {
if (!editing) setText(String(value));
}, [value, editing]);
const commit = () => {
const n = Math.round(Number(text));
if (Number.isFinite(n) && text.trim() !== "") onChange(clamp(n, min, max));
setEditing(false);
setText(String(value));
};
return (
{iconNode ?? }
onChange(Number(e.target.value))}
style={{ "--track": p.secondaryContainer, "--thumb": p.primary } as React.CSSProperties}
/>
{
setEditing(true);
setText(String(value));
e.currentTarget.select();
}}
onChange={(e) => {
setText(e.target.value);
// apply as you type once the number is already in range, so the canvas follows
const n = Math.round(Number(e.target.value));
if (e.target.value.trim() !== "" && Number.isFinite(n) && n >= min && n <= max) onChange(n);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") {
setText(String(value));
e.currentTarget.blur();
}
}}
className="m3-number"
style={{
width: unit ? 56 : 52,
height: 30,
padding: unit ? "0 18px 0 6px" : "0 6px",
borderRadius: 8,
border: "none",
background: p.surfaceContainerHigh,
color: p.onSurface,
fontSize: 12,
fontWeight: 600,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
fontFamily: "inherit",
outline: editing ? `2px solid ${p.primary}` : "none",
outlineOffset: -1,
boxSizing: "border-box",
}}
/>
{unit && (
{unit}
)}
);
}
/** Quick picks for a size: the values the frame is built from (screen width,
* content width, half a row) or a component's standard sizes. */
export function SizePresets({
values,
value,
min,
max,
onChange,
p,
labelOf,
}: {
values: number[];
value: number;
min: number;
max: number;
onChange: (v: number) => void;
p: Palette;
/** optional hover text for a value, e.g. "Screen width" for 412 */
labelOf?: (v: number) => string | undefined;
}) {
const shown = values.filter((v) => v >= min && v <= max);
if (shown.length === 0) return null;
return (
{shown.map((v) => {
const on = v === value;
const label = labelOf?.(v);
return (
);
})}
);
}
export function Toggle({
on,
onChange,
p,
icon,
label,
grow,
}: {
on: boolean;
onChange: (v: boolean) => void;
p: Palette;
icon?: string;
label?: string;
/** fill the row and push the switch to the trailing edge */
grow?: boolean;
}) {
return (
);
}
/** Collapsible section; collapsed state is remembered per key. */
export function Section({
id,
icon,
title,
p,
children,
right,
defaultOpen = true,
}: {
id: string;
icon: string;
title: string;
p: Palette;
children: React.ReactNode;
right?: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
try {
const v = localStorage.getItem(`m3e:sec:${id}`);
if (v !== null) setOpen(v === "1");
} catch {}
}, [id]);
const toggle = () => {
setOpen((o) => {
try {
localStorage.setItem(`m3e:sec:${id}`, o ? "0" : "1");
} catch {}
return !o;
});
};
return (
);
}
/** M3 basic dialog for a destructive confirmation. */
/** A run of buttons fused like the canvas's connected buttons: round outside, small corners where they meet. */
export function ButtonRun({ children }: { children: React.ReactNode }) {
return
{children}
;
}
export type TidyState = "tidy" | "undo" | "done";
/** One button that reads as "Tidy", turns into "Undo tidy" right after, and is
* disabled while the screen is already tidy. */
export function TidyButton({ state, onClick, p, pill }: { state: TidyState; onClick: () => void; p: Palette; /** the toolbar version next to the zoom pill */ pill?: boolean }) {
const lang = useLang();
const done = state === "done";
const label = state === "undo" ? t("tidyUndo", lang) : state === "done" ? t("tidyDone", lang) : t("tidy", lang);
return (
);
}
export function ConfirmDialog({
open,
title,
body,
icon = "delete_sweep",
p,
onCancel,
onConfirm,
}: {
open: boolean;
title: string;
body: string;
icon?: string;
p: Palette;
onCancel: () => void;
onConfirm: () => void;
}) {
const lang = useLang();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onCancel();
}
if (e.key === "Enter") {
e.stopPropagation();
onConfirm();
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [open, onCancel, onConfirm]);
const btn = (label: string, primary: boolean, onClick: () => void) => (
);
return (
{open && (
e.stopPropagation()}
style={{
width: "min(100%, 340px)",
padding: 24,
borderRadius: 28,
background: p.surfaceContainerHigh,
color: p.onSurface,
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)",
display: "flex",
flexDirection: "column",
gap: 16,
}}
>