import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { clampToRoom, fitScale, isValidPlacement, tryUpdate } from "./geometry"; import { initialState, reducer } from "./store"; import type { FurnitureItem, FurnitureType, Rect } from "./types"; import { placedCount, remaining } from "./inventory"; import { Palette } from "./components/Palette"; import { Room, type Preview } from "./components/Room"; import { ScaleBar } from "./components/ScaleBar"; import { TypeMenu } from "./components/TypeMenu"; import { ItemPanel } from "./components/ItemPanel"; import { CustomForm, type CustomDraft } from "./components/CustomForm"; import { ShapeIcon } from "./components/Icons"; const WALL_PX = 6; // room wall border, must match Room.tsx const SCALE_BAR_PX = 40; interface PaletteDrag { type: FurnitureType; clientX: number; clientY: number; } // What the sidebar shows besides the tiles. An item panel appears whenever an item is selected. type Sidebar = { kind: "tiles" } | { kind: "type"; id: FurnitureType } | { kind: "custom" }; export default function App() { const [state, dispatch] = useReducer(reducer, initialState); const [paletteDrag, setPaletteDrag] = useState(null); const [sidebar, setSidebar] = useState({ kind: "tiles" }); const [selectedId, setSelectedId] = useState(null); const floorRef = useRef(null); const mainRef = useRef(null); const [pxPerM, setPxPerM] = useState(60); const nextItemId = useRef(1); const nextCustomId = useRef(1); const selectedItem = state.items.find((i) => i.id === selectedId) ?? null; // Selecting an item, opening a menu and dragging from the palette each replace the others. const select = useCallback((id: string | null) => { setSelectedId(id); if (id) setSidebar({ kind: "tiles" }); }, []); const openSidebar = (s: Sidebar) => { setSelectedId(null); setSidebar(s); }; const closeSidebar = useCallback(() => setSidebar({ kind: "tiles" }), []); const deselect = useCallback(() => setSelectedId(null), []); const deleteSelected = useCallback(() => { if (!selectedId) return; dispatch({ kind: "remove", id: selectedId }); setSelectedId(null); }, [selectedId]); // Delete / Backspace (Mac "delete") removes the selected item, unless the user is typing. useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== "Delete" && e.key !== "Backspace") return; const t = e.target as HTMLElement | null; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; if (!selectedId) return; e.preventDefault(); deleteSelected(); }; window.addEventListener("keydown", key); return () => window.removeEventListener("keydown", key); }, [selectedId, deleteSelected]); // Maximise the room inside the center area (minus wall border and the scale bar). useEffect(() => { const el = mainRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; setPxPerM(fitScale(state.room, width - WALL_PX * 2, height - WALL_PX * 2 - SCALE_BAR_PX)); }); ro.observe(el); return () => ro.disconnect(); }, [state.room]); // Cursor position -> a new item's box (centered on cursor, clamped), or null if outside the floor. const toRoomRect = (d: PaletteDrag): Rect | null => { const box = floorRef.current?.getBoundingClientRect(); if (!box) return null; // Floor excludes the CSS border: the Stage sits inside it. const border = (box.width - state.room.width * pxPerM) / 2; const px = d.clientX - box.left - border; const py = d.clientY - box.top - border; if (px < 0 || py < 0 || px > state.room.width * pxPerM || py > state.room.height * pxPerM) return null; const { defaultWidth: width, defaultHeight: height } = state.configs[d.type]; return clampToRoom({ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height }, state.room); }; useEffect(() => { if (!paletteDrag) return; const type = paletteDrag.type; const move = (e: globalThis.PointerEvent) => setPaletteDrag({ type, clientX: e.clientX, clientY: e.clientY }); const up = (e: globalThis.PointerEvent) => { const r = toRoomRect({ type, clientX: e.clientX, clientY: e.clientY }); if ( r && remaining(state.items, type, state.configs[type].available) > 0 && isValidPlacement(r, state.items, state.room) ) { dispatch({ kind: "add", id: `item-${nextItemId.current++}`, type, x: r.x, y: r.y }); } // outside the room or invalid: cancel setPaletteDrag(null); }; const cancel = () => setPaletteDrag(null); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", cancel); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", cancel); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [paletteDrag?.type, state.items, state.room, state.configs, pxPerM]); const preview: Preview | null = useMemo(() => { if (!paletteDrag) return null; const r = toRoomRect(paletteDrag); if (!r) return null; return { shape: state.configs[paletteDrag.type].shape, ...r, valid: isValidPlacement(r, state.items, state.room), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [paletteDrag, state.items, state.room, pxPerM]); const startDrag = (type: FurnitureType, clientX: number, clientY: number) => { setSidebar({ kind: "tiles" }); setPaletteDrag({ type, clientX, clientY }); }; /** Rotate / resize / lock / seats on one item. Geometry changes are checked; returns false if rejected. */ const patchItem = (item: FurnitureItem, patch: Partial): boolean => { const geometric = "rotation" in patch || "width" in patch || "height" in patch; if (!geometric) { dispatch({ kind: "update", id: item.id, patch }); return true; } const next = tryUpdate(item, patch, state.items, state.room); if (!next) return false; dispatch({ kind: "update", id: item.id, patch: { ...patch, x: next.x, y: next.y } }); return true; }; const saveCustom = (d: CustomDraft) => { const id = `custom-${nextCustomId.current++}`; dispatch({ kind: "addConfig", config: { id, name: d.name, shape: d.isTable ? "table" : "chair", defaultWidth: d.width, defaultHeight: d.height, isTable: d.isTable, available: d.available, seatsPerUnit: d.seats, }, }); setSidebar({ kind: "tiles" }); }; const remainingByType = Object.fromEntries( state.typeOrder.map((t) => [t, remaining(state.items, t, state.configs[t].available)]), ); let panel = null; if (selectedItem) { panel = ( patchItem(selectedItem, p)} onDelete={deleteSelected} onClose={deselect} /> ); } else if (sidebar.kind === "type") { const id = sidebar.id; panel = ( dispatch({ kind: "setAvailable", type: id, value })} onSetSeats={(value) => dispatch({ kind: "setSeats", type: id, value })} onClose={closeSidebar} /> ); } else if (sidebar.kind === "custom") { panel = ; } return (
state.configs[t])} remaining={remainingByType} onStartDrag={startDrag} onOpenMenu={(id) => openSidebar({ kind: "type", id })} onNewCustom={() => openSidebar({ kind: "custom" })} panel={panel} />
dispatch({ kind: "move", id, x, y })} onResize={(id, r) => dispatch({ kind: "update", id, patch: { ...r } })} />
{paletteDrag && !preview && (
)}
); }