third commit
This commit is contained in:
+64
-35
@@ -1,12 +1,12 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group, Rect } from "react-konva";
|
||||
import { Stage, Layer, Group, Rect, Path } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
import { MIN_SCREEN_SIZE, RESIZABLE, isValidPlacement, clampToRoom, resizeRect } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureType, Rect as RectM, Room as RoomT } from "../types";
|
||||
import { FurnitureShape } from "./FurnitureShape";
|
||||
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
|
||||
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
||||
|
||||
export interface Preview {
|
||||
type: FurnitureType;
|
||||
shape: Shape;
|
||||
x: number; // meters
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -17,9 +17,12 @@ export interface Preview {
|
||||
interface Props {
|
||||
room: RoomT;
|
||||
items: FurnitureItem[];
|
||||
configs: Record<string, FurnitureTypeConfig>;
|
||||
preview: Preview | null; // new item being dragged in from the palette
|
||||
pxPerM: number;
|
||||
floorRef: RefObject<HTMLDivElement | null>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onMove: (id: string, x: number, y: number) => void;
|
||||
onResize: (id: string, rect: RectM) => void;
|
||||
}
|
||||
@@ -36,21 +39,34 @@ interface ResizeInfo {
|
||||
}
|
||||
|
||||
const HANDLE_PX = 10;
|
||||
// Top-down view: a screen only changes in length, its thickness is fixed.
|
||||
const EDGES: ("left" | "right")[] = ["left", "right"];
|
||||
const ENDS: ("left" | "right")[] = ["left", "right"];
|
||||
|
||||
export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize }: Props) {
|
||||
function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
|
||||
// 24x24 padlock centered in the item, counter-rotated so it stays upright.
|
||||
const s = Math.min(1, Math.max(0.35, (Math.min(w, h) * 0.7) / 24));
|
||||
return (
|
||||
<Group x={w / 2} y={h / 2} offsetX={12} offsetY={12} scaleX={s} scaleY={s} rotation={-rotation} listening={false}>
|
||||
<Path data="M8 11V8a4 4 0 0 1 8 0v3" stroke="#1a1a1a" strokeWidth={2.5} />
|
||||
<Rect x={5} y={11} width={14} height={10} cornerRadius={2} fill="#fff" stroke="#1a1a1a" strokeWidth={2} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
|
||||
const [drag, setDrag] = useState<DragInfo | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
||||
const resizeStart = useRef<RectM | null>(null);
|
||||
const w = room.width * pxPerM;
|
||||
const h = room.height * pxPerM;
|
||||
|
||||
// Where the item would land if dropped now: clamped to walls.
|
||||
const candidate = (item: FurnitureItem, node: Konva.Node) =>
|
||||
// The group's origin is the item's CENTER (offset puts the rect's center there), so rotation pivots on it.
|
||||
const candidate = (item: FurnitureItem, node: Konva.Node): FurnitureItem =>
|
||||
clampToRoom(
|
||||
{ x: node.x() / pxPerM, y: node.y() / pxPerM, width: item.width, height: item.height },
|
||||
{
|
||||
...item,
|
||||
x: node.x() / pxPerM - item.width / 2,
|
||||
y: node.y() / pxPerM - item.height / 2,
|
||||
},
|
||||
room,
|
||||
);
|
||||
|
||||
@@ -70,31 +86,42 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
<Stage
|
||||
width={w}
|
||||
height={h}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
>
|
||||
<Layer>
|
||||
{items.map((item) => {
|
||||
const shape = configs[item.type]?.shape ?? "chair";
|
||||
const isResizing = resize?.id === item.id;
|
||||
const r: RectM = isResizing ? resize.rect : item;
|
||||
const outline = isResizing
|
||||
const outline: Outline = isResizing
|
||||
? resize.valid ? "valid" : "invalid"
|
||||
: drag?.id === item.id
|
||||
? drag.valid ? "valid" : "invalid"
|
||||
: selectedId === item.id ? "selected" : "normal";
|
||||
const half = boundsOf({ ...r, rotation: item.rotation });
|
||||
const wpx = r.width * pxPerM;
|
||||
const hpx = r.height * pxPerM;
|
||||
return (
|
||||
<Group
|
||||
key={item.id}
|
||||
x={r.x * pxPerM}
|
||||
y={r.y * pxPerM}
|
||||
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
x={(r.x + r.width / 2) * pxPerM}
|
||||
y={(r.y + r.height / 2) * pxPerM}
|
||||
offsetX={wpx / 2}
|
||||
offsetY={hpx / 2}
|
||||
rotation={item.rotation}
|
||||
draggable={!item.locked}
|
||||
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => ({
|
||||
x: Math.min(Math.max(pos.x, 0), w - item.width * pxPerM),
|
||||
y: Math.min(Math.max(pos.y, 0), h - item.height * pxPerM),
|
||||
})}
|
||||
onMouseDown={() => onSelect(item.id)}
|
||||
onTouchStart={() => onSelect(item.id)}
|
||||
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => {
|
||||
const hx = (half.width / 2) * pxPerM;
|
||||
const hy = (half.height / 2) * pxPerM;
|
||||
return {
|
||||
x: Math.min(Math.max(pos.x, hx), w - hx),
|
||||
y: Math.min(Math.max(pos.y, hy), h - hy),
|
||||
};
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.target.moveToTop();
|
||||
}}
|
||||
@@ -107,31 +134,34 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
const c = candidate(item, node);
|
||||
const ok = isValidPlacement(c, items, room, item.id);
|
||||
const final = ok ? c : item; // snap back to last valid position
|
||||
node.position({ x: final.x * pxPerM, y: final.y * pxPerM });
|
||||
node.position({ x: (final.x + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM });
|
||||
if (ok) onMove(item.id, c.x, c.y);
|
||||
setDrag(null);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "grab";
|
||||
if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
>
|
||||
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
<FurnitureShape shape={shape} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
{item.locked && <LockIcon w={wpx} h={hpx} rotation={item.rotation} />}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => {
|
||||
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null;
|
||||
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
||||
const isScreen = configs[item.type]?.shape === "screen";
|
||||
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
|
||||
const r: RectM = resize?.id === item.id ? resize.rect : item;
|
||||
const mid: Record<"left" | "right", readonly [number, number]> = {
|
||||
const mid = {
|
||||
left: [r.x, r.y + r.height / 2],
|
||||
right: [r.x + r.width, r.y + r.height / 2],
|
||||
};
|
||||
return EDGES.map((edge) => (
|
||||
} as const;
|
||||
return ENDS.map((edge) => (
|
||||
<Rect
|
||||
key={`${item.id}-${edge}`}
|
||||
x={mid[edge][0] * pxPerM}
|
||||
@@ -158,8 +188,8 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
const start = resizeStart.current;
|
||||
if (!p || !start) return;
|
||||
const next = resizeRect(start, edge, p.x / pxPerM, p.y / pxPerM, room, MIN_SCREEN_SIZE);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement(next, items, room, item.id) });
|
||||
const next = resizeLength(start, edge, p.x / pxPerM, room);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
|
||||
@@ -180,7 +210,7 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
{preview && (
|
||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||
<FurnitureShape
|
||||
type={preview.type}
|
||||
shape={preview.shape}
|
||||
width={preview.width}
|
||||
height={preview.height}
|
||||
pxPerM={pxPerM}
|
||||
@@ -194,4 +224,3 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user