third commit
This commit is contained in:
+128
-37
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { DEFAULT_SIZES, fitScale, isValidPlacement, clampToRoom } from "./geometry";
|
||||
import { clampToRoom, fitScale, isValidPlacement, tryUpdate } from "./geometry";
|
||||
import { initialState, reducer } from "./store";
|
||||
import { FURNITURE_TYPES, type FurnitureType } from "./types";
|
||||
import { remaining, placedCount } from "./inventory";
|
||||
import { Palette, TYPE_ICON } from "./components/Palette";
|
||||
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;
|
||||
@@ -17,14 +20,53 @@ interface PaletteDrag {
|
||||
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 [menu, setMenu] = useState<FurnitureType | null>(null);
|
||||
const closeMenu = useCallback(() => setMenu(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(() => {
|
||||
@@ -37,10 +79,9 @@ export default function App() {
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [state.room]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
// Cursor position -> room meters (item centered on cursor), or null if outside the floor.
|
||||
const toRoomRect = (d: PaletteDrag) => {
|
||||
// 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.
|
||||
@@ -48,11 +89,8 @@ export default function App() {
|
||||
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 { width, height } = DEFAULT_SIZES[d.type];
|
||||
return clampToRoom(
|
||||
{ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height },
|
||||
state.room,
|
||||
);
|
||||
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(() => {
|
||||
@@ -67,7 +105,7 @@ export default function App() {
|
||||
remaining(state.items, type, state.configs[type].available) > 0 &&
|
||||
isValidPlacement(r, state.items, state.room)
|
||||
) {
|
||||
dispatch({ kind: "add", id: `item-${nextId.current++}`, type, x: r.x, y: r.y });
|
||||
dispatch({ kind: "add", id: `item-${nextItemId.current++}`, type, x: r.x, y: r.y });
|
||||
} // outside the room or invalid: cancel
|
||||
setPaletteDrag(null);
|
||||
};
|
||||
@@ -87,37 +125,90 @@ export default function App() {
|
||||
if (!paletteDrag) return null;
|
||||
const r = toRoomRect(paletteDrag);
|
||||
if (!r) return null;
|
||||
return { type: paletteDrag.type, ...r, valid: isValidPlacement(r, state.items, state.room) };
|
||||
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) => {
|
||||
setMenu(null);
|
||||
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(
|
||||
FURNITURE_TYPES.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
|
||||
) as Record<FurnitureType, number>;
|
||||
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={setMenu}
|
||||
panel={
|
||||
menu && (
|
||||
<TypeMenu
|
||||
type={menu}
|
||||
config={state.configs[menu]}
|
||||
placed={placedCount(state.items, menu)}
|
||||
onSetAvailable={(value) => dispatch({ kind: "setAvailable", type: menu, value })}
|
||||
onSetSeats={(value) => dispatch({ kind: "setSeats", value })}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
||||
onNewCustom={() => openSidebar({ kind: "custom" })}
|
||||
panel={panel}
|
||||
/>
|
||||
<main
|
||||
ref={mainRef}
|
||||
@@ -127,10 +218,13 @@ export default function App() {
|
||||
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: "resize", id, ...r })}
|
||||
onResize={(id, r) => dispatch({ kind: "update", id, patch: { ...r } })}
|
||||
/>
|
||||
<ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} />
|
||||
</main>
|
||||
@@ -145,10 +239,7 @@ export default function App() {
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = TYPE_ICON[paletteDrag.type];
|
||||
return <Icon />;
|
||||
})()}
|
||||
<ShapeIcon shape={state.configs[paletteDrag.type].shape} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user