"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"; /** 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 (
setText(value)}> { 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" />
); } 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 ( ); } 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 ( 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 { 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 ( ); }; return (
{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"))}
); } function TransitionPicker({ value, onChange, p }: { value: Transition; onChange: (t: Transition) => void; p: Palette }) { const lang = useLang(); return ( 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 (
onChange(to ? { to, transition: action?.transition ?? "slide" } : undefined)} p={p} back /> {action && action.to !== BACK_TARGET && ( onChange({ ...action, transition })} p={p} /> )}
); } /** 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 (
{!!history?.length && }
); } export function FrameInspector({ frame, palette: p, onChange, onDelete, onDuplicate, onPreview, prompt, onSaveImage, frames, tidy, onTidy, ai, onSize, }: { frame: Frame; palette: Palette; onChange: (patch: Partial) => void; onDelete: () => void; onDuplicate: () => void; onPreview: () => void; prompt: string; onSaveImage: () => Promise; 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 [copied, setCopied] = useState(false); const [saving, setSaving] = useState(false); const [swipeDir, setSwipeDir] = useState("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) => ( ); return (
{t("screen", lang)}
onChange({ name })} placeholder={t("screenName", lang)} p={p} icon={isPhoneFrame(frame) ? "smartphone" : "desktop_windows"} />
onChange(popHistory(frame.note, frame.noteHistory, "note", "noteHistory"))} p={p} value={frame.note ?? ""} onChange={(note) => onChange({ note: note || undefined })} placeholder={t("screenDescription", lang)} />
onChange({ bg })} p={p} />
{frames.length > 1 && (
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} /> 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 />
)}
{actionBtn( copied ? "check" : "content_copy", copied ? t("copied", lang) : t("prompt", lang), async () => { try { 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, )}
{prompt}
); } 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) => 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(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) => ( ); return (
{grouped ? t("group", lang) : lang === "en" ? `${multi} ${t("selectedParts", lang)}` : `${multi}${t("selectedParts", lang)}`}
{grouped ? bigBtn("ungroup", t("ungroup", lang), onUngroup) : bigBtn("group_work", t("makeGroup", lang), onGroup)}
{grouped ? t("groupEditNote", lang) : `${t("groupHint", lang)} (Ctrl+G)`}
); } return (
); } 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) => { 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) => ( ); 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 (
{KIND_TEXT[lang][item.kind]?.noun ?? spec.label}
{TOGGLEABLE.includes(item.kind) && (
{ onChange({ toggle: on ? {} : undefined }); setOnTab(on); setPickerOpen(false); }} p={p} icon="swap_horiz" label={t("toggle", lang)} grow /> {item.toggle && ( <> 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 &&
{t("onStateHint", lang)}
} )}
)} {(spec.hasLabel || spec.hasSupporting) && (
{spec.hasLabel && (
change({ label })} placeholder={t("label", lang)} p={p} icon="short_text" /> {item.kind === "text" && ( onChange({ bold: !item.bold })} title={t("bold", lang)} /> )}
)} {spec.hasSupporting && !editOn && ( onChange({ supporting })} placeholder={item.kind === "snackbar" ? t("action", lang) : t("supporting", lang)} p={p} icon="notes" /> )}
)} {spec.hasTabs && !editOn && (
({ key: String(n), label: String(n) }))} value={String(tabs.length)} onChange={(k) => setTabCount(Number(k))} p={p} height={36} />
{tabs.map((tab, i) => { const on = slotKey === `tab:${i}` && pickerOpen; return (
{hasSelected && ( onChange({ selected: i })} title={t("selectedTab", lang)} /> )} {tabIcons && ( )} {tabLabels && setTabLabel(i, v)} placeholder={t("label", lang)} p={p} height={40} />} {tabIcons && tab.icon && ( onChange(setIconSlot(item, `tab:${i}`, null))} title={t("noIcon", lang)} /> )}
); })}
)} {(item.kind === "image" || item.kind === "card") && !editOn && (
{item.kind === "card" && (
onChange({ noImage: on ? undefined : true })} p={p} icon="image" label={t("imageArea", lang)} grow />
)} { const f = e.target.files?.[0]; e.target.value = ""; if (!f) return; try { onChange({ src: await readImage(f) }); } catch {} }} />
{item.src && ( onChange({ src: undefined })} title={t("removeImage", lang)} /> )}
{/* a picture on the web by its address; a picked file shows as data and is not editable here */}
onChange({ src })} placeholder={t("imageUrl", lang)} p={p} />
)} {mainSlots.length > 0 && activeSlot && !item.src && (
{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:") && ( { // a slot without an icon cannot be tapped, so its action goes too const patch: Partial = 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)} /> )}
)} {pickerOpen && activeSlot && (
change(setIconSlot(item, activeSlot.key, icon))} palette={p} />
)} {variants.length > 0 && (
{variants.map((v) => ( change({ variant: v.key })} /> ))}
)} {spec.hasFill && !editOn && (
onChange({ fill })} p={p} none={item.kind === "card"} noneOn={item.kind === "card" && !item.fill} onNone={() => onChange({ fill: undefined })} /> {item.kind === "listItem" && ( <>
{t("iconBackground", lang)}
onChange({ iconFill })} p={p} none noneOn={item.iconFill === "none"} onNone={() => onChange({ iconFill: "none" })} /> )}
)} {(spec.hasChecked || spec.hasValue || spec.hasWavy || spec.hasContained || item.kind === "listItem") && !editOn && (
{item.kind === "listItem" && ( /* a switch at the trailing end takes the place of the trailing icon */ onChange({ switch: on || undefined })} p={p} icon="toggle_on" label={t("listSwitch", lang)} grow /> )} {item.kind === "listItem" && item.switch && ( onChange({ checked })} p={p} icon="toggle_on" label={t("on", lang)} grow /> )} {spec.hasChecked && ( 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" && ( onChange({ noCheck: on ? undefined : true })} p={p} icon="check" label={t("thumbCheck", lang)} grow /> )} {spec.hasContained && ( onChange({ contained })} p={p} icon="circle" label={t("container", lang)} grow /> )} {spec.hasWavy && ( onChange({ wavy })} p={p} icon="airwave" label={t("wavy", lang)} grow /> )} {spec.hasValue && item.kind !== "slider" && ( onChange({ value: on ? 60 : undefined })} p={p} icon="percent" label={t("determinate", lang)} grow /> )} {spec.hasValue && (item.kind === "slider" || item.value !== undefined) && ( onChange({ value })} p={p} unit="%" /> )}
)} {(spec.size || hasRadius) && !editOn && (
{spec.size && ( <> onChange({ size })} p={p} unit={item.kind === "text" ? "sp" : ""} /> {spec.size.presets && (
{(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 */ )} 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)} />
)} )} {spec.size2 && ( <> onChange({ size2 })} p={p} /> {spec.size2.presets && ( onChange({ size2 })} p={p} labelOf={(v) => heightPresetLabel(v, frameSize.h)} /> )} )} {hasRadius && (item.kind === "card" || item.kind === "image") && ( onChange({ radiusTop })} p={p} /> )} {hasRadius && item.kind === "box" && ( 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) => ( } 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 */} } title={t(item.kind === "navRail" ? "cornerLeft" : "cornerTop", lang)} value={item.radiusTop ?? 0} min={0} max={40} step={1} onChange={(radiusTop) => onChange({ radiusTop })} p={p} /> } title={t(item.kind === "navRail" ? "cornerRight" : "cornerBottom", lang)} value={item.radiusBottom ?? 0} min={0} max={40} step={1} onChange={(radiusBottom) => onChange({ radiusBottom })} p={p} /> )}
)} {(TAPPABLE.includes(item.kind) || actionSlots.length > 0) && frames.length > 0 && !editOn && (
{actionSlots.length > 0 ? (
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 ( { const actions = { ...(item.actions ?? {}) }; if (a) actions[key] = a; else delete actions[key]; onChange({ actions: Object.keys(actions).length ? actions : undefined }); }} p={p} /> ); })()}
) : ( onChange({ action })} p={p} /> )}
)} {!editOn && (
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)} />
)}
); }