From 3685fabf9e6ab85f2c028078dc5fae10dc257e21 Mon Sep 17 00:00:00 2001 From: t0is Date: Wed, 23 Jul 2025 10:53:24 +0200 Subject: [PATCH] floorplan edits --- resources/js/Pages/FloorPlan.tsx | 304 ++++++++++++++++++++++++++++--- 1 file changed, 279 insertions(+), 25 deletions(-) diff --git a/resources/js/Pages/FloorPlan.tsx b/resources/js/Pages/FloorPlan.tsx index ae8f4a1..0c82c4b 100644 --- a/resources/js/Pages/FloorPlan.tsx +++ b/resources/js/Pages/FloorPlan.tsx @@ -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(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(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 = {}; + + // 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> = {}; + 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 (
@@ -492,9 +709,17 @@ export default function FloorPlan() {
{/* Canvas */} - setSelectedId(null)}> + - + {bgImage && ( { 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() { ))} + {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 ( + + ); + })()} +