177 lines
5.1 KiB
TypeScript
177 lines
5.1 KiB
TypeScript
import { useState, type PointerEvent, type ReactNode } from "react";
|
|
import type { FurnitureType, FurnitureTypeConfig } from "../types";
|
|
import { ShapeIcon } from "./Icons";
|
|
|
|
const CLICK_THRESHOLD_PX = 5;
|
|
|
|
interface Props {
|
|
types: FurnitureTypeConfig[]; // in palette order
|
|
remaining: Record<FurnitureType, number>;
|
|
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
|
|
onOpenMenu: (type: FurnitureType) => void;
|
|
onNewCustom: () => void;
|
|
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
|
|
}
|
|
|
|
const tile: React.CSSProperties = {
|
|
width: "100%",
|
|
height: 84,
|
|
boxSizing: "border-box",
|
|
background: "#fff",
|
|
border: "1px solid #cfcfcf",
|
|
borderRadius: 8,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
cursor: "grab",
|
|
userSelect: "none",
|
|
touchAction: "none",
|
|
font: "12px system-ui, sans-serif",
|
|
color: "#444",
|
|
};
|
|
|
|
const MIN_W = 152;
|
|
const MAX_W = 600;
|
|
|
|
interface TileProps {
|
|
config: FurnitureTypeConfig;
|
|
remaining: number;
|
|
onStartDrag: Props["onStartDrag"];
|
|
onOpenMenu: Props["onOpenMenu"];
|
|
}
|
|
|
|
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
|
|
function Tile({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
|
const type = config.id;
|
|
const empty = remaining <= 0;
|
|
|
|
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
const { clientX: x0, clientY: y0 } = e;
|
|
let dragging = false;
|
|
const move = (ev: globalThis.PointerEvent) => {
|
|
if (dragging || empty) return;
|
|
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) >= CLICK_THRESHOLD_PX) {
|
|
dragging = true;
|
|
cleanup();
|
|
onStartDrag(type, ev.clientX, ev.clientY);
|
|
}
|
|
};
|
|
const up = (ev: globalThis.PointerEvent) => {
|
|
cleanup();
|
|
// Out-of-stock tiles can't be dragged, so any release over the tile counts as a click.
|
|
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < CLICK_THRESHOLD_PX || empty) {
|
|
onOpenMenu(type);
|
|
}
|
|
};
|
|
const cleanup = () => {
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", up);
|
|
};
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", up);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
aria-label={`${config.name} (${remaining} remaining)`}
|
|
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
|
|
onPointerDown={onPointerDown}
|
|
>
|
|
<span
|
|
style={{
|
|
position: "absolute",
|
|
top: 6,
|
|
right: 6,
|
|
minWidth: 20,
|
|
height: 20,
|
|
padding: "0 6px",
|
|
boxSizing: "border-box",
|
|
borderRadius: 10,
|
|
background: empty ? "#888" : "#2b2b2b",
|
|
color: "#fff",
|
|
font: "600 11px/20px system-ui, sans-serif",
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
{remaining}
|
|
</span>
|
|
<ShapeIcon shape={config.shape} />
|
|
{config.name}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NewCustomTile({ onClick }: { onClick: () => void }) {
|
|
return (
|
|
<div
|
|
role="button"
|
|
aria-label="New custom furniture"
|
|
onClick={onClick}
|
|
style={{ ...tile, border: "1.5px dashed #9a9a9a", background: "transparent", cursor: "pointer" }}
|
|
>
|
|
<span style={{ font: "300 30px/30px system-ui, sans-serif", color: "#666" }}>+</span>
|
|
Custom
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
|
|
const [width, setWidth] = useState(264);
|
|
|
|
const startResize = (e: PointerEvent) => {
|
|
e.preventDefault();
|
|
const startX = e.clientX;
|
|
const startW = width;
|
|
const move = (ev: globalThis.PointerEvent) =>
|
|
setWidth(Math.min(MAX_W, Math.max(MIN_W, startW + ev.clientX - startX)));
|
|
const up = () => {
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", up);
|
|
};
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", up);
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
style={{
|
|
position: "relative",
|
|
width,
|
|
flexShrink: 0,
|
|
borderRight: "1px solid #d4d4d4",
|
|
background: "#ececec",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
boxSizing: "border-box",
|
|
overflowY: "auto",
|
|
padding: 16,
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
|
|
alignContent: "start",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{panel ?? (
|
|
<>
|
|
{types.map((cfg) => (
|
|
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
|
|
))}
|
|
<NewCustomTile onClick={onNewCustom} />
|
|
</>
|
|
)}
|
|
</div>
|
|
<div
|
|
onPointerDown={startResize}
|
|
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
|
|
/>
|
|
</aside>
|
|
);
|
|
}
|