Files
furniture/src/App.tsx
T
2026-10-17 12:43:25 -04:00

248 lines
9.2 KiB
TypeScript

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<PaletteDrag | null>(null);
const [sidebar, setSidebar] = useState<Sidebar>({ kind: "tiles" });
const [selectedId, setSelectedId] = useState<string | null>(null);
const floorRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(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<FurnitureItem>): 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 = (
<ItemPanel
key={selectedItem.id}
item={selectedItem}
config={state.configs[selectedItem.type]}
onPatch={(p) => patchItem(selectedItem, p)}
onDelete={deleteSelected}
onClose={deselect}
/>
);
} else if (sidebar.kind === "type") {
const id = sidebar.id;
panel = (
<TypeMenu
config={state.configs[id]}
placed={placedCount(state.items, id)}
onSetAvailable={(value) => dispatch({ kind: "setAvailable", type: id, value })}
onSetSeats={(value) => dispatch({ kind: "setSeats", type: id, value })}
onClose={closeSidebar}
/>
);
} else if (sidebar.kind === "custom") {
panel = <CustomForm onSave={saveCustom} onCancel={closeSidebar} />;
}
return (
<div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}>
<Palette
types={state.typeOrder.map((t) => state.configs[t])}
remaining={remainingByType}
onStartDrag={startDrag}
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
onNewCustom={() => openSidebar({ kind: "custom" })}
panel={panel}
/>
<main
ref={mainRef}
style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", overflow: "hidden", padding: 24 }}
>
<Room
pxPerM={pxPerM}
room={state.room}
items={state.items}
configs={state.configs}
preview={preview}
floorRef={floorRef}
selectedId={selectedId}
onSelect={select}
onMove={(id, x, y) => dispatch({ kind: "move", id, x, y })}
onResize={(id, r) => dispatch({ kind: "update", id, patch: { ...r } })}
/>
<ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} />
</main>
{paletteDrag && !preview && (
<div
style={{
position: "fixed",
left: paletteDrag.clientX,
top: paletteDrag.clientY,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
opacity: 0.8,
}}
>
<ShapeIcon shape={state.configs[paletteDrag.type].shape} />
</div>
)}
</div>
);
}