floorplan edits
This commit is contained in:
parent
f48dd1ef7c
commit
3685fabf9e
@ -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);
|
||||
|
||||
// Marquee‐selection 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 multi‐selection
|
||||
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;
|
||||
// don’t 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;
|
||||
|
||||
// next‐letter 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>
|
||||
|
Loading…
Reference in New Issue
Block a user