Files
m3e-canvas/web/vendor/components/Inspector.tsx
T
pastilhasandClaude Fable 5 e99b4afbf9 the vendoring machinery: one command reproduces the whole state
scripts/vendor.sh clones upstream, rewrites @/ imports to relative
paths, and applies scripts/apply-host.py. web/host.ts is ours and gets
placed into the vendored tree, because upstream declined it.

a script rather than a .patch, and that is not a preference. between
cfde698 and 5c83ac6 — one day — upstream changed an import line,
re-indented the component from 6 spaces to 8, and added an editAccess
guard. a context diff fails on all three, none of which concern us. so
every anchor matches structure with flexible whitespace and re-indents
to whatever it finds, and a missing anchor stops the run loudly.

all three prompt-export paths are wired, which is what upstream said a
complete version needs. the other two clipboard writes are ShareMenu's
share-link and ask-an-AI blurb, and the reason they are excluded is
written down so the count does not read as the same oversight twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:04:34 +01:00

1296 lines
46 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import {
Action,
BACK_TARGET,
CONTENT_W,
Frame,
FramePreset,
HALF_W,
Item,
KIND_SPEC,
PHONE_H,
PHONE_W,
Kind,
NavTab,
Palette,
SWIPE_DIRS,
SwipeDir,
TAPPABLE,
TOGGLEABLE,
TRANSITIONS,
Transition,
VARIANTS,
Variant,
actionSlotsOf,
contentWidth,
defaultTabsFor,
framePresetOf,
cardFillOf,
frameSizeOf,
halfWidth,
isPhoneFrame,
toggleIcon,
iconSlotsOf,
setIconSlot,
variantStyle,
} from "../lib/tokens";
import { IconPicker } from "./IconPicker";
import { Icon } from "./M3Node";
import { ButtonRun, CornerIcon, Field, IconBtn, Section, Segmented, SizePresets, Slider, TidyButton, TidyState, Toggle, TokenChips } from "./ui";
import { AiWriteBtn } from "./AiPanel";
import { popHistory } from "../lib/ai";
import { KIND_TEXT, SWIPE_TEXT, TRANSITION_TEXT, t, useLang } from "../lib/i18n";
import { useHost } from "../lib/host";
/** A text field for a web address: what is typed stays in the box, and only a complete
* http(s) address (or an emptied box) reaches the part. */
function UrlField({ value, onChange, placeholder, p }: { value: string; onChange: (src: string | undefined) => void; placeholder: string; p: Palette }) {
const [text, setText] = useState(value);
useEffect(() => setText(value), [value]);
return (
<div onBlurCapture={() => setText(value)}>
<Field
value={text}
onChange={(v) => {
setText(v);
const s = v.trim();
/* an emptied box removes a URL; a picked file (which shows as an empty box) is left alone */
if (!s && value) onChange(undefined);
else if (/^https?:\/\/\S+$/.test(s)) onChange(s);
}}
placeholder={placeholder}
p={p}
icon="link"
/>
</div>
);
}
export function variantsOf(kind: Kind): { key: Variant; label: string }[] {
const variants = VARIANTS.map((v) => ({ ...v, label: t(v.key) }));
switch (kind) {
case "card":
return [
{ key: "tonal", label: t("filled") },
{ key: "elevated", label: t("elevated") },
{ key: "outlined", label: t("outlined") },
];
case "textField":
return [
{ key: "outlined", label: t("outlined") },
{ key: "filled", label: t("filled") },
];
case "chip":
return [
{ key: "outlined", label: t("outlined") },
{ key: "tonal", label: t("elevated") },
];
case "fab":
case "extendedFab":
case "fabMenu":
return variants.filter((v) => v.key !== "text" && v.key !== "elevated" && v.key !== "outlined");
case "splitButton":
return variants.filter((v) => v.key !== "text");
case "toolbar":
return [
{ key: "tonal", label: t("standard") },
{ key: "filled", label: t("vibrant") },
];
case "iconButton":
return variants.filter((v) => v.key !== "elevated" && v.key !== "text").concat({
key: "text",
label: t("standard"),
});
default:
return variants;
}
}
export function VariantSwatch({
v,
label,
p,
on,
onClick,
small,
}: {
v: Variant;
label: string;
p: Palette;
on: boolean;
onClick: () => void;
small?: boolean;
}) {
const st = variantStyle(v, p);
const h = small ? 32 : 40;
return (
<button
onClick={onClick}
title={label}
aria-label={label}
aria-pressed={on}
className="m3-press"
style={{
height: h,
borderRadius: h / 2,
cursor: "pointer",
fontSize: small ? 11 : 12,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
padding: small ? "0 10px" : "0 12px",
...st,
boxShadow: v === "elevated" ? "0 1px 3px rgba(0,0,0,0.2)" : "none",
outline: on ? `2px solid ${p.primary}` : "2px solid transparent",
outlineOffset: 2,
}}
>
{on && <Icon name="check" size={small ? 14 : 16} />}
{label}
</button>
);
}
const MAX_IMAGE_PX = 1200;
/** hover text for a width preset derived from the selected frame */
export const widthPresetLabel = (v: number, frameWidth = PHONE_W): string | undefined =>
v === frameWidth
? t("screenWidth")
: v === contentWidth(frameWidth)
? t("contentWidth")
: v === halfWidth(frameWidth)
? t("halfWidth")
: frameWidth !== PHONE_W && v === CONTENT_W
? t("columnWidth")
: undefined;
const heightPresetLabel = (v: number, frameHeight = PHONE_H): string | undefined =>
v === frameHeight ? t("screenHeight") : v === frameHeight / 2 ? t("halfHeight") : undefined;
export function FrameSizePicker({
frame,
palette: p,
onChange,
compact,
}: {
frame: Frame;
palette: Palette;
onChange: (preset: FramePreset) => void;
compact?: boolean;
}) {
const lang = useLang();
return (
<Segmented<FramePreset>
options={[
{ key: "phone", icon: "smartphone", label: compact ? undefined : t("phoneFrame", lang), title: t("phoneFrame", lang) },
{ key: "desktop", icon: "desktop_windows", label: compact ? undefined : t("desktopFrame", lang), title: t("desktopFrame", lang) },
]}
value={framePresetOf(frame)}
onChange={onChange}
p={p}
height={compact ? 36 : 40}
grow={!compact}
/>
);
}
/** Downscale a picked file so the document stays small enough for localStorage. */
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const s = Math.min(1, MAX_IMAGE_PX / Math.max(img.width, img.height));
const c = document.createElement("canvas");
c.width = Math.max(1, Math.round(img.width * s));
c.height = Math.max(1, Math.round(img.height * s));
c.getContext("2d")!.drawImage(img, 0, 0, c.width, c.height);
URL.revokeObjectURL(url);
resolve(c.toDataURL("image/webp", 0.86));
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("image"));
};
img.src = url;
});
}
function FrameChips({
frames,
value,
onChange,
p,
back,
small,
}: {
frames: Frame[];
value: string | null;
onChange: (id: string | null) => void;
p: Palette;
/** offer "go back" as a target */
back?: boolean;
small?: boolean;
}) {
const lang = useLang();
const h = small ? 32 : 36;
const chip = (id: string | null, label: string, icon: string) => {
const on = value === id;
return (
<button
key={id ?? "none"}
onClick={() => onChange(id)}
className="m3-press"
style={{
height: h,
padding: "0 12px 0 8px",
borderRadius: h / 2,
border: "none",
background: on ? p.primary : p.surfaceContainerHigh,
color: on ? p.onPrimary : p.onSurfaceVariant,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
maxWidth: "100%",
}}
>
<Icon name={icon} size={18} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
</button>
);
};
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{chip(null, t("none", lang), "block")}
{back && chip(BACK_TARGET, t("goBack", lang), "arrow_back")}
{frames.map((f) => chip(f.id, f.name || t("screen", lang), isPhoneFrame(f) ? "smartphone" : "desktop_windows"))}
</div>
);
}
function TransitionPicker({ value, onChange, p }: { value: Transition; onChange: (t: Transition) => void; p: Palette }) {
const lang = useLang();
return (
<Segmented<Transition>
options={TRANSITIONS.map((tr) => ({ key: tr.key, icon: tr.icon, title: TRANSITION_TEXT[lang][tr.key] }))}
value={value}
onChange={onChange}
p={p}
height={34}
/>
);
}
/** target frame (or back) plus the transition, for one tap target */
function ActionEditor({
frames,
action,
onChange,
p,
}: {
frames: Frame[];
action: Action | undefined;
onChange: (a: Action | undefined) => void;
p: Palette;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<FrameChips
frames={frames}
value={action?.to ?? null}
onChange={(to) => onChange(to ? { to, transition: action?.transition ?? "slide" } : undefined)}
p={p}
back
/>
{action && action.to !== BACK_TARGET && (
<TransitionPicker value={action.transition} onChange={(transition) => onChange({ ...action, transition })} p={p} />
)}
</div>
);
}
/** what a field's AI button needs from the page; `reason` explains a disabled button */
export type AiHooks = { ready: boolean; reason?: string; busy: boolean; onRun: () => void; onCancel: () => void };
/** a multiline field with the AI button under it, fused with a button that swaps the AI text and the original once the AI has written it */
function AiField({ ai, history, onRestore, p, value, onChange, placeholder }: { ai: AiHooks; history?: string[]; onRestore: () => void; p: Palette; value: string; onChange: (v: string) => void; placeholder: string }) {
const lang = useLang();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Field value={value} onChange={onChange} placeholder={placeholder} p={p} multiline rows={3} />
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonRun>
<AiWriteBtn p={p} busy={ai.busy} disabled={!ai.ready} onClick={ai.onRun} onCancel={ai.onCancel} label={t("aiWriteShort", lang)} title={ai.ready ? t("aiWrite", lang) : (ai.reason ?? t("aiNoKey", lang))} />
{!!history?.length && <IconBtn icon="undo" p={p} size={40} on onClick={onRestore} title={t("aiRestore", lang)} />}
</ButtonRun>
</div>
</div>
);
}
export function FrameInspector({
frame,
palette: p,
onChange,
onDelete,
onDuplicate,
onPreview,
prompt,
onSaveImage,
frames,
tidy,
onTidy,
ai,
onSize,
}: {
frame: Frame;
palette: Palette;
onChange: (patch: Partial<Frame>) => void;
onDelete: () => void;
onDuplicate: () => void;
onPreview: () => void;
prompt: string;
onSaveImage: () => Promise<void>;
frames: Frame[];
/** what the tidy button offers: tidy the screen, undo the last tidy, or nothing (already tidy) */
tidy: TidyState;
onTidy: () => void;
ai: AiHooks;
onSize: (preset: FramePreset) => void;
}) {
const lang = useLang();
const host = useHost();
const [copied, setCopied] = useState(false);
const [saving, setSaving] = useState(false);
const [swipeDir, setSwipeDir] = useState<SwipeDir>("left");
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(t);
}, [copied]);
const actionBtn = (icon: string, label: string, onClick: () => void, busy?: boolean) => (
<button
onClick={onClick}
disabled={busy}
className="m3-press"
style={{
flex: 1,
height: 44,
borderRadius: 22,
border: "none",
background: p.secondaryContainer,
color: p.onSecondaryContainer,
fontSize: 13,
fontWeight: 600,
cursor: busy ? "default" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: busy ? 0.6 : 1,
}}
>
<Icon name={icon} size={20} />
{label}
</button>
);
return (
<div className="no-scrollbar" style={{ padding: "12px 12px 20px", overflowY: "auto", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
padding: "6px 6px 6px 14px",
borderRadius: 20,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
}}
>
<Icon name={isPhoneFrame(frame) ? "smartphone" : "desktop_windows"} size={20} />
<span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0 }}>{t("screen", lang)}</span>
<IconBtn icon="play_arrow" p={p} onClick={onPreview} title={t("previewFrom", lang)} size={32} fill />
<IconBtn icon="content_copy" p={p} onClick={onDuplicate} title={t("duplicate", lang)} size={32} />
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("delete", lang)} size={32} />
</div>
<Section id="frame-size" icon="aspect_ratio" title={t("frameSize", lang)} p={p}>
<FrameSizePicker frame={frame} palette={p} onChange={onSize} />
</Section>
<Section id="frame-name" icon="label" title={t("name", lang)} p={p}>
<Field value={frame.name} onChange={(name) => onChange({ name })} placeholder={t("screenName", lang)} p={p} icon={isPhoneFrame(frame) ? "smartphone" : "desktop_windows"} />
</Section>
<Section id="frame-note" icon="notes" title={t("description", lang)} p={p}>
<AiField ai={ai} history={frame.noteHistory} onRestore={() => onChange(popHistory(frame.note, frame.noteHistory, "note", "noteHistory"))} p={p} value={frame.note ?? ""} onChange={(note) => onChange({ note: note || undefined })} placeholder={t("screenDescription", lang)} />
</Section>
<Section id="frame-bg" icon="format_color_fill" title={t("background", lang)} p={p}>
<TokenChips value={frame.bg ?? "surface"} onChange={(bg) => onChange({ bg })} p={p} />
</Section>
<Section id="frame-tidy" icon="align_space_even" title={t("tidy", lang)} p={p}>
<TidyButton state={tidy} onClick={onTidy} p={p} />
</Section>
{frames.length > 1 && (
<Section id="frame-swipe" icon="swipe" title={t("swipeTo", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Segmented<SwipeDir>
options={SWIPE_DIRS.map((d) => ({ key: d.key, icon: d.icon, title: SWIPE_TEXT[lang][d.key], dot: !!frame.swipe?.[d.key] }))}
value={swipeDir}
onChange={setSwipeDir}
p={p}
height={36}
/>
<FrameChips
frames={frames.filter((f) => f.id !== frame.id)}
value={frame.swipe?.[swipeDir] ?? null}
onChange={(to) => {
const swipe = { ...(frame.swipe ?? {}) };
if (to) swipe[swipeDir] = to;
else delete swipe[swipeDir];
onChange({ swipe: Object.keys(swipe).length ? swipe : undefined });
}}
p={p}
small
/>
</div>
</Section>
)}
<Section id="frame-export" icon="ios_share" title={t("export", lang)} p={p}>
<ButtonRun>
{actionBtn(
copied ? "check" : "content_copy",
copied ? t("copied", lang) : t("prompt", lang),
async () => {
try {
let handled = false;
try { handled = host.onExport?.(prompt, null) === true; } catch {}
if (handled) { setCopied(true); return; }
await navigator.clipboard.writeText(prompt);
setCopied(true);
} catch {}
},
)}
{actionBtn(
"image",
saving ? t("saving", lang) : t("saveImage", lang),
async () => {
setSaving(true);
try {
await onSaveImage();
} finally {
setSaving(false);
}
},
saving,
)}
</ButtonRun>
<div
className="no-scrollbar"
style={{
marginTop: 10,
maxHeight: 260,
overflowY: "auto",
borderRadius: 16,
background: p.surfaceContainerLow,
padding: 12,
fontSize: 12,
lineHeight: 1.7,
color: p.onSurfaceVariant,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{prompt}
</div>
</Section>
</div>
);
}
export function Inspector({
ai,
item,
palette: p,
frames,
frame,
onChange,
onDelete,
onDuplicate,
multi,
grouped,
onGroup,
onUngroup,
}: {
/** the AI button beside the behavior field */
ai: AiHooks;
item: Item | null;
palette: Palette;
frames: Frame[];
/** frame containing the selected part; its dimensions bound size controls */
frame?: Frame | null;
onChange: (patch: Partial<Item>) => void;
onDelete: () => void;
onDuplicate: () => void;
multi: number;
/** the selection is exactly one hand-made group */
grouped?: boolean;
onGroup?: () => void;
onUngroup?: () => void;
}) {
const lang = useLang();
const fileRef = useRef<HTMLInputElement>(null);
const slots = item ? iconSlotsOf(item) : [];
const [slotKey, setSlotKey] = useState("icon");
const [pickerOpen, setPickerOpen] = useState(false);
const [actionSlot, setActionSlot] = useState("");
/** editing the "on" look of a toggle button instead of its normal look */
const [onTab, setOnTab] = useState(false);
useEffect(() => {
setSlotKey(item ? (iconSlotsOf(item)[0]?.key ?? "icon") : "icon");
setPickerOpen(false);
setActionSlot("");
setOnTab(false);
}, [item?.id, item?.kind, item?.tabs?.length]);
if (!item) {
if (multi > 1) {
const bigBtn = (icon: string, label: string, onClick?: () => void) => (
<button
onClick={onClick}
className="m3-press"
style={{
height: 48,
borderRadius: 24,
border: "none",
background: p.primary,
color: p.onPrimary,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
}}
>
<Icon name={icon} size={22} />
{label}
</button>
);
return (
<div className="no-scrollbar" style={{ padding: "12px 12px 20px", overflowY: "auto", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
padding: "6px 6px 6px 14px",
borderRadius: 20,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
}}
>
<Icon name={grouped ? "group_work" : "select_all"} size={20} />
<span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0 }}>
{grouped ? t("group", lang) : lang === "en" ? `${multi} ${t("selectedParts", lang)}` : `${multi}${t("selectedParts", lang)}`}
</span>
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("deleteSelection", lang)} size={32} />
</div>
{grouped ? bigBtn("ungroup", t("ungroup", lang), onUngroup) : bigBtn("group_work", t("makeGroup", lang), onGroup)}
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.5, color: p.onSurfaceVariant, padding: "0 6px" }}>
{grouped ? t("groupEditNote", lang) : `${t("groupHint", lang)} (Ctrl+G)`}
</div>
</div>
);
}
return (
<div
style={{
height: "100%",
display: "grid",
placeItems: "center",
color: p.outlineVariant,
padding: 24,
textAlign: "center",
}}
>
<Icon name="ads_click" size={44} />
</div>
);
}
const spec = KIND_SPEC[item.kind];
const frameSize = frame ? frameSizeOf(frame) : { w: PHONE_W, h: PHONE_H };
const mapWidthPreset = (v: number) =>
v === PHONE_W ? frameSize.w : v === CONTENT_W ? contentWidth(frameSize.w) : v === HALF_W ? halfWidth(frameSize.w) : v;
const mapHeightPreset = (v: number) => (v === PHONE_H ? frameSize.h : v === PHONE_H / 2 ? frameSize.h / 2 : v);
const widthMax = (max: number) =>
max === PHONE_W ? frameSize.w : max === CONTENT_W ? contentWidth(frameSize.w) : max;
const heightMax = (max: number) => (max === PHONE_H ? frameSize.h : max);
const editOn = !!item.toggle && onTab;
/* the on-state is edited through the same text / icon / style controls:
* `shown` is what they display, `change` routes their patches into `toggle` */
const shown: Item = editOn
? {
...item,
label: item.toggle?.label ?? item.label,
icon: toggleIcon(item),
variant: item.toggle?.variant ?? item.variant,
}
: item;
const change = (patch: Partial<Item>) => {
if (!editOn) {
onChange(patch);
return;
}
const next = { ...(item.toggle ?? {}) };
if ("label" in patch) next.label = patch.label;
if ("icon" in patch) next.icon = patch.icon;
if ("variant" in patch) next.variant = patch.variant;
onChange({ toggle: next });
};
const activeSlot: { key: string; value: string | null } | undefined = (() => {
const s = slots.find((x) => x.key === slotKey) ?? slots[0];
return s && editOn && s.key === "icon" ? { ...s, value: shown.icon } : s;
})();
const actionSlots = actionSlotsOf(item);
const slotBtn = (key: string, label: string | undefined, icon: string | null, on: boolean, onClick: () => void, dim?: boolean) => (
<button
key={key}
onClick={onClick}
title={label}
className="m3-press"
style={{
height: 44,
minWidth: 44,
padding: label ? "0 14px 0 10px" : 0,
borderRadius: 22,
border: "none",
background: on ? p.primary : p.surfaceContainerHigh,
color: on ? p.onPrimary : dim ? p.outline : p.onSurface,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
}}
>
<Icon name={icon ?? "block"} size={22} />
{label && <span>{label}</span>}
</button>
);
const tabs: NavTab[] = item.tabs ?? [];
const variants = spec.hasVariant ? variantsOf(item.kind) : [];
const setTabCount = (n: number) => {
const next: NavTab[] = [];
const defaults = defaultTabsFor(item.kind);
for (let i = 0; i < n; i++) next.push(tabs[i] ? { ...tabs[i] } : { ...defaults[i % defaults.length] });
onChange({ tabs: next });
};
/** entries of a tab row have no icon; toolbar buttons have no label */
const tabIcons = item.kind !== "tabs";
const tabLabels = item.kind !== "toolbar";
const mainSlots = slots.filter((s) => !s.key.startsWith("tab:"));
const setTabLabel = (i: number, label: string) =>
onChange({ tabs: tabs.map((t, j) => (j === i ? { ...t, label } : t)) });
/** bars, rails and tab rows show one destination as selected */
const hasSelected = item.kind === "bottomNav" || item.kind === "navRail" || item.kind === "tabs";
const selectedTab = Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1));
const hasRadius =
item.kind === "bottomNav" ||
item.kind === "navRail" ||
item.kind === "topAppBar" ||
item.kind === "card" ||
item.kind === "image" ||
item.kind === "box";
return (
<div className="no-scrollbar" style={{ padding: "12px 12px 20px", overflowY: "auto", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
padding: "6px 6px 6px 14px",
borderRadius: 20,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
}}
>
<Icon name={spec.paletteIcon} size={20} />
<span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0 }}>{KIND_TEXT[lang][item.kind]?.noun ?? spec.label}</span>
<IconBtn icon="content_copy" p={p} onClick={onDuplicate} title={t("duplicateKey", lang)} size={32} />
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("delete", lang)} size={32} />
</div>
{TOGGLEABLE.includes(item.kind) && (
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: "10px 4px 12px", marginBottom: 12 }}>
<Toggle
on={!!item.toggle}
onChange={(on) => {
onChange({ toggle: on ? {} : undefined });
setOnTab(on);
setPickerOpen(false);
}}
p={p}
icon="swap_horiz"
label={t("toggle", lang)}
grow
/>
{item.toggle && (
<>
<Segmented<"off" | "on">
options={[
{ key: "off", icon: "radio_button_unchecked", label: t("normalState", lang) },
{ key: "on", icon: "check_circle", label: t("onState", lang) },
]}
value={onTab ? "on" : "off"}
onChange={(k) => {
setOnTab(k === "on");
setPickerOpen(false);
}}
p={p}
height={36}
/>
{editOn && <div style={{ fontSize: 11, color: p.onSurfaceVariant, padding: "0 4px" }}>{t("onStateHint", lang)}</div>}
</>
)}
</div>
)}
{(spec.hasLabel || spec.hasSupporting) && (
<Section id="text" icon="title" title={t("text", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{spec.hasLabel && (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<Field
value={shown.label}
onChange={(label) => change({ label })}
placeholder={t("label", lang)}
p={p}
icon="short_text"
/>
{item.kind === "text" && (
<IconBtn
icon="format_bold"
p={p}
size={44}
on={!!item.bold}
onClick={() => onChange({ bold: !item.bold })}
title={t("bold", lang)}
/>
)}
</div>
)}
{spec.hasSupporting && !editOn && (
<Field
value={item.supporting ?? ""}
onChange={(supporting) => onChange({ supporting })}
placeholder={item.kind === "snackbar" ? t("action", lang) : t("supporting", lang)}
p={p}
icon="notes"
/>
)}
</div>
</Section>
)}
{spec.hasTabs && !editOn && (
<Section id="tabs" icon="view_column" title={t("tabs", lang)} p={p}>
<Segmented
options={(item.kind === "toolbar" ? [2, 3, 4, 5, 6] : [2, 3, 4, 5]).map((n) => ({ key: String(n), label: String(n) }))}
value={String(tabs.length)}
onChange={(k) => setTabCount(Number(k))}
p={p}
height={36}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
{tabs.map((tab, i) => {
const on = slotKey === `tab:${i}` && pickerOpen;
return (
<div key={i} style={{ display: "flex", gap: 6, alignItems: "center" }}>
{hasSelected && (
<IconBtn
icon={selectedTab === i ? "radio_button_checked" : "radio_button_unchecked"}
p={p}
size={40}
on={selectedTab === i}
onClick={() => onChange({ selected: i })}
title={t("selectedTab", lang)}
/>
)}
{tabIcons && (
<button
onClick={() => {
setSlotKey(`tab:${i}`);
setPickerOpen(true);
}}
title={t("changeIcon", lang)}
aria-label={t("changeIcon", lang)}
className="m3-press"
style={{
width: 40,
height: 40,
flex: "0 0 auto",
borderRadius: 20,
border: "none",
background: on ? p.primary : p.surfaceContainerHigh,
color: on ? p.onPrimary : p.onSurfaceVariant,
cursor: "pointer",
display: "grid",
placeItems: "center",
}}
>
<Icon name={tab.icon || "add"} size={20} />
</button>
)}
{tabLabels && <Field value={tab.label} onChange={(v) => setTabLabel(i, v)} placeholder={t("label", lang)} p={p} height={40} />}
{tabIcons && tab.icon && (
<IconBtn icon="close" p={p} size={40} onClick={() => onChange(setIconSlot(item, `tab:${i}`, null))} title={t("noIcon", lang)} />
)}
</div>
);
})}
</div>
</Section>
)}
{(item.kind === "image" || item.kind === "card") && !editOn && (
<Section id="image" icon="image" title={t("image", lang)} p={p}>
{item.kind === "card" && (
<div style={{ marginBottom: 10 }}>
<Toggle on={!item.noImage} onChange={(on) => onChange({ noImage: on ? undefined : true })} p={p} icon="image" label={t("imageArea", lang)} grow />
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (!f) return;
try {
onChange({ src: await readImage(f) });
} catch {}
}}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button
onClick={() => fileRef.current?.click()}
className="m3-press"
style={{
flex: 1,
height: 44,
borderRadius: 22,
border: "none",
background: p.primary,
color: p.onPrimary,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<Icon name="upload" size={20} />
{t("pickImage", lang)}
</button>
{item.src && (
<IconBtn icon="close" p={p} size={44} onClick={() => onChange({ src: undefined })} title={t("removeImage", lang)} />
)}
</div>
{/* a picture on the web by its address; a picked file shows as data and is not editable here */}
<div style={{ marginTop: 8 }}>
<UrlField key={item.id} value={item.src && /^https?:\/\//.test(item.src) ? item.src : ""} onChange={(src) => onChange({ src })} placeholder={t("imageUrl", lang)} p={p} />
</div>
</Section>
)}
{mainSlots.length > 0 && activeSlot && !item.src && (
<Section id="icon" icon="emoji_symbols" title={t("icon", lang)} p={p}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{mainSlots.map((s) =>
slotBtn(
s.key,
mainSlots.length > 1 ? s.label : undefined,
editOn && s.key === "icon" ? shown.icon : s.value,
s.key === activeSlot.key && pickerOpen,
() => {
const on = s.key === activeSlot.key && pickerOpen;
setSlotKey(s.key);
setPickerOpen(!on);
},
!s.value,
),
)}
{activeSlot.value && !activeSlot.key.startsWith("tab:") && (
<IconBtn
icon="close"
p={p}
size={44}
onClick={() => {
// a slot without an icon cannot be tapped, so its action goes too
const patch: Partial<Item> = setIconSlot(item, activeSlot.key, null);
if (!editOn && item.actions?.[activeSlot.key]) {
const actions = { ...item.actions };
delete actions[activeSlot.key];
patch.actions = Object.keys(actions).length ? actions : undefined;
}
change(patch);
}}
title={t("noIcon", lang)}
/>
)}
</div>
</Section>
)}
{pickerOpen && activeSlot && (
<div style={{ margin: "-4px 4px 12px" }}>
<IconPicker
value={activeSlot.value}
onChange={(icon) => change(setIconSlot(item, activeSlot.key, icon))}
palette={p}
/>
</div>
)}
{variants.length > 0 && (
<Section id="style" icon="palette" title={t("style", lang)} p={p}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{variants.map((v) => (
<VariantSwatch
key={v.key}
v={v.key}
label={v.label}
p={p}
on={shown.variant === v.key}
onClick={() => change({ variant: v.key })}
/>
))}
</div>
</Section>
)}
{spec.hasFill && !editOn && (
<Section id="fill" icon="format_color_fill" title={t("background", lang)} p={p}>
<TokenChips
value={item.kind === "card" ? cardFillOf(item) : (item.fill ?? "surfaceContainerLow")}
onChange={(fill) => onChange({ fill })}
p={p}
none={item.kind === "card"}
noneOn={item.kind === "card" && !item.fill}
onNone={() => onChange({ fill: undefined })}
/>
{item.kind === "listItem" && (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: p.onSurfaceVariant, margin: "10px 0 6px" }}>{t("iconBackground", lang)}</div>
<TokenChips
value={item.iconFill && item.iconFill !== "none" ? item.iconFill : "primaryContainer"}
onChange={(iconFill) => onChange({ iconFill })}
p={p}
none
noneOn={item.iconFill === "none"}
onNone={() => onChange({ iconFill: "none" })}
/>
</>
)}
</Section>
)}
{(spec.hasChecked || spec.hasValue || spec.hasWavy || spec.hasContained || item.kind === "listItem") && !editOn && (
<Section id="state" icon="tune" title={t("state", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "2px 0" }}>
{item.kind === "listItem" && (
/* a switch at the trailing end takes the place of the trailing icon */
<Toggle on={!!item.switch} onChange={(on) => onChange({ switch: on || undefined })} p={p} icon="toggle_on" label={t("listSwitch", lang)} grow />
)}
{item.kind === "listItem" && item.switch && (
<Toggle on={!!item.checked} onChange={(checked) => onChange({ checked })} p={p} icon="toggle_on" label={t("on", lang)} grow />
)}
{spec.hasChecked && (
<Toggle
on={!!item.checked}
onChange={(checked) => onChange({ checked })}
p={p}
icon={item.kind === "chip" ? "check_circle" : item.kind === "box" ? "drag_handle" : "toggle_on"}
label={item.kind === "chip" ? t("selected", lang) : item.kind === "box" ? t("handle", lang) : t("on", lang)}
grow
/>
)}
{item.kind === "switch" && (
<Toggle on={!item.noCheck} onChange={(on) => onChange({ noCheck: on ? undefined : true })} p={p} icon="check" label={t("thumbCheck", lang)} grow />
)}
{spec.hasContained && (
<Toggle
on={!!item.contained}
onChange={(contained) => onChange({ contained })}
p={p}
icon="circle"
label={t("container", lang)}
grow
/>
)}
{spec.hasWavy && (
<Toggle on={!!item.wavy} onChange={(wavy) => onChange({ wavy })} p={p} icon="airwave" label={t("wavy", lang)} grow />
)}
{spec.hasValue && item.kind !== "slider" && (
<Toggle
on={item.value !== undefined}
onChange={(on) => onChange({ value: on ? 60 : undefined })}
p={p}
icon="percent"
label={t("determinate", lang)}
grow
/>
)}
{spec.hasValue && (item.kind === "slider" || item.value !== undefined) && (
<Slider
icon="percent"
value={item.value ?? 40}
min={0}
max={100}
step={1}
onChange={(value) => onChange({ value })}
p={p}
unit="%"
/>
)}
</div>
</Section>
)}
{(spec.size || hasRadius) && !editOn && (
<Section id="size" icon="straighten" title={t("size", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{spec.size && (
<>
<Slider
icon={spec.size.icon}
title={
item.kind === "text"
? t("fontSize", lang)
: spec.size.icon === "width"
? t("width", lang)
: t("size", lang)
}
value={item.size ?? spec.defSize ?? spec.w}
min={spec.size.min}
max={widthMax(spec.size.max)}
step={spec.size.step}
onChange={(size) => onChange({ size })}
p={p}
unit={item.kind === "text" ? "sp" : ""}
/>
{spec.size.presets && (
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
{(item.kind === "button" || item.kind === "switch") && (
/* these two are as wide as their text unless a width was set; this chip goes back to that */
<button
onClick={() => onChange({ size: undefined })}
aria-pressed={item.size === undefined}
className="m3-press"
style={{
height: 28,
padding: "0 12px",
borderRadius: 14,
border: "none",
background: item.size === undefined ? p.secondaryContainer : p.surfaceContainerHigh,
color: item.size === undefined ? p.onSecondaryContainer : p.onSurfaceVariant,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
{t("autoWidth", lang)}
</button>
)}
<SizePresets
values={[...new Set([...(frameSize.w !== PHONE_W && spec.size.icon === "width" && spec.size.presets.includes(CONTENT_W) ? [CONTENT_W] : []), ...spec.size.presets.map(mapWidthPreset)])].sort((a, b) => a - b)}
value={item.size ?? spec.defSize ?? spec.w}
min={spec.size.min}
max={widthMax(spec.size.max)}
onChange={(size) => onChange({ size })}
p={p}
labelOf={item.kind === "text" ? undefined : (v) => widthPresetLabel(v, frameSize.w)}
/>
</div>
)}
</>
)}
{spec.size2 && (
<>
<Slider
icon={spec.size2.icon}
title={t("height", lang)}
value={item.size2 ?? spec.h}
min={spec.size2.min}
max={heightMax(spec.size2.max)}
step={spec.size2.step}
onChange={(size2) => onChange({ size2 })}
p={p}
/>
{spec.size2.presets && (
<SizePresets
values={[...new Set(spec.size2.presets.map(mapHeightPreset))]}
value={item.size2 ?? spec.h}
min={spec.size2.min}
max={heightMax(spec.size2.max)}
onChange={(size2) => onChange({ size2 })}
p={p}
labelOf={(v) => heightPresetLabel(v, frameSize.h)}
/>
)}
</>
)}
{hasRadius && (item.kind === "card" || item.kind === "image") && (
<Slider
icon="rounded_corner"
title={t("cornerRadius", lang)}
value={item.radiusTop ?? spec.radius}
min={0}
max={48}
step={1}
onChange={(radiusTop) => onChange({ radiusTop })}
p={p}
/>
)}
{hasRadius && item.kind === "box" && (
<Toggle
on={!!item.corners}
onChange={(each) =>
onChange(
each
? { corners: { tl: item.radiusTop ?? 0, tr: item.radiusTop ?? 0, bl: item.radiusBottom ?? 0, br: item.radiusBottom ?? 0 } }
: { corners: undefined, radiusTop: item.corners?.tl ?? item.radiusTop, radiusBottom: item.corners?.bl ?? item.radiusBottom },
)
}
p={p}
icon="crop_free"
label={t("cornersEach", lang)}
grow
/>
)}
{hasRadius && item.kind === "box" && item.corners && (
<>
{(["tl", "tr", "bl", "br"] as const).map((k) => (
<Slider
key={k}
iconNode={<CornerIcon side={k} />}
title={t(k === "tl" ? "cornerTl" : k === "tr" ? "cornerTr" : k === "bl" ? "cornerBl" : "cornerBr", lang)}
value={item.corners![k]}
min={0}
max={40}
step={1}
onChange={(v) => onChange({ corners: { ...item.corners!, [k]: v } })}
p={p}
/>
))}
</>
)}
{hasRadius && (item.kind === "bottomNav" || item.kind === "navRail" || item.kind === "topAppBar" || (item.kind === "box" && !item.corners)) && (
<>
{/* a rail's two sliders are its left and right sides; the fields are shared with the bars */}
<Slider
iconNode={<CornerIcon side={item.kind === "navRail" ? "left" : "top"} />}
title={t(item.kind === "navRail" ? "cornerLeft" : "cornerTop", lang)}
value={item.radiusTop ?? 0}
min={0}
max={40}
step={1}
onChange={(radiusTop) => onChange({ radiusTop })}
p={p}
/>
<Slider
iconNode={<CornerIcon side={item.kind === "navRail" ? "right" : "bottom"} />}
title={t(item.kind === "navRail" ? "cornerRight" : "cornerBottom", lang)}
value={item.radiusBottom ?? 0}
min={0}
max={40}
step={1}
onChange={(radiusBottom) => onChange({ radiusBottom })}
p={p}
/>
</>
)}
</div>
</Section>
)}
{(TAPPABLE.includes(item.kind) || actionSlots.length > 0) && frames.length > 0 && !editOn && (
<Section id="action" icon="ads_click" title={t("tapTo", lang)} p={p}>
{actionSlots.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Segmented<string>
options={actionSlots.map((s) => ({
key: s.key,
icon: s.value ?? undefined,
label: s.value ? undefined : s.label,
title: s.label,
dot: !!item.actions?.[s.key],
}))}
value={actionSlot || actionSlots[0].key}
onChange={setActionSlot}
p={p}
height={40}
/>
{(() => {
const key = actionSlot || actionSlots[0].key;
return (
<ActionEditor
frames={frames}
action={item.actions?.[key]}
onChange={(a) => {
const actions = { ...(item.actions ?? {}) };
if (a) actions[key] = a;
else delete actions[key];
onChange({ actions: Object.keys(actions).length ? actions : undefined });
}}
p={p}
/>
);
})()}
</div>
) : (
<ActionEditor frames={frames} action={item.action} onChange={(action) => onChange({ action })} p={p} />
)}
</Section>
)}
{!editOn && (
<Section id="note" icon="bolt" title={t("behavior", lang)} p={p}>
<AiField
ai={ai}
history={item.noteHistory}
onRestore={() => onChange(popHistory(item.note, item.noteHistory, "note", "noteHistory"))}
p={p}
value={item.note ?? ""}
onChange={(note) => onChange({ note })}
placeholder={item.kind === "button" || item.kind === "fab" || item.kind === "iconButton" || item.kind === "extendedFab" ? t("whenPressed", lang) : t("whatItDoes", lang)}
/>
</Section>
)}
</div>
);
}