floorplan edits
This commit is contained in:
parent
f48dd1ef7c
commit
3685fabf9e
@ -11,10 +11,30 @@ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|||||||
|
|
||||||
export default function FloorPlan() {
|
export default function FloorPlan() {
|
||||||
|
|
||||||
// at the top of FloorPlan()
|
|
||||||
const [showEditSectionsModal, setShowEditSectionsModal] = useState(false);
|
const [showEditSectionsModal, setShowEditSectionsModal] = useState(false);
|
||||||
const [selectedPositionForEditing, setSelectedPositionForEditing] = useState<StockPosition | null>(null);
|
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”
|
// fire this when RackModalDetails tells us “user clicked a position”
|
||||||
const handleOpenSections = async (posId: number) => {
|
const handleOpenSections = async (posId: number) => {
|
||||||
try {
|
try {
|
||||||
@ -105,6 +125,20 @@ export default function FloorPlan() {
|
|||||||
}
|
}
|
||||||
}, [showEditSectionsModal, editModal.visible]);
|
}, [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)
|
// Load a room's layout (or show create modal)
|
||||||
const loadLayout = async (roomId: number) => {
|
const loadLayout = async (roomId: number) => {
|
||||||
try {
|
try {
|
||||||
@ -130,18 +164,6 @@ export default function FloorPlan() {
|
|||||||
if (selectedRoom) loadLayout(selectedRoom.room_id);
|
if (selectedRoom) loadLayout(selectedRoom.room_id);
|
||||||
}, [selectedRoom]);
|
}, [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
|
// AABB intersection for rack-on-line test
|
||||||
const intersects = (r: LayoutItem, l: LayoutLine) =>
|
const intersects = (r: LayoutItem, l: LayoutLine) =>
|
||||||
@ -181,14 +203,16 @@ export default function FloorPlan() {
|
|||||||
const h = Math.max(5, node.height() * scaleY);
|
const h = Math.max(5, node.height() * scaleY);
|
||||||
node.scaleX(1); node.scaleY(1);
|
node.scaleX(1); node.scaleY(1);
|
||||||
|
|
||||||
|
// In onTransformEnd, after node.scale reset:
|
||||||
|
const rot = node.rotation();
|
||||||
if (selectedType === 'line') {
|
if (selectedType === 'line') {
|
||||||
setLayoutLines(ls => ls.map(l => l.id === id ? {
|
setLayoutLines(ls => ls.map(l =>
|
||||||
...l, x: node.x(), y: node.y(), width: w, height: h
|
l.id === id ? { ...l, x: node.x(), y: node.y(), width: w, height: h, rotation: rot } : l
|
||||||
} : l));
|
));
|
||||||
} else {
|
} else {
|
||||||
setLayoutItems(is => is.map(i => i.id === id ? {
|
setLayoutItems(is => is.map(i =>
|
||||||
...i, x: node.x(), y: node.y(), width: w, height: h
|
i.id === id ? { ...i, x: node.x(), y: node.y(), width: w, height: h, rotation: rot } : i
|
||||||
} : i));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -214,7 +238,9 @@ export default function FloorPlan() {
|
|||||||
setLayoutLines(ls => [...ls, {
|
setLayoutLines(ls => [...ls, {
|
||||||
id, dbId: null,
|
id, dbId: null,
|
||||||
name: `Line ${letter}`, symbol: letter,
|
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);
|
setSelectedId(id);
|
||||||
setSelectedType('line');
|
setSelectedType('line');
|
||||||
@ -233,7 +259,8 @@ export default function FloorPlan() {
|
|||||||
name: `Rack ${num}`, symbol: String(num),
|
name: `Rack ${num}`, symbol: String(num),
|
||||||
x: line.x + 10, y: line.y + line.height + 10,
|
x: line.x + 10, y: line.y + line.height + 10,
|
||||||
width: 60, height: 60, fill: 'yellow',
|
width: 60, height: 60, fill: 'yellow',
|
||||||
lineId: line.id
|
lineId: line.id,
|
||||||
|
rotation: 0
|
||||||
}]);
|
}]);
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
setSelectedType('rack');
|
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 (
|
return (
|
||||||
<AppLayout title="Floor Plan">
|
<AppLayout title="Floor Plan">
|
||||||
<div className="p-6 flex flex-col items-center space-y-4">
|
<div className="p-6 flex flex-col items-center space-y-4">
|
||||||
@ -492,9 +709,17 @@ export default function FloorPlan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* 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}>
|
<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 && (
|
{bgImage && (
|
||||||
<KonvaImage
|
<KonvaImage
|
||||||
image={bgImage}
|
image={bgImage}
|
||||||
@ -512,8 +737,13 @@ export default function FloorPlan() {
|
|||||||
x={line.x} y={line.y}
|
x={line.x} y={line.y}
|
||||||
width={line.width} height={line.height}
|
width={line.width} height={line.height}
|
||||||
fill={line.fill} stroke="black"
|
fill={line.fill} stroke="black"
|
||||||
|
rotation={line.rotation}
|
||||||
draggable
|
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')}
|
onDragEnd={e => onDragEnd(e, line.id, 'line')}
|
||||||
onTransformEnd={e => onTransformEnd(e, line.id)}
|
onTransformEnd={e => onTransformEnd(e, line.id)}
|
||||||
/>
|
/>
|
||||||
@ -531,8 +761,13 @@ export default function FloorPlan() {
|
|||||||
fill={item.fill}
|
fill={item.fill}
|
||||||
stroke={item.lineId ? 'black' : 'red'}
|
stroke={item.lineId ? 'black' : 'red'}
|
||||||
strokeWidth={item.lineId ? 1 : 2}
|
strokeWidth={item.lineId ? 1 : 2}
|
||||||
|
rotation={item.rotation}
|
||||||
draggable
|
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')}
|
onDragEnd={e => onDragEnd(e, item.id, 'rack')}
|
||||||
onTransformEnd={e => onTransformEnd(e, item.id)}
|
onTransformEnd={e => onTransformEnd(e, item.id)}
|
||||||
/>
|
/>
|
||||||
@ -540,6 +775,25 @@ export default function FloorPlan() {
|
|||||||
</React.Fragment>
|
</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} />
|
<Transformer ref={transformerRef} />
|
||||||
</Layer>
|
</Layer>
|
||||||
</Stage>
|
</Stage>
|
||||||
|
Loading…
Reference in New Issue
Block a user