floorplan edits

This commit is contained in:
t0is 2025-07-23 10:53:24 +02:00
parent f48dd1ef7c
commit 3685fabf9e

View File

@ -11,10 +11,30 @@ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export default function FloorPlan() {
// at the top of FloorPlan()
const [showEditSectionsModal, setShowEditSectionsModal] = useState(false);
const [selectedPositionForEditing, setSelectedPositionForEditing] = useState<StockPosition | null>(null);
// Multi-select & copy/paste
const [selectedShapes, setSelectedShapes] = useState<
{ id: string; type: 'rack' | 'line' }[]
>([]);
const [copyBuffer, setCopyBuffer] = useState<{
lines: LayoutLine[];
racks: LayoutItem[];
} | null>(null);
// Marqueeselection rectangle
const [marquee, setMarquee] = useState({
visible: false,
x: 0,
y: 0,
width: 0,
height: 0,
});
const stageRef = useRef<any>(null);
// record where the drag began
const dragStart = useRef<{ x: number; y: number } | null>(null);
// fire this when RackModalDetails tells us “user clicked a position”
const handleOpenSections = async (posId: number) => {
try {
@ -105,6 +125,20 @@ export default function FloorPlan() {
}
}, [showEditSectionsModal, editModal.visible]);
useEffect(() => {
const tr = transformerRef.current;
if (!tr) return;
if (selectedShapes.length) {
const nodes = selectedShapes
.map(s => layerRef.current.findOne(`#${s.id}`))
.filter(n => !!n);
tr.nodes(nodes);
} else {
tr.nodes([]);
}
tr.getLayer().batchDraw();
}, [selectedShapes]);
// Load a room's layout (or show create modal)
const loadLayout = async (roomId: number) => {
try {
@ -130,18 +164,6 @@ export default function FloorPlan() {
if (selectedRoom) loadLayout(selectedRoom.room_id);
}, [selectedRoom]);
// Hook up the Konva Transformer to the selected shape
useEffect(() => {
const tr = transformerRef.current;
if (!tr) return;
if (selectedId) {
const node = layerRef.current.findOne(`#${selectedId}`);
node ? tr.nodes([node]) : tr.nodes([]);
} else {
tr.nodes([]);
}
tr.getLayer().batchDraw();
}, [selectedId]);
// AABB intersection for rack-on-line test
const intersects = (r: LayoutItem, l: LayoutLine) =>
@ -181,14 +203,16 @@ export default function FloorPlan() {
const h = Math.max(5, node.height() * scaleY);
node.scaleX(1); node.scaleY(1);
// In onTransformEnd, after node.scale reset:
const rot = node.rotation();
if (selectedType === 'line') {
setLayoutLines(ls => ls.map(l => l.id === id ? {
...l, x: node.x(), y: node.y(), width: w, height: h
} : l));
setLayoutLines(ls => ls.map(l =>
l.id === id ? { ...l, x: node.x(), y: node.y(), width: w, height: h, rotation: rot } : l
));
} else {
setLayoutItems(is => is.map(i => i.id === id ? {
...i, x: node.x(), y: node.y(), width: w, height: h
} : i));
setLayoutItems(is => is.map(i =>
i.id === id ? { ...i, x: node.x(), y: node.y(), width: w, height: h, rotation: rot } : i
));
}
};
@ -214,7 +238,9 @@ export default function FloorPlan() {
setLayoutLines(ls => [...ls, {
id, dbId: null,
name: `Line ${letter}`, symbol: letter,
x: 100, y: 100, width: 200, height: 20, fill: 'lightblue'
x:100, y:100, width:200, height:20,
fill:'lightblue',
rotation: 0
}]);
setSelectedId(id);
setSelectedType('line');
@ -233,7 +259,8 @@ export default function FloorPlan() {
name: `Rack ${num}`, symbol: String(num),
x: line.x + 10, y: line.y + line.height + 10,
width: 60, height: 60, fill: 'yellow',
lineId: line.id
lineId: line.id,
rotation: 0
}]);
setSelectedId(id);
setSelectedType('rack');
@ -467,6 +494,196 @@ export default function FloorPlan() {
}
};
// Shift-click or single-click to build multiselection
const onShapeClick = (e: any, id: string, type: 'rack' | 'line') => {
e.cancelBubble = true;
const isMulti = e.evt.shiftKey;
setSelectedShapes(current => {
if (!isMulti) return [{ id, type }];
const exists = current.find(s => s.id === id);
if (exists) return current.filter(s => s.id !== id);
return [...current, { id, type }];
});
};
const onStageClick = (e: any) => {
const stage = stageRef.current.getStage();
if (e.target === stage) setSelectedShapes([]);
};
const onStageMouseDown = (e: any) => {
if (e.target !== e.target.getStage()) return;
const stage = stageRef.current.getStage();
const pos = stage.getPointerPosition()!;
dragStart.current = pos;
// dont show marquee yet
setMarquee({ visible: false, x: pos.x, y: pos.y, width: 0, height: 0 });
setSelectedShapes([]);
};
const onStageMouseMove = (e: any) => {
if (!dragStart.current) return;
const stage = stageRef.current.getStage();
const pos = stage.getPointerPosition()!;
const dx = pos.x - dragStart.current.x;
const dy = pos.y - dragStart.current.y;
// only start showing after a small threshold
if (!marquee.visible) {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
setMarquee({
visible: true,
x: dragStart.current.x,
y: dragStart.current.y,
width: dx,
height: dy,
});
} else {
setMarquee(m => ({ ...m, width: dx, height: dy }));
}
};
const onStageMouseUp = () => {
if (marquee.visible) {
const box = {
x: Math.min(marquee.x, marquee.x + marquee.width),
y: Math.min(marquee.y, marquee.y + marquee.height),
width: Math.abs(marquee.width),
height: Math.abs(marquee.height),
};
const rectIntersect = (r1: any, r2: any) =>
r1.x < r2.x + r2.width &&
r1.x + r1.width > r2.x &&
r1.y < r2.y + r2.height &&
r1.y + r1.height > r2.y;
// get *all* Rect nodes
const allRects = layerRef.current.find('Rect');
const hits = allRects
.filter((node: any) => {
const id = node.id();
// only pick actual racks/lines (they all start with "rack-" or "line-")
if (!id.startsWith('rack-') && !id.startsWith('line-')) return false;
const r = node.getClientRect();
return rectIntersect(r, box);
});
setSelectedShapes(
hits.map((n: any) => ({
id: n.id(),
type: n.id().startsWith('line-') ? 'line' : 'rack',
}))
);
}
dragStart.current = null;
setMarquee(m => ({ ...m, visible: false }));
};
const handleCopy = () => {
if (!selectedShapes.length) return;
setCopyBuffer({
lines: selectedShapes
.filter(s => s.type === 'line')
.map(s => ({ ...layoutLines.find(l => l.id === s.id)! })),
racks: selectedShapes
.filter(s => s.type === 'rack')
.map(s => ({ ...layoutItems.find(r => r.id === s.id)! })),
});
};
const handlePaste = () => {
if (!copyBuffer) return;
// nextletter for lines
const usedLetters = new Set(layoutLines.map(l => l.symbol));
const nextLetter = () => {
for (let c of ALPHABET) if (!usedLetters.has(c)) {
usedLetters.add(c);
return c;
}
return ALPHABET[0];
};
// map old line IDs → new IDs
const lineMap: Record<string,string> = {};
// create new lines
const newLines = copyBuffer.lines.map(orig => {
const sym = nextLetter();
const id = `line-${Date.now()}-${Math.random().toString(36).slice(2)}`;
lineMap[orig.id] = id;
return {
...orig,
rotation: orig.rotation ?? 0,
id,
dbId: null,
symbol: sym,
name: `Line ${sym}`,
x: orig.x + 10,
y: orig.y + 10,
};
});
setLayoutLines(ls => [...ls, ...newLines]);
// helper for next rack number per line
const usedNums: Record<string, Set<number>> = {};
layoutItems.forEach(r => {
const n = parseInt(r.symbol) || 0;
(usedNums[r.lineId] ||= new Set()).add(n);
});
const nextRackNum = (lineId: string) => {
const set = (usedNums[lineId] ||= new Set());
let n = 1;
while (set.has(n)) n++;
set.add(n);
return n;
};
// create new racks
const newRacks = copyBuffer.racks.map(orig => {
const target = lineMap[orig.lineId] || orig.lineId;
const num = nextRackNum(target);
const id = `rack-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return {
...orig,
rotation: orig.rotation ?? 0,
id,
dbId: null,
lineId: target,
symbol: String(num),
name: `Rack ${num}`,
x: orig.x + 10,
y: orig.y + 10,
};
});
setLayoutItems(rs => [...rs, ...newRacks]);
// select the new shapes
setSelectedShapes([
...newLines.map(l => ({ id: l.id, type: 'line' as const })),
...newRacks.map(r => ({ id: r.id, type: 'rack' as const })),
]);
};
useEffect(() => {
const down = (e: KeyboardEvent) => {
const isMac = navigator.platform.startsWith('Mac');
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === 'c') {
e.preventDefault();
handleCopy();
}
if (mod && e.key === 'v') {
e.preventDefault();
handlePaste();
}
};
window.addEventListener('keydown', down);
return () => window.removeEventListener('keydown', down);
}, [copyBuffer, selectedShapes, layoutLines, layoutItems]);
return (
<AppLayout title="Floor Plan">
<div className="p-6 flex flex-col items-center space-y-4">
@ -492,9 +709,17 @@ export default function FloorPlan() {
</div>
{/* Canvas */}
<Stage width={1300} height={900} onClick={() => setSelectedId(null)}>
<Stage
width={1300}
height={900}
ref={stageRef}
onMouseDown={onStageMouseDown}
onMouseMove={onStageMouseMove}
onMouseUp={onStageMouseUp}
onClick={onStageClick}
>
<Layer ref={layerRef}>
<Rect x={0} y={0} width={1300} height={900} fill="white" />
<Rect x={0} y={0} width={1300} height={900} fill="white" listening={false}/>
{bgImage && (
<KonvaImage
image={bgImage}
@ -512,8 +737,13 @@ export default function FloorPlan() {
x={line.x} y={line.y}
width={line.width} height={line.height}
fill={line.fill} stroke="black"
rotation={line.rotation}
draggable
onClick={e => { onSelect(e, line.id, 'line'); openEditModal(line, 'line'); }}
// onClick={e => { onSelect(e, line.id, 'line'); openEditModal(line, 'line'); }}
onClick={e => {
onShapeClick(e, line.id, 'line');
openEditModal(line, 'line');
}}
onDragEnd={e => onDragEnd(e, line.id, 'line')}
onTransformEnd={e => onTransformEnd(e, line.id)}
/>
@ -531,8 +761,13 @@ export default function FloorPlan() {
fill={item.fill}
stroke={item.lineId ? 'black' : 'red'}
strokeWidth={item.lineId ? 1 : 2}
rotation={item.rotation}
draggable
onClick={e => { onSelect(e, item.id, 'rack'); openEditModal(item, 'rack'); }}
// onClick={e => { onSelect(e, item.id, 'rack'); openEditModal(item, 'rack'); }}
onClick={e => {
onShapeClick(e, item.id, 'rack');
openEditModal(item, 'rack');
}}
onDragEnd={e => onDragEnd(e, item.id, 'rack')}
onTransformEnd={e => onTransformEnd(e, item.id)}
/>
@ -540,6 +775,25 @@ export default function FloorPlan() {
</React.Fragment>
))}
{marquee.visible && (() => {
const x = marquee.width < 0 ? marquee.x + marquee.width : marquee.x;
const y = marquee.height < 0 ? marquee.y + marquee.height : marquee.y;
const w = Math.abs(marquee.width);
const h = Math.abs(marquee.height);
return (
<Rect
x={x}
y={y}
width={w}
height={h}
fill="rgba(0,0,255,0.1)"
stroke="blue"
dash={[4,4]}
listening={false}
/>
);
})()}
<Transformer ref={transformerRef} />
</Layer>
</Stage>