// @ts-nocheck — vendored, see PROVENANCE.md "use client"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { AnimatePresence, motion, useReducedMotion, useSpring } from "motion/react"; import { toPng } from "html-to-image"; import { buildPrompt, effectivePrompt } from "./lib/prompt"; import { Action, actionsOf, Axis, BACK_TARGET, baseRadii, explodeGroup, freeRadii, BEZEL, canJoin, clamp, connectSpecOf, Doc, isPlatform, Platform, Frame, FramePreset, FRAME_GAP, FRAME_LABEL_H, FrameMode, frameOfGroup, framePresetPatch, frameRadius, frameRect, frameSizeOf, carryItemSize, defaultPlatformOf, GAP, Group, groupBounds, Item, Kind, KIND_ORDER, KIND_SPEC, collapseFree, layoutOf, lerp, makeItem, DEFAULT_THEME, Theme, fontFamilyOf, uiFontFamily, normalizeTheme, setGlobalShape, MEASURED, NAV_BAR_H, Palette, paletteOf, PHONE_H, PHONE_MARGIN, PHONE_W, PULL_EXP, Radii, SETTLE_MS, sizeOf, SNAP_CROSS, SNAP_MAIN, Transition, TRANSITIONS, uid, uniformRadii, FULL_WIDTH, fitHeight, } from "./lib/tokens"; import { Icon, M3Node, M3Static, MeasuredContent } from "./components/M3Node"; import { LayersPanel } from "./components/Layers"; import { FrameInspector, FrameSizePicker, Inspector } from "./components/Inspector"; import { Preview } from "./components/Preview"; import { Logo } from "./components/Logo"; import { PartsPalette } from "./components/PartsPalette"; import { PromptPanel } from "./components/PromptPanel"; import { GitHubLink, Mode, Toolbar } from "./components/Toolbar"; import { LangMenu } from "./components/Menus"; import { AiActionKey, AiPanel, aiErrorText } from "./components/AiPanel"; import { TidyState } from "./components/ui"; import { AiSettings, DEFAULT_AI, hasKey, isSecureUrl, loadAiSettings, proposeBehavior, proposeDescription, pushHistory, saveAiSettings } from "./lib/ai"; import { barSlotOf, carryFrame, pullInto, tidyFrame } from "./lib/tidy"; import { isProject, readProject, saveProject } from "./lib/project"; import { hasShareHash, readShareHash } from "./lib/share"; import { LoadingIndicator } from "./components/Loading"; import { draftDesign } from "./lib/ai"; import { ShareDialog } from "./components/ShareMenu"; import { ColorPanel } from "./components/ColorPanel"; import { MotionPanel, ShapePanel, TypePanel } from "./components/ThemePanel"; import { ThemeContext, ensureFontLoaded, ensureLangFontLoaded } from "./lib/theme"; import { BottomSheet, MobileActionBar, MobileInspector, MobileLang, MobileSettings } from "./components/Mobile"; import { ConfirmDialog, IconBtn, Segmented } from "./components/ui"; import { Lang, LangContext, SEED_TEXT, getLang, isLang, setGlobalLang, t, translateDefaultFrameName, translateDefaultText } from "./lib/i18n"; import { useHost } from "./lib/host"; /** the screens while a model drafts: primary, tertiary and primary container, drifting */ const DRAFT_GRADIENT = (p: Palette) => `linear-gradient(120deg, ${p.primaryContainer}, ${p.tertiaryContainer}, ${p.primary}, ${p.secondaryContainer}, ${p.primaryContainer})`; const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; /** the dragged part's own travel: a little lag reads as weight */ const CARRY = { type: "spring" as const, stiffness: 620, damping: 38, mass: 0.7, }; /** the gap opening and the run's counter-shift; identical configs so they cancel */ const OPEN = { type: "spring" as const, stiffness: 700, damping: 42, mass: 0.55, }; const INSTANT = { duration: 0 }; /** the icon rail on the left edge of the parts / layers panel */ const RAIL_W = 52; const MIN_Z = 0.25; const MAX_Z = 3; const HISTORY_MAX = 100; const DOC_KEY = "m3e:doc"; /** the design a draft or a link replaced, until the author keeps or undoes it */ const BEFORE_KEY = "m3e:doc:before"; const DOC_LOCK = "m3e:doc:editor"; const UI_KEY = "m3e:ui"; type View = { x: number; y: number; z: number }; type Snap = { groupId: string; index: number; pull: number }; /** alignment guide: the snapped position plus the line to draw */ type Guide = { x?: number; y?: number; gx?: number; gy?: number }; const GUIDE_PX = 7; const FRAME_MARGIN = PHONE_MARGIN; type DragState = { item: Item; guide: Guide | null; offX: number; offY: number; startX: number; startY: number; px: number; py: number; active: boolean; fromPalette: boolean; overBin: boolean; snap: Snap | null; settling: boolean; }; type Gesture = | { kind: "pan"; sx: number; sy: number; vx: number; vy: number } | { kind: "marquee"; x0: number; y0: number; x1: number; y1: number; moved: boolean; } | { kind: "frame"; id: string; sx: number; sy: number; fx: number; fy: number; groups: { id: string; x: number; y: number }[]; moved: boolean; } | { kind: "group"; id: string; sx: number; sy: number; gx: number; gy: number; moved: boolean; overBin: boolean }; /** everything in a document apart from its screens and parts */ type DocMeta = Omit; /** an undo step: the screens and parts, plus the rest of the document for steps that replaced it all */ type Snapshot = { groups: Group[]; frames: Frame[]; meta?: DocMeta }; /** a screen changing size eases the way a settling part does */ const SIZE_TRANSITION = `width ${SETTLE_MS}ms cubic-bezier(0.2, 0, 0, 1), height ${SETTLE_MS}ms cubic-bezier(0.2, 0, 0, 1), border-radius ${SETTLE_MS}ms cubic-bezier(0.2, 0, 0, 1)`; function translateSnapshot(snap: Snapshot, lang: Lang): Snapshot { return { groups: snap.groups.map((group) => ({ ...group, items: group.items.map((item) => ({ ...item, label: translateDefaultText(item.label, item.kind, "label", lang), ...(item.supporting !== undefined && { supporting: translateDefaultText(item.supporting, item.kind, "supporting", lang) }), ...(item.tabs && { tabs: item.tabs.map((tab) => ({ ...tab, label: translateDefaultText(tab.label, item.kind, "tab", lang) })) }), })), })), frames: snap.frames.map((frame) => ({ ...frame, name: translateDefaultFrameName(frame.name, lang) })), }; } const SEED_FRAMES: Frame[] = [{ id: "seedF1", name: "Home", x: 0, y: 0 }]; /** Documents saved before the bars grew their system insets have the navigation * bar flush with the old 80dp bottom; keep it on the bottom edge. */ function migrateGroups(groups: Group[], frames: Frame[]): Group[] { const oldNavH = KIND_SPEC.bottomNav.h - NAV_BAR_H; return groups.map((g) => { if (g.items.length !== 1 || g.items[0].kind !== "bottomNav") return g; const f = frames.find((fr) => { const r = frameRect(fr); return g.x >= r.l - 1 && g.x <= r.r && g.y === r.b - oldNavH; }); return f ? { ...g, y: frameRect(f).b - KIND_SPEC.bottomNav.h } : g; }); } /** Seed ids are deterministic so server and client render the same markup. */ const seed = (lang: Lang = getLang()): Group[] => { const text = SEED_TEXT[lang]; let n = 0; const sid = () => `seed${++n}`; const mk = (k: Kind) => ({ ...makeItem(k), id: sid() }); const bar = mk("topAppBar"); const a = mk("button"); const b = mk("button"); a.label = text.favorite; a.icon = "star"; b.label = text.share; b.icon = "share"; b.variant = "tonal"; const rows = [text.inbox, text.starred, text.archive].map((t, i) => { const it = mk("listItem"); it.label = t; it.icon = ["inbox", "star", "archive"][i]; it.supporting = text.supporting; return it; }); const nav = mk("bottomNav"); const fab = mk("fab"); return [ { id: sid(), x: 0, y: 0, axis: "x", items: [bar] }, { id: sid(), x: PHONE_MARGIN, y: 96, axis: "x", items: [a, b] }, { id: sid(), x: PHONE_MARGIN, y: 184, axis: "y", items: rows }, { id: sid(), x: PHONE_W - 56 - PHONE_MARGIN, y: PHONE_H - KIND_SPEC.bottomNav.h - 56 - PHONE_MARGIN, axis: "x", items: [fab], }, { id: sid(), x: 0, y: PHONE_H - KIND_SPEC.bottomNav.h, axis: "x", items: [nav] }, ]; }; /** The phone version starts with buttons only: that is all it edits. */ const mobileSeed = (lang: Lang = getLang()): Group[] => { const text = SEED_TEXT[lang]; const mk = (k: Kind) => makeItem(k); const a = mk("button"); const b = mk("button"); const c = mk("button"); a.label = text.favorite; a.icon = "star"; b.label = text.share; b.icon = "share"; b.variant = "tonal"; c.label = text.start; c.icon = "arrow_forward"; return [ { id: uid(), x: PHONE_MARGIN, y: 120, axis: "x", items: [a, b] }, { id: uid(), x: PHONE_MARGIN, y: 200, axis: "x", items: [c] }, ]; }; /** While the model works on a screen, the scheme's colors drift through its bezel. */ function ThinkingRing({ p, frame }: { p: Palette; frame: Frame }) { const still = useReducedMotion(); const { w: frameW, h: frameH } = frameSizeOf(frame); const w = frameW + BEZEL * 2; const h = frameH + BEZEL * 2; const d = Math.ceil(Math.hypot(w, h)) + 80; const stops = [p.primary, p.tertiaryContainer, p.inversePrimary, p.secondaryContainer, p.primaryContainer, p.primary]; return ( ); } type LeftTab = "parts" | "layers" | "color" | "shape" | "type" | "motion" | "ai"; /** the left rail: parts and layers, then the four theme axes of the whole design */ const LEFT_TABS: { key: LeftTab; icon: string; title: "parts" | "layers" | "colors" | "shape" | "typography" | "motion" | "ai" }[] = [ { key: "parts", icon: "add_box", title: "parts" }, { key: "layers", icon: "layers", title: "layers" }, { key: "color", icon: "palette", title: "colors" }, { key: "shape", icon: "rounded_corner", title: "shape" }, { key: "type", icon: "text_fields", title: "typography" }, { key: "motion", icon: "animation", title: "motion" }, { key: "ai", icon: "auto_awesome", title: "ai" }, ]; export default function Page() { // Optional host hooks; every default is the standalone behaviour. See lib/host.ts. const host = useHost(); /* ---------- document ---------- */ const [editAccess, setEditAccess] = useState<"checking" | "editable" | "readonly">("checking"); const [groups, setGroups] = useState(seed); const [frames, setFrames] = useState(SEED_FRAMES); const [paletteKey, setPaletteKey] = useState("purple"); const [customPalette, setCustomPalette] = useState(null); const [dynamicColor, setDynamicColor] = useState(false); const [theme, setTheme] = useState(DEFAULT_THEME); const patchTheme = (patch: Partial) => setTheme((t) => ({ ...t, ...patch })); const [frame, setFrame] = useState("phone"); const [lang, setLang] = useState("ja"); const changeLanguage = (next: Lang) => { setGlobalLang(next); initialLangRef.current = next; setLang(next); const translated = translateSnapshot({ groups: groupsRef.current, frames: framesRef.current }, next); const tidy = tidyRef.current; if (tidy) { tidyRef.current = tidy.after === groupsRef.current ? { ...tidy, before: translateSnapshot({ groups: tidy.before, frames: framesRef.current }, next).groups, after: translated.groups, } : null; } setGroups(translated.groups); setFrames(translated.frames); pastRef.current = pastRef.current.map((snap) => translateSnapshot(snap, next)); futureRef.current = futureRef.current.map((snap) => translateSnapshot(snap, next)); }; const [isMobile, setIsMobile] = useState(false); const [sheet, setSheet] = useState<"edit" | "settings" | "lang" | null>(null); const [confirmClear, setConfirmClear] = useState(false); /** frame being rendered offscreen for the PNG export */ const [exportFrame, setExportFrame] = useState(null); const [toast, setToast] = useState(null); const [title, setTitle] = useState(""); const [brief, setBrief] = useState(""); const [promptEdit, setPromptEdit] = useState(undefined); /** the author's explicit target; null follows the screens (web once a desktop screen exists) */ const [platform, setPlatform] = useState(null); /** a project file waiting for the author to confirm replacing the canvas */ const [pendingImport, setPendingImport] = useState(null); const [shareOpen, setShareOpen] = useState(false); /** the idea typed into the "ask an AI" dialog; kept here so a failed draft does not lose it */ const [ideaText, setIdeaText] = useState(""); /** a model is drafting a design right now */ const [draftBusy, setDraftBusy] = useState(false); /** the design a draft replaced, kept until the author keeps or undoes the draft */ const [draftBefore, setDraftBefore] = useState(null); const draftBeforeRef = useRef(null); draftBeforeRef.current = draftBefore; /** true for the moment after a design arrives, so its colours ease over */ const [revealing, setRevealing] = useState(false); const revealTimer = useRef | null>(null); useEffect(() => () => { if (revealTimer.current) clearTimeout(revealTimer.current); }, []); const guideRef = useRef(null); /* ---------- editor ui ---------- */ const [view, setView] = useState({ x: 0, y: 0, z: 1 }); /** Keep the server-rendered world hidden until its first fit has completed. */ const [viewReady, setViewReady] = useState(false); /** screens ease to their new place and size for a moment after one changes size */ const [easing, setEasing] = useState(false); /** the canvas transform eases while the camera glides to or from a screen */ const [cameraEasing, setCameraEasing] = useState(false); const [mode, setMode] = useState("select"); const [spaceHeld, setSpaceHeld] = useState(false); const [leftOpen, setLeftOpen] = useState(true); const [rightOpen, setRightOpen] = useState(true); const [leftW, setLeftW] = useState(RAIL_W + 268); const [leftTab, setLeftTab] = useState("parts"); /** pointer over the collapsed rail: the logo becomes the open button */ const [railHover, setRailHover] = useState(false); /** the screen whose layers are listed when nothing on a screen is selected */ const [layersFrameId, setLayersFrameId] = useState(null); const [rightW, setRightW] = useState(320); const [rightTab, setRightTab] = useState<"edit" | "prompt">("edit"); const [favorites, setFavorites] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [selectedFrameId, setSelectedFrameId] = useState(null); const [selectedLinkId, setSelectedLinkId] = useState(null); const [previewId, setPreviewId] = useState(null); const [pressedId, setPressedId] = useState(null); const [drag, setDrag] = useState(null); const [gesture, setGesture] = useState(null); const [widths, setWidths] = useState>({}); const [resizing, setResizing] = useState<"left" | "right" | null>(null); const [, bumpHistory] = useState(0); /* ---------- tidy and ai ---------- */ /** the groups before and after the last tidy; "undo" is offered only while the after-state is still current */ const tidyRef = useRef<{ frameId: string; before: Group[]; after: Group[] } | null>(null); const [aiSettings, setAiSettings] = useState(DEFAULT_AI); const [aiBusy, setAiBusy] = useState(false); /** the screen the model is working on, which wears the animated ring meanwhile */ const [aiFrameId, setAiFrameId] = useState(null); /** the "applied" confirmation beside the tidy button */ const [aiNote, setAiNote] = useState<{ text: string; icon: string } | null>(null); const projectFileRef = useRef(null); const aiNoteTimer = useRef(null); const aiAbortRef = useRef(null); const p = paletteOf(paletteKey, customPalette, theme); /* corner helpers read the shape scale outside React; keep it current before anything renders */ setGlobalShape(theme.shape); const canvasRef = useRef(null); const measureEls = useRef>(new Map()); const dragRef = useRef(null); const gestureRef = useRef(null); const pendingRef = useRef<{ timer: number; commit: () => void } | null>(null); const groupsRef = useRef(groups); groupsRef.current = groups; const framesRef = useRef(frames); framesRef.current = frames; const widthsRef = useRef(widths); widthsRef.current = widths; const viewRef = useRef(view); viewRef.current = view; const leftOpenRef = useRef(leftOpen); leftOpenRef.current = leftOpen; const leftWRef = useRef(leftW); leftWRef.current = leftW; const modeRef = useRef(mode); modeRef.current = mode; const spaceRef = useRef(spaceHeld); spaceRef.current = spaceHeld; const frameRef = useRef(frame); frameRef.current = frame; const mobileRef = useRef(isMobile); mobileRef.current = isMobile; /** active touch points, for pinch zoom */ const touchesRef = useRef>(new Map()); const pinchRef = useRef<{ d0: number; z0: number; mx: number; my: number; vx: number; vy: number; } | null>(null); /** groups that must reposition without animating on the next render */ const instantRef = useRef>(new Set()); const loadedRef = useRef(false); const initialLangRef = useRef("ja"); /** A saved document or the first viewport initialization prevents later reseeding. */ const hadDocRef = useRef(false); /* ---------- history ---------- */ const pastRef = useRef([]); const futureRef = useRef([]); const lastPatchRef = useRef<{ key: string; at: number }>({ key: "", at: 0 }); const snapshot = useCallback((withMeta = false) => { setQuickUndo(false); pastRef.current.push(current(withMeta)); if (pastRef.current.length > HISTORY_MAX) pastRef.current.shift(); futureRef.current = []; bumpHistory((v) => v + 1); }, []); /** consecutive edits of the same field collapse into one undo step */ const snapshotFor = useCallback( (key: string) => { const now = Date.now(); const last = lastPatchRef.current; if (last.key !== key || now - last.at > 800) snapshot(); lastPatchRef.current = { key, at: now }; }, [snapshot], ); const restore = (snap: Snapshot) => { for (const g of snap.groups) instantRef.current.add(g.id); if (snap.meta) { applyDoc({ ...snap.meta, groups: snap.groups, frames: snap.frames }, true); if (!mobileRef.current) { const mode = snap.meta.frame === "blank" ? "blank" : "phone"; setFrame(mode); frameRef.current = mode; } } else { setGroups(snap.groups); setFrames(snap.frames); } bumpHistory((v) => v + 1); }; /** the current step as a snapshot; `withMeta` when the step being crossed replaced the whole document */ const current = (withMeta: boolean): Snapshot => { const { groups: _g, frames: _f, ...meta } = docRef.current; return { groups: groupsRef.current, frames: framesRef.current, meta: withMeta ? meta : undefined }; }; const undo = useCallback(() => { setQuickUndo(false); const prev = pastRef.current.pop(); if (!prev) return; futureRef.current.push(current(!!prev.meta)); restore(prev); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const redo = useCallback(() => { const next = futureRef.current.pop(); if (!next) return; pastRef.current.push(current(!!next.meta)); restore(next); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { instantRef.current.clear(); }); /* ---------- persistence ---------- */ useEffect(() => { /* The first tab keeps this promise pending for its lifetime. Later tabs get `null` immediately and stay read-only until they are reloaded. Where the browser has no locks (an insecure origin, an old WebKit) the editor works as it always did, without the guard. */ if (!navigator.locks) { setEditAccess("editable"); return; } let active = true; let releaseLock: (() => void) | undefined; /* Wait until React has finished its development-only effect replay. This prevents the discarded setup from briefly competing with the real one. */ queueMicrotask(() => { if (!active) return; void navigator.locks .request(DOC_LOCK, { ifAvailable: true }, async (lock) => { if (!active) return; if (!lock) { setEditAccess("readonly"); return; } setEditAccess("editable"); await new Promise((resolve) => { releaseLock = resolve; }); }) .catch(() => { if (active) setEditAccess("editable"); }); }); return () => { active = false; releaseLock?.(); }; }, []); /** Puts a stored or opened document into the editor. Fields a partial document * leaves out keep their current value, or go back to the default when `reset`. */ const applyDoc = (doc: Partial, reset: boolean) => { const frames = Array.isArray(doc.frames) ? doc.frames : framesRef.current; if (Array.isArray(doc.groups)) setGroups(migrateGroups(doc.groups, frames)); if (Array.isArray(doc.frames)) setFrames(doc.frames); if (typeof doc.paletteKey === "string" && doc.paletteKey) setPaletteKey(doc.paletteKey); else if (reset) setPaletteKey("purple"); if (doc.customPalette && typeof doc.customPalette.primary === "string") setCustomPalette(doc.customPalette); else if (reset) setCustomPalette(null); if (typeof doc.dynamicColor === "boolean") setDynamicColor(doc.dynamicColor); else if (reset) setDynamicColor(false); if (doc.theme && typeof doc.theme === "object") setTheme(normalizeTheme(doc.theme)); else if (reset) setTheme(normalizeTheme(undefined)); if (typeof doc.title === "string") setTitle(doc.title); else if (reset) setTitle(""); if (typeof doc.brief === "string") setBrief(doc.brief); else if (reset) setBrief(""); if (typeof doc.promptEdit === "string") setPromptEdit(doc.promptEdit); else if (reset) setPromptEdit(undefined); if (isPlatform(doc.platform)) setPlatform(doc.platform); else if (reset) setPlatform(null); }; useEffect(() => { // React's development double-run would otherwise read back its own first save if (loadedRef.current) return; try { // Branch on whether the host SUPPLIED a loader, not on what it returned: a host // returning null means "no saved document", which must start fresh rather than // fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56. const hosted = host.loadDoc ? host.loadDoc() : null; const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY); if (hosted) { hadDocRef.current = true; applyDoc(hosted, false); } else if (d) { hadDocRef.current = true; applyDoc(JSON.parse(d) as Partial, false); // frame mode is decided by the device (media-query effect), not restored } const before = d ? localStorage.getItem(BEFORE_KEY) : null; if (!d) localStorage.removeItem(BEFORE_KEY); if (before) { const value: unknown = JSON.parse(before); if (isProject(value)) setDraftBefore(value); else localStorage.removeItem(BEFORE_KEY); } let initialLang: Lang = "ja"; const stored = host.loadUi ? null : localStorage.getItem(UI_KEY); const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null; if (ui) { if (ui.view) setView(ui.view); if (typeof ui.leftOpen === "boolean") setLeftOpen(ui.leftOpen); if (typeof ui.rightOpen === "boolean") setRightOpen(ui.rightOpen); if (ui.leftW) setLeftW(Math.max(RAIL_W + 244, ui.leftW)); if (ui.rightW) setRightW(ui.rightW); if (Array.isArray(ui.favorites)) setFavorites(ui.favorites); if (ui.mode) setMode(ui.mode); if (isLang(ui.lang)) { initialLang = ui.lang; setLang(ui.lang); } } else { const nl = (navigator.language ?? "").toLowerCase(); initialLang = nl.startsWith("zh") ? "zh" : nl.startsWith("ko") ? "ko" : nl.startsWith("ja") ? "ja" : "en"; setLang(initialLang); queueMicrotask(() => fitRef.current()); } setGlobalLang(initialLang); initialLangRef.current = initialLang; if (!d) { setGroups(seed(initialLang)); setFrames([{ ...SEED_FRAMES[0], name: t("home", initialLang) }]); } } catch {} setAiSettings(loadAiSettings()); loadedRef.current = true; }, []); useEffect(() => { document.documentElement.lang = lang; /* the editor's own text and the parts both pick up the language's Noto face */ document.body.style.fontFamily = uiFontFamily(lang); ensureLangFontLoaded(lang, () => setWidths({})); }, [lang]); useEffect(() => { /* an empty width map makes every measured part read its width again in the new face */ ensureFontLoaded(theme.font, () => setWidths({})); }, [theme.font]); /* the page background outside the app root follows the scheme, so dark mode has no white edges */ useEffect(() => { document.body.style.background = p.surface; document.body.style.color = p.onSurface; }, [p.surface, p.onSurface]); /* in-app browsers size the page behind their own toolbars and may ignore dvh, so the measured inner height wins over the CSS height (innerHeight, not the visual viewport, so pinch-zoom and the keyboard leave the layout alone) */ useEffect(() => { const apply = () => { const h = Math.round(window.innerHeight); if (h > 0) document.documentElement.style.setProperty("--app-h", `${h}px`); }; /* in-app browsers (X, Instagram, LINE...) keep their own action bar over the page bottom, so the controls sit one button higher there. App names in the user agent are unreliable; the embedded web view itself is not: iOS web views omit the Safari token and Android ones carry "wv" */ const ua = navigator.userAgent; const iosWebView = /iPhone|iPad|iPod/.test(ua) && !/Safari\//.test(ua); const androidWebView = /Android/.test(ua) && /(?:^|\W)wv(?:\W|$)/.test(ua); if (iosWebView || androidWebView || /Twitter|Instagram|FBAN|FBAV|Line\//i.test(ua)) { document.documentElement.style.setProperty("--bottom-ui", "64px"); } apply(); window.addEventListener("resize", apply); window.addEventListener("orientationchange", apply); return () => { window.removeEventListener("resize", apply); window.removeEventListener("orientationchange", apply); }; }, []); /* everyone works on phone screens; a phone gets one fixed screen and the select tool only */ useEffect(() => { const mq = window.matchMedia( "(max-width: 840px), (pointer: coarse) and (max-width: 1024px)", ); const apply = () => { const m = mq.matches; setIsMobile(m); mobileRef.current = m; if (m) { setMode("select"); setSheet(null); if (!hadDocRef.current) { hadDocRef.current = true; setGroups(mobileSeed(initialLangRef.current)); setFrames([{ id: uid(), name: t("home", initialLangRef.current), x: 0, y: 0 }]); } } hadDocRef.current = true; if (frameRef.current !== "phone") { setFrame("phone"); frameRef.current = "phone"; } ensureFrameRef.current(); queueMicrotask(() => { fitRef.current(); setViewReady(true); }); }; apply(); mq.addEventListener("change", apply); return () => mq.removeEventListener("change", apply); }, []); useEffect(() => { if (!loadedRef.current || editAccess !== "editable") return; try { const payload = { groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme }; if (host.saveDoc) host.saveDoc(payload as Partial); else localStorage.setItem(DOC_KEY, JSON.stringify(payload)); } catch {} }, [editAccess, groups, frames, paletteKey, frame, title, brief, promptEdit, platform, customPalette, dynamicColor, theme]); useEffect(() => { if (!loadedRef.current) return; try { const ui = {view, leftOpen, rightOpen, leftW, rightW, favorites, mode, lang,}; if (host.saveUi) host.saveUi(ui); else localStorage.setItem(UI_KEY, JSON.stringify(ui)); } catch {} }, [ view, leftOpen, rightOpen, leftW, rightW, favorites, mode, lang, ]); /* ---------- measurement (text-sized kinds) ---------- */ const allItems = useMemo(() => { const map = new Map(); for (const g of groups) for (const it of g.items) map.set(it.id, it); if (drag) map.set(drag.item.id, drag.item); return [...map.values()]; }, [groups, drag]); useLayoutEffect(() => { const next: Record = {}; measureEls.current.forEach((el, id) => { next[id] = Math.ceil(el.getBoundingClientRect().width); }); const keys = Object.keys(next); const changed = keys.length !== Object.keys(widthsRef.current).length || keys.some((k) => widthsRef.current[k] !== next[k]); if (changed) setWidths(next); }); useEffect(() => { const refreshWidths = () => setWidths({}); document.fonts?.ready.then(refreshWidths); document.fonts?.addEventListener("loadingdone", refreshWidths); return () => document.fonts?.removeEventListener("loadingdone", refreshWidths); }, []); const sizeRef = useCallback((it: Item) => sizeOf(it, widthsRef.current), []); const alongRef = useCallback( (it: Item, axis: Axis) => (axis === "x" ? sizeRef(it).w : sizeRef(it).h), [sizeRef], ); const prefixOf = useCallback( (g: Group, k: number) => g.items.slice(0, k).reduce((s, it) => s + alongRef(it, g.axis) + GAP, 0), [alongRef], ); /* ---------- coordinates ---------- */ const canvasRect = () => canvasRef.current?.getBoundingClientRect(); const toWorld = (clientX: number, clientY: number) => { const r = canvasRect(); const v = viewRef.current; return { x: (clientX - (r?.left ?? 0) - v.x) / v.z, y: (clientY - (r?.top ?? 0) - v.y) / v.z, }; }; const inBin = (clientX: number) => !mobileRef.current && clientX >= 0 && clientX <= (leftOpenRef.current ? leftWRef.current : RAIL_W); const setZoomAt = useCallback((nz: number, cx?: number, cy?: number) => { const r = canvasRect(); const v = viewRef.current; const z = clamp(nz, MIN_Z, MAX_Z); const px = cx === undefined ? (r?.width ?? 0) / 2 : cx - (r?.left ?? 0); const py = cy === undefined ? (r?.height ?? 0) / 2 : cy - (r?.top ?? 0); setView({ x: px - ((px - v.x) * z) / v.z, y: py - ((py - v.y) * z) / v.z, z, }); }, []); const fit = useCallback(() => { const r = canvasRect(); if (!r) return; const gs = groupsRef.current; let x0 = -BEZEL; let y0 = -BEZEL - FRAME_LABEL_H; let x1 = PHONE_W + BEZEL; let y1 = PHONE_H + BEZEL; const fs = framesRef.current; if (frameRef.current === "phone" && fs.length > 0) { x0 = Math.min(...fs.map((f) => f.x)) - BEZEL; y0 = Math.min(...fs.map((f) => f.y)) - BEZEL - FRAME_LABEL_H; x1 = Math.max(...fs.map((f) => frameRect(f).r)) + BEZEL; y1 = Math.max(...fs.map((f) => frameRect(f).b)) + BEZEL; } if (frameRef.current === "blank") { if (gs.length === 0) { setView({ x: 48, y: 48, z: 1 }); return; } x0 = Infinity; y0 = Infinity; x1 = -Infinity; y1 = -Infinity; for (const g of gs) { for (const pl of layoutOf(g, widthsRef.current)) { x0 = Math.min(x0, pl.x); y0 = Math.min(y0, pl.y); x1 = Math.max(x1, pl.x + pl.w); y1 = Math.max(y1, pl.y + pl.h); } } } const mobile = mobileRef.current; const pad = mobile ? 14 : 40; const top = mobile ? 96 : 84; // keep the floating toolbar clear of the frame const bottom = mobile ? 96 : pad; if (mobile) { // a phone zooms to the screen's width and starts at its top; the rest scrolls const z = clamp((r.width - pad * 2) / (x1 - x0), MIN_Z, MAX_Z); setView({ x: (r.width - (x1 - x0) * z) / 2 - x0 * z, y: top - y0 * z, z }); return; } const z = clamp( Math.min( (r.width - pad * 2) / (x1 - x0), (r.height - top - bottom) / (y1 - y0), 1, ), MIN_Z, MAX_Z, ); setView({ x: (r.width - (x1 - x0) * z) / 2 - x0 * z, y: top + (r.height - top - bottom - (y1 - y0) * z) / 2 - y0 * z, z, }); }, []); const fitRef = useRef(fit); fitRef.current = fit; /* touch: two fingers pinch-zoom and pan, cancelling whatever one finger started */ const onTouchCapture = (e: React.PointerEvent) => { if (e.pointerType !== "touch") return; touchesRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (touchesRef.current.size === 2) { const [a, b] = [...touchesRef.current.values()]; const v = viewRef.current; pinchRef.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), z0: v.z, mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2, vx: v.x, vy: v.y, }; dragRef.current = null; setDrag(null); setPressedId(null); gestureRef.current = null; setGesture(null); } }; useEffect(() => { const move = (e: PointerEvent) => { if (e.pointerType !== "touch" || !touchesRef.current.has(e.pointerId)) return; touchesRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const pinch = pinchRef.current; if (!pinch || touchesRef.current.size < 2) return; const [a, b] = [...touchesRef.current.values()]; const r = canvasRef.current?.getBoundingClientRect(); const d = Math.hypot(a.x - b.x, a.y - b.y); const z = clamp((pinch.z0 * d) / Math.max(1, pinch.d0), MIN_Z, MAX_Z); const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const px = pinch.mx - (r?.left ?? 0); const py = pinch.my - (r?.top ?? 0); setView({ x: px - ((px - pinch.vx) * z) / pinch.z0 + (mx - pinch.mx), y: py - ((py - pinch.vy) * z) / pinch.z0 + (my - pinch.my), z, }); }; const up = (e: PointerEvent) => { if (e.pointerType !== "touch") return; touchesRef.current.delete(e.pointerId); if (touchesRef.current.size < 2) pinchRef.current = null; }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; }, []); /* wheel: pan, or zoom with ctrl / pinch */ useEffect(() => { const el = canvasRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); if (e.ctrlKey || e.metaKey) { setZoomAt( viewRef.current.z * Math.exp(-e.deltaY * 0.0022), e.clientX, e.clientY, ); } else { setView((v) => ({ ...v, x: v.x - e.deltaX, y: v.y - e.deltaY })); } }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, [setZoomAt]); /* ---------- rest positions and the magnet ---------- */ const restPos = useCallback( (g: Group, k: number, sz: { w: number; h: number }) => g.axis === "x" ? { left: k === 0 ? g.x - sz.w - GAP : g.x + prefixOf(g, k), top: g.y } : { left: g.x, top: k === 0 ? g.y - sz.h - GAP : g.y + prefixOf(g, k) }, [prefixOf], ); /** Nearest slot inside the magnetic field with an attraction that ramps * from 0 at the edge to 1 on target. A part only fuses with its own kind. */ const findSnap = useCallback( (item: Item, left: number, top: number): Snap | null => { const spec = connectSpecOf(item); if (!spec) return null; const sz = sizeRef(item); let best: Snap | null = null; let bestD = 1; for (const g of groupsRef.current) { if (g.free || g.axis !== spec.axis || !g.items[0] || !canJoin(g.items[0], item)) continue; for (let k = 0; k <= g.items.length; k++) { const r = restPos(g, k, sz); const dx = left - r.left; const dy = top - r.top; const nMain = (spec.axis === "x" ? dx : dy) / SNAP_MAIN; const nCross = (spec.axis === "x" ? dy : dx) / SNAP_CROSS; if (Math.abs(nMain) >= 1 || Math.abs(nCross) >= 1) continue; const d = Math.hypot(nMain, nCross); if (d < bestD) { bestD = d; best = { groupId: g.id, index: k, pull: Math.pow(1 - d, PULL_EXP) }; } } } return best; }, [restPos, sizeRef], ); const sx = useSpring(0, CARRY); const sy = useSpring(0, CARRY); /** Canva-style alignment: edges and centres of neighbours and of the frame * pull the part gently into line and draw a guide while they do. */ const findGuide = useCallback( (item: Item, left: number, top: number): Guide | null => { const sz = sizeRef(item); const tol = GUIDE_PX / viewRef.current.z; const xs: number[] = []; const ys: number[] = []; for (const g of groupsRef.current) { for (const pl of layoutOf(g, widthsRef.current)) { if (pl.item.id === item.id) continue; xs.push(pl.x, pl.x + pl.w / 2, pl.x + pl.w); ys.push(pl.y, pl.y + pl.h / 2, pl.y + pl.h); } } if (frameRef.current === "phone") { for (const f of framesRef.current) { const { w, h } = frameSizeOf(f); xs.push( f.x, f.x + FRAME_MARGIN, f.x + w / 2, f.x + w - FRAME_MARGIN, f.x + w, ); ys.push( f.y, f.y + FRAME_MARGIN, f.y + h / 2, f.y + h - FRAME_MARGIN, f.y + h, ); } } const mine = (pos: number, len: number) => [ pos, pos + len / 2, pos + len, ]; let best: Guide = {}; let bx = tol; for (const c of xs) for (const m of mine(left, sz.w)) { const d = Math.abs(c - m); if (d < bx) { bx = d; best = { ...best, x: left + (c - m), gx: c }; } } let by = tol; for (const c of ys) for (const m of mine(top, sz.h)) { const d = Math.abs(c - m); if (d < by) { by = d; best = { ...best, y: top + (c - m), gy: c }; } } return best.x === undefined && best.y === undefined ? null : best; }, [sizeRef], ); /* ---------- pointer: parts ---------- */ const flushPending = useCallback(() => { const pend = pendingRef.current; if (!pend) return; clearTimeout(pend.timer); pendingRef.current = null; pend.commit(); }, []); useEffect(() => () => flushPending(), [flushPending]); const startPan = (clientX: number, clientY: number) => { const g: Gesture = { kind: "pan", sx: clientX, sy: clientY, vx: viewRef.current.x, vy: viewRef.current.y, }; gestureRef.current = g; setGesture(g); }; const onItemPointerDown = ( e: React.PointerEvent, g: Group, index: number, item: Item, ) => { if (e.button === 1 || modeRef.current === "hand" || spaceRef.current) { e.preventDefault(); e.stopPropagation(); startPan(e.clientX, e.clientY); return; } if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); flushPending(); if (g.free) { setSelectedIds((cur) => (e.shiftKey ? [...cur.filter((x) => !g.items.some((it) => it.id === x)), ...g.items.map((it) => it.id)] : g.items.map((it) => it.id))); setSelectedFrameId(null); setSelectedLinkId(null); setRightTab("edit"); const gg: Gesture = { kind: "group", id: g.id, sx: e.clientX, sy: e.clientY, gx: g.x, gy: g.y, moved: false, overBin: false }; gestureRef.current = gg; setGesture(gg); return; } const pt = toWorld(e.clientX, e.clientY); const off = prefixOf(g, index); const left = g.axis === "x" ? g.x + off : g.x; const top = g.axis === "x" ? g.y : g.y + off; sx.jump(left); sy.jump(top); setSelectedIds((cur) => e.shiftKey ? [...cur.filter((x) => x !== item.id), item.id] : [item.id], ); setSelectedFrameId(null); setSelectedLinkId(null); setRightTab("edit"); setPressedId(item.id); const d: DragState = { item, offX: pt.x - left, offY: pt.y - top, startX: pt.x, startY: pt.y, px: pt.x, py: pt.y, active: false, fromPalette: false, overBin: false, snap: null, settling: false, guide: null, }; dragRef.current = d; setDrag({ ...d }); }; const onPartPointerDown = (e: React.PointerEvent, kind: Kind) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); flushPending(); const item = makeItem(kind); const pt = toWorld(e.clientX, e.clientY); const sz = sizeOf(item, widthsRef.current); const offX = Math.min(sz.w / 2, 90); const offY = Math.min(sz.h / 2, 40); sx.jump(pt.x - offX); sy.jump(pt.y - offY); setSelectedIds([item.id]); setRightTab("edit"); const d: DragState = { item, offX, offY, startX: pt.x, startY: pt.y, px: pt.x, py: pt.y, active: true, fromPalette: true, overBin: false, snap: null, settling: false, guide: null, }; dragRef.current = d; setDrag({ ...d }); }; const isDragging = drag !== null; useEffect(() => { if (!isDragging) return; const move = (e: PointerEvent) => { const d = dragRef.current; if (!d) return; const pt = toWorld(e.clientX, e.clientY); d.px = pt.x; d.py = pt.y; if (!d.active) { if ( Math.hypot(pt.x - d.startX, pt.y - d.startY) * viewRef.current.z < 5 ) { setDrag({ ...d }); return; } d.active = true; d.snap = null; const id = d.item.id; snapshot(); setGroups((prev) => { const out: Group[] = []; for (const g of prev) { const idx = g.items.findIndex((it) => it.id === id); if (idx < 0) { out.push(g); continue; } const rest = g.items.filter((it) => it.id !== id); if (rest.length === 0) continue; const sz = sizeOf(g.items[idx], widthsRef.current); const back = idx === 0; // The anchor moves to the new first item; that jump must not animate, // otherwise the remaining run springs sideways for a frame. if (back) instantRef.current.add(g.id); out.push({ ...g, x: back && g.axis === "x" ? g.x + sz.w + GAP : g.x, y: back && g.axis === "y" ? g.y + sz.h + GAP : g.y, items: rest, }); } return out; }); setPressedId(null); setDrag({ ...d }); return; } d.overBin = inBin(e.clientX); d.snap = d.overBin ? null : findSnap(d.item, pt.x - d.offX, pt.y - d.offY); d.guide = d.overBin || d.snap ? null : findGuide(d.item, pt.x - d.offX, pt.y - d.offY); setDrag({ ...d }); }; const up = () => { const d = dragRef.current; dragRef.current = null; setPressedId(null); if (!d) return; if (!d.active) { setDrag(null); return; } const item = d.item; const sz = sizeRef(item); if (d.overBin) { setSelectedIds((cur) => cur.filter((x) => x !== item.id)); setDrag(null); return; } if (d.snap) { const t = d.snap; setDrag({ ...d, snap: { ...t, pull: 1 }, settling: true }); const commit = () => { setGroups((prev) => { if (prev.some((g) => g.items.some((it) => it.id === item.id))) return prev; return prev.map((g) => { if (g.id !== t.groupId) return g; const front = t.index === 0; return { ...g, x: front && g.axis === "x" ? g.x - sz.w - GAP : g.x, y: front && g.axis === "y" ? g.y - sz.h - GAP : g.y, items: [ ...g.items.slice(0, t.index), item, ...g.items.slice(t.index), ], }; }); }); setDrag(null); }; const timer = window.setTimeout(() => { pendingRef.current = null; commit(); }, SETTLE_MS); pendingRef.current = { timer, commit }; return; } const rect = canvasRect(); const v = viewRef.current; const rawX = d.guide?.x ?? d.px - d.offX; const rawY = d.guide?.y ?? d.py - d.offY; const screenL = (rawX + sz.w) * v.z + v.x; const screenT = (rawY + sz.h) * v.z + v.y; const screenR = rawX * v.z + v.x; const screenB = rawY * v.z + v.y; const cw = rect?.width ?? 0; const ch = rect?.height ?? 0; if ( d.fromPalette && (screenL < 0 || screenT < 0 || screenR > cw || screenB > ch) ) { setSelectedIds((cur) => cur.filter((x) => x !== item.id)); setDrag(null); return; } if (d.fromPalette) snapshot(); const targetFrame = frameRef.current === "phone" ? framesRef.current.find((f) => { const r = frameRect(f); const cx = rawX + sz.w / 2; const cy = rawY + sz.h / 2; return cx >= r.l && cx <= r.r && cy >= r.t && cy <= r.b; }) : undefined; /* a bar spans the screen it lands on, beside its rail; any other part keeps its phone-sized * default (a list or a field as wide as a desktop is rarely what the author means), but no * taller than the screen */ const slot = targetFrame ? barSlotOf(groupsRef.current, targetFrame, framesRef.current, widthsRef.current) : null; const isBar = FULL_WIDTH.includes(item.kind); const placedItem = targetFrame && slot ? (isBar ? carryItemSize(item, { w: PHONE_W, h: PHONE_H }, { w: slot.w, h: frameSizeOf(targetFrame).h }) : fitHeight(item, frameSizeOf(targetFrame).h)) : item; const dropped: Group = { id: uid(), x: Math.round(isBar && slot ? slot.x : rawX), y: Math.round(rawY), axis: connectSpecOf(item)?.axis ?? "x", items: [placedItem], }; /* a part that grew to the screen's width is kept inside it */ const ng = targetFrame ? pullInto(dropped, targetFrame, widthsRef.current) : dropped; setGroups((prev) => prev.some((g) => g.items.some((it) => it.id === item.id)) ? prev : [...prev, ng], ); setDrag(null); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; // handlers read live state through refs, so this binds once per drag // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDragging]); /* the overlay sits between the cursor and the slot, weighted by attraction */ useEffect(() => { if (!drag?.active) return; const cursorL = drag.px - drag.offX; const cursorT = drag.py - drag.offY; if (drag.snap) { const g = groupsRef.current.find((x) => x.id === drag.snap!.groupId); if (g) { const r = restPos(g, drag.snap.index, sizeRef(drag.item)); sx.set(lerp(cursorL, r.left, drag.snap.pull)); sy.set(lerp(cursorT, r.top, drag.snap.pull)); return; } } sx.set(drag.guide?.x ?? cursorL); sy.set(drag.guide?.y ?? cursorT); }, [drag, restPos, sizeRef, sx, sy]); /* ---------- pointer: canvas (pan / marquee) ---------- */ const itemRects = useCallback(() => { const out: { id: string; l: number; t: number; r: number; b: number }[] = []; for (const g of groupsRef.current) { for (const pl of layoutOf(g, widthsRef.current)) { out.push({ id: pl.item.id, l: pl.x, t: pl.y, r: pl.x + pl.w, b: pl.y + pl.h }); } } return out; }, []); const clearSelection = () => { setSelectedIds([]); setSelectedFrameId(null); setSelectedLinkId(null); }; const onCanvasPointerDown = (e: React.PointerEvent) => { if (mobileRef.current && e.pointerType === "touch") { e.preventDefault(); clearSelection(); startPan(e.clientX, e.clientY); return; } if (e.button === 1 || modeRef.current === "hand" || spaceRef.current) { e.preventDefault(); startPan(e.clientX, e.clientY); return; } if (e.button !== 0) return; const pt = toWorld(e.clientX, e.clientY); const g: Gesture = { kind: "marquee", x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y, moved: false, }; gestureRef.current = g; setGesture(g); if (!e.shiftKey) setSelectedIds([]); setSelectedFrameId(null); setSelectedLinkId(null); }; /** grab a phone frame by its bezel or label: it carries everything on it; * on a phone the screen stays put and a tap on it just clears the selection */ const onFramePointerDown = (e: React.PointerEvent, f: Frame) => { if (mobileRef.current) { e.preventDefault(); e.stopPropagation(); clearSelection(); startPan(e.clientX, e.clientY); return; } if (e.button === 1 || modeRef.current === "hand" || spaceRef.current) { e.preventDefault(); e.stopPropagation(); startPan(e.clientX, e.clientY); return; } if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); setSelectedFrameId(f.id); setSelectedIds([]); setSelectedLinkId(null); setRightTab("edit"); const carried = groupsRef.current .filter( (g) => frameOfGroup(g, framesRef.current, widthsRef.current)?.id === f.id, ) .map((g) => ({ id: g.id, x: g.x, y: g.y })); const g: Gesture = { kind: "frame", id: f.id, sx: e.clientX, sy: e.clientY, fx: f.x, fy: f.y, groups: carried, moved: false, }; gestureRef.current = g; setGesture(g); }; const isGesturing = gesture !== null; useEffect(() => { if (!isGesturing) return; const move = (e: PointerEvent) => { const g = gestureRef.current; if (!g) return; if (g.kind === "pan") { setView((v) => ({ ...v, x: g.vx + (e.clientX - g.sx), y: g.vy + (e.clientY - g.sy), })); return; } if (g.kind === "group") { const z = viewRef.current.z; const dx = (e.clientX - g.sx) / z; const dy = (e.clientY - g.sy) / z; if (!g.moved) { if (Math.hypot(dx, dy) * z < 4) return; g.moved = true; snapshot(); } instantRef.current.add(g.id); g.overBin = inBin(e.clientX); setGesture({ ...g }); setGroups((gs) => gs.map((gr) => (gr.id === g.id ? { ...gr, x: Math.round(g.gx + dx), y: Math.round(g.gy + dy) } : gr))); return; } if (g.kind === "frame") { const z = viewRef.current.z; const dx = (e.clientX - g.sx) / z; const dy = (e.clientY - g.sy) / z; if (!g.moved) { if (Math.hypot(dx, dy) * z < 4) return; g.moved = true; snapshot(); } const ids = new Map(g.groups.map((o) => [o.id, o])); for (const o of g.groups) instantRef.current.add(o.id); setFrames((fs) => fs.map((f) => f.id === g.id ? { ...f, x: Math.round(g.fx + dx), y: Math.round(g.fy + dy) } : f, ), ); setGroups((gs) => gs.map((gr) => { const o = ids.get(gr.id); return o ? { ...gr, x: Math.round(o.x + dx), y: Math.round(o.y + dy) } : gr; }), ); return; } const pt = toWorld(e.clientX, e.clientY); g.x1 = pt.x; g.y1 = pt.y; if ( !g.moved && Math.hypot(pt.x - g.x0, pt.y - g.y0) * viewRef.current.z > 4 ) g.moved = true; if (g.moved) { const l = Math.min(g.x0, g.x1); const r = Math.max(g.x0, g.x1); const t = Math.min(g.y0, g.y1); const b = Math.max(g.y0, g.y1); const hit = itemRects() .filter((it) => it.l < r && it.r > l && it.t < b && it.b > t) .map((it) => it.id); setSelectedIds(hit); } setGesture({ ...g }); }; const up = (e: PointerEvent) => { const g = gestureRef.current; gestureRef.current = null; setGesture(null); // a group dragged onto the parts panel is deleted, like a single part if (g?.kind === "group" && g.moved && inBin(e.clientX)) { setGroups((gs) => gs.filter((x) => x.id !== g.id)); setSelectedIds([]); } }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isGesturing]); /* ---------- panel resize ---------- */ useEffect(() => { if (!resizing) return; const move = (e: PointerEvent) => { if (resizing === "left") setLeftW(clamp(e.clientX, RAIL_W + 244, 480)); else setRightW(clamp(window.innerWidth - e.clientX, 280, 480)); }; const up = () => setResizing(null); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [resizing]); /* ---------- editing ---------- */ const primaryId = selectedIds[selectedIds.length - 1] ?? null; const selected = useMemo(() => { for (const g of groups) { const it = g.items.find((i) => i.id === primaryId); if (it) return it; } return drag?.item.id === primaryId ? (drag?.item ?? null) : null; }, [groups, primaryId, drag]); useEffect(() => { if (!selected && sheet === "edit") setSheet(null); }, [selected, sheet]); /** Resizing a lone part keeps whatever it was lined up with on the frame: * its centre on the centre line, or its far edge on the margin or screen edge. * Otherwise the near (left / top) edge stays put, as the sliders always did. */ const resizeShift = (g: Group, before: Item, after: Item) => { const none = { dx: 0, dy: 0 }; if (g.items.length !== 1 || frameRef.current !== "phone") return none; const f = frameOfGroup(g, framesRef.current, widthsRef.current); if (!f) return none; const { w: frameW, h: frameH } = frameSizeOf(f); const a = sizeOf(before, widthsRef.current); const b = sizeOf(after, widthsRef.current); const shift = (pos: number, len: number, next: number, f0: number, fLen: number) => { const d = next - len; if (d === 0) return 0; const near = (v: number, target: number) => Math.abs(v - target) <= 1; if (near(pos + len / 2, f0 + fLen / 2)) return -Math.round(d / 2); if (near(pos + len, f0 + fLen - FRAME_MARGIN) || near(pos + len, f0 + fLen)) return -d; return 0; }; return { dx: shift(g.x, a.w, b.w, f.x, frameW), dy: shift(g.y, a.h, b.h, f.y, frameH), }; }; const patchSelected = (patch: Partial) => { if (!primaryId) return; const id = primaryId; snapshotFor(id + ":" + Object.keys(patch).join(",")); const resizes = "size" in patch || "size2" in patch; setGroups((prev) => prev.map((g) => { const idx = g.items.findIndex((it) => it.id === id); if (idx < 0) return g; const next = { ...g.items[idx], ...patch }; const { dx, dy } = resizes ? resizeShift(g, g.items[idx], next) : { dx: 0, dy: 0 }; if (dx || dy) instantRef.current.add(g.id); return { ...g, x: g.x + dx, y: g.y + dy, items: g.items.map((it, i) => (i === idx ? next : it)), }; }), ); if (dragRef.current?.item.id === id) { dragRef.current.item = { ...dragRef.current.item, ...patch }; } }; const deleteSelected = useCallback(() => { if (selectedIds.length === 0) return; const ids = new Set(selectedIds); snapshot(); setGroups((prev) => prev .map((g) => { if (g.free) return collapseFree({ ...g, items: g.items.filter((it) => !ids.has(it.id)) }, widthsRef.current); let x = g.x; let y = g.y; let items = g.items; while (items.length && ids.has(items[0].id)) { const sz = sizeOf(items[0], widthsRef.current); if (g.axis === "x") x += sz.w + GAP; else y += sz.h + GAP; items = items.slice(1); } if (x !== g.x || y !== g.y) instantRef.current.add(g.id); return { ...g, x, y, items: items.filter((it) => !ids.has(it.id)) }; }) .filter((g) => g.items.length > 0), ); setSelectedIds([]); }, [selectedIds, snapshot]); const duplicateSelected = useCallback(() => { if (!selected) return; /* a selected hand-made group is copied whole, keeping its layout */ const fg = groupsRef.current.find((g) => g.free && g.items.some((it) => it.id === selected.id)); if (fg && fg.items.every((it) => selectedIds.includes(it.id))) { const idMap = new Map(fg.items.map((it) => [it.id, uid()])); const pos: Record = {}; for (const it of fg.items) pos[idMap.get(it.id)!] = fg.pos?.[it.id] ?? { x: 0, y: 0 }; const copyG: Group = { ...fg, id: uid(), x: fg.x + 24, y: fg.y + 24, pos, items: fg.items.map((it) => ({ ...it, id: idMap.get(it.id)!, tabs: it.tabs?.map((t) => ({ ...t })) })), }; snapshot(); setGroups((prev) => [...prev, copyG]); setSelectedIds(copyG.items.map((it) => it.id)); return; } const rect = itemRects().find((r) => r.id === selected.id); if (!rect) return; const copy: Item = { ...selected, id: uid(), tabs: selected.tabs?.map((t) => ({ ...t })), }; snapshot(); setGroups((prev) => [ ...prev, { id: uid(), x: rect.l + 24, y: rect.t + 24, axis: connectSpecOf(copy)?.axis ?? "x", items: [copy], }, ]); setSelectedIds([copy.id]); }, [selected, selectedIds, itemRects, snapshot]); /** the free group the whole selection belongs to, if it is exactly one */ const selectedGroup = useMemo(() => { if (selectedIds.length === 0) return null; const g = groups.find((x) => x.free && x.items.some((it) => it.id === selectedIds[0])); if (!g) return null; const ids = new Set(g.items.map((it) => it.id)); return selectedIds.every((id) => ids.has(id)) && selectedIds.length === g.items.length ? g : null; }, [groups, selectedIds]); /** Pull the selected parts out of their runs into one free group that keeps * their positions. It takes the layer slot of the topmost run involved. */ const groupSelected = useCallback(() => { const ids = new Set(selectedIds); if (ids.size < 2) return; const rects = new Map(itemRects().map((r) => [r.id, r])); const picked: Item[] = []; let top = -1; groupsRef.current.forEach((g, i) => { for (const it of g.items) if (ids.has(it.id)) { picked.push(it); top = i; } }); if (picked.length < 2) return; const l = Math.min(...picked.map((it) => rects.get(it.id)!.l)); const t = Math.min(...picked.map((it) => rects.get(it.id)!.t)); const pos: Record = {}; for (const it of picked) pos[it.id] = { x: rects.get(it.id)!.l - l, y: rects.get(it.id)!.t - t }; const ng: Group = { id: uid(), x: l, y: t, axis: "x", items: picked, free: true, pos }; snapshot(); setGroups((prev) => { const out: Group[] = []; prev.forEach((g, i) => { if (g.free) { const rest = g.items.filter((it) => !ids.has(it.id)); if (rest.length) out.push(collapseFree({ ...g, items: rest }, widthsRef.current)); } else { let x = g.x; let y = g.y; let items = g.items; while (items.length && ids.has(items[0].id)) { const sz = sizeOf(items[0], widthsRef.current); if (g.axis === "x") x += sz.w + GAP; else y += sz.h + GAP; items = items.slice(1); } items = items.filter((it) => !ids.has(it.id)); if (items.length) { if (x !== g.x || y !== g.y) instantRef.current.add(g.id); out.push({ ...g, x, y, items }); } } if (i === top) out.push(ng); }); return out; }); setSelectedIds(picked.map((it) => it.id)); }, [selectedIds, itemRects, snapshot]); /** Split a free group back into single runs at their current positions, in the same layer slot. */ const ungroupSelected = useCallback(() => { const g = selectedGroup; if (!g) return; snapshot(); const singles: Group[] = explodeGroup(g, widthsRef.current).map((run) => ({ ...run, id: uid() })); for (const sg of singles) instantRef.current.add(sg.id); setGroups((prev) => prev.flatMap((x) => (x.id === g.id ? singles : [x]))); }, [selectedGroup, snapshot]); const nudge = useCallback( (dx: number, dy: number) => { if (selectedIds.length === 0 && selectedFrameId) { const f = framesRef.current.find((x) => x.id === selectedFrameId); if (!f) return; snapshotFor("nudge:frame:" + f.id); const carried = new Set( groupsRef.current .filter( (g) => frameOfGroup(g, framesRef.current, widthsRef.current)?.id === f.id, ) .map((g) => g.id), ); for (const id of carried) instantRef.current.add(id); setFrames((fs) => fs.map((x) => x.id === f.id ? { ...x, x: x.x + dx, y: x.y + dy } : x, ), ); setGroups((gs) => gs.map((g) => carried.has(g.id) ? { ...g, x: g.x + dx, y: g.y + dy } : g, ), ); return; } if (selectedIds.length === 0) return; const ids = new Set(selectedIds); snapshotFor("nudge:" + selectedIds.join(",")); setGroups((prev) => prev.map((g) => g.items.some((it) => ids.has(it.id)) ? { ...g, x: g.x + dx, y: g.y + dy } : g, ), ); }, [selectedIds, selectedFrameId, snapshotFor], ); const clearAll = () => { setConfirmClear(false); if (groupsRef.current.length === 0 && framesRef.current.length === 0) return; setDraftBefore(null); setQuickUndo(false); try { localStorage.removeItem(BEFORE_KEY); } catch {} snapshot(); setGroups([]); setFrames([]); setSelectedIds([]); setSelectedFrameId(null); }; /** Opening a project file replaces the canvas with the same restore path the saved * document goes through, then starts the editor fresh on it. */ const importDoc = (next: Doc) => { hadDocRef.current = true; /* the whole document being replaced stays one undo away */ snapshot(true); /* whatever was under review is over: a file, a clear or a new arrival replaces it */ setDraftBefore(null); setQuickUndo(false); try { localStorage.removeItem(BEFORE_KEY); } catch {} applyDoc(next, true); if (!mobileRef.current) { const nextFrame = next.frame === "blank" ? "blank" : "phone"; setFrame(nextFrame); frameRef.current = nextFrame; } setSelectedIds([]); setSelectedFrameId(null); setSelectedLinkId(null); setWidths({}); lastPatchRef.current = { key: "", at: 0 }; queueMicrotask(() => fitRef.current()); }; /* A link with a design in its hash offers it once the editor is ready to take it, whether the page opened on that link or the link was pasted into this tab. */ useEffect(() => { if (editAccess !== "editable" || typeof window === "undefined") return; let active = true; const offer = () => { if (!hasShareHash(window.location.hash)) return; void readShareHash(window.location.hash).then((next) => { if (!active) return; if (next) { setShareOpen(false); arrive(next); clearShareHash(); } else { clearShareHash(); showToast(t("invalidProject", getLang()), 3000, "error"); } }); }; offer(); window.addEventListener("hashchange", offer); return () => { active = false; window.removeEventListener("hashchange", offer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [editAccess]); /** Asks the author's own model for a design and puts it on the canvas. The guide it reads * is the one a coding agent reads (public/agent.md). The replaced design waits in * `draftBefore` until the author keeps or undoes the draft. */ /** A design that arrived from a model or a link takes the canvas with its colours easing * over; the design it replaced waits in `draftBefore` until the author keeps or undoes it. */ const arrive = (next: Doc) => { /* the phone editor has no keep / undo buttons: a link simply opens there, undoable as usual */ if (mobileRef.current) { importDoc(next); return; } const before = draftBeforeRef.current ?? docRef.current; setRevealing(true); if (revealTimer.current) clearTimeout(revealTimer.current); revealTimer.current = setTimeout(() => setRevealing(false), 900); importDoc(next); setDraftBefore(before); try { localStorage.setItem(BEFORE_KEY, JSON.stringify(before)); } catch {} }; const startDraft = async (idea: string) => { setShareOpen(false); setDraftBusy(true); try { if (guideRef.current === null) { const res = await fetch(`${BASE_PATH}/agent.md`); if (!res.ok) throw new Error("guide"); guideRef.current = await res.text(); } const next = await draftDesign(aiSettings, guideRef.current, idea, lang); arrive(next); } catch (e) { const m = e instanceof Error ? e.message : ""; showToast(m === "json" ? t("aiErrorJson", lang) : m === "refusal" ? t("aiErrorRefusal", lang) : m === "long" ? t("aiErrorLong", lang) : t("aiError", lang), 3200, "error"); } finally { setDraftBusy(false); } }; /** true after a kept draft until the author undoes something, so the header's undo also sits by the opener */ const [quickUndo, setQuickUndo] = useState(false); const keepDraft = () => { setDraftBefore(null); setQuickUndo(true); try { localStorage.removeItem(BEFORE_KEY); } catch {} }; const undoDraft = () => { if (draftBefore) { setRevealing(true); if (revealTimer.current) clearTimeout(revealTimer.current); revealTimer.current = setTimeout(() => setRevealing(false), 900); importDoc(draftBefore); } setDraftBefore(null); }; /** drops the design from the URL once it has been taken or declined */ const clearShareHash = () => { if (typeof window !== "undefined" && window.location.hash) window.history.replaceState(null, "", window.location.pathname + window.location.search); }; const selectedFrame = useMemo( () => frames.find((f) => f.id === selectedFrameId) ?? null, [frames, selectedFrameId], ); const selectedPartFrame = useMemo(() => { if (!primaryId || frame !== "phone") return null; const g = groups.find((g) => g.items.some((it) => it.id === primaryId)); return g ? (frameOfGroup(g, frames, widths) ?? null) : null; }, [primaryId, frame, groups, frames, widths]); /** the screen the tidy button works on: the selected one, or the one under the selected part */ const tidyTarget = useMemo((): Frame | null => { if (frame !== "phone" || isMobile) return null; if (selectedFrame) return selectedFrame; if (!primaryId) return null; const g = groups.find((g) => g.items.some((it) => it.id === primaryId)); return g ? (frameOfGroup(g, frames, widths) ?? null) : null; }, [frame, isMobile, selectedFrame, primaryId, groups, frames, widths]); const nextFrameX = () => framesRef.current.length ? Math.max(...framesRef.current.map((f) => frameRect(f).r)) + FRAME_GAP : 0; /** Entering phone mode with no frames wraps the existing parts in one. */ const ensureFrame = () => { if (framesRef.current.length > 0) return; const gs = groupsRef.current; let x = 0; let y = 0; if (gs.length) { const bbs = gs.map((g) => groupBounds(g, widthsRef.current)); const l = Math.min(...bbs.map((b) => b.l)); const t = Math.min(...bbs.map((b) => b.t)); const r = Math.max(...bbs.map((b) => b.r)); x = Math.round(Math.max(l - 24, r - PHONE_W + 24 > l ? l : l - 24)); y = Math.round(t - 72); x = Math.min(x, l); y = Math.min(y, t); } const f: Frame = { id: uid(), name: t("home"), x, y }; setFrames([f]); }; const ensureFrameRef = useRef(() => {}); ensureFrameRef.current = ensureFrame; /** phone UI: the plus button drops a new button where the view is looking, * kept inside the screen, and nudged down when that spot is already taken */ const addButton = () => { const r = canvasRect(); const v = viewRef.current; const item = makeItem("button"); const sz = sizeOf(item, widthsRef.current); const f = framesRef.current[0]; let x = ((r?.width ?? 0) / 2 - v.x) / v.z - sz.w / 2; let y = ((r?.height ?? 0) / 2 - v.y) / v.z - sz.h / 2; if (f) { const { w, h } = frameSizeOf(f); const lx = f.x + Math.min(FRAME_MARGIN, (w - sz.w) / 2); const ly = f.y + Math.min(FRAME_MARGIN, (h - sz.h) / 2); x = clamp(x, lx, Math.max(lx, f.x + w - FRAME_MARGIN - sz.w)); y = clamp(y, ly, Math.max(ly, f.y + h - FRAME_MARGIN - sz.h)); const taken = (yy: number) => itemRects().some((o) => o.l < x + sz.w && o.r > x && o.t < yy + sz.h && o.b > yy); let tries = 0; while (taken(y) && y + sz.h * 2 < f.y + h && tries++ < 12) y += sz.h + 12; } snapshot(); setGroups((gs) => [ ...gs, { id: uid(), x: Math.round(x), y: Math.round(y), axis: "x", items: [item], }, ]); setSelectedIds([item.id]); setSelectedFrameId(null); setSheet(null); }; const changeFrame = (f: FrameMode) => { if (f === frame) return; snapshot(); setFrame(f); frameRef.current = f; if (f === "phone") ensureFrame(); setSelectedFrameId(null); setSelectedLinkId(null); queueMicrotask(() => fitRef.current()); }; const addFrame = () => { snapshot(); const base = framesRef.current[0]; const f: Frame = { id: uid(), name: `${t("screenN")} ${framesRef.current.length + 1}`, x: nextFrameX(), y: base?.y ?? 0, }; setFrames((fs) => [...fs, f]); setSelectedFrameId(f.id); setSelectedIds([]); const r = canvasRect(); if (r) { const z = viewRef.current.z; const { w, h } = frameSizeOf(f); setView({ x: r.width / 2 - (f.x + w / 2) * z, y: r.height / 2 - (f.y + h / 2) * z, z, }); } }; const patchFrame = (id: string, patch: Partial) => { snapshotFor("frame:" + id + ":" + Object.keys(patch).join(",")); setFrames((fs) => fs.map((f) => (f.id === id ? { ...f, ...patch } : f))); }; const setFramePreset = (id: string, preset: FramePreset) => { const current = framesRef.current.find((f) => f.id === id); if (!current) return; const next = { ...current, ...framePresetPatch(preset) }; const before = frameSizeOf(current); const after = frameSizeOf(next); if (before.w === after.w && before.h === after.h) return; const frames = framesRef.current; /* the screens to the right move over, parts take the sizes the new screen calls for, * and the screen is laid out again by the tidy rules */ const laid = carryFrame(groupsRef.current, current, next, frames, widthsRef.current); /* a target the author never picked follows the screens */ if (platform === defaultPlatformOf(frames, frameRef.current)) setPlatform(null); snapshot(); tidyRef.current = null; setEasing(true); window.setTimeout(() => setEasing(false), SETTLE_MS + 40); setFrames(laid.frames); setGroups(laid.groups); }; /** the tidy button's state for the screen in play; the layout pass runs only when the document changes */ const tidyState = useMemo((): TidyState | null => { if (!tidyTarget) return null; const last = tidyRef.current; if (last && last.frameId === tidyTarget.id && last.after === groups) return "undo"; return tidyFrame(groups, tidyTarget, frames, widths) ? "tidy" : "done"; }, [tidyTarget, groups, frames, widths]); const tidy = (f: Frame) => { const last = tidyRef.current; if (last && last.frameId === f.id && last.after === groupsRef.current) { snapshot(); setGroups(last.before); tidyRef.current = null; return; } const after = tidyFrame(groupsRef.current, f, framesRef.current, widthsRef.current); if (!after) return; snapshot(); tidyRef.current = { frameId: f.id, before: groupsRef.current, after }; setGroups(after); }; const toastTimer = useRef(null); /** the desktop's message pill beside the tidy button; the phone keeps its centered toast */ const showAiNote = (text: string, icon = "check", ms = 2200) => { setAiNote({ text, icon }); if (aiNoteTimer.current) window.clearTimeout(aiNoteTimer.current); aiNoteTimer.current = window.setTimeout(() => setAiNote(null), ms); }; const showToast = (msg: string, ms = 2200, icon = "info") => { if (!mobileRef.current) { showAiNote(msg, icon, ms); return; } setToast(msg); if (toastTimer.current) window.clearTimeout(toastTimer.current); toastTimer.current = window.setTimeout(() => setToast(null), ms); }; const updateAiSettings = (s: AiSettings) => { setAiSettings(s); saveAiSettings(s); }; const aiReady = hasKey(aiSettings) && aiSettings.model.trim().length > 0 && isSecureUrl(aiSettings.baseUrl); const aiReason = !aiReady ? t("aiNoKey", lang) : !tidyTarget ? t("aiSelectScreen", lang) : undefined; /** Writes one field with the model: a part's behavior note, or a screen's description. * The result goes straight in; the field remembers what it said so the rewrite can be undone. */ const runAi = async (action: AiActionKey, f: Frame, itemId?: string) => { if (!aiReady) { showToast(t("aiNoKey", lang)); return; } const curDoc = doc; aiAbortRef.current?.abort(); const ac = new AbortController(); aiAbortRef.current = ac; setAiBusy(true); setAiFrameId(f.id); try { if (action === "describe") { const r = await proposeDescription(aiSettings, curDoc, widthsRef.current, f, lang, ac.signal); if (ac.signal.aborted) return; snapshot(); setFrames((fs) => fs.map((x) => (x.id === f.id ? { ...x, note: r.note, noteHistory: pushHistory(x.noteHistory, x.note), name: r.name ?? x.name } : x))); showAiNote(t("aiApplied", lang)); return; } if (!itemId) return; const note = await proposeBehavior(aiSettings, curDoc, widthsRef.current, f, lang, itemId, ac.signal); if (ac.signal.aborted) return; if (!note) { showToast(t("aiErrorJson", lang)); return; } snapshot(); setGroups((gs) => gs.map((g) => (g.items.some((it) => it.id === itemId) ? { ...g, items: g.items.map((it) => (it.id === itemId ? { ...it, note, noteHistory: pushHistory(it.noteHistory, it.note) } : it)) } : g))); showAiNote(t("aiApplied", lang)); } catch (e) { if (!ac.signal.aborted) showToast(aiErrorText(e, lang), 4000, "error"); } finally { if (aiAbortRef.current === ac) { aiAbortRef.current = null; setAiBusy(false); setAiFrameId(null); } } }; const cancelAi = () => { aiAbortRef.current?.abort(); aiAbortRef.current = null; setAiBusy(false); setAiFrameId(null); }; useEffect( () => () => { aiAbortRef.current?.abort(); if (aiNoteTimer.current) window.clearTimeout(aiNoteTimer.current); if (toastTimer.current) window.clearTimeout(toastTimer.current); }, [], ); /** a screen takes everything on it along, and links into it are dropped */ const deleteFrame = useCallback( (id: string) => { snapshot(); const gone = new Set( groupsRef.current .filter((g) => frameOfGroup(g, framesRef.current, widthsRef.current)?.id === id) .map((g) => g.id), ); setFrames((fs) => fs .filter((f) => f.id !== id) .map((f) => { if (!f.swipe) return f; const swipe = Object.fromEntries(Object.entries(f.swipe).filter(([, to]) => to !== id)); return { ...f, swipe: Object.keys(swipe).length ? swipe : undefined }; }), ); setGroups((gs) => gs .filter((g) => !gone.has(g.id)) .map((g) => ({ ...g, items: g.items.map((it) => { const next = { ...it }; if (next.action?.to === id) next.action = undefined; if (next.actions) { const actions = Object.fromEntries(Object.entries(next.actions).filter(([, a]) => a.to !== id)); next.actions = Object.keys(actions).length ? actions : undefined; } return next; }), })), ); setSelectedFrameId(null); setSelectedIds((cur) => cur.filter((x) => !groupsRef.current.some((g) => gone.has(g.id) && g.items.some((it) => it.id === x)))); }, [snapshot], ); const duplicateFrame = (id: string) => { const f = framesRef.current.find((x) => x.id === id); if (!f) return; snapshot(); const nf: Frame = { ...f, id: uid(), name: `${f.name}${t("copySuffix")}`, x: nextFrameX(), }; const dx = nf.x - f.x; const copies = groupsRef.current .filter( (g) => frameOfGroup(g, framesRef.current, widthsRef.current)?.id === id, ) .map((g) => { const idMap = new Map(g.items.map((it) => [it.id, uid()])); const pos = g.pos ? Object.fromEntries(Object.entries(g.pos).map(([id, o]) => [idMap.get(id) ?? id, o])) : undefined; return { ...g, id: uid(), x: g.x + dx, pos, items: g.items.map((it) => ({ ...it, id: idMap.get(it.id)!, tabs: it.tabs?.map((t) => ({ ...t })), })), }; }); setFrames((fs) => [...fs, nf]); setGroups((gs) => [...gs, ...copies]); setSelectedFrameId(nf.id); }; const duplicateFrameRef = useRef(duplicateFrame); duplicateFrameRef.current = duplicateFrame; /** The screen is re-rendered offscreen at 1:1 with static parts, so the * canvas zoom, selection outlines and in-flight animations never leak into the PNG. */ const saveFrameImage = async (f: Frame) => { setExportFrame(f); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null)))); try { await document.fonts?.ready; const el = document.querySelector(`[data-export="${f.id}"]`); if (!el) return; const { w, h } = frameSizeOf(f); const url = await toPng(el, { pixelRatio: 2, cacheBust: true, width: w, height: h }); const a = document.createElement("a"); a.href = url; a.download = `${f.name || "screen"}.png`; a.click(); } finally { setExportFrame(null); } }; /** the runs of one screen drawn with plain divs: the export layer */ const renderExport = (f: Frame) => { const gs = groups.filter((g) => frameOfGroup(g, frames, widths)?.id === f.id); const { w, h } = frameSizeOf(f); return (
{gs.map((g) => g.free ? ( ((corners) => layoutOf(g, widths).map((pl) => (
)))(freeRadii(g, widths)) ) : (
{g.items.map((it, i) => { const conn = connectSpecOf(it); const n = g.items.length; const radii = conn && n > 1 ? runRadii(g.axis, i === 0, i === n - 1, false, false, 0, conn.outer, conn.inner) : conn ? uniformRadii(conn.outer) : baseRadii(it); return ( ); })}
), )}
); }; /** the view before the preview opened, restored when it closes */ const viewBeforePreview = useRef(null); /** the camera glides for a moment: a screen is brought to the center before the * preview opens over it, and the view returns once the preview closes */ const glide = (v: View) => { setCameraEasing(true); setView(v); window.setTimeout(() => setCameraEasing(false), SETTLE_MS + 40); }; const openPreview = (startId?: string | null) => { if (frame !== "phone") { changeFrame("phone"); } queueMicrotask(() => { const id = startId ?? selectedFrameId ?? framesRef.current[0]?.id ?? null; const f = framesRef.current.find((x) => x.id === id); const r = canvasRect(); if (f && r) { /* the preview's own fit and center, in window coordinates: its stage is sized for the * largest screen and sits left of the control column, so the screen lands where the * preview will show it */ const { w, h } = frameSizeOf(f); const wide = window.innerWidth >= 720; const maxW = Math.max(...framesRef.current.map((x) => frameSizeOf(x).w)) + BEZEL * 2; const maxH = Math.max(...framesRef.current.map((x) => frameSizeOf(x).h)) + BEZEL * 2; const z = clamp(Math.min(1.4, (window.innerHeight - 32) / maxH, (window.innerWidth - (wide ? 236 : 16)) / maxW), MIN_Z, MAX_Z); const cx = (window.innerWidth - (wide ? 220 : 0)) / 2 - r.left; const cy = window.innerHeight / 2 - (wide ? 0 : 28) - r.top; viewBeforePreview.current = viewRef.current; glide({ x: cx - (f.x + w / 2) * z, y: cy - (f.y + h / 2) * z, z }); window.setTimeout(() => setPreviewId(id), SETTLE_MS); } else { setPreviewId(id); } }); }; const closePreview = () => { setPreviewId(null); const back = viewBeforePreview.current; viewBeforePreview.current = null; if (back) window.setTimeout(() => glide(back), 220); }; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (editAccess !== "editable") return; const t = e.target as HTMLElement; const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); if (typing) return; // the confirm dialog and the preview own the keyboard while they are up if (confirmClear || previewId !== null) return; const mod = e.ctrlKey || e.metaKey; if (mod && e.key.toLowerCase() === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; } if (mod && e.key.toLowerCase() === "y") { e.preventDefault(); redo(); return; } if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); if (selectedIds.length === 0 && selectedFrameId) duplicateFrameRef.current(selectedFrameId); else duplicateSelected(); return; } if (mod && e.key.toLowerCase() === "g") { e.preventDefault(); if (e.shiftKey) ungroupSelected(); else groupSelected(); return; } if (e.key === " " && !e.repeat) { e.preventDefault(); setSpaceHeld(true); return; } if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); if (selectedIds.length === 0 && selectedFrameId) deleteFrame(selectedFrameId); else deleteSelected(); return; } if (e.key === "Escape") { setSelectedIds([]); setSelectedFrameId(null); setSelectedLinkId(null); return; } if (e.key === "p" || e.key === "P") { openPreviewRef.current(); return; } if (e.key.startsWith("Arrow")) { e.preventDefault(); const s = e.shiftKey ? 10 : 1; nudge( e.key === "ArrowLeft" ? -s : e.key === "ArrowRight" ? s : 0, e.key === "ArrowUp" ? -s : e.key === "ArrowDown" ? s : 0, ); return; } if (mod) return; if (e.key === "v" || e.key === "V") setMode("select"); if (e.key === "h" || e.key === "H") setMode("hand"); if (e.key === "=" || e.key === "+") setZoomAt(viewRef.current.z * 1.2); if (e.key === "-" || e.key === "_") setZoomAt(viewRef.current.z / 1.2); if (e.key === "0") fitRef.current(); }; const onKeyUp = (e: KeyboardEvent) => { if (e.key === " ") setSpaceHeld(false); }; window.addEventListener("keydown", onKey); window.addEventListener("keyup", onKeyUp); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("keyup", onKeyUp); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ deleteSelected, duplicateSelected, groupSelected, ungroupSelected, nudge, redo, undo, setZoomAt, selectedIds, selectedFrameId, deleteFrame, confirmClear, previewId, editAccess, ]); const openPreviewRef = useRef(openPreview); openPreviewRef.current = openPreview; /* ---------- render ---------- */ const dragSize = drag ? sizeOf(drag.item, widths) : { w: 0, h: 0 }; const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const doc: Doc = useMemo( () => ({ groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme }), [groups, frames, paletteKey, frame, title, brief, promptEdit, platform, customPalette, dynamicColor, theme], ); /** the same document, for callbacks that were created on an earlier render */ const docRef = useRef(doc); docRef.current = doc; /** arrows from tappable parts to the frames they open */ const links = useMemo(() => { if (frame !== "phone") return []; const rects = itemRects(); const out: { id: string; d: string; mx: number; my: number; tx: number; ty: number; ang: number; t: Transition; }[] = []; for (const g of groups) { for (const it of g.items) { for (const { slot, action } of actionsOf(it)) { if (action.to === BACK_TARGET) continue; const f = frames.find((x) => x.id === action.to); const r = rects.find((x) => x.id === it.id); if (!f || !r) continue; const fr = frameRect(f); const rightward = (fr.l + fr.r) / 2 >= (r.l + r.r) / 2; const sx = rightward ? r.r : r.l; const sy = (r.t + r.b) / 2; const tx = rightward ? fr.l - BEZEL : fr.r + BEZEL; const ty = clamp(sy, fr.t + 40, fr.b - 40); const dx = Math.max(60, Math.abs(tx - sx) * 0.5); const c1x = sx + (rightward ? dx : -dx); const c2x = tx + (rightward ? -dx : dx); const d = `M${sx} ${sy} C${c1x} ${sy} ${c2x} ${ty} ${tx} ${ty}`; // midpoint of the cubic at t = 0.5 const mx = 0.125 * sx + 0.375 * c1x + 0.375 * c2x + 0.125 * tx; const my = 0.125 * sy + 0.375 * sy + 0.375 * ty + 0.125 * ty; out.push({ id: `${it.id}|${slot}`, d, mx, my, tx, ty, ang: rightward ? 0 : 180, t: action.transition, }); } } } return out; }, [groups, frames, frame, itemRects, widths]); /** apply a change to the action behind a link id ("itemId|slot") */ const patchLink = (linkId: string, fn: (a: Action) => Action | undefined) => { const [itemId, slot] = linkId.split("|"); setGroups((gs) => gs.map((g) => ({ ...g, items: g.items.map((it) => { if (it.id !== itemId) return it; if (!slot) return { ...it, action: it.action ? fn(it.action) : undefined }; const cur = it.actions?.[slot]; if (!cur) return it; const next = fn(cur); const actions = { ...(it.actions ?? {}) }; if (next) actions[slot] = next; else delete actions[slot]; return { ...it, actions: Object.keys(actions).length ? actions : undefined }; }), })), ); }; const setLinkTransition = (linkId: string, transition: Transition) => { snapshotFor("link:" + linkId); patchLink(linkId, (a) => ({ ...a, transition })); }; const removeLink = (linkId: string) => { snapshot(); patchLink(linkId, () => undefined); setSelectedLinkId(null); }; const runRadii = ( axis: Axis, first: boolean, last: boolean, prevPh: boolean, nextPh: boolean, pull: number, outer: number, inner: number, ): Radii => { const soft = lerp(outer, inner, pull); const s = first ? outer : prevPh ? soft : inner; const e = last ? outer : nextPh ? soft : inner; return axis === "x" ? { tl: s, bl: s, tr: e, br: e } : { tl: s, tr: s, bl: e, br: e }; }; /** which frame each run sits on (phone mode only) */ const frameOf = useMemo(() => { const m = new Map(); if (frame !== "phone") return m; for (const g of groups) { const f = frameOfGroup(g, frames, widths); if (f) m.set(g.id, f.id); } return m; }, [groups, frames, frame, widths]); /** the screen whose layers the panel lists: the selection's, else the chosen one */ const layersFrame = useMemo(() => { if (frame !== "phone") return null; if (primaryId) { const g = groups.find((x) => x.items.some((it) => it.id === primaryId)); const fid = g ? frameOf.get(g.id) : undefined; if (fid) return frames.find((f) => f.id === fid) ?? null; } if (selectedFrameId) return frames.find((f) => f.id === selectedFrameId) ?? null; return frames.find((f) => f.id === layersFrameId) ?? frames[0] ?? null; }, [frame, primaryId, groups, frameOf, frames, selectedFrameId, layersFrameId]); const layerGroups = useMemo( () => (layersFrame ? groups.filter((g) => frameOf.get(g.id) === layersFrame.id) : []), [groups, frameOf, layersFrame], ); /** A drag in the layers panel is one undo step: the snapshot is taken when it starts, * and the reorders it fires along the way record nothing more. */ const layerDragRef = useRef(false); const onLayerDragging = (dragging: boolean) => { if (dragging && !layerDragRef.current) snapshot(); layerDragRef.current = dragging; }; const layerSnapshot = (key: string) => { if (!layerDragRef.current) snapshotFor(key); }; const reorderLayers = (topFirst: string[]) => { const inFrame = new Set(topFirst); const byId = new Map(groupsRef.current.map((g) => [g.id, g])); const ordered = [...topFirst].reverse().map((id) => byId.get(id)).filter((g): g is Group => !!g); if (ordered.length !== inFrame.size) return; layerSnapshot("layers:" + (layersFrame?.id ?? "")); for (const id of inFrame) instantRef.current.add(id); setGroups((gs) => [...gs.filter((g) => !inFrame.has(g.id)), ...ordered]); }; /** The parts of one group in a new order: reading order for a connected run, back to * front for a free group. Inside a free group a hidden run keeps its slots, handed out * again in the new order, so reordering a list really moves its rows. */ const reorderGroupItems = (groupId: string, order: string[]) => { const g = groupsRef.current.find((x) => x.id === groupId); if (!g) return; const byId = new Map(g.items.map((it) => [it.id, it])); const items = order.map((id) => byId.get(id)).filter((it): it is Item => !!it); if (items.length !== g.items.length || new Set(order).size !== order.length) return; layerSnapshot("layers:items:" + groupId); instantRef.current.add(groupId); if (!g.free) { setGroups((gs) => gs.map((x) => (x.id === groupId ? { ...x, items } : x))); return; } const rank = new Map(items.map((it, i) => [it.id, i])); const pos = { ...(g.pos ?? {}) }; for (const run of explodeGroup(g, widthsRef.current)) { if (run.items.length < 2) continue; const slots = run.items.map((it) => pos[it.id] ?? { x: 0, y: 0 }); const members = [...run.items].sort((a, b) => rank.get(a.id)! - rank.get(b.id)!); members.forEach((it, i) => { pos[it.id] = slots[i]; }); } setGroups((gs) => gs.map((x) => (x.id === groupId ? { ...x, items, pos } : x))); }; const renderGroup = (g: Group, ox: number, oy: number) => { if (g.free) { const instantG = instantRef.current.has(g.id); const allOn = g.items.every((it) => selectedSet.has(it.id)); const corners = freeRadii(g, widths); return ( {layoutOf(g, widths).map((pl) => (
onItemPointerDown(e, g, pl.index, pl.item)} />
))} {allOn && (
)} ); } const snap = drag?.active && drag.snap?.groupId === g.id ? drag.snap : null; const pull = snap?.pull ?? 0; const phMain = snap ? (g.axis === "x" ? dragSize.w : dragSize.h) * pull : 0; const shift = snap && snap.index === 0 ? -(phMain + GAP) : 0; const conn = g.items[0] ? connectSpecOf(g.items[0]) : undefined; type Cell = { ph: true } | { ph: false; item: Item; index: number }; const cells: Cell[] = []; for (let i = 0; i <= g.items.length; i++) { if (snap && snap.index === i) cells.push({ ph: true }); if (i < g.items.length) cells.push({ ph: false, item: g.items[i], index: i }); } const m = cells.length; const instant = instantRef.current.has(g.id); return ( {cells.map((c, r) => { if (c.ph) { return ( ); } const ic = connectSpecOf(c.item); const radii = conn && ic ? runRadii( g.axis, r === 0, r === m - 1, r > 0 && cells[r - 1].ph, r < m - 1 && cells[r + 1].ph, pull, ic.outer, ic.inner, ) : baseRadii(c.item); return ( onItemPointerDown(e, g, c.index, c.item)} /> ); })} ); }; const handMode = !isMobile && (mode === "hand" || spaceHeld); const panning = gesture?.kind === "pan"; const marquee = gesture?.kind === "marquee" && gesture.moved ? gesture : null; const canvasBg = frame === "phone" ? p.surfaceContainerLow : "#ffffff"; const panelStyle: React.CSSProperties = { background: p.surface, display: "flex", flexDirection: "column", overflow: "hidden", position: "relative", flex: "0 0 auto", }; const showRight = rightOpen && !isMobile; const guide = drag?.active ? drag.guide : null; const visibleWorld = (() => { const r = canvasRef.current?.getBoundingClientRect(); return { l: -view.x / view.z, t: -view.y / view.z, w: (r?.width ?? 0) / view.z, h: (r?.height ?? 0) / view.z, }; })(); return (
{/* hidden measuring layer for text-sized kinds */}
{allItems .filter((it) => MEASURED.includes(it.kind)) .map((it) => (
{ if (el) measureEls.current.set(it.id, el); else measureEls.current.delete(it.id); }} style={{ display: "inline-flex", boxSizing: "border-box", border: it.variant === "outlined" && (it.kind === "button" || it.kind === "chip" || it.kind === "extendedFab") ? "1px solid transparent" : "none", }} >
))}
{exportFrame && (
{renderExport(exportFrame)}
)} {/* ---- left: rail + parts / layers ---- */} {!isMobile && ( )} {/* ---- canvas ---- */}
{frame === "phone" && frames.map((f) => { const on = f.id === selectedFrameId; const bg = p[f.bg ?? "surface"]; const { w, h } = frameSizeOf(f); const radius = frameRadius(f); return (
onFramePointerDown(e, f)} style={{ position: "absolute", left: -BEZEL, top: -BEZEL - FRAME_LABEL_H, height: FRAME_LABEL_H, /* follows the zoom, softened: a little larger when zoomed out, a little smaller when zoomed in */ transform: `scale(${clamp(1 / view.z, 0.7, 1.4)})`, transformOrigin: "left bottom", display: "flex", alignItems: "center", gap: 10, padding: "0 8px", fontSize: 20, fontWeight: 600, color: on ? p.primary : p.onSurfaceVariant, cursor: handMode ? "grab" : "move", userSelect: "none", whiteSpace: "nowrap", fontFamily: uiFontFamily(lang), }} >
e.stopPropagation()}> setFramePreset(f.id, preset)} palette={p} compact />
{f.name || t("screen", lang)}
onFramePointerDown(e, f)} style={{ position: "absolute", left: -BEZEL, top: -BEZEL, overflow: "hidden", width: w + BEZEL * 2, height: h + BEZEL * 2, borderRadius: radius + BEZEL, background: draftBusy ? DRAFT_GRADIENT(p) : p.inverseSurface, backgroundSize: draftBusy ? "300% 300%" : undefined, animation: draftBusy ? "m3e-drift 3s ease-in-out infinite" : undefined, boxShadow: on ? `0 0 0 3px ${p.primary}, 0 18px 50px rgba(0,0,0,0.16)` : "0 18px 50px rgba(0,0,0,0.14)", cursor: handMode ? "grab" : "move", transition: `box-shadow 120ms, ${SIZE_TRANSITION}`, }} > {aiFrameId === f.id && }
{groups .filter((g) => frameOf.get(g.id) === f.id) .map((g) => renderGroup(g, f.x, f.y))} {draftBusy && (
)}
); })} {groups .filter((g) => !frameOf.has(g.id)) .map((g) => renderGroup(g, 0, 0))} {/* the part in flight */} {drag?.active && ( { const conn = connectSpecOf(drag.item); if (!conn || !drag.snap) return baseRadii(drag.item); const g = groupsRef.current.find( (x) => x.id === drag.snap!.groupId, ); const mm = (g?.items.length ?? 0) + 1; const k = drag.snap.index; return runRadii( conn.axis, k === 0, k === mm - 1, k > 0, k < mm - 1, drag.snap.pull, conn.outer, conn.inner, ); })()} /> )} {links.length > 0 && ( {!draftBusy && links.map((l) => { const on = l.id === selectedLinkId; return ( { e.stopPropagation(); setSelectedLinkId(l.id); setSelectedIds([]); setSelectedFrameId(null); }} /> ); })} )} {links .filter((l) => l.id === selectedLinkId) .map((l) => (
e.stopPropagation()} style={{ position: "absolute", left: l.mx, top: l.my, transform: `translate(-50%, 14px) scale(${1 / view.z})`, transformOrigin: "50% 0", display: "flex", alignItems: "center", gap: 4, padding: 6, borderRadius: 26, background: p.surfaceContainerLow, boxShadow: "0 4px 16px rgba(0,0,0,0.16)", zIndex: 60, }} > options={TRANSITIONS.map((t) => ({ key: t.key, icon: t.icon, title: t.label, }))} value={l.t} onChange={(t) => setLinkTransition(l.id, t)} p={p} height={36} grow={false} /> removeLink(l.id)} title={t("removeLink", lang)} size={36} />
))} {guide?.gx !== undefined && (
)} {guide?.gy !== undefined && (
)} {marquee && (
)}
{draftBusy && (
)} setZoomAt(z)} onFit={fit} canUndo={pastRef.current.length > 0} canRedo={futureRef.current.length > 0} onUndo={undo} onRedo={redo} onClear={() => { if (groupsRef.current.length || framesRef.current.length) setConfirmClear(true); }} onAddFrame={addFrame} onPreview={() => openPreview()} tidy={tidyState ?? undefined} onTidy={tidyTarget ? () => tidy(tidyTarget) : undefined} note={aiNote} onSaveProject={() => saveProject(doc)} onOpenProject={() => projectFileRef.current?.click()} onShare={!isMobile ? () => setShareOpen(true) : undefined} shareState={draftBusy ? "busy" : draftBefore ? "review" : "idle"} onDraftKeep={keepDraft} onDraftUndo={undoDraft} onDraftSave={() => saveProject(doc)} quickUndo={quickUndo} rightInset={showRight ? rightW : 0} mobile={isMobile} onSettings={() => setSheet(sheet === "settings" ? null : "settings")} onLangSheet={() => setSheet(sheet === "lang" ? null : "lang")} onPrompt={async () => { try { const text = effectivePrompt(doc, widths, lang); // The hook's own failure is isolated: a broken host must not also break copying. let handled = false; try { handled = host.onExport?.(text, doc) === true; } catch {} if (handled) return; await navigator.clipboard.writeText(text); showToast(t("copied", lang), 1400, "check"); } catch {} }} /> {isMobile && (
{t("mobileNote", lang)}
)} {isMobile && sheet === null && ( )} {isMobile && selected && sheet === null && ( setSheet("edit")} onDuplicate={duplicateSelected} onDelete={deleteSelected} /> )} {isMobile && sheet === "edit" && selected && ( setSheet(null)}> { deleteSelected(); setSheet(null); }} onDuplicate={duplicateSelected} onClose={() => setSheet(null)} /> )} {isMobile && sheet === "settings" && ( setSheet(null)}> )} {isMobile && sheet === "lang" && ( setSheet(null)}> { changeLanguage(l); setSheet(null); }} /> )} {toast && (
{toast}
)} {!rightOpen && !isMobile && (
setRightOpen(true)} title={t("edit", lang)} size={44} />
)}
{/* ---- right: inspector / prompt ---- */} {showRight && ( )} { const file = e.target.files?.[0]; e.target.value = ""; if (file) void readProject(file).then((next) => (next ? setPendingImport(next) : showToast(t("invalidProject", lang), 3000, "error"))); }} /> setPendingImport(null)} onConfirm={() => { if (pendingImport) importDoc(pendingImport); setPendingImport(null); }} /> setShareOpen(false)} onDraft={(idea) => void startDraft(idea)} onSetupAi={() => { setShareOpen(false); setLeftOpen(true); setLeftTab("ai"); }} /> setConfirmClear(false)} onConfirm={clearAll} /> {previewId !== null && frames.length > 0 && ( )}
{editAccess === "readonly" && (

{t("readOnlyTitle", lang)}

{t("readOnlyBody", lang)}

)}
); }