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 <noreply@anthropic.com>
3829 lines
134 KiB
TypeScript
3829 lines
134 KiB
TypeScript
"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<Doc, "groups" | "frames">;
|
|
/** 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 (
|
|
<motion.div
|
|
aria-hidden
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.6, ease: [0.2, 0, 0, 1] }}
|
|
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
|
|
>
|
|
<motion.div
|
|
animate={still ? undefined : { rotate: 360 }}
|
|
transition={{ repeat: Infinity, duration: 3.2, ease: "linear" }}
|
|
style={{
|
|
position: "absolute",
|
|
left: "50%",
|
|
top: "50%",
|
|
width: d,
|
|
height: d,
|
|
marginLeft: -d / 2,
|
|
marginTop: -d / 2,
|
|
background: `conic-gradient(from 0deg, ${stops.join(", ")})`,
|
|
filter: "blur(22px)",
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
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<Group[]>(seed);
|
|
const [frames, setFrames] = useState<Frame[]>(SEED_FRAMES);
|
|
const [paletteKey, setPaletteKey] = useState("purple");
|
|
const [customPalette, setCustomPalette] = useState<Palette | null>(null);
|
|
const [dynamicColor, setDynamicColor] = useState(false);
|
|
const [theme, setTheme] = useState<Theme>(DEFAULT_THEME);
|
|
const patchTheme = (patch: Partial<Theme>) => setTheme((t) => ({ ...t, ...patch }));
|
|
const [frame, setFrame] = useState<FrameMode>("phone");
|
|
const [lang, setLang] = useState<Lang>("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<Frame | null>(null);
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
const [title, setTitle] = useState("");
|
|
const [brief, setBrief] = useState("");
|
|
const [promptEdit, setPromptEdit] = useState<string | undefined>(undefined);
|
|
/** the author's explicit target; null follows the screens (web once a desktop screen exists) */
|
|
const [platform, setPlatform] = useState<Platform | null>(null);
|
|
/** a project file waiting for the author to confirm replacing the canvas */
|
|
const [pendingImport, setPendingImport] = useState<Doc | null>(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<Doc | null>(null);
|
|
const draftBeforeRef = useRef<Doc | null>(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<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => () => {
|
|
if (revealTimer.current) clearTimeout(revealTimer.current);
|
|
}, []);
|
|
const guideRef = useRef<string | null>(null);
|
|
|
|
/* ---------- editor ui ---------- */
|
|
const [view, setView] = useState<View>({ 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<Mode>("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<LeftTab>("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<string | null>(null);
|
|
const [rightW, setRightW] = useState(320);
|
|
const [rightTab, setRightTab] = useState<"edit" | "prompt">("edit");
|
|
const [favorites, setFavorites] = useState<Kind[]>([]);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
|
|
const [selectedLinkId, setSelectedLinkId] = useState<string | null>(null);
|
|
const [previewId, setPreviewId] = useState<string | null>(null);
|
|
const [pressedId, setPressedId] = useState<string | null>(null);
|
|
const [drag, setDrag] = useState<DragState | null>(null);
|
|
const [gesture, setGesture] = useState<Gesture | null>(null);
|
|
const [widths, setWidths] = useState<Record<string, number>>({});
|
|
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<AiSettings>(DEFAULT_AI);
|
|
const [aiBusy, setAiBusy] = useState(false);
|
|
/** the screen the model is working on, which wears the animated ring meanwhile */
|
|
const [aiFrameId, setAiFrameId] = useState<string | null>(null);
|
|
/** the "applied" confirmation beside the tidy button */
|
|
const [aiNote, setAiNote] = useState<{ text: string; icon: string } | null>(null);
|
|
const projectFileRef = useRef<HTMLInputElement>(null);
|
|
const aiNoteTimer = useRef<number | null>(null);
|
|
const aiAbortRef = useRef<AbortController | null>(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<HTMLDivElement>(null);
|
|
const measureEls = useRef<Map<string, HTMLElement>>(new Map());
|
|
const dragRef = useRef<DragState | null>(null);
|
|
const gestureRef = useRef<Gesture | null>(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<Map<number, { x: number; y: number }>>(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<Set<string>>(new Set());
|
|
const loadedRef = useRef(false);
|
|
const initialLangRef = useRef<Lang>("ja");
|
|
/** A saved document or the first viewport initialization prevents later reseeding. */
|
|
const hadDocRef = useRef(false);
|
|
|
|
/* ---------- history ---------- */
|
|
const pastRef = useRef<Snapshot[]>([]);
|
|
const futureRef = useRef<Snapshot[]>([]);
|
|
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<void>((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<Doc>, 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<Doc>, 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<string, Item>();
|
|
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<string, number> = {};
|
|
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<Item>) => {
|
|
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<string, { x: number; y: number }> = {};
|
|
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<string, { x: number; y: number }> = {};
|
|
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<Frame>) => {
|
|
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<number | null>(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<HTMLElement>(`[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 (
|
|
<div
|
|
data-export={f.id}
|
|
style={{
|
|
position: "relative",
|
|
width: w,
|
|
height: h,
|
|
background: p[f.bg ?? "surface"],
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{gs.map((g) =>
|
|
g.free ? (
|
|
((corners) =>
|
|
layoutOf(g, widths).map((pl) => (
|
|
<div key={pl.item.id} style={{ position: "absolute", left: pl.x - f.x, top: pl.y - f.y }}>
|
|
<M3Static
|
|
item={pl.item}
|
|
palette={p}
|
|
radii={corners.get(pl.item.id)}
|
|
style={MEASURED.includes(pl.item.kind) ? undefined : { width: pl.w, height: pl.h }}
|
|
/>
|
|
</div>
|
|
)))(freeRadii(g, widths))
|
|
) : (
|
|
<div
|
|
key={g.id}
|
|
style={{
|
|
position: "absolute",
|
|
left: g.x - f.x,
|
|
top: g.y - f.y,
|
|
display: "flex",
|
|
flexDirection: g.axis === "x" ? "row" : "column",
|
|
alignItems: g.axis === "x" ? "center" : "stretch",
|
|
gap: GAP,
|
|
}}
|
|
>
|
|
{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 (
|
|
<M3Static
|
|
key={it.id}
|
|
item={it}
|
|
palette={p}
|
|
radii={radii}
|
|
style={MEASURED.includes(it.kind) ? undefined : { width: sizeOf(it, widths).w, height: sizeOf(it, widths).h }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** the view before the preview opened, restored when it closes */
|
|
const viewBeforePreview = useRef<View | null>(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<string, string>();
|
|
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 (
|
|
<motion.div
|
|
key={g.id}
|
|
initial={false}
|
|
animate={{ x: g.x - ox, y: g.y - oy }}
|
|
transition={instantG ? INSTANT : OPEN}
|
|
style={{ position: "absolute", left: 0, top: 0 }}
|
|
>
|
|
{layoutOf(g, widths).map((pl) => (
|
|
<div key={pl.item.id} style={{ position: "absolute", left: pl.x - g.x, top: pl.y - g.y }}>
|
|
<M3Node
|
|
item={pl.item}
|
|
palette={p}
|
|
widths={widths}
|
|
radii={corners.get(pl.item.id)}
|
|
pressed={false}
|
|
selected={selectedSet.has(pl.item.id)}
|
|
interactive={!handMode}
|
|
onPointerDown={(e) => onItemPointerDown(e, g, pl.index, pl.item)}
|
|
/>
|
|
</div>
|
|
))}
|
|
{allOn && (
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
position: "absolute",
|
|
left: -6,
|
|
top: -6,
|
|
width: groupBounds(g, widths).r - g.x + 12,
|
|
height: groupBounds(g, widths).b - g.y + 12,
|
|
border: `${1.5 / view.z}px dashed ${p.primary}`,
|
|
borderRadius: 10,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|
|
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 (
|
|
<motion.div
|
|
key={g.id}
|
|
initial={false}
|
|
animate={{
|
|
x: g.x - ox + (g.axis === "x" ? shift : 0),
|
|
y: g.y - oy + (g.axis === "y" ? shift : 0),
|
|
}}
|
|
transition={instant ? INSTANT : OPEN}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
display: "flex",
|
|
flexDirection: g.axis === "x" ? "row" : "column",
|
|
alignItems: g.axis === "x" ? "center" : "stretch",
|
|
gap: GAP,
|
|
}}
|
|
>
|
|
{cells.map((c, r) => {
|
|
if (c.ph) {
|
|
return (
|
|
<motion.div
|
|
key="__gap"
|
|
initial={g.axis === "x" ? { width: 0 } : { height: 0 }}
|
|
animate={
|
|
g.axis === "x" ? { width: phMain } : { height: phMain }
|
|
}
|
|
transition={OPEN}
|
|
style={{
|
|
flex: "0 0 auto",
|
|
height: g.axis === "x" ? dragSize.h : undefined,
|
|
width: g.axis === "y" ? dragSize.w : undefined,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
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 (
|
|
<M3Node
|
|
key={c.item.id}
|
|
item={c.item}
|
|
palette={p}
|
|
widths={widths}
|
|
radii={radii}
|
|
pressed={pressedId === c.item.id}
|
|
selected={selectedSet.has(c.item.id)}
|
|
interactive={!handMode}
|
|
onPointerDown={(e) => onItemPointerDown(e, g, c.index, c.item)}
|
|
/>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<LangContext.Provider value={lang}>
|
|
<ThemeContext.Provider value={theme}>
|
|
<div
|
|
className={revealing ? "app-root m3e-reveal" : "app-root"}
|
|
inert={editAccess !== "editable"}
|
|
aria-hidden={editAccess !== "editable"}
|
|
style={{
|
|
display: "flex",
|
|
overflow: "hidden",
|
|
background: p.surfaceContainer,
|
|
cursor: resizing ? "col-resize" : undefined,
|
|
userSelect: resizing ? "none" : undefined,
|
|
["--sb" as string]: p.outlineVariant,
|
|
}}
|
|
>
|
|
{/* hidden measuring layer for text-sized kinds */}
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
position: "fixed",
|
|
left: -99999,
|
|
top: 0,
|
|
visibility: "hidden",
|
|
pointerEvents: "none",
|
|
fontFamily: fontFamilyOf(theme.font, lang),
|
|
}}
|
|
>
|
|
{allItems
|
|
.filter((it) => MEASURED.includes(it.kind))
|
|
.map((it) => (
|
|
<div
|
|
key={it.id}
|
|
ref={(el) => {
|
|
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",
|
|
}}
|
|
>
|
|
<MeasuredContent item={it} p={p} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{exportFrame && (
|
|
<div aria-hidden style={{ position: "fixed", left: -99999, top: 0, pointerEvents: "none", fontFamily: fontFamilyOf(theme.font, lang) }}>
|
|
{renderExport(exportFrame)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ---- left: rail + parts / layers ---- */}
|
|
{!isMobile && (
|
|
<aside style={{ ...panelStyle, width: leftOpen ? leftW : RAIL_W, flexDirection: "row", transition: "width 200ms cubic-bezier(0.2, 0, 0, 1)" }}>
|
|
<div
|
|
onPointerEnter={() => setRailHover(true)}
|
|
onPointerLeave={() => setRailHover(false)}
|
|
onClick={(e) => {
|
|
// a click on the rail's empty background opens the panel
|
|
if (!leftOpen && e.target === e.currentTarget) setLeftOpen(true);
|
|
}}
|
|
style={{
|
|
width: RAIL_W,
|
|
flex: "0 0 auto",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "10px 0",
|
|
background: p.surfaceContainerLow,
|
|
cursor: leftOpen ? undefined : "pointer",
|
|
}}
|
|
>
|
|
{!leftOpen && railHover ? (
|
|
<IconBtn icon="left_panel_open" p={p} on onClick={() => setLeftOpen(true)} title={t("openPanel", lang)} size={40} />
|
|
) : (
|
|
<div
|
|
onClick={() => !leftOpen && setLeftOpen(true)}
|
|
style={{ width: 40, height: 40, display: "grid", placeItems: "center", cursor: leftOpen ? "default" : "pointer" }}
|
|
>
|
|
<Logo size={32} color={p.primary} glyph={p.onPrimary} />
|
|
</div>
|
|
)}
|
|
<div style={{ height: 6 }} />
|
|
{LEFT_TABS.map((tab, i) => (
|
|
<div key={tab.key} style={{ marginTop: i === 2 || i === 6 ? 10 : 0 }}>
|
|
<IconBtn
|
|
icon={tab.icon}
|
|
p={p}
|
|
on={leftOpen && leftTab === tab.key}
|
|
onClick={() => {
|
|
setLeftTab(tab.key);
|
|
setLeftOpen(true);
|
|
}}
|
|
title={t(tab.title, lang)}
|
|
size={44}
|
|
/>
|
|
</div>
|
|
))}
|
|
<div style={{ flex: 1 }} onClick={() => !leftOpen && setLeftOpen(true)} />
|
|
<LangMenu p={p} onLang={changeLanguage} side="right" size={44} />
|
|
<GitHubLink p={p} size={44} />
|
|
</div>
|
|
{leftOpen && (
|
|
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "10px 10px 0 14px",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontWeight: 700,
|
|
fontSize: 14,
|
|
color: p.onSurface,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{t(LEFT_TABS.find((x) => x.key === leftTab)?.title ?? "parts", lang)}
|
|
</span>
|
|
<IconBtn
|
|
icon="left_panel_close"
|
|
p={p}
|
|
onClick={() => setLeftOpen(false)}
|
|
title={t("closePanel", lang)}
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0 }}>
|
|
{leftTab === "parts" ? (
|
|
<PartsPalette
|
|
palette={p}
|
|
favorites={favorites}
|
|
onToggleFavorite={(k) =>
|
|
setFavorites((f) =>
|
|
f.includes(k) ? f.filter((x) => x !== k) : [...f, k],
|
|
)
|
|
}
|
|
onPartPointerDown={onPartPointerDown}
|
|
overBin={(!!drag?.active && drag.overBin) || (gesture?.kind === "group" && gesture.overBin)}
|
|
/>
|
|
) : leftTab === "color" ? (
|
|
<ColorPanel
|
|
p={p}
|
|
paletteKey={paletteKey}
|
|
onPalette={setPaletteKey}
|
|
custom={customPalette}
|
|
onCustom={setCustomPalette}
|
|
dynamic={dynamicColor}
|
|
onDynamic={setDynamicColor}
|
|
theme={theme}
|
|
onTheme={patchTheme}
|
|
/>
|
|
) : leftTab === "shape" ? (
|
|
<ShapePanel p={p} theme={theme} onChange={patchTheme} />
|
|
) : leftTab === "type" ? (
|
|
<TypePanel p={p} theme={theme} onChange={patchTheme} />
|
|
) : leftTab === "motion" ? (
|
|
<MotionPanel p={p} theme={theme} onChange={patchTheme} />
|
|
) : leftTab === "ai" ? (
|
|
<AiPanel p={p} settings={aiSettings} onSettings={updateAiSettings} />
|
|
) : (
|
|
<LayersPanel
|
|
p={p}
|
|
frames={frames}
|
|
frameId={layersFrame?.id ?? null}
|
|
onFrame={(id) => {
|
|
setLayersFrameId(id);
|
|
setSelectedIds([]);
|
|
setSelectedFrameId(id);
|
|
}}
|
|
groups={layerGroups}
|
|
widths={widths}
|
|
selectedIds={selectedIds}
|
|
onSelect={(ids, add) => {
|
|
setSelectedIds((cur) => (add ? [...cur.filter((x) => !ids.includes(x)), ...ids] : ids));
|
|
setSelectedFrameId(null);
|
|
setSelectedLinkId(null);
|
|
setRightTab("edit");
|
|
}}
|
|
onReorder={reorderLayers}
|
|
onReorderItems={reorderGroupItems}
|
|
onDragging={onLayerDragging}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{leftOpen && (
|
|
<div
|
|
onPointerDown={(e) => {
|
|
e.preventDefault();
|
|
setResizing("left");
|
|
}}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
right: -3,
|
|
width: 6,
|
|
cursor: "col-resize",
|
|
zIndex: 5,
|
|
}}
|
|
/>
|
|
)}
|
|
</aside>
|
|
)}
|
|
|
|
{/* ---- canvas ---- */}
|
|
<main
|
|
style={{
|
|
flex: 1,
|
|
position: "relative",
|
|
minWidth: 0,
|
|
padding: isMobile ? 6 : 8,
|
|
}}
|
|
>
|
|
<div
|
|
ref={canvasRef}
|
|
onPointerDown={onCanvasPointerDown}
|
|
onPointerDownCapture={onTouchCapture}
|
|
style={{
|
|
position: "absolute",
|
|
inset: isMobile ? 6 : 8,
|
|
overflow: "hidden",
|
|
borderRadius: 24,
|
|
background: canvasBg,
|
|
cursor: panning ? "grabbing" : handMode ? "grab" : "default",
|
|
touchAction: "none",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
transform: `translate(${view.x}px, ${view.y}px) scale(${view.z})`,
|
|
transformOrigin: "0 0",
|
|
transition: cameraEasing ? `transform ${SETTLE_MS}ms cubic-bezier(0.2, 0, 0, 1)` : undefined,
|
|
willChange: "transform",
|
|
visibility: viewReady ? "visible" : "hidden",
|
|
fontFamily: fontFamilyOf(theme.font, lang),
|
|
}}
|
|
>
|
|
{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 (
|
|
<div
|
|
key={f.id}
|
|
data-frame={f.id}
|
|
style={{ position: "absolute", left: f.x, top: f.y, transition: easing ? `left ${SETTLE_MS}ms cubic-bezier(0.2, 0, 0, 1)` : undefined }}
|
|
>
|
|
<div
|
|
onPointerDown={(e) => 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),
|
|
}}
|
|
>
|
|
<div onPointerDown={(e) => e.stopPropagation()}>
|
|
<FrameSizePicker frame={f} onChange={(preset) => setFramePreset(f.id, preset)} palette={p} compact />
|
|
</div>
|
|
{f.name || t("screen", lang)}
|
|
</div>
|
|
<div
|
|
onPointerDown={(e) => 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}`,
|
|
}}
|
|
>
|
|
<AnimatePresence>{aiFrameId === f.id && <ThinkingRing key="ring" p={p} frame={f} />}</AnimatePresence>
|
|
<div
|
|
data-screen={f.id}
|
|
style={{
|
|
position: "absolute",
|
|
left: BEZEL,
|
|
top: BEZEL,
|
|
width: w,
|
|
height: h,
|
|
borderRadius: radius,
|
|
background: bg,
|
|
overflow: "hidden",
|
|
transition: SIZE_TRANSITION,
|
|
}}
|
|
>
|
|
{groups
|
|
.filter((g) => frameOf.get(g.id) === f.id)
|
|
.map((g) => renderGroup(g, f.x, f.y))}
|
|
{draftBusy && (
|
|
<div style={{ position: "absolute", inset: 0, zIndex: 90, background: canvasBg, display: "grid", placeItems: "center" }}>
|
|
<LoadingIndicator size={96} color="url(#m3e-drafting)" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{groups
|
|
.filter((g) => !frameOf.has(g.id))
|
|
.map((g) => renderGroup(g, 0, 0))}
|
|
|
|
{/* the part in flight */}
|
|
{drag?.active && (
|
|
<motion.div
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
x: sx,
|
|
y: sy,
|
|
pointerEvents: "none",
|
|
zIndex: 50,
|
|
}}
|
|
animate={{
|
|
opacity: drag.overBin ? 0.4 : 1,
|
|
scale: drag.overBin ? 0.84 : 1,
|
|
}}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 520,
|
|
damping: 34,
|
|
mass: 0.6,
|
|
}}
|
|
>
|
|
<M3Node
|
|
item={drag.item}
|
|
palette={p}
|
|
widths={widths}
|
|
dragging
|
|
radii={(() => {
|
|
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,
|
|
);
|
|
})()}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{links.length > 0 && (
|
|
<svg
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
overflow: "visible",
|
|
pointerEvents: "none",
|
|
}}
|
|
width={1}
|
|
height={1}
|
|
>
|
|
<defs>
|
|
<marker
|
|
id="m3e-arrow"
|
|
viewBox="0 0 10 10"
|
|
refX="9"
|
|
refY="5"
|
|
markerWidth="8"
|
|
markerHeight="8"
|
|
orient="auto"
|
|
>
|
|
<path d="M0 0 L10 5 L0 10 z" fill={p.primary} />
|
|
</marker>
|
|
</defs>
|
|
{!draftBusy && links.map((l) => {
|
|
const on = l.id === selectedLinkId;
|
|
return (
|
|
<g key={l.id}>
|
|
<path
|
|
d={l.d}
|
|
fill="none"
|
|
stroke="transparent"
|
|
strokeWidth={18 / view.z}
|
|
style={{ pointerEvents: "stroke", cursor: "pointer" }}
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedLinkId(l.id);
|
|
setSelectedIds([]);
|
|
setSelectedFrameId(null);
|
|
}}
|
|
/>
|
|
<path
|
|
d={l.d}
|
|
fill="none"
|
|
stroke={p.primary}
|
|
strokeWidth={on ? 3 : 2}
|
|
strokeDasharray={on ? undefined : "6 6"}
|
|
strokeLinecap="round"
|
|
markerEnd="url(#m3e-arrow)"
|
|
opacity={on ? 1 : 0.7}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
)}
|
|
|
|
{links
|
|
.filter((l) => l.id === selectedLinkId)
|
|
.map((l) => (
|
|
<div
|
|
key={l.id}
|
|
onPointerDown={(e) => 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,
|
|
}}
|
|
>
|
|
<Segmented<Transition>
|
|
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}
|
|
/>
|
|
<IconBtn
|
|
icon="link_off"
|
|
p={p}
|
|
danger
|
|
onClick={() => removeLink(l.id)}
|
|
title={t("removeLink", lang)}
|
|
size={36}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{guide?.gx !== undefined && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: guide.gx,
|
|
top: visibleWorld.t,
|
|
width: 1.5 / view.z,
|
|
height: visibleWorld.h,
|
|
background: p.primary,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
)}
|
|
{guide?.gy !== undefined && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: guide.gy,
|
|
left: visibleWorld.l,
|
|
height: 1.5 / view.z,
|
|
width: visibleWorld.w,
|
|
background: p.primary,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{marquee && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: Math.min(marquee.x0, marquee.x1),
|
|
top: Math.min(marquee.y0, marquee.y1),
|
|
width: Math.abs(marquee.x1 - marquee.x0),
|
|
height: Math.abs(marquee.y1 - marquee.y0),
|
|
border: `${1 / view.z}px solid ${p.primary}`,
|
|
background: `${p.primary}14`,
|
|
borderRadius: 4 / view.z,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{draftBusy && (
|
|
<div style={{ position: "absolute", width: 0, height: 0, overflow: "hidden" }} aria-hidden>
|
|
<svg width={0} height={0} style={{ position: "absolute" }} aria-hidden>
|
|
<defs>
|
|
<linearGradient id="m3e-drafting" x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0%" stopColor={p.primary} />
|
|
<stop offset="50%" stopColor={p.tertiaryContainer} />
|
|
<stop offset="100%" stopColor={p.primaryContainer} />
|
|
<animateTransform attributeName="gradientTransform" type="rotate" from="0 0.5 0.5" to="360 0.5 0.5" dur="3s" repeatCount="indefinite" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
<Toolbar
|
|
p={p}
|
|
mode={mode}
|
|
onMode={setMode}
|
|
frame={frame}
|
|
onFrame={changeFrame}
|
|
zoom={view.z}
|
|
onZoom={(z) => 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 && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
top: 80,
|
|
textAlign: "center",
|
|
fontSize: 11,
|
|
lineHeight: 1.4,
|
|
color: p.onSurfaceVariant,
|
|
pointerEvents: "none",
|
|
zIndex: 40,
|
|
}}
|
|
>
|
|
{t("mobileNote", lang)}
|
|
</div>
|
|
)}
|
|
|
|
{isMobile && sheet === null && (
|
|
<button
|
|
onClick={addButton}
|
|
title={t("addButton", lang)}
|
|
aria-label={t("addButton", lang)}
|
|
className="m3-press"
|
|
style={{
|
|
position: "absolute",
|
|
right: 16,
|
|
bottom: "calc(16px + var(--bottom-ui, 0px) + env(safe-area-inset-bottom))",
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 20,
|
|
border: "none",
|
|
background: p.primary,
|
|
color: p.onPrimary,
|
|
cursor: "pointer",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
zIndex: 46,
|
|
boxShadow: "0 6px 18px rgba(0,0,0,0.18)",
|
|
}}
|
|
>
|
|
<Icon name="add" size={32} />
|
|
</button>
|
|
)}
|
|
|
|
{isMobile && selected && sheet === null && (
|
|
<MobileActionBar
|
|
p={p}
|
|
onEdit={() => setSheet("edit")}
|
|
onDuplicate={duplicateSelected}
|
|
onDelete={deleteSelected}
|
|
/>
|
|
)}
|
|
|
|
<AnimatePresence>
|
|
{isMobile && sheet === "edit" && selected && (
|
|
<BottomSheet key="edit" p={p} onClose={() => setSheet(null)}>
|
|
<MobileInspector
|
|
item={selected}
|
|
palette={p}
|
|
onChange={patchSelected}
|
|
onDelete={() => {
|
|
deleteSelected();
|
|
setSheet(null);
|
|
}}
|
|
onDuplicate={duplicateSelected}
|
|
onClose={() => setSheet(null)}
|
|
/>
|
|
</BottomSheet>
|
|
)}
|
|
{isMobile && sheet === "settings" && (
|
|
<BottomSheet key="settings" p={p} onClose={() => setSheet(null)}>
|
|
<MobileSettings palette={p} paletteKey={paletteKey} onPalette={setPaletteKey} theme={theme} onTheme={patchTheme} />
|
|
</BottomSheet>
|
|
)}
|
|
{isMobile && sheet === "lang" && (
|
|
<BottomSheet key="lang" p={p} onClose={() => setSheet(null)}>
|
|
<MobileLang
|
|
palette={p}
|
|
lang={lang}
|
|
onLang={(l) => {
|
|
changeLanguage(l);
|
|
setSheet(null);
|
|
}}
|
|
/>
|
|
</BottomSheet>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{toast && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: "50%",
|
|
bottom: 96,
|
|
transform: "translateX(-50%)",
|
|
padding: "10px 18px",
|
|
borderRadius: 20,
|
|
background: p.inverseSurface,
|
|
color: p.inverseOnSurface,
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
zIndex: 47,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{toast}
|
|
</div>
|
|
)}
|
|
|
|
{!rightOpen && !isMobile && (
|
|
<div
|
|
style={{ position: "absolute", right: 20, top: 20, zIndex: 45 }}
|
|
>
|
|
<IconBtn
|
|
icon="right_panel_open"
|
|
p={p}
|
|
on
|
|
onClick={() => setRightOpen(true)}
|
|
title={t("edit", lang)}
|
|
size={44}
|
|
/>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* ---- right: inspector / prompt ---- */}
|
|
{showRight && (
|
|
<aside style={{ ...panelStyle, width: rightW }}>
|
|
<div
|
|
onPointerDown={(e) => {
|
|
e.preventDefault();
|
|
setResizing("right");
|
|
}}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
left: -3,
|
|
width: 6,
|
|
cursor: "col-resize",
|
|
zIndex: 5,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "10px 10px 6px 12px",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<Segmented<"edit" | "prompt">
|
|
options={[
|
|
{ key: "edit", icon: "tune", title: t("edit", lang), grow: false, wide: true },
|
|
{ key: "prompt", icon: "auto_awesome", label: t("prompt", lang), title: t("prompt", lang), grow: true },
|
|
]}
|
|
value={rightTab}
|
|
onChange={setRightTab}
|
|
p={p}
|
|
height={40}
|
|
/>
|
|
</div>
|
|
<IconBtn
|
|
icon="right_panel_close"
|
|
p={p}
|
|
onClick={() => setRightOpen(false)}
|
|
title={t("closePanel", lang)}
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0 }}>
|
|
{rightTab === "edit" && selectedFrame && !selected ? (
|
|
<FrameInspector
|
|
frame={selectedFrame}
|
|
palette={p}
|
|
onSize={(preset) => setFramePreset(selectedFrame.id, preset)}
|
|
onChange={(patch) => patchFrame(selectedFrame.id, patch)}
|
|
onDelete={() => deleteFrame(selectedFrame.id)}
|
|
onDuplicate={() => duplicateFrame(selectedFrame.id)}
|
|
onPreview={() => openPreview(selectedFrame.id)}
|
|
prompt={buildPrompt(doc, widths, selectedFrame.id, lang)}
|
|
onSaveImage={() => saveFrameImage(selectedFrame)}
|
|
frames={frames}
|
|
tidy={tidyState ?? "done"}
|
|
onTidy={() => tidy(selectedFrame)}
|
|
ai={{ ready: aiReady, reason: aiReason, busy: aiBusy && aiFrameId === selectedFrame.id, onRun: () => runAi("describe", selectedFrame), onCancel: cancelAi }}
|
|
/>
|
|
) : rightTab === "edit" ? (
|
|
<Inspector
|
|
ai={{
|
|
ready: aiReady && !!tidyTarget,
|
|
reason: aiReason,
|
|
busy: aiBusy,
|
|
onRun: () => {
|
|
if (tidyTarget && selected) runAi("behavior", tidyTarget, selected.id);
|
|
},
|
|
onCancel: cancelAi,
|
|
}}
|
|
item={selectedIds.length > 1 ? null : selected}
|
|
frame={selectedPartFrame}
|
|
palette={p}
|
|
frames={frame === "phone" ? frames : []}
|
|
onChange={patchSelected}
|
|
onDelete={deleteSelected}
|
|
onDuplicate={duplicateSelected}
|
|
multi={selectedIds.length}
|
|
grouped={!!selectedGroup}
|
|
onGroup={groupSelected}
|
|
onUngroup={ungroupSelected}
|
|
/>
|
|
) : (
|
|
<PromptPanel
|
|
doc={doc}
|
|
widths={widths}
|
|
palette={p}
|
|
onDoc={(patch) => {
|
|
if (patch.title !== undefined) setTitle(patch.title);
|
|
if (patch.brief !== undefined) setBrief(patch.brief);
|
|
if ("promptEdit" in patch) setPromptEdit(patch.promptEdit);
|
|
if ("platform" in patch) setPlatform(isPlatform(patch.platform) ? patch.platform : null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
)}
|
|
|
|
<input
|
|
ref={projectFileRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
hidden
|
|
onChange={(e) => {
|
|
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")));
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={pendingImport !== null}
|
|
icon="file_open"
|
|
title={t("replaceProjectTitle", lang)}
|
|
body={t("replaceProject", lang)}
|
|
p={p}
|
|
onCancel={() => setPendingImport(null)}
|
|
onConfirm={() => {
|
|
if (pendingImport) importDoc(pendingImport);
|
|
setPendingImport(null);
|
|
}}
|
|
/>
|
|
|
|
<ShareDialog
|
|
p={p}
|
|
doc={doc}
|
|
aiReady={aiReady}
|
|
idea={ideaText}
|
|
onIdea={setIdeaText}
|
|
open={shareOpen}
|
|
onClose={() => setShareOpen(false)}
|
|
onDraft={(idea) => void startDraft(idea)}
|
|
onSetupAi={() => {
|
|
setShareOpen(false);
|
|
setLeftOpen(true);
|
|
setLeftTab("ai");
|
|
}}
|
|
/>
|
|
|
|
|
|
<ConfirmDialog
|
|
open={confirmClear}
|
|
title={t("clearAllTitle", lang)}
|
|
body={t("clearAllBody", lang)}
|
|
p={p}
|
|
onCancel={() => setConfirmClear(false)}
|
|
onConfirm={clearAll}
|
|
/>
|
|
|
|
<AnimatePresence>
|
|
{previewId !== null && frames.length > 0 && (
|
|
<Preview
|
|
key="preview"
|
|
doc={doc}
|
|
widths={widths}
|
|
palette={p}
|
|
startId={previewId}
|
|
onClose={closePreview}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{editAccess === "readonly" && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="edit-access-title"
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 100,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
padding: 24,
|
|
background: "rgba(0,0,0,0.32)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: "min(420px, 100%)",
|
|
padding: 24,
|
|
borderRadius: 28,
|
|
background: p.surfaceContainerHigh,
|
|
color: p.onSurface,
|
|
boxShadow: "0 12px 40px rgba(0,0,0,0.22)",
|
|
}}
|
|
>
|
|
<Icon name="lock" size={28} />
|
|
<h1 id="edit-access-title" style={{ margin: "16px 0 8px", fontSize: 22, lineHeight: 1.25 }}>
|
|
{t("readOnlyTitle", lang)}
|
|
</h1>
|
|
<p style={{ margin: 0, color: p.onSurfaceVariant, fontSize: 14, lineHeight: 1.5 }}>
|
|
{t("readOnlyBody", lang)}
|
|
</p>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 24 }}>
|
|
<button
|
|
autoFocus
|
|
className="m3-press"
|
|
onClick={() => window.location.reload()}
|
|
style={{
|
|
minHeight: 40,
|
|
padding: "0 20px",
|
|
border: "none",
|
|
borderRadius: 20,
|
|
background: p.primary,
|
|
color: p.onPrimary,
|
|
fontSize: 14,
|
|
fontWeight: 700,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
{t("reload", lang)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ThemeContext.Provider>
|
|
</LangContext.Provider>
|
|
);
|
|
}
|