commit 240d63940de262c48beec2ef9645497045939a68 Author: André Padez Date: Sat Sep 5 12:00:17 2026 +0100 vendor m3e-canvas 5c83ac6, with the import rewrite scripted upstream imports through a @/ alias, and this platform already binds @/* to src/apps/officer-web and @/components/* to its own shared components — so an unrewritten import resolves to the WRONG module rather than failing. the rewrite is mechanical and lives in scripts/vendor.sh so a re-vendor produces a readable diff. lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them meant editing vendored code, and Canvas.tsx references them throughout. a faithful copy carrying an unused module beats a doctored one. Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/scripts/vendor.sh b/scripts/vendor.sh new file mode 100755 index 0000000..ba6d727 --- /dev/null +++ b/scripts/vendor.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Re-vendor the M3E Canvas editor from upstream. +# +# ./scripts/vendor.sh [] default: main +# +# ── Why a script rather than a copy ── +# +# The vendored copy is NOT byte-identical to upstream, and it cannot be. Upstream imports through a +# `@/` path alias, and this platform already binds `@/*` to `src/apps/officer-web` and +# `@/components/*` to its own shared components. Left alone, `import { M3Node } from +# "@/components/M3Node"` inside the vendored editor would silently resolve to Officer's components +# directory — a wrong module rather than a build error, which is the worst way to be broken. +# +# So every import is rewritten to a relative path. That transformation is MECHANICAL and lives here, +# which is what keeps the copy reviewable: `git diff` after a re-vendor shows upstream's real changes, +# because the rewrite produces the same output for the same input every time. +# +# Nothing else is edited. Officer's own changes live outside `web/vendor/`, reached through +# `web/adapter.ts`. +set -euo pipefail + +REF="${1:-main}" +UPSTREAM="https://github.com/lnkiai/m3e-canvas.git" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST="$HERE/web/vendor" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "cloning $UPSTREAM @ $REF" +git clone -q --depth 1 --branch "$REF" "$UPSTREAM" "$WORK/src" +SHA="$(git -C "$WORK/src" rev-parse HEAD)" +WHEN="$(git -C "$WORK/src" log -1 --format=%ad --date=short)" + +rm -rf "$DEST" +mkdir -p "$DEST" + +# lib/ and components/ only. `app/` is upstream's Next.js route and is deliberately NOT taken: +# app/page.tsx becomes web/vendor/Canvas.tsx below, and app/layout.tsx has no meaning here. +cp -r "$WORK/src/lib" "$DEST/lib" +cp -r "$WORK/src/components" "$DEST/components" +cp "$WORK/src/app/page.tsx" "$DEST/Canvas.tsx" +cp "$WORK/src/app/globals.css" "$DEST/globals.css" +cp "$WORK/src/LICENSE" "$DEST/LICENSE" + +# lib/ai.ts and components/AiPanel.tsx ARE vendored, despite being the parts Officer least needs. +# +# The first attempt deleted them — they keep model-provider API keys in localStorage and call the +# provider from the browser, which Officer has no use for and would not do that way. But Canvas.tsx +# references them in about a dozen places, so deleting meant editing vendored code, and the rule one +# paragraph up says not to. A faithful copy that carries an unused module beats a doctored one that +# cannot be re-vendored. +# +# They stay inert: the panel does nothing without a key, and Officer's shell does not surface the +# entry point. See ../README.md, "What we do not use". + +# ── the mechanical rewrite ── +# `@/lib/x` -> relative path to vendor/lib/x +# `@/components/x` -> relative path to vendor/components/x +python3 - "$DEST" <<'PY' +import os, re, sys +dest = sys.argv[1] +for root, _, files in os.walk(dest): + for name in files: + if not name.endswith((".ts", ".tsx")): + continue + path = os.path.join(root, name) + depth = os.path.relpath(root, dest) + prefix = "./" if depth == "." else "../" * len(depth.split(os.sep)) + src = open(path, encoding="utf-8").read() + out = re.sub(r'(["\'])@/(lib|components)/', lambda m: f"{m.group(1)}{prefix}{m.group(2)}/", src) + if out != src: + open(path, "w", encoding="utf-8").write(out) +PY + +cat > "$DEST/PROVENANCE.md" < web/vendor/" +echo " $(find "$DEST" -name '*.ts' -o -name '*.tsx' | wc -l) source files" diff --git a/web/vendor/Canvas.tsx b/web/vendor/Canvas.tsx new file mode 100644 index 0000000..7602246 --- /dev/null +++ b/web/vendor/Canvas.tsx @@ -0,0 +1,3828 @@ +"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"; + +/** 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() { + /* ---------- 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 { + const d = localStorage.getItem(DOC_KEY); + 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 u = localStorage.getItem(UI_KEY); + if (u) { + const ui = JSON.parse(u); + 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 { + localStorage.setItem( + DOC_KEY, + JSON.stringify({ groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme }), + ); + } catch {} + }, [editAccess, groups, frames, paletteKey, frame, title, brief, promptEdit, platform, customPalette, dynamicColor, theme]); + + useEffect(() => { + if (!loadedRef.current) return; + try { + localStorage.setItem( + UI_KEY, + JSON.stringify({ + view, + leftOpen, + rightOpen, + leftW, + rightW, + favorites, + mode, + lang, + }), + ); + } 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 { + await navigator.clipboard.writeText(effectivePrompt(doc, widths, lang)); + 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)} +

+
+ +
+
+
+ )} +
+
+ ); +} diff --git a/web/vendor/LICENSE b/web/vendor/LICENSE new file mode 100644 index 0000000..9e3a5f3 --- /dev/null +++ b/web/vendor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lnkiai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/vendor/PROVENANCE.md b/web/vendor/PROVENANCE.md new file mode 100644 index 0000000..88a277b --- /dev/null +++ b/web/vendor/PROVENANCE.md @@ -0,0 +1,34 @@ +# Vendored from lnkiai/m3e-canvas + +| | | +| --- | --- | +| upstream | https://github.com/lnkiai/m3e-canvas.git | +| ref | `main` | +| commit | `5c83ac6f437153b4df4d2348c4f94c4ff71b864a` | +| dated | 2026-09-05 | +| licence | MIT — see `LICENSE` beside this file | +| vendored | 2026-09-05 by `scripts/vendor.sh` | + +**Do not edit anything in this directory.** Officer's changes live outside it, reached through +`web/adapter.ts`. Editing here makes the next `vendor.sh` run either clobber the change or +produce a diff nobody can read. + +## What was taken, and what was not + +Taken: `lib/`, `components/`, upstream's `app/page.tsx` as `Canvas.tsx`, and `globals.css`. + +Not taken: `app/layout.tsx` — a Next.js route shell with no meaning here. + +`lib/ai.ts` and `components/AiPanel.tsx` ARE taken even though Officer does not use them. They +keep model-provider API keys in `localStorage`, which is not how anything here should work — but +`Canvas.tsx` references them throughout, so removing them would mean editing vendored code. A +faithful copy carrying an unused module beats a doctored one that cannot be re-vendored. They are +inert without a key, and the shell does not surface them. + +## The one edit `vendor.sh` makes + +Every `@/lib/…` and `@/components/…` import is rewritten to a relative path. This platform already +binds `@/*` to `src/apps/officer-web` and `@/components/*` to its own shared components, so an +unrewritten import resolves to the WRONG module rather than failing — silent breakage. + +The rewrite is deterministic, so a re-vendor produces a readable diff of upstream's real changes. diff --git a/web/vendor/components/AiPanel.tsx b/web/vendor/components/AiPanel.tsx new file mode 100644 index 0000000..f786497 --- /dev/null +++ b/web/vendor/components/AiPanel.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useRef } from "react"; +import { Palette } from "../lib/tokens"; +import { t, useLang } from "../lib/i18n"; +import { AiSettings, PROVIDERS, Provider, providerSpec } from "../lib/ai"; +import { Icon } from "./M3Node"; + +/** the message shown for a failed request, mapped from the error codes lib/ai throws */ +export function aiErrorText(e: unknown, lang: ReturnType): string { + const m = e instanceof Error ? e.message : String(e); + if (m === "refusal") return t("aiErrorRefusal", lang); + if (m === "json" || m === "empty") return t("aiErrorJson", lang); + if (m === "model") return t("aiErrorModel", lang); + if (m === "insecure") return t("aiErrorInsecure", lang); + if (/failed to fetch|networkerror|load failed/i.test(m)) return t("aiErrorNetwork", lang); + return `${t("aiError", lang)}: ${m}`; +} + +export type AiActionKey = "behavior" | "describe"; + +/** The button that asks the model to write the field above it; spins while it works. */ +export function AiWriteBtn({ p, busy, disabled, onClick, onCancel, label, title }: { p: Palette; busy: boolean; disabled?: boolean; onClick: () => void; onCancel: () => void; label: string; title: string }) { + const lang = useLang(); + const shown = busy ? t("cancel", lang) : title; + return ( + + ); +} + +const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +const field = (p: Palette): React.CSSProperties => ({ + width: "100%", + height: 44, + padding: "0 14px", + borderRadius: 22, + border: `1px solid ${p.outlineVariant}`, + background: p.surface, + color: p.onSurface, + fontSize: 13, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + outline: "none", + boxSizing: "border-box", +}); + +function Input({ value, onChange, placeholder, p, type = "text", label }: { value: string; onChange: (v: string) => void; placeholder?: string; p: Palette; type?: string; label: string }) { + return onChange(e.target.value)} placeholder={placeholder} aria-label={label} spellCheck={false} autoComplete="off" style={field(p)} />; +} + +const Label = ({ children, p, right }: { children: React.ReactNode; p: Palette; right?: React.ReactNode }) => ( +
+ {children} + {right} +
+); + +/** The providers as one connected button group; each segment shows the provider's mark in the button's color. */ +function ProviderGroup({ value, onChange, p }: { value: Provider; onChange: (k: Provider) => void; p: Palette }) { + const n = PROVIDERS.length; + const ref = useRef(null); + const move = (from: number, delta: number) => { + const to = (from + delta + n) % n; + onChange(PROVIDERS[to].key); + (ref.current?.children[to] as HTMLElement | undefined)?.focus(); + }; + return ( +
+ {PROVIDERS.map((pr, i) => { + const on = pr.key === value; + const outer = 22; + const inner = 8; + const l = i === 0 ? outer : inner; + const r = i === n - 1 ? outer : inner; + return ( + + ); + })} +
+ ); +} + +/** The AI tab of the left rail: the provider settings. The actions live with each screen on the right. */ +export function AiPanel({ p, settings, onSettings }: { p: Palette; settings: AiSettings; onSettings: (s: AiSettings) => void }) { + const lang = useLang(); + const spec = providerSpec(settings.provider); + const pick = (k: Provider) => { + const s = providerSpec(k); + onSettings({ ...settings, provider: k, baseUrl: s.baseUrl, model: s.model }); + }; + return ( +
+
{t("aiSettings", lang)}
+
+
+
+ + +
+
+ + onSettings({ ...settings, model })} placeholder={spec.model || "model"} p={p} /> +
+
+ + onSettings({ ...settings, baseUrl })} placeholder={spec.baseUrl} p={p} /> +
+
+ + onSettings({ ...settings, key })} placeholder="sk-…" p={p} type="password" /> +
{t("aiKeyHint", lang)}
+
+
+
+
+ ); +} diff --git a/web/vendor/components/ColorPanel.tsx b/web/vendor/components/ColorPanel.tsx new file mode 100644 index 0000000..f075642 --- /dev/null +++ b/web/vendor/components/ColorPanel.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CONTRASTS, Contrast, PALETTES, Palette, Theme } from "../lib/tokens"; +import { isHex, onColorFor, schemeFromSeed } from "../lib/color"; +import { t, useLang } from "../lib/i18n"; +import { Section, Segmented } from "./ui"; +import { Icon } from "./M3Node"; + +/** roles the author can override by hand; their "on" color follows automatically */ +type Role = Exclude; +const TUNABLE: { key: Role; on?: Role }[] = [ + { key: "primary", on: "onPrimary" }, + { key: "primaryContainer", on: "onPrimaryContainer" }, + { key: "secondaryContainer", on: "onSecondaryContainer" }, + { key: "tertiaryContainer", on: "onTertiaryContainer" }, + { key: "surfaceContainer" }, + { key: "surfaceContainerHigh" }, + { key: "inverseSurface", on: "inverseOnSurface" }, +]; + +/** the palette's key colors as overlapping dots, right-aligned in the row */ +function Swatches({ pal, p }: { pal: Palette; p: Palette }) { + const colors = [pal.primary, pal.primaryContainer, pal.secondaryContainer, pal.tertiaryContainer, pal.surfaceContainerHigh]; + return ( + + {colors.map((c, i) => ( + + ))} + + ); +} + +function ColorField({ value, onChange, p, label }: { value: string; onChange: (hex: string) => void; p: Palette; label: string }) { + const [text, setText] = useState(value); + useEffect(() => setText(value), [value]); + return ( + + ); +} + +/** A settings row in the same shape as the palette rows: icon disc, label, and the + * value or switch at the trailing edge. The whole row is the control. */ +function SettingRow({ + p, + icon, + label, + onClick, + pressed, + children, +}: { + p: Palette; + icon: string; + label: string; + onClick: () => void; + pressed?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** the switch of a settings row, drawn without its own button */ +function Knob({ on, p }: { on: boolean; p: Palette }) { + return ( + + + + ); +} + +/** The color tab of the theme panel: light / dark and contrast, preset themes, + * a seed-based custom scheme with per-role tweaks, and the dynamic-color switch. */ +export function ColorPanel({ + p, + paletteKey, + onPalette, + custom, + onCustom, + dynamic, + onDynamic, + theme, + onTheme, +}: { + p: Palette; + paletteKey: string; + onPalette: (key: string) => void; + custom: Palette | null; + onCustom: (pal: Palette) => void; + dynamic: boolean; + onDynamic: (on: boolean) => void; + theme: Theme; + onTheme: (patch: Partial) => void; +}) { + const lang = useLang(); + const contrastLabel = (c: Contrast) => (c === "high" ? t("contrastHigh", lang) : c === "medium" ? t("contrastMedium", lang) : t("contrastStandard", lang)); + const [tab, setTab] = useState<"templates" | "custom">(paletteKey === "custom" ? "custom" : "templates"); + const [seed, setSeed] = useState(custom?.seed ?? custom?.primary ?? "#6750A4"); + + const applySeed = (hex: string) => { + setSeed(hex); + onCustom(schemeFromSeed(hex)); + onPalette("custom"); + }; + const cur = custom ?? schemeFromSeed(seed); + + return ( +
+
+ onTheme({ dark: !theme.dark })} pressed={theme.dark}> + + {theme.dark ? t("dark", lang) : t("light", lang)} + + + + onTheme({ bothModes: !theme.bothModes })} pressed={theme.bothModes}> + + + { + const i = CONTRASTS.findIndex((c) => c.key === theme.contrast); + onTheme({ contrast: CONTRASTS[(i + 1) % CONTRASTS.length].key }); + }} + > + + {contrastLabel(theme.contrast)} + + + +
+ + + options={[ + { key: "templates", icon: "palette", label: t("templates", lang) }, + { key: "custom", icon: "colorize", label: t("customColor", lang) }, + ]} + value={tab} + onChange={setTab} + p={p} + height={38} + /> + + {tab === "templates" ? ( +
+ {PALETTES.map((pal) => { + const on = pal.key === paletteKey; + return ( + + ); + })} +
+ ) : ( +
+
+
+ +
+ {paletteKey !== "custom" && ( + + )} +
+
{t("seedHint", lang)}
+
+ {["#6750A4", "#0B57D0", "#2E6A45", "#984061", "#8B5000", "#00696E", "#B3261E", "#4A4459"].map((c) => ( +
+
+
+ {TUNABLE.map((r) => ( + { + const next: Palette = { ...cur, [r.key]: hex }; + if (r.on) (next as Record)[r.on] = onColorFor(hex); + onCustom(next); + onPalette("custom"); + }} + /> + ))} +
+
+
+ )} + +
+ onDynamic(!dynamic)} pressed={dynamic}> + + +
+ {dynamic ? t("dynamicOnHint", lang) : t("dynamicOffHint", lang)} +
+
+
+ ); +} diff --git a/web/vendor/components/IconPicker.tsx b/web/vendor/components/IconPicker.tsx new file mode 100644 index 0000000..6d4497f --- /dev/null +++ b/web/vendor/components/IconPicker.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Palette } from "../lib/tokens"; +import { t, useLang } from "../lib/i18n"; + +type IconMeta = { n: string; p: number; t: string }; + +const FONT = '24px "Material Symbols Rounded"'; +/** module-level so validation survives re-mounts of the panel */ +let cache: IconMeta[] | null = null; +const glyphOk = new Map(); + +/** Candidates measured per pass. A real Material Symbols glyph is exactly 1em + * wide; a name with no glyph falls back to text and measures wider. Icon names + * can be as short as "tv"/"4k", so we compare against a known-good reference + * glyph rather than a width threshold. */ +const BATCH = 400; +const SHOWN = 240; + +export function IconPicker({ + value, + onChange, + palette, +}: { + value: string | null; + onChange: (icon: string | null) => void; + palette: Palette; +}) { + const lang = useLang(); + const [icons, setIcons] = useState(cache); + const [q, setQ] = useState(""); + const [fontReady, setFontReady] = useState(false); + /* bumped after each measuring pass; the visible list depends on it because + * glyphOk is a plain map, not React state */ + const [tick, bump] = useState(0); + + const refEl = useRef(null); + const probeEls = useRef>(new Map()); + + useEffect(() => { + if (cache) return; + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/material-symbols.json`) + .then((r) => r.json()) + .then((d: IconMeta[]) => { + cache = d; + setIcons(d); + }) + .catch(() => setIcons([])); + }, []); + + useEffect(() => { + let alive = true; + const done = () => alive && setFontReady(true); + if (document.fonts.check(FONT)) { + done(); + return; + } + document.fonts.load(FONT, "search").then(done, done); + return () => { + alive = false; + }; + }, []); + + const candidates = useMemo(() => { + if (!icons) return []; + const s = q.trim().toLowerCase().replace(/\s+/g, "_"); + if (!s) return icons.slice(0, BATCH); + const raw = q.trim().toLowerCase(); + const starts: IconMeta[] = []; + const rest: IconMeta[] = []; + for (const i of icons) { + if (i.n.startsWith(s)) starts.push(i); + else if (i.n.includes(s) || i.t.includes(raw)) rest.push(i); + if (starts.length + rest.length >= BATCH * 2) break; + } + return [...starts, ...rest].slice(0, BATCH); + }, [icons, q]); + + const unknown = useMemo(() => candidates.filter((c) => !glyphOk.has(c.n)), [candidates]); + + useLayoutEffect(() => { + if (!fontReady || unknown.length === 0) return; + const ref = refEl.current?.getBoundingClientRect().width ?? 0; + if (ref <= 0) return; + let learned = false; + for (const c of unknown) { + const el = probeEls.current.get(c.n); + if (!el) continue; + const w = el.getBoundingClientRect().width; + glyphOk.set(c.n, Math.abs(w - ref) < 0.75); + learned = true; + } + if (learned) bump((v) => v + 1); + }, [fontReady, unknown]); + + const visible = useMemo( + () => candidates.filter((c) => glyphOk.get(c.n) === true).slice(0, SHOWN), + // eslint-disable-next-line react-hooks/exhaustive-deps + [candidates, fontReady, tick], + ); + + const loading = !icons || !fontReady; + + return ( +
+ {/* hidden probes — measured before paint, so nothing broken ever renders */} +
+ + search + + {unknown.map((c) => ( + { + if (el) probeEls.current.set(c.n, el); + else probeEls.current.delete(c.n); + }} + className="msr" + style={{ fontSize: 24 }} + > + {c.n} + + ))} +
+ +
+
+ + search + + setQ(e.target.value)} + placeholder={icons ? t("searchIcons", lang) : "…"} + style={{ + width: "100%", + height: 40, + paddingLeft: 40, + paddingRight: 12, + borderRadius: 20, + border: "none", + background: palette.surfaceContainerHigh, + color: palette.onSurface, + fontSize: 14, + outline: "none", + }} + /> +
+
+ +
+ {visible.map((i) => ( + + ))} + {!loading && visible.length === 0 && ( +
+ search_off +
+ )} + {loading && ( +
+ hourglass_top +
+ )} +
+
+ ); +} diff --git a/web/vendor/components/Inspector.tsx b/web/vendor/components/Inspector.tsx new file mode 100644 index 0000000..9cd5797 --- /dev/null +++ b/web/vendor/components/Inspector.tsx @@ -0,0 +1,1290 @@ +"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)} + /> +
+ )} +
+ ); +} diff --git a/web/vendor/components/Layers.tsx b/web/vendor/components/Layers.tsx new file mode 100644 index 0000000..a63124c --- /dev/null +++ b/web/vendor/components/Layers.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { ReactNode, useMemo, useState } from "react"; +import { Reorder, useDragControls } from "motion/react"; +import { Frame, Group, Item, KIND_SPEC, Palette, explodeGroup, isPhoneFrame } from "../lib/tokens"; +import { Icon } from "./M3Node"; +import { Lang, KIND_TEXT, t, useLang } from "../lib/i18n"; + +/* Rows never animate their size: opening a row only adds rows under it, so + * nothing stretches. Only the drag itself moves. + * + * The panel is a tree with two kinds of level. A z level lists what is drawn on + * top of what, front first: the runs of a screen, and the runs hidden inside a + * free group. A run level lists the parts of one connected run in reading order + * (left to right, top to bottom). Every level is reordered by dragging the + * handle; the page turns the new order back into a group. */ + +function nameOf(it: Item, lang: Lang) { + const spec = KIND_SPEC[it.kind]; + const noun = KIND_TEXT[lang][it.kind]?.noun ?? spec.label; + return it.label.trim() || (it.kind === "iconButton" || it.kind === "fab" ? (it.icon ?? noun) : noun); +} + +function runLabel(g: Group, lang: Lang) { + const first = g.items[0]; + const noun = KIND_TEXT[lang][first.kind]?.noun ?? KIND_SPEC[first.kind].label; + return g.free ? `${t("group", lang)} × ${g.items.length}` : g.items.length > 1 ? `${noun} × ${g.items.length}` : nameOf(first, lang); +} + +function Row({ + id, + p, + depth, + icon, + label, + on, + onSelect, + open, + onToggle, + onDragging, + children, +}: { + id: string; + p: Palette; + depth: number; + icon: ReactNode; + label: string; + on: boolean; + onSelect: (add: boolean) => void; + /** set when the row can open to show what it holds */ + open?: boolean; + onToggle?: () => void; + onDragging: (dragging: boolean) => void; + children?: ReactNode; +}) { + const lang = useLang(); + const controls = useDragControls(); + const h = depth === 0 ? 40 : 36; + return ( + onDragging(true)} onDragEnd={() => onDragging(false)} style={{ listStyle: "none", display: "flex", flexDirection: "column", gap: 4, position: "relative" }}> +
+ { + e.preventDefault(); + controls.start(e); + }} + style={{ cursor: "grab", color: p.outline, display: "grid", placeItems: "center", width: depth === 0 ? 24 : 20, height: h, touchAction: "none" }} + > + + + + {onToggle && ( + + )} +
+ {open && children} +
+ ); +} + +/** One reorderable level: drag the handles; `values` is the shown order. */ +function Level({ values, onReorder, children }: { values: string[]; onReorder: (next: string[]) => void; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** the parts of one connected run, in reading order */ +function RunParts({ + run, + p, + depth, + sel, + onSelect, + onReorder, + onDragging, +}: { + run: Group; + p: Palette; + depth: number; + sel: Set; + onSelect: (itemIds: string[], add: boolean) => void; + /** the run's parts in a new reading order */ + onReorder: (ids: string[]) => void; + onDragging: (dragging: boolean) => void; +}) { + const lang = useLang(); + const ids = run.items.map((it) => it.id); + return ( + + {run.items.map((it) => ( + } + label={nameOf(it, lang)} + on={sel.has(it.id)} + onSelect={(add) => onSelect([it.id], add)} + onDragging={onDragging} + /> + ))} + + ); +} + +/** The z-order of one screen, top layer first, reordered by dragging the handles. + * A connected run opens to its parts in reading order; a free group opens to the + * runs it holds, front first, and each of those opens in turn. */ +export function LayersPanel({ + p, + frames, + frameId, + onFrame, + groups, + widths, + selectedIds, + onSelect, + onReorder, + onReorderItems, + onDragging, +}: { + p: Palette; + frames: Frame[]; + frameId: string | null; + onFrame: (id: string) => void; + /** the runs on the chosen frame, in canvas order (bottom first) */ + groups: Group[]; + widths: Record; + selectedIds: string[]; + /** `add` is set when Shift was held, to extend the selection */ + onSelect: (itemIds: string[], add: boolean) => void; + /** new order, top layer first */ + onReorder: (topFirst: string[]) => void; + /** a group's parts in a new order: back to front for a free group, reading order for a run */ + onReorderItems: (groupId: string, ids: string[]) => void; + /** a drag on any level starting or ending, so the page can record one undo step for the whole drag */ + onDragging: (dragging: boolean) => void; +}) { + const lang = useLang(); + const topFirst = useMemo(() => [...groups].reverse(), [groups]); + const ids = topFirst.map((g) => g.id); + const sel = new Set(selectedIds); + const [openIds, setOpenIds] = useState>(() => new Set()); + const toggle = (id: string) => + setOpenIds((cur) => { + const next = new Set(cur); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + /** the runs hidden in a free group, front first, and the flat back-to-front list they make */ + const freeRuns = (g: Group) => [...explodeGroup(g, widths)].reverse(); + const flatten = (runsTopFirst: Group[]) => [...runsTopFirst].reverse().flatMap((r) => r.items.map((it) => it.id)); + + const groupBody = (g: Group, depth: number) => { + if (!g.free) { + return onReorderItems(g.id, order)} onDragging={onDragging} />; + } + const runs = freeRuns(g); + const runIds = runs.map((r) => r.id); + const byId = new Map(runs.map((r) => [r.id, r])); + const reorderRuns = (next: string[]) => onReorderItems(g.id, flatten(next.map((id) => byId.get(id)).filter((r): r is Group => !!r))); + return ( + + {runs.map((r) => { + const many = r.items.length > 1; + const open = many && openIds.has(r.id); + return ( + )} + label={runLabel(r, lang)} + on={r.items.some((it) => sel.has(it.id))} + onSelect={(add) => onSelect(r.items.map((it) => it.id), add)} + open={many ? open : undefined} + onToggle={many ? () => toggle(r.id) : undefined} + onDragging={onDragging} + > + { + /* the run's members take each other's places in the list; every other part keeps its own */ + const members = new Set(r.items.map((it) => it.id)); + let k = 0; + onReorderItems( + g.id, + g.items.map((it) => (members.has(it.id) ? order[k++] : it.id)), + ); + }} + /> + + ); + })} + + ); + }; + + return ( +
+ {frames.length > 1 && ( +
+ {frames.map((f) => { + const on = f.id === frameId; + return ( + + ); + })} +
+ )} +
+ {topFirst.length === 0 ? ( +
+ +
{t("noLayers", lang)}
+
+ ) : ( + + {topFirst.map((g) => { + const canOpen = g.free || g.items.length > 1; + const open = canOpen && openIds.has(g.id); + return ( + : g.items.slice(0, 3).map((it, k) => )} + label={runLabel(g, lang)} + on={g.items.some((it) => sel.has(it.id))} + onSelect={(add) => onSelect(g.items.map((it) => it.id), add)} + open={canOpen ? open : undefined} + onToggle={canOpen ? () => toggle(g.id) : undefined} + onDragging={onDragging} + > + {groupBody(g, 1)} + + ); + })} + + )} +
+
+ ); +} diff --git a/web/vendor/components/Loading.tsx b/web/vendor/components/Loading.tsx new file mode 100644 index 0000000..1725747 --- /dev/null +++ b/web/vendor/components/Loading.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { LoadingAnimator, morphedShape } from "../lib/shapes"; + +/* ---------- shared helpers ---------- */ + +/** Cubic bezier easing (same parametrisation as CSS / Compose CubicBezierEasing). */ +function bezier(x1: number, y1: number, x2: number, y2: number) { + const ax = 3 * x1 - 3 * x2 + 1; + const bx = 3 * x2 - 6 * x1; + const cx = 3 * x1; + const ay = 3 * y1 - 3 * y2 + 1; + const by = 3 * y2 - 6 * y1; + const cy = 3 * y1; + const sx = (t: number) => ((ax * t + bx) * t + cx) * t; + const sy = (t: number) => ((ay * t + by) * t + cy) * t; + return (x: number) => { + if (x <= 0) return 0; + if (x >= 1) return 1; + let lo = 0; + let hi = 1; + let t = x; + for (let i = 0; i < 24; i++) { + const v = sx(t); + if (Math.abs(v - x) < 1e-4) break; + if (v < x) lo = t; + else hi = t; + t = (lo + hi) / 2; + } + return sy(t); + }; +} + +const seg = (t: number, from: number, to: number, ease: (x: number) => number) => + ease(Math.min(1, Math.max(0, (t - from) / (to - from)))); + +function useFrame(cb: (ts: number) => void) { + const ref = useRef(cb); + ref.current = cb; + useEffect(() => { + let raf = 0; + const loop = (ts: number) => { + ref.current(ts); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); +} + +/* ---------- loading indicator (shape morph) ---------- */ + +export function LoadingIndicator({ + size, + color, + contained, + containerColor, +}: { + size: number; + color: string; + contained?: boolean; + containerColor?: string; +}) { + const pathRef = useRef(null); + const anim = useRef(null); + if (!anim.current) anim.current = new LoadingAnimator(); + + const scale = (size * 0.79) / 2; + const c = size / 2; + + useFrame((ts) => { + const a = anim.current!; + a.update(ts); + const pts = morphedShape(a.morph); + let d = ""; + for (let i = 0; i < pts.length; i++) { + d += (i === 0 ? "M" : "L") + (pts[i][0] * scale).toFixed(2) + " " + (pts[i][1] * scale).toFixed(2); + } + const el = pathRef.current; + if (el) { + el.setAttribute("d", d + "Z"); + el.setAttribute("transform", `translate(${c} ${c}) rotate(${a.rotation.toFixed(2)})`); + } + }); + + return ( + + {contained && } + + + ); +} + +/* ---------- linear progress (flat / wavy) ---------- */ + +const LINEAR_CYCLE = 1800; +const l1Head = bezier(0.2, 0, 0.8, 1); +const l1Tail = bezier(0.4, 0, 1, 1); +const l2Head = bezier(0, 0, 0.65, 1); +const l2Tail = bezier(0.1, 0, 0.45, 1); + +const STROKE = 4; +const TRACK_GAP = 4; +const STOP_SIZE = 4; +const LINEAR_WAVELENGTH = 40; +const LINEAR_AMPLITUDE = 3; +const WAVE_SPEED = 40; // px per second + +function wavePath(x0: number, x1: number, mid: number, amp: number, phase: number, wl: number) { + if (x1 - x0 < 0.5) return ""; + const step = amp > 0 ? 2 : Math.max(2, x1 - x0); + let d = ""; + for (let x = x0; ; x += step) { + const xx = Math.min(x, x1); + const y = mid + amp * Math.sin((2 * Math.PI * xx) / wl + phase); + d += (d ? "L" : "M") + xx.toFixed(2) + " " + y.toFixed(2); + if (xx >= x1) break; + } + return d; +} + +export function LinearProgress({ + width, + color, + trackColor, + wavy, + value, +}: { + width: number; + color: string; + trackColor: string; + wavy?: boolean; + /** 0..1, undefined = indeterminate */ + value?: number; +}) { + const height = wavy ? STROKE + LINEAR_AMPLITUDE * 2 + 2 : STROKE + 2; + const mid = height / 2; + const inset = STROKE / 2; + const w = width - STROKE; + const activeRef = useRef(null); + const active2Ref = useRef(null); + const trackRef = useRef(null); + const start = useRef(0); + + useFrame((ts) => { + if (!start.current) start.current = ts; + const ms = ts - start.current; + const phase = wavy ? (-(ms / 1000) * WAVE_SPEED * 2 * Math.PI) / LINEAR_WAVELENGTH : 0; + const ampBase = wavy ? LINEAR_AMPLITUDE : 0; + const a = activeRef.current; + const b = active2Ref.current; + const t = trackRef.current; + if (!a || !b || !t) return; + + if (value !== undefined) { + const v = Math.min(1, Math.max(0, value)); + const amp = v < 0.1 || v > 0.95 ? 0 : ampBase; + const end = inset + w * v; + a.setAttribute("d", wavePath(inset, end, mid, amp, phase, LINEAR_WAVELENGTH)); + b.setAttribute("d", ""); + const trackStart = Math.min(inset + w - STOP_SIZE, end + TRACK_GAP + STROKE / 2); + t.setAttribute("d", wavePath(v <= 0 ? inset : trackStart, inset + w, mid, 0, 0, 1)); + return; + } + + const c = ms % LINEAR_CYCLE; + const h1 = seg(c, 0, 750, l1Head); + const t1 = seg(c, 333, 1183, l1Tail); + const h2 = seg(c, 1000, 1567, l2Head); + const t2 = seg(c, 1267, 1800, l2Tail); + const s1 = [inset + w * t1, inset + w * h1]; + const s2 = [inset + w * t2, inset + w * h2]; + a.setAttribute("d", wavePath(s1[0], s1[1], mid, ampBase, phase, LINEAR_WAVELENGTH)); + b.setAttribute("d", wavePath(s2[0], s2[1], mid, ampBase, phase, LINEAR_WAVELENGTH)); + + const segs = [s1, s2].filter((s) => s[1] - s[0] > 0.5).sort((p, q) => p[0] - q[0]); + let cursor = inset; + let d = ""; + for (const s of segs) { + const to = s[0] - TRACK_GAP - STROKE / 2; + if (to - cursor > 0.5) d += wavePath(cursor, to, mid, 0, 0, 1); + cursor = Math.max(cursor, s[1] + TRACK_GAP + STROKE / 2); + } + if (inset + w - cursor > 0.5) d += wavePath(cursor, inset + w, mid, 0, 0, 1); + t.setAttribute("d", d); + }); + + const stroke = { fill: "none", strokeWidth: STROKE, strokeLinecap: "round" as const }; + return ( + + + + + {value !== undefined && ( + + )} + + ); +} + +/* ---------- circular progress (flat / wavy) ---------- */ + +const ROTATION_MS = 1332; +const ROTATIONS_PER_CYCLE = 5; +const BASE_ROTATION = 286; +const JUMP_ROTATION = 290; +const ROTATION_OFFSET = (BASE_ROTATION + JUMP_ROTATION) % 360; +const circEase = bezier(0.4, 0, 0.2, 1); +const CIRC_WAVELENGTH = 20; +const CIRC_AMPLITUDE = 1.5; + +function arcPath( + cx: number, + cy: number, + r: number, + a0: number, + a1: number, + amp: number, + k: number, + phase: number, +) { + if (a1 - a0 < 0.05) return ""; + const step = amp > 0 ? 2 : 6; + let d = ""; + for (let a = a0; ; a += step) { + const aa = Math.min(a, a1); + const rad = (aa * Math.PI) / 180; + const rr = r + amp * Math.sin(k * rad + phase); + d += (d ? "L" : "M") + (cx + rr * Math.cos(rad)).toFixed(2) + " " + (cy + rr * Math.sin(rad)).toFixed(2); + if (aa >= a1) break; + } + return d; +} + +export function CircularProgress({ + size, + color, + trackColor, + wavy, + value, +}: { + size: number; + color: string; + trackColor: string; + wavy?: boolean; + value?: number; +}) { + const amp = wavy ? CIRC_AMPLITUDE : 0; + const r = size / 2 - STROKE / 2 - amp; + const c = size / 2; + const k = Math.max(3, Math.round((2 * Math.PI * r) / CIRC_WAVELENGTH)); + const gapDeg = ((TRACK_GAP + STROKE) / (2 * Math.PI * r)) * 360; + const activeRef = useRef(null); + const trackRef = useRef(null); + const start = useRef(0); + + useFrame((ts) => { + if (!start.current) start.current = ts; + const ms = ts - start.current; + const phase = wavy ? -(ms / 1000) * 2 * Math.PI : 0; + const a = activeRef.current; + const t = trackRef.current; + if (!a || !t) return; + + if (value !== undefined) { + const v = Math.min(1, Math.max(0, value)); + const sweep = 360 * v; + const useAmp = v < 0.05 || v > 0.95 ? 0 : amp; + a.setAttribute("d", arcPath(c, c, r, -90, -90 + sweep, useAmp, k, phase)); + const trackFrom = -90 + sweep + (v > 0 ? gapDeg : 0); + const trackTo = 270 - (v < 1 ? gapDeg : 0); + t.setAttribute("d", v >= 1 ? "" : arcPath(c, c, r, trackFrom, trackTo, 0, k, 0)); + return; + } + + const cycle = ROTATION_MS * ROTATIONS_PER_CYCLE; + const inCycle = ms % cycle; + const rotIndex = Math.floor(inCycle / ROTATION_MS); + const inRot = inCycle % ROTATION_MS; + const baseRotation = (BASE_ROTATION * inCycle) / cycle * ROTATIONS_PER_CYCLE; + const head = seg(inRot, 0, 666, circEase) * JUMP_ROTATION; + const tail = seg(inRot, 666, 1332, circEase) * JUMP_ROTATION; + const offset = (rotIndex * ROTATION_OFFSET) % 360; + const startAngle = tail + offset + baseRotation - 90; + const sweep = Math.max(0.1, head - tail); + a.setAttribute("d", arcPath(c, c, r, startAngle, startAngle + sweep, amp, k, phase)); + const trackFrom = startAngle + sweep + gapDeg; + const trackTo = startAngle + 360 - gapDeg; + t.setAttribute("d", trackTo - trackFrom > 1 ? arcPath(c, c, r, trackFrom, trackTo, 0, k, 0) : ""); + }); + + const stroke = { fill: "none", strokeWidth: STROKE, strokeLinecap: "round" as const }; + return ( + + + + + ); +} diff --git a/web/vendor/components/Logo.tsx b/web/vendor/components/Logo.tsx new file mode 100644 index 0000000..2a47303 --- /dev/null +++ b/web/vendor/components/Logo.tsx @@ -0,0 +1,14 @@ +/** The app mark: one frozen frame of the M3 Expressive loading indicator with the layers glyph inside. */ +const COOKIE = + "M56.3679 6.02002C57.1442 5.3783 57.5324 5.05744 57.8867 4.78656C66.2354 -1.59552 77.7646 -1.59552 86.1133 4.78656C86.4676 5.05744 86.8558 5.3783 87.6321 6.02002C87.9786 6.30648 88.1519 6.44971 88.3233 6.58606C92.2522 9.71203 97.0693 11.4835 102.068 11.6405C102.286 11.6473 102.51 11.6501 102.957 11.6558C103.96 11.6684 104.462 11.6747 104.906 11.6973C115.361 12.2303 124.193 19.7179 126.528 30.0289C126.627 30.4666 126.721 30.9644 126.907 31.9602C126.99 32.4047 127.032 32.627 127.076 32.8427C128.097 37.789 130.661 42.2744 134.39 45.6409C134.552 45.7878 134.722 45.9353 135.062 46.2304C135.822 46.8914 136.202 47.2219 136.528 47.5275C144.198 54.7262 146.2 66.1979 141.429 75.6132C141.227 76.0128 140.981 76.4547 140.49 77.3386C140.271 77.7332 140.162 77.9304 140.059 78.1246C137.694 82.5768 136.804 87.6775 137.519 92.6782C137.55 92.8964 137.586 93.1196 137.658 93.5661C137.82 94.5662 137.901 95.0663 137.956 95.5117C139.252 106.008 133.488 116.096 123.843 120.21C123.434 120.385 122.965 120.564 122.026 120.922C121.608 121.082 121.398 121.162 121.196 121.244C116.552 123.119 112.625 126.448 109.991 130.743C109.876 130.93 109.762 131.125 109.533 131.514C109.021 132.385 108.765 132.821 108.523 133.198C102.839 142.08 92.0048 146.064 81.9992 142.952C81.5745 142.82 81.1011 142.652 80.1544 142.318C79.7318 142.168 79.5205 142.094 79.3133 142.025C74.5631 140.445 69.4369 140.445 64.6867 142.025C64.4795 142.094 64.2682 142.168 63.8456 142.318C62.8989 142.652 62.4255 142.82 62.0008 142.952C51.9952 146.064 41.1613 142.08 35.4766 133.198C35.2353 132.821 34.9791 132.385 34.4669 131.514C34.2382 131.125 34.1239 130.93 34.009 130.743C31.3752 126.448 27.4482 123.119 22.8044 121.244C22.6018 121.162 22.3924 121.082 21.9736 120.922C21.0354 120.564 20.5663 120.385 20.1569 120.21C10.5122 116.096 4.74763 106.008 6.04367 95.5117C6.09868 95.0663 6.17963 94.5662 6.34151 93.5661C6.41377 93.1196 6.4499 92.8964 6.48109 92.6783C7.19603 87.6775 6.30587 82.5768 3.94121 78.1246C3.83807 77.9304 3.72855 77.7332 3.50951 77.3386C3.01883 76.4547 2.77349 76.0128 2.57099 75.6132C-2.19995 66.1979 -0.197931 54.7262 7.47248 47.5275C7.79804 47.2219 8.17819 46.8914 8.93848 46.2304C9.27787 45.9353 9.44757 45.7878 9.61023 45.6409C13.3394 42.2744 15.9025 37.789 16.9235 32.8427C16.9681 32.627 17.0097 32.4047 17.0929 31.9602C17.2793 30.9644 17.3725 30.4666 17.4717 30.0289C19.8069 19.7179 28.6387 12.2303 39.0944 11.6973C39.5382 11.6747 40.0397 11.6684 41.0426 11.6558C41.4903 11.6501 41.7142 11.6473 41.9322 11.6405C46.9307 11.4835 51.7478 9.71203 55.6767 6.58606C55.8481 6.44971 56.0214 6.30648 56.3679 6.02002Z"; +const LAYERS = + "M32 14 16 24l16 10 16-10-16-10zm-11.6 15.4L16 32l16 10 16-10-4.4-2.6L32 36.6l-11.6-7.2zm0 8L16 40l16 10 16-10-4.4-2.6L32 44.6l-11.6-7.2z"; + +export function Logo({ size = 32, color, glyph = "#FFFFFF" }: { size?: number; color: string; glyph?: string }) { + return ( + + ); +} diff --git a/web/vendor/components/M3Node.tsx b/web/vendor/components/M3Node.tsx new file mode 100644 index 0000000..aa5ffe5 --- /dev/null +++ b/web/vendor/components/M3Node.tsx @@ -0,0 +1,1251 @@ +"use client"; + +import { motion } from "motion/react"; +import { + FAB_MENU_GAP, + FAB_MENU_ITEM_H, + H, + Item, + Kind, + MEASURED, + NAV_BAR_H, + Palette, + Radii, + STATUS_BAR_H, + baseRadii, + cardFillOf, + onToken, + scaleR, + sizeOf, + variantShadow, + variantStyle, + SETTLE_MS, + RAIL_W, + RAIL_TOP, + RAIL_ITEM_H, + RAIL_GAP, +} from "../lib/tokens"; +import { CircularProgress, LinearProgress, LoadingIndicator } from "./Loading"; +import { t, useLang } from "../lib/i18n"; +import { useTheme } from "../lib/theme"; + +/** weight of a heading or label: heavier under the emphasized type setting */ +const useWeight = () => { + const emphasized = useTheme().emphasized; + return (normal: number, strong: number) => (emphasized ? strong : normal); +}; + +export function Icon({ + name, + size = 24, + color, + fill, + weight, +}: { + name: string; + size?: number; + color?: string; + fill?: boolean; + weight?: number; +}) { + return ( + + {name} + + ); +} + +const ellipsis = { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +} as const; + +const NO_BOX: Kind[] = [ + "circularProgress", + "linearProgress", + "loadingIndicator", + "switch", + "checkbox", + "radio", + "slider", + "text", + "divider", + "splitButton", + "fabMenu", + "badge", +]; + +/** Padding follows M3: icon+label is tighter than label alone. */ +export function ButtonContent({ item }: { item: Item }) { + const w = useWeight(); + const hasIcon = !!item.icon; + const hasLabel = item.label.trim().length > 0; + const padX = hasLabel ? (hasIcon ? 22 : 26) : 16; + return ( + + {hasIcon && } + {hasLabel && {item.label}} + + ); +} + +function ExtendedFabContent({ item }: { item: Item }) { + const w = useWeight(); + const hasIcon = !!item.icon; + const hasLabel = item.label.trim().length > 0; + return ( + + {hasIcon && } + {hasLabel && {item.label}} + + ); +} + +function ChipContent({ item, p }: { item: Item; p: Palette }) { + const on = !!item.checked; + const lead = on ? "check" : item.icon; + return ( + + {lead && } + {item.label} + + ); +} + +function SwitchContent({ item, p }: { item: Item; p: Palette }) { + const hasLabel = item.label.trim().length > 0; + return ( + + {hasLabel && {item.label}} + + + ); +} + +/** the M3 switch track and handle, 52 × 32 */ +function SwitchControl({ on, noCheck, p }: { on: boolean; noCheck?: boolean; p: Palette }) { + return ( + + + {on && !noCheck && } + + + ); +} + +function CheckboxContent({ item, p }: { item: Item; p: Palette }) { + const on = !!item.checked; + const hasLabel = item.label.trim().length > 0; + return ( + + + + {on && } + + + {hasLabel && {item.label}} + + ); +} + +function TextContent({ item, p }: { item: Item; p: Palette }) { + const w = useWeight(); + const fs = item.size ?? 28; + return ( + = 22 ? 600 : 500), + letterSpacing: fs >= 28 ? -0.25 : 0, + color: p.onSurface, + whiteSpace: "nowrap", + padding: "0 2px", + }} + > + {item.label || " "} + + ); +} + +/** A split button: the labeled action and, after a hairline gap, a menu trigger. + * The two halves keep their own corners, so the box around them stays plain. */ +function SplitButtonContent({ item, p }: { item: Item; p: Palette }) { + const w = useWeight(); + const st = variantStyle(item.variant, p); + const outer = scaleR(28); + const inner = scaleR(8); + const hasLabel = item.label.trim().length > 0; + const shadow = variantShadow(item.variant); + return ( + + + {item.icon && } + {hasLabel && {item.label}} + + + + + + ); +} + +function RadioContent({ item, p }: { item: Item; p: Palette }) { + const on = !!item.checked; + const hasLabel = item.label.trim().length > 0; + return ( + + + + {on && } + + + {hasLabel && {item.label}} + + ); +} + +/** A badge: a 6dp dot when it has no text, a 16dp pill with the count otherwise. */ +function BadgeContent({ item, p }: { item: Item; p: Palette }) { + const text = item.label.trim(); + if (!text) return ; + return ( + + {text} + + ); +} + +/** Content for kinds that size to their text; rendered again offscreen to measure. */ +export function MeasuredContent({ item, p }: { item: Item; p: Palette }) { + switch (item.kind) { + case "button": + return ; + case "extendedFab": + return ; + case "chip": + return ; + case "switch": + return ; + case "checkbox": + return ; + case "text": + return ; + case "splitButton": + return ; + case "radio": + return ; + case "badge": + return ; + default: + return null; + } +} + +function Body({ item, p }: { item: Item; p: Palette }) { + const lang = useLang(); + const w = useWeight(); + const hasLabel = item.label.trim().length > 0; + const hasSupporting = !!item.supporting?.trim(); + + if (MEASURED.includes(item.kind)) return ; + + switch (item.kind) { + case "box": + return item.checked ? ( +
+
+
+ ) : null; + + case "iconButton": { + const s = item.size ?? 48; + return ( +
+ {item.icon && } +
+ ); + } + + case "fab": { + const s = item.size ?? 56; + return ( +
+ {item.icon && } +
+ ); + } + + case "topAppBar": + return ( +
+
+ {item.icon && } +
+
+ {item.label} +
+
+ {item.icon2 && } +
+
+ ); + + case "searchBar": + return ( +
+ {item.icon && } +
+ {item.label} +
+ {item.icon2 && } +
+ ); + + case "card": { + const cw = item.size ?? 320; + return ( +
+ {!item.noImage && ( +
+ {item.src ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + item.icon && + )} +
+ )} + {hasLabel && ( +
{item.label}
+ )} + {hasSupporting && ( +
+ {item.supporting} +
+ )} +
+ ); + } + + case "listItem": { + const iconBg = item.iconFill === "none" ? null : (item.iconFill ?? "primaryContainer"); + return ( +
+ {item.icon && ( +
+ +
+ )} +
+ {hasLabel &&
{item.label}
} + {hasSupporting && ( +
{item.supporting}
+ )} +
+ {item.switch ? : item.icon2 && } +
+ ); + } + + case "dialog": + return ( +
+ {item.icon && ( +
+ +
+ )} + {hasLabel && ( +
+ {item.label} +
+ )} + {hasSupporting && ( +
+ {item.supporting} +
+ )} +
+ {[t("cancel", lang), t("ok", lang)].map((t) => ( + + {t} + + ))} +
+
+ ); + + case "snackbar": + return ( +
+
+ {item.label} +
+ {hasSupporting && ( + + {item.supporting} + + )} +
+ ); + + case "textField": { + const filled = item.variant === "filled"; + return ( +
+
+ {item.icon && } + + {filled ? "" : ""} + +
+ {hasLabel && ( + + {item.label} + + )} + {filled && ( + + )} + {hasSupporting && ( + + {item.supporting} + + )} +
+ ); + } + + case "slider": { + const v = Math.min(100, Math.max(0, item.value ?? 40)) / 100; + const w = item.size ?? 280; + const handleX = 2 + (w - 4) * v; + return ( +
+
+
+
+
+
+ ); + } + + case "image": + if (item.src) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + return ( +
+ {item.icon && } +
+ ); + + case "divider": + return ( +
+
+
+ ); + + case "navRail": { + const tabs = item.tabs ?? []; + return ( +
+ {tabs.map((t, i) => { + const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1)); + const withLabel = t.label.trim().length > 0; + return ( +
+
+ {t.icon && } +
+ {withLabel && ( + + {t.label} + + )} +
+ ); + })} +
+ ); + } + + case "bottomNav": { + const tabs = item.tabs ?? []; + return ( +
+ {tabs.map((t, i) => { + const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1)); + const withLabel = t.label.trim().length > 0; + return ( +
+
+ {t.icon && } +
+ {withLabel && ( + + {t.label} + + )} +
+ ); + })} +
+ ); + } + + case "circularProgress": + return ( + + ); + + case "linearProgress": + return ( +
+ +
+ ); + + case "fabMenu": { + const tabs = item.tabs ?? []; + const filled = item.variant === "filled"; + const fabStyle = variantStyle(item.variant, p); + const itemStyle = filled + ? { background: p.primaryContainer, color: p.onPrimaryContainer } + : { background: p.secondaryContainer, color: p.onSecondaryContainer }; + return ( +
+ {tabs.map((tab, i) => ( + + {tab.icon && } + {tab.label} + + ))} + + {item.icon && } + +
+ ); + } + + case "toolbar": { + const tabs = item.tabs ?? []; + const vibrant = item.variant === "filled"; + return ( +
+ {tabs.map((tab, i) => ( + + {tab.icon && } + + ))} +
+ ); + } + + case "tabs": { + const tabs = item.tabs ?? []; + return ( +
+ {tabs.map((tab, i) => { + const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1)); + return ( +
+ + {tab.label} + + {on && ( + + )} +
+ ); + })} + +
+ ); + } + + case "loadingIndicator": { + const s = item.size ?? 48; + return ( + + ); + } + } + return null; +} + +function boxStyle(item: Item, p: Palette): React.CSSProperties { + if (NO_BOX.includes(item.kind)) return { background: "transparent", border: "none" }; + switch (item.kind) { + case "box": { + const t = item.fill ?? "surfaceContainerLow"; + return { background: p[t], color: onToken(t, p), border: "none" }; + } + case "button": + case "iconButton": + case "fab": + case "extendedFab": + return variantStyle(item.variant, p); + case "chip": + if (item.checked) return { background: p.secondaryContainer, color: p.onSecondaryContainer, border: "none" }; + return item.variant === "outlined" + ? { background: "transparent", color: p.onSurfaceVariant, border: `1px solid ${p.outlineVariant}` } + : { background: p.surfaceContainerLow, color: p.onSurfaceVariant, border: "none" }; + case "card": + return { background: p[cardFillOf(item)], border: item.variant === "outlined" ? `1px solid ${p.outlineVariant}` : "none" }; + case "textField": + return item.variant === "filled" + ? { background: p.surfaceContainerHighest, border: "none", color: p.onSurface } + : { background: p.surface, border: `1px solid ${p.outline}`, color: p.onSurface }; + case "topAppBar": + case "bottomNav": + case "navRail": + return { background: p.surfaceContainer, border: "none", color: p.onSurface }; + case "toolbar": + return item.variant === "filled" + ? { background: p.primaryContainer, border: "none", color: p.onPrimaryContainer } + : { background: p.surfaceContainer, border: "none", color: p.onSurfaceVariant }; + case "tabs": + return { background: p.surface, border: "none", color: p.onSurface }; + case "searchBar": + return { background: p.surfaceContainerHigh, border: "none", color: p.onSurface }; + case "dialog": + return { background: p.surfaceContainerHigh, border: "none", color: p.onSurface }; + case "snackbar": + return { background: p.inverseSurface, border: "none", color: p.inverseOnSurface }; + case "image": + return { background: p.surfaceContainerHighest, border: "none" }; + case "listItem": { + const t = item.fill ?? "surfaceContainerLow"; + return { background: p[t], border: "none", color: onToken(t, p) }; + } + default: + return { background: p.surfaceContainerHigh, border: "none", color: p.onSurface }; + } +} + +function shadowOf(item: Item): string { + if (NO_BOX.includes(item.kind)) return "none"; + switch (item.kind) { + case "button": + case "iconButton": + case "extendedFab": + return variantShadow(item.variant); + case "fab": + return "0 3px 8px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)"; + case "card": + return item.variant === "elevated" ? "0 1px 3px rgba(0,0,0,0.20), 0 2px 6px rgba(0,0,0,0.10)" : "none"; + case "dialog": + return "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)"; + case "snackbar": + return "0 3px 8px rgba(0,0,0,0.18)"; + case "toolbar": + return "0 2px 6px rgba(0,0,0,0.14), 0 1px 2px rgba(0,0,0,0.10)"; + default: + return "none"; + } +} + +export type { Radii }; + +/** Corner radii are driven continuously by the magnet; a stiff spring keeps them on the pointer. */ +const RADIUS_TWEEN = { type: "spring" as const, stiffness: 900, damping: 48, mass: 0.4 }; + +export function M3Node({ + item, + palette, + radii, + widths, + pressed, + dragging, + selected, + interactive = true, + onPointerDown, +}: { + item: Item; + palette: Palette; + radii?: Radii; + widths: Record; + pressed?: boolean; + dragging?: boolean; + selected?: boolean; + interactive?: boolean; + onPointerDown?: (e: React.PointerEvent) => void; +}) { + const r = radii ?? baseRadii(item); + const size = sizeOf(item, widths); + const measured = MEASURED.includes(item.kind) && !((item.kind === "switch" || item.kind === "button") && item.size); + const clips = !NO_BOX.includes(item.kind) && item.kind !== "textField"; + + return ( + + + + ); +} + +/** Plain (non-animated) rendering of a part; used where frames must be deterministic. */ +export function M3Static({ + item, + palette, + radii, + style, +}: { + item: Item; + palette: Palette; + radii?: Radii; + style?: React.CSSProperties; +}) { + const r = radii ?? baseRadii(item); + const size = sizeOf(item, {}); + const measured = MEASURED.includes(item.kind) && !((item.kind === "switch" || item.kind === "button") && item.size); + const clips = !NO_BOX.includes(item.kind) && item.kind !== "textField"; + return ( +
+ +
+ ); +} diff --git a/web/vendor/components/Menus.tsx b/web/vendor/components/Menus.tsx new file mode 100644 index 0000000..eb34035 --- /dev/null +++ b/web/vendor/components/Menus.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { Palette } from "../lib/tokens"; +import { LANGS, Lang, t, useLang } from "../lib/i18n"; +import { IconBtn } from "./ui"; +import { Icon } from "./M3Node"; + +const EASE = [0.2, 0, 0, 1] as const; + +/** An icon button with a small menu anchored to it. `side` is where the menu opens. */ +export function Popover({ + p, + icon, + title, + side = "down", + size = 40, + children, +}: { + p: Palette; + icon: string; + title: string; + side?: "down" | "right"; + size?: number; + children: (close: () => void) => React.ReactNode; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const onDown = (e: PointerEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + window.addEventListener("pointerdown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("pointerdown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [open]); + const anchor: React.CSSProperties = + side === "down" + ? { top: size + 8, right: 0, transformOrigin: "top right" } + : { left: size + 8, bottom: 0, transformOrigin: "bottom left" }; + const hidden = side === "down" ? { opacity: 0, y: -6, scale: 0.96 } : { opacity: 0, x: -6, scale: 0.96 }; + return ( +
+ setOpen((o) => !o)} title={title} size={size} /> + + {open && ( + + {children(() => setOpen(false))} + + )} + +
+ ); +} + +export function LangMenu({ p, onLang, side, size }: { p: Palette; onLang: (l: Lang) => void; side?: "down" | "right"; size?: number }) { + const lang = useLang(); + return ( + + {(close) => ( +
+ {LANGS.map((l) => { + const on = l.key === lang; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/web/vendor/components/Mobile.tsx b/web/vendor/components/Mobile.tsx new file mode 100644 index 0000000..d32a11c --- /dev/null +++ b/web/vendor/components/Mobile.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion, useDragControls } from "motion/react"; +import { CONTRASTS, Contrast, FONTS, Item, KIND_SPEC, NavTab, PALETTES, Palette, SHAPES, ShapeScale, Theme, defaultTabsFor, iconSlotsOf, setIconSlot } from "../lib/tokens"; +import { ensureFontLoaded } from "../lib/theme"; +import { KIND_TEXT, LANGS, Lang, t, useLang } from "../lib/i18n"; +import { IconPicker } from "./IconPicker"; +import { Icon } from "./M3Node"; +import { VariantSwatch, variantsOf } from "./Inspector"; +import { Field, IconBtn, Segmented, Toggle } from "./ui"; + +/** Sheet that slides up from the bottom edge; the canvas above stays usable. + * Dragging the handle moves the sheet with the finger; a flick or a long pull closes it. */ +export function BottomSheet({ p, onClose, children }: { p: Palette; onClose: () => void; children: React.ReactNode }) { + const lang = useLang(); + const controls = useDragControls(); + return ( + { + if (info.offset.y > 90 || info.velocity.y > 600) onClose(); + }} + style={{ + position: "absolute", + left: 0, + right: 0, + bottom: 0, + maxHeight: "72%", + display: "flex", + flexDirection: "column", + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + background: p.surfaceContainerLow, + boxShadow: "0 -6px 24px rgba(0,0,0,0.16)", + zIndex: 60, + paddingBottom: "calc(var(--bottom-ui, 0px) + env(safe-area-inset-bottom))", + }} + > + +
+ {children} +
+
+ ); +} + +function Row({ icon, label, p, children }: { icon: string; label: string; p: Palette; children: React.ReactNode }) { + return ( +
+
+ + {label} +
+ {children} +
+ ); +} + +/** The compact phone editor: text, icon, style, state and a one-line note. */ +export function MobileInspector({ + item, + palette: p, + onChange, + onDelete, + onDuplicate, + onClose, +}: { + item: Item; + palette: Palette; + onChange: (patch: Partial) => void; + onDelete: () => void; + onDuplicate: () => void; + onClose: () => void; +}) { + const lang = useLang(); + const spec = KIND_SPEC[item.kind]; + const slots = iconSlotsOf(item).filter((s) => !s.key.startsWith("tab:")); + const [slotKey, setSlotKey] = useState(slots[0]?.key ?? "icon"); + const [pickerOpen, setPickerOpen] = useState(false); + useEffect(() => { + setSlotKey(iconSlotsOf(item)[0]?.key ?? "icon"); + setPickerOpen(false); + }, [item.id]); + const activeSlot = slots.find((s) => s.key === slotKey) ?? slots[0]; + const variants = spec.hasVariant ? variantsOf(item.kind) : []; + const tabs: NavTab[] = item.tabs ?? []; + const [tabSlot, setTabSlot] = useState(null); + const setTabCount = (n: number) => { + const defaults = defaultTabsFor(item.kind); + const next: NavTab[] = []; + for (let i = 0; i < n; i++) next.push(tabs[i] ? { ...tabs[i] } : { ...defaults[i % defaults.length] }); + onChange({ tabs: next }); + }; + + return ( +
+
+
+ +
+ {KIND_TEXT[lang][item.kind]?.noun ?? spec.label} + + + +
+ + {(spec.hasLabel || spec.hasSupporting) && ( + +
+ {spec.hasLabel && ( +
+ onChange({ label })} placeholder={t("label", lang)} p={p} icon="short_text" height={48} /> + {item.kind === "text" && ( + onChange({ bold: !item.bold })} title={t("bold", lang)} /> + )} +
+ )} + {spec.hasSupporting && ( + onChange({ supporting })} + placeholder={item.kind === "snackbar" ? t("action", lang) : t("supporting", lang)} + p={p} + icon="notes" + height={48} + /> + )} +
+
+ )} + + {spec.hasTabs && ( + + ({ key: String(n), label: String(n) }))} + value={String(tabs.length)} + onChange={(k) => setTabCount(Number(k))} + p={p} + height={44} + /> +
+ {tabs.map((tab, i) => ( +
+ {item.kind !== "tabs" && ( + setTabSlot(tabSlot === i ? null : i)} title={t("changeIcon", lang)} /> + )} + {item.kind !== "toolbar" && ( + onChange({ tabs: tabs.map((x, j) => (j === i ? { ...x, label } : x)) })} placeholder={t("label", lang)} p={p} height={48} /> + )} +
+ ))} +
+ {tabSlot !== null && tabs[tabSlot] && ( +
+ onChange(setIconSlot(item, `tab:${tabSlot}`, icon))} palette={p} /> +
+ )} +
+ )} + + {slots.length > 0 && activeSlot && ( + +
+ {slots.map((s) => { + const on = s.key === activeSlot.key && pickerOpen; + return ( + + ); + })} + {activeSlot.value && ( + + )} +
+ {pickerOpen && ( +
+ onChange(setIconSlot(item, activeSlot.key, icon))} palette={p} /> +
+ )} +
+ )} + + {variants.length > 0 && ( + +
+ {variants.map((v) => ( + onChange({ variant: v.key })} /> + ))} +
+
+ )} + + {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)} + /> + + )} + + + onChange({ note })} placeholder={["button", "fab", "iconButton", "extendedFab"].includes(item.kind) ? t("whenPressed", lang) : t("whatItDoes", lang)} p={p} icon="bolt" height={48} /> + +
+ ); +} + +/** The language list, one row per language. */ +export function MobileLang({ palette: p, lang, onLang }: { palette: Palette; lang: Lang; onLang: (l: Lang) => void }) { + return ( + +
+ {LANGS.map((l) => { + const on = l.key === lang; + return ( + + ); + })} +
+
+ ); +} + +/** The theme sheet: palette, light / dark, shape, type and motion, sized for thumbs. */ +export function MobileSettings({ + palette: p, + paletteKey, + onPalette, + theme, + onTheme, +}: { + palette: Palette; + paletteKey: string; + onPalette: (key: string) => void; + theme: Theme; + onTheme: (patch: Partial) => void; +}) { + const lang = useLang(); + const shapeLabel = (k: ShapeScale) => (k === "square" ? t("shapeSquare", lang) : k === "full" ? t("shapeFull", lang) : t("shapeRounded", lang)); + return ( +
+ + + options={[ + { key: "light", icon: "light_mode", label: t("light", lang) }, + { key: "dark", icon: "dark_mode", label: t("dark", lang) }, + ]} + value={theme.dark ? "dark" : "light"} + onChange={(k) => onTheme({ dark: k === "dark" })} + p={p} + height={44} + /> +
+ onTheme({ bothModes })} p={p} icon="routine" label={t("bothModes", lang)} grow /> +
+
+ + + options={CONTRASTS.map((c) => ({ key: c.key, label: c.key === "high" ? t("contrastHigh", lang) : c.key === "medium" ? t("contrastMedium", lang) : t("contrastStandard", lang) }))} + value={theme.contrast} + onChange={(contrast) => onTheme({ contrast })} + p={p} + height={44} + /> + + +
+ {PALETTES.map((pal) => { + const on = pal.key === paletteKey; + return ( + + ); + })} +
+
+ + + options={SHAPES.map((s) => ({ key: s.key, icon: s.icon, label: shapeLabel(s.key) }))} + value={theme.shape} + onChange={(shape) => onTheme({ shape })} + p={p} + height={44} + /> + + +
+ {FONTS.map((f) => { + const on = theme.font === f.key; + ensureFontLoaded(f.key); + return ( + + ); + })} +
+ onTheme({ emphasized })} p={p} icon="format_bold" label={t("emphasized", lang)} /> +
+
+
+ + + options={[ + { key: "standard", label: t("motionStandard", lang) }, + { key: "expressive", label: t("motionExpressive", lang) }, + ]} + value={theme.motion} + onChange={(motion) => onTheme({ motion })} + p={p} + height={44} + /> + +
+ ); +} + +/** Edit / duplicate / delete for the selected part, sized for thumbs. */ +export function MobileActionBar({ + p, + onEdit, + onDuplicate, + onDelete, +}: { + p: Palette; + onEdit: () => void; + onDuplicate: () => void; + onDelete: () => void; +}) { + const lang = useLang(); + return ( +
+ + + +
+ ); +} diff --git a/web/vendor/components/PartsPalette.tsx b/web/vendor/components/PartsPalette.tsx new file mode 100644 index 0000000..915a4aa --- /dev/null +++ b/web/vendor/components/PartsPalette.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { CATEGORIES, KIND_ORDER, KIND_SPEC, Category, Kind, Palette } from "../lib/tokens"; +import { Icon } from "./M3Node"; +import { KIND_TEXT, t, useLang } from "../lib/i18n"; +import { Field, Section, Tile } from "./ui"; + +const CATEGORY_TEXT = { + ja: { actions: "操作", navigation: "ナビゲーション", containment: "コンテナ", inputs: "入力", content: "コンテンツ", progress: "進捗" }, + zh: { actions: "操作", navigation: "导航", containment: "容器", inputs: "输入", content: "内容", progress: "进度" }, + ko: { actions: "동작", navigation: "내비게이션", containment: "컨테이너", inputs: "입력", content: "콘텐츠", progress: "진행 상태" }, +} satisfies Record>; + +export function PartsPalette({ + palette: p, + favorites, + onToggleFavorite, + onPartPointerDown, + overBin, +}: { + palette: Palette; + favorites: Kind[]; + onToggleFavorite: (k: Kind) => void; + onPartPointerDown: (e: React.PointerEvent, kind: Kind) => void; + overBin: boolean; +}) { + const lang = useLang(); + const [q, setQ] = useState(""); + const labelOf = (k: Kind) => lang === "en" ? KIND_SPEC[k].label : KIND_TEXT[lang][k]?.noun ?? KIND_SPEC[k].label; + + const filtered = useMemo(() => { + const s = q.trim().toLowerCase(); + if (!s) return KIND_ORDER; + return KIND_ORDER.filter((k) => { + const sp = KIND_SPEC[k]; + return labelOf(k).toLowerCase().includes(s) || sp.label.toLowerCase().includes(s) || sp.noun.includes(s) || k.toLowerCase().includes(s); + }); + }, [q, lang]); + + const tile = (k: Kind) => { + const s = KIND_SPEC[k]; + return ( + onPartPointerDown(e, k)} + starred={favorites.includes(k)} + onStar={() => onToggleFavorite(k)} + /> + ); + }; + + const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(78px, 1fr))", + gap: 6, + }; + + return ( +
+
+ +
+ +
+ {!q && favorites.length > 0 && ( +
+
{favorites.filter((k) => KIND_SPEC[k]).map(tile)}
+
+ )} + {q ? ( +
+ {filtered.map(tile)} + {filtered.length === 0 && ( +
+ +
+ )} +
+ ) : ( + CATEGORIES.map((c) => ( +
+
{KIND_ORDER.filter((k) => KIND_SPEC[k].category === c.key).map(tile)}
+
+ )) + )} +
+ + {overBin && ( +
+
+ +
+
+ )} +
+ ); +} diff --git a/web/vendor/components/Preview.tsx b/web/vendor/components/Preview.tsx new file mode 100644 index 0000000..9487491 --- /dev/null +++ b/web/vendor/components/Preview.tsx @@ -0,0 +1,913 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Item } from "../lib/tokens"; +import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react"; +import type { TargetAndTransition, Variants } from "motion/react"; +import { + Action, + BACK_TARGET, + BEZEL, + Doc, + Frame, + GAP, + Group, + NAV_BAR_H, + PHONE_H, + PHONE_R, + PHONE_W, + Palette, + SLIDE_SPEC, + STATUS_BAR_H, + SWIPE_DIRS, + SwipeDir, + TAPPABLE, + Transition, + baseRadii, + connectSpecOf, + fontFamilyOf, + freeRadii, + frameRadius, + frameSizeOf, + groupsInFrame, + isPhoneFrame, + normalizeTheme, + toggleIcon, + uniformRadii, + RAIL_W, + RAIL_TOP, + RAIL_ITEM_H, + RAIL_GAP, + sizeOf, +} from "../lib/tokens"; +import { Icon, M3Node } from "./M3Node"; +import { IconBtn } from "./ui"; +import { t, useLang } from "../lib/i18n"; + +const EASE = [0.2, 0, 0, 1] as const; +const SLIDE_MS = 0.42; +/** room reserved for the wide preview controls: panel, right margin and breathing space */ +const WIDE_CONTROL_SPACE = 220; + +type Anim = { t: Transition; back: boolean; /** the expressive motion scheme: springs instead of eased tweens */ spring?: boolean }; + +/** M3 Expressive spatial spring, with a visible overshoot */ +const SPRING = { type: "spring" as const, stiffness: 360, damping: 26, mass: 1 }; +/** how the current screen was reached, so "back" can play it in reverse */ +type Entry = { id: string; t: Transition }; + +const pct = (v: number) => `${v * 100}%`; + +/** offset along one axis, as a percentage of the screen */ +const off = (axis: "x" | "y", v: number) => (axis === "x" ? { x: pct(v), y: 0 } : { x: 0, y: pct(v) }); + +type Pose = TargetAndTransition; + +/** enter / leave poses for one screen change; the same spec drives forward and back */ +function poses(c: Anim): { initial: Pose; animate: Pose; exit: Pose } { + const zi = { zIndex: { duration: 0 } }; + const s = SLIDE_SPEC[c.t]; + if (s) { + const tr = c.spring ? { ...SPRING, ...zi } : { duration: SLIDE_MS, ease: EASE, ...zi }; + return c.back + ? { + initial: { ...off(s.axis, s.exit), opacity: 0.6, scale: 1, zIndex: 1 }, + animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 1, transition: tr }, + exit: { ...off(s.axis, s.enter), opacity: 1, scale: 1, zIndex: 2, transition: tr }, + } + : { + initial: { ...off(s.axis, s.enter), opacity: 1, scale: 1, zIndex: 2 }, + animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2, transition: tr }, + exit: { ...off(s.axis, s.exit), opacity: 0.6, scale: 1, zIndex: 1, transition: tr }, + }; + } + if (c.t === "fade") { + const tr = { duration: 0.3, ease: EASE, ...zi }; + return { + initial: { x: 0, y: 0, opacity: 0, scale: 1, zIndex: 2 }, + animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2, transition: tr }, + exit: { x: 0, y: 0, opacity: 0, scale: 1, zIndex: 1, transition: tr }, + }; + } + if (c.t === "expand") { + const tr = c.spring ? { ...SPRING, ...zi } : { duration: 0.36, ease: EASE, ...zi }; + return c.back + ? { + initial: { x: 0, y: 0, scale: 0.92, opacity: 0, zIndex: 1 }, + animate: { x: 0, y: 0, scale: 1, opacity: 1, zIndex: 1, transition: tr }, + exit: { x: 0, y: 0, scale: 1.06, opacity: 0, zIndex: 2, transition: tr }, + } + : { + initial: { x: 0, y: 0, scale: 0.92, opacity: 0, zIndex: 2 }, + animate: { x: 0, y: 0, scale: 1, opacity: 1, zIndex: 2, transition: tr }, + exit: { x: 0, y: 0, scale: 1.06, opacity: 0, zIndex: 1, transition: tr }, + }; + } + const tr = { duration: 0, ...zi }; + return { + initial: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2 }, + animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2, transition: tr }, + exit: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 1, transition: tr }, + }; +} + +const screenVariants: Variants = { + initial: (c: Anim) => poses(c).initial, + animate: (c: Anim) => poses(c).animate, + exit: (c: Anim) => poses(c).exit, +}; + +/** kinds whose on/off state flips when tapped in the preview */ +const TOGGLES = ["switch", "checkbox", "chip"] as const; +const flips = (it: Item) => (TOGGLES as readonly string[]).includes(it.kind) || !!it.toggle; + +/** the look of a part after the visitor tapped it */ +function flippedLook(it: Item): Item { + if ((TOGGLES as readonly string[]).includes(it.kind)) return { ...it, checked: !it.checked }; + if (it.toggle) { + return { + ...it, + label: it.toggle.label ?? it.label, + icon: toggleIcon(it), + variant: it.toggle.variant ?? it.variant, + }; + } + return it; +} + +/** A part in the preview: presses down and shows a state layer while the + * pointer is on it, then fires its action on release, like a real widget. */ +function Tappable({ + item, + p, + radii, + widths, + onTap, + onSlot, + onValue, +}: { + item: Item; + p: Palette; + radii: ReturnType; + widths: Record; + onTap?: () => void; + /** per-slot targets on bars */ + onSlot?: (slot: string) => void; + /** live value for sliders */ + onValue?: (v: number) => void; +}) { + const [pressed, setPressed] = useState(false); + const [hot, setHot] = useState(null); + const live = !!onTap || (TAPPABLE.includes(item.kind) && item.kind !== "text"); + const ref = useRef(null); + + const dragValue = (e: React.PointerEvent) => { + const r = ref.current?.getBoundingClientRect(); + if (!r || !onValue) return; + onValue(Math.round(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * 100)); + }; + + /** hit areas for the icons on a top app bar and the destinations on a navigation bar */ + const slots: { key: string; style: React.CSSProperties }[] = []; + if (onSlot && item.kind === "topAppBar") { + /* the icons sit below the status-bar inset only where the bar has one (see sizeOf) */ + const inset = sizeOf(item, {}).h - 64; + if (item.icon) slots.push({ key: "icon", style: { left: 4, top: inset + 8, width: 48, height: 48, borderRadius: 24 } }); + if (item.icon2) slots.push({ key: "icon2", style: { right: 4, top: inset + 8, width: 48, height: 48, borderRadius: 24 } }); + } + if (onSlot && (item.kind === "bottomNav" || item.kind === "tabs")) { + const n = item.tabs?.length ?? 0; + for (let i = 0; i < n; i++) + slots.push({ key: `tab:${i}`, style: { left: `${(i / n) * 100}%`, width: `${100 / n}%`, top: 0, bottom: item.kind === "bottomNav" ? NAV_BAR_H : 0, borderRadius: 16 } }); + } + if (onSlot && item.kind === "navRail") { + const n = item.tabs?.length ?? 0; + for (let i = 0; i < n; i++) + slots.push({ key: `tab:${i}`, style: { left: 6, width: RAIL_W - 12, top: RAIL_TOP + i * (RAIL_ITEM_H + RAIL_GAP), height: RAIL_ITEM_H, borderRadius: 16 } }); + } + if (onSlot && item.kind === "toolbar") { + const n = item.tabs?.length ?? 0; + for (let i = 0; i < n; i++) slots.push({ key: `tab:${i}`, style: { left: 8 + i * 52, width: 48, top: 8, height: 48, borderRadius: 24 } }); + } + if (onSlot && item.kind === "fabMenu") { + /* the pills hug their text on the right; the hit area covers the right part of the row */ + const n = item.tabs?.length ?? 0; + for (let i = 0; i < n; i++) slots.push({ key: `tab:${i}`, style: { right: 0, width: "70%", top: i * 64, height: 56, borderRadius: 28 } }); + } + + return ( +
{ + if (onValue) { + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + dragValue(e); + setPressed(true); + return; + } + if (live) setPressed(true); + }} + onPointerMove={(e) => { + if (onValue && pressed) dragValue(e); + }} + onPointerUp={() => setPressed(false)} + onPointerCancel={() => setPressed(false)} + onPointerLeave={() => !onValue && setPressed(false)} + onClick={onTap} + style={{ cursor: live || onValue ? "pointer" : "default", display: "flex", position: "relative", touchAction: "none" }} + > + + {live && ( + + )} + {slots.map((s) => ( +
{ + e.stopPropagation(); + setHot(s.key); + }} + onPointerUp={() => setHot(null)} + onPointerCancel={() => setHot(null)} + onPointerLeave={() => setHot(null)} + onClick={(e) => { + e.stopPropagation(); + onSlot!(s.key); + }} + style={{ + position: "absolute", + cursor: "pointer", + background: hot === s.key ? `color-mix(in srgb, ${p.onSurface} 12%, transparent)` : "transparent", + transition: "background 120ms", + ...s.style, + }} + /> + ))} +
+ ); +} + +function Screen({ + frame, + groups, + widths, + p, + onAction, + flipped, + onFlip, + values, + onValue, +}: { + frame: Frame; + groups: Group[]; + widths: Record; + p: Palette; + onAction: (a: Action) => void; + /** ids of toggles the visitor has flipped since the preview opened */ + flipped: Set; + onFlip: (id: string) => void; + values: Record; + onValue: (id: string, v: number) => void; +}) { + return ( +
+ {groups.map((g) => ( +
+ {((corners) => g.items.map((it, i) => { + const conn = connectSpecOf(it); + const n = g.free ? 1 : g.items.length; + const radii = g.free + ? (corners?.get(it.id) ?? baseRadii(it)) + : conn && n > 1 + ? g.axis === "x" + ? { + tl: i === 0 ? conn.outer : conn.inner, + bl: i === 0 ? conn.outer : conn.inner, + tr: i === n - 1 ? conn.outer : conn.inner, + br: i === n - 1 ? conn.outer : conn.inner, + } + : { + tl: i === 0 ? conn.outer : conn.inner, + tr: i === 0 ? conn.outer : conn.inner, + bl: i === n - 1 ? conn.outer : conn.inner, + br: i === n - 1 ? conn.outer : conn.inner, + } + : conn + ? uniformRadii(conn.outer) + : baseRadii(it); + const act = it.action; + let shown = flipped.has(it.id) ? flippedLook(it) : it; + if (it.kind === "slider" && values[it.id] !== undefined) shown = { ...shown, value: values[it.id] }; + const navKind = it.kind === "bottomNav" || it.kind === "navRail" || it.kind === "tabs"; + /* bars with the same destinations are one bar to the visitor: the choice follows them across screens */ + const navKey = navKind ? `nav:${it.kind}:${(it.tabs ?? []).map((t) => t.label).join("|")}` : ""; + if (navKind && values[navKey] !== undefined && values[navKey] >= 0) shown = { ...shown, selected: values[navKey] }; + const tap = + act || flips(it) + ? () => { + if (flips(it)) onFlip(it.id); + if (act) onAction(act); + } + : undefined; + const slotActions = it.actions; + const node = ( + { + /* a tapped destination lights up where it opens nothing; where it opens a + screen, that screen's bar shows its own selected destination */ + const a = slotActions?.[slot]; + if (navKind && slot.startsWith("tab:")) onValue(navKey, a ? -1 : Number(slot.slice(4))); + if (a) onAction(a); + } + : undefined + } + onValue={it.kind === "slider" ? (v) => onValue(it.id, v) : undefined} + /> + ); + if (!g.free) return node; + const o = g.pos?.[it.id] ?? { x: 0, y: 0 }; + return ( +
+ {node} +
+ ); + }))(g.free ? freeRadii(g, widths) : null)} +
+ ))} +
+ ); +} + +/** the screen being pulled in by a swipe, and the slide it arrives with */ +type Peek = { frameId: string; t: Transition }; + +export function Preview({ + doc, + widths, + palette: p, + startId, + onClose, +}: { + doc: Doc; + widths: Record; + palette: Palette; + startId: string | null; + onClose: () => void; +}) { + const lang = useLang(); + const frames = doc.frames; + const [stack, setStack] = useState(() => [{ id: startId ?? frames[0]?.id ?? "", t: "none" }]); + const [anim, setAnim] = useState({ t: "none", back: false }); + const theme = normalizeTheme(doc.theme); + const spring = theme.motion === "expressive"; + const [scale, setScale] = useState(1); + const [flipped, setFlipped] = useState>(() => new Set()); + const [values, setValues] = useState>({}); + const [peek, setPeek] = useState(null); + const stackRef = useRef(stack); + stackRef.current = stack; + const peekRef = useRef(peek); + peekRef.current = peek; + const swiped = useRef(false); + + const flip = (id: string) => + setFlipped((s) => { + const n = new Set(s); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + + const top = stack[stack.length - 1]; + const current = frames.find((f) => f.id === top?.id) ?? frames[0]; + const peekFrame = peek ? frames.find((f) => f.id === peek.frameId) : undefined; + const { w: frameW, h: frameH } = current ? frameSizeOf(current) : { w: PHONE_W, h: PHONE_H }; + const phone = current ? isPhoneFrame(current) : true; + /* every screen sits in the same bezel; only the corners tell a phone from a window */ + const radius = current ? frameRadius(current) : PHONE_R; + const outerW = frameW + BEZEL * 2; + const outerH = frameH + BEZEL * 2; + const targetFrame = peekFrame ?? current; + const { w: targetFrameW, h: targetFrameH } = targetFrame ? frameSizeOf(targetFrame) : { w: frameW, h: frameH }; + const targetRadius = targetFrame ? frameRadius(targetFrame) : radius; + const targetOuterW = targetFrameW + BEZEL * 2; + const targetOuterH = targetFrameH + BEZEL * 2; + + /* The shell is the size of the screen on show. When the screen changes it eases to + * the new size in step with the slide; a tracked swipe steers it toward the target + * with the finger, so a phone and a desktop screen hand over without a snap. */ + const prog = useMotionValue(0); + const shellW = useMotionValue(outerW); + const shellH = useMotionValue(outerH); + const screenRadius = useMotionValue(radius); + useEffect(() => { + const opts = { duration: SLIDE_MS, ease: EASE }; + const runs = [animate(shellW, outerW, opts), animate(shellH, outerH, opts), animate(screenRadius, radius, opts)]; + return () => runs.forEach((r) => r.stop()); + }, [outerW, outerH, radius, shellW, shellH, screenRadius]); + useEffect( + () => + prog.on("change", (v) => { + if (!peekRef.current) return; + shellW.set(outerW + (targetOuterW - outerW) * v); + shellH.set(outerH + (targetOuterH - outerH) * v); + screenRadius.set(radius + (targetRadius - radius) * v); + }), + [prog, outerW, outerH, radius, targetOuterW, targetOuterH, targetRadius, shellW, shellH, screenRadius], + ); + const screenW = useTransform(shellW, (v) => v - BEZEL * 2); + const screenH = useTransform(shellH, (v) => v - BEZEL * 2); + const shellRadius = useTransform(screenRadius, (v) => v + BEZEL); + /* the stage is sized for the largest screen in the document, so the scale never + * changes while a swipe crosses sizes or a screen is opened from the picker */ + const maxOuterW = Math.max(outerW, ...frames.map((f) => frameSizeOf(f).w + BEZEL * 2)); + const maxOuterH = Math.max(outerH, ...frames.map((f) => frameSizeOf(f).h + BEZEL * 2)); + const shellLeft = useTransform(shellW, (v) => ((maxOuterW - v) * scale) / 2); + const shellTop = useTransform(shellH, (v) => ((maxOuterH - v) * scale) / 2); + + /* on a wide window the controls stand in a column at the right edge, clear of the phone; + * on a phone they stay along the bottom, where the frame fills the width anyway */ + const [wide, setWide] = useState(false); + useEffect(() => { + const fit = () => { + const isWide = window.innerWidth >= 720; + setWide(isWide); + setScale( + Math.min(1.4, (window.innerHeight - 32) / maxOuterH, (window.innerWidth - (isWide ? WIDE_CONTROL_SPACE + 16 : 16)) / maxOuterW), + ); + }; + fit(); + window.addEventListener("resize", fit); + return () => window.removeEventListener("resize", fit); + }, [maxOuterH, maxOuterW]); + + /* a slide between a phone and a desktop screen would drag one shape through the + * other; screens of different sizes cross-fade while the shell changes size */ + const sameSize = useCallback( + (fromId: string, toId: string) => { + const a = frames.find((f) => f.id === fromId); + const b = frames.find((f) => f.id === toId); + if (!a || !b) return true; + const sa = frameSizeOf(a); + const sb = frameSizeOf(b); + return sa.w === sb.w && sa.h === sb.h; + }, + [frames], + ); + + const back = useCallback(() => { + const s = stackRef.current; + if (s.length < 2 || peekRef.current) return; + const from = s[s.length - 1]; + const to = s[s.length - 2]; + setAnim({ t: sameSize(from.id, to.id) ? from.t : "fade", back: true, spring }); + setStack(s.slice(0, -1)); + }, [spring, sameSize]); + + const go = useCallback( + (a: Action) => { + if (swiped.current) return; + if (a.to === BACK_TARGET) { + back(); + return; + } + if (!frames.some((f) => f.id === a.to)) return; + const s = stackRef.current; + const t = sameSize(s[s.length - 1].id, a.to) ? a.transition : "fade"; + setAnim({ t, back: false, spring }); + setStack((cur) => [...cur, { id: a.to, t }]); + }, + [frames, back, spring, sameSize], + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "Backspace" || e.key === "ArrowLeft") back(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [back, onClose]); + + const groupsFor = useCallback((f: Frame) => groupsInFrame(doc.groups, f, frames, widths), [doc.groups, frames, widths]); + const groups = useMemo(() => (current ? groupsFor(current) : []), [current, groupsFor]); + const peekGroups = useMemo(() => (peekFrame ? groupsFor(peekFrame) : []), [peekFrame, groupsFor]); + + /* ---- finger-tracked swipes: only the swipes the author set on the frame ---- */ + const axisMV = useMotionValue(0); // 0 = x, 1 = y + const enterMV = useMotionValue(0); + const exitMV = useMotionValue(0); + const curX = useTransform([prog, axisMV, exitMV], ([p, a, ex]: number[]) => (a === 0 ? pct(ex * p) : "0%")); + const curY = useTransform([prog, axisMV, exitMV], ([p, a, ex]: number[]) => (a === 1 ? pct(ex * p) : "0%")); + const curOp = useTransform(prog, (p: number) => 1 - 0.4 * p); + const peekX = useTransform([prog, axisMV, enterMV], ([p, a, en]: number[]) => (a === 0 ? pct(en * (1 - p)) : "0%")); + const peekY = useTransform([prog, axisMV, enterMV], ([p, a, en]: number[]) => (a === 1 ? pct(en * (1 - p)) : "0%")); + + const gesture = useRef<{ + id: number; + x0: number; + y0: number; + phase: "idle" | "drag" | "none"; + dir?: SwipeDir; + size: number; + last: number; + lastT: number; + vel: number; + } | null>(null); + + const onScreenPointerDown = (e: React.PointerEvent) => { + if (peekRef.current || e.button !== 0) return; + gesture.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, phase: "idle", size: frameW, last: 0, lastT: e.timeStamp, vel: 0 }; + swiped.current = false; + }; + + useEffect(() => { + const move = (e: PointerEvent) => { + const g = gesture.current; + if (!g || g.id !== e.pointerId) return; + const dx = (e.clientX - g.x0) / scale; + const dy = (e.clientY - g.y0) / scale; + if (g.phase === "none") return; + if (g.phase === "idle") { + if (Math.hypot(dx, dy) < 8) return; + const dir: SwipeDir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up"; + const s = stackRef.current; + const cur = frames.find((f) => f.id === s[s.length - 1]?.id); + if (!cur) return; + const to = cur.swipe?.[dir]; + const spec = SWIPE_DIRS.find((d) => d.key === dir)!; + /* only swipes the author set up move screens; nothing is inferred */ + let pk: Peek | null = null; + if (to && frames.some((f) => f.id === to)) pk = { frameId: to, t: spec.transition }; + if (!pk) { + g.phase = "none"; + return; + } + const sl = SLIDE_SPEC[pk.t]!; + g.phase = "drag"; + g.dir = dir; + const { w, h } = frameSizeOf(cur); + g.size = sl.axis === "x" ? w : h; + swiped.current = true; + axisMV.set(sl.axis === "x" ? 0 : 1); + enterMV.set(sl.enter); + exitMV.set(sl.exit); + prog.set(0); + peekRef.current = pk; + setPeek(pk); + } + const along = g.dir === "left" ? -dx : g.dir === "right" ? dx : g.dir === "up" ? -dy : dy; + const pr = Math.max(0, Math.min(1, along / g.size)); + const dt = Math.max(1, e.timeStamp - g.lastT); + g.vel = (pr - g.last) / dt; + g.last = pr; + g.lastT = e.timeStamp; + prog.set(pr); + }; + const up = (e: PointerEvent) => { + const g = gesture.current; + if (!g || g.id !== e.pointerId) return; + gesture.current = null; + if (g.phase !== "drag") return; + const pk = peekRef.current; + if (!pk) return; + const commit = prog.get() > 0.25 || g.vel > 0.0012; + animate(prog, commit ? 1 : 0, { duration: 0.26, ease: EASE }).then(() => { + if (commit) { + setAnim({ t: "none", back: false, spring }); + setStack((s) => [...s, { id: pk.frameId, t: pk.t }]); + } + peekRef.current = null; + setPeek(null); + enterMV.set(0); + exitMV.set(0); + prog.set(0); + window.setTimeout(() => { + swiped.current = false; + }, 50); + }); + }; + 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); + }; + }, [frames, scale, prog, axisMV, enterMV, exitMV]); + + const [picker, setPicker] = useState(false); + const pickerRef = useRef(null); + useEffect(() => { + if (!picker) return; + const onDown = (e: PointerEvent) => { + if (!pickerRef.current?.contains(e.target as Node)) setPicker(false); + }; + window.addEventListener("pointerdown", onDown); + return () => window.removeEventListener("pointerdown", onDown); + }, [picker]); + + if (!current) { + return null; + } + + const screenProps = { + widths, + p, + onAction: go, + flipped, + onFlip: flip, + values, + onValue: (id: string, v: number) => setValues((m) => ({ ...m, [id]: v })), + }; + + const barBtn: React.CSSProperties = { + height: 40, + padding: "0 14px 0 10px", + borderRadius: 20, + border: "none", + background: "transparent", + fontSize: 13, + fontWeight: 600, + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + gap: 6, + whiteSpace: "nowrap", + /* the column has a fixed width, so labels are cut with an ellipsis instead of widening it */ + width: wide ? "100%" : undefined, + minWidth: 0, + }; + const label: React.CSSProperties = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }; + + return ( + +
+ + { + if (swiped.current) { + e.stopPropagation(); + e.preventDefault(); + } + }} + style={{ + position: "absolute", + left: BEZEL, + top: BEZEL, + width: screenW, + height: screenH, + borderRadius: screenRadius, + overflow: "hidden", + background: p[current.bg ?? "surface"], + fontFamily: fontFamilyOf(theme.font, lang), + touchAction: "none", + }} + > + + + + + + + + {peek && peekFrame && ( + + + + )} + + +
+ +
+
+ +
+ + + {picker && ( + + {frames.map((f) => { + const on = f.id === current.id; + return ( + + ); + })} + + )} + +
+ +
+
+
+ ); +} diff --git a/web/vendor/components/PromptPanel.tsx b/web/vendor/components/PromptPanel.tsx new file mode 100644 index 0000000..af64f2a --- /dev/null +++ b/web/vendor/components/PromptPanel.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { buildPrompt } from "../lib/prompt"; +import { Doc, Palette, Platform, defaultPlatformOf } from "../lib/tokens"; +import { Icon } from "./M3Node"; +import { Field, IconBtn, Segmented } from "./ui"; +import { t, useLang } from "../lib/i18n"; + +export function PromptPanel({ + doc, + widths, + palette: p, + onDoc, +}: { + doc: Doc; + widths: Record; + palette: Palette; + onDoc: (patch: Partial) => void; +}) { + const lang = useLang(); + const generated = useMemo(() => buildPrompt(doc, widths, undefined, lang), [doc, widths, lang]); + const edited = doc.promptEdit !== undefined; + const text = edited ? doc.promptEdit! : generated; + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const t = setTimeout(() => setCopied(false), 1400); + return () => clearTimeout(t); + }, [copied]); + + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + } catch {} + }; + + const projectButton = (icon: string, label: string, onClick: () => void) => ( + + ); + + return ( +
+ onDoc({ title })} + placeholder={t("appName", lang)} + p={p} + icon="smartphone" + /> + onDoc({ brief })} + placeholder={t("brief", lang)} + p={p} + icon="lightbulb" + multiline + rows={3} + /> + + options={[ + { key: "android", icon: "android", label: "Android", title: t("targetAndroid", lang) }, + { key: "web", icon: "language", label: "Web", title: t("targetWeb", lang) }, + ]} + value={doc.platform ?? defaultPlatformOf(doc.frames, doc.frame)} + onChange={(platform) => onDoc({ platform })} + p={p} + height={40} + /> +
+