const { useState, useEffect, useRef, useCallback } = React; const MasterPlanViewer = ({ src, onClose }) => { const containerRef = useRef(null); const imageRef = useRef(null); const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [initialPinchDist, setInitialPinchDist] = useState(null); const [initialScale, setInitialScale] = useState(1); const minScale = 1; const maxScale = 12; // Increased to 12x for very high level HD zoom // Close on ESC useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Prevent background scroll useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); const clampPosition = (x, y, currentScale) => { if (!containerRef.current || !imageRef.current) return { x, y }; const container = containerRef.current.getBoundingClientRect(); const image = imageRef.current.getBoundingClientRect(); // The unscaled layout size of the image const layoutWidth = imageRef.current.offsetWidth; const layoutHeight = imageRef.current.offsetHeight; const scaledWidth = layoutWidth * currentScale; const scaledHeight = layoutHeight * currentScale; // Max allowed movement in each direction const maxX = Math.max(0, (scaledWidth - container.width) / 2); const maxY = Math.max(0, (scaledHeight - container.height) / 2); return { x: Math.min(Math.max(x, -maxX), maxX), y: Math.min(Math.max(y, -maxY), maxY) }; }; const handleZoom = (delta, clientX, clientY, isPinch = false) => { if (!containerRef.current) return; const container = containerRef.current.getBoundingClientRect(); // Relative position of zoom center to the container center const x = clientX - container.left - container.width / 2; const y = clientY - container.top - container.height / 2; setScale(prevScale => { let newScale = isPinch ? delta : prevScale + delta; newScale = Math.min(Math.max(newScale, minScale), maxScale); if (newScale === minScale) { setPosition({ x: 0, y: 0 }); return newScale; } // Adjust position to zoom into mouse/center const ratio = newScale / prevScale; const newX = position.x - (x - position.x) * (ratio - 1); const newY = position.y - (y - position.y) * (ratio - 1); setPosition(clampPosition(newX, newY, newScale)); return newScale; }); }; const handleWheel = (e) => { e.preventDefault(); const delta = e.deltaY < 0 ? 0.4 : -0.4; handleZoom(delta, e.clientX, e.clientY); }; const zoomIn = () => { if (containerRef.current) { const container = containerRef.current.getBoundingClientRect(); handleZoom(1.0, container.left + container.width / 2, container.top + container.height / 2); } }; const zoomOut = () => { if (containerRef.current) { const container = containerRef.current.getBoundingClientRect(); handleZoom(-1.0, container.left + container.width / 2, container.top + container.height / 2); } }; const getDistance = (touches) => { return Math.hypot( touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY ); }; // Dragging state using refs for instant updates without re-renders lagging const isDraggingRef = useRef(false); const dragStartRef = useRef({ x: 0, y: 0 }); const handlePointerDown = useCallback((e) => { // Only allow left click (button 0) or touch if (e.button !== 0 && e.pointerType === 'mouse') return; if (scale <= minScale) return; e.preventDefault(); isDraggingRef.current = true; // Store where the mouse is relative to the CURRENT position dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y }; // Force cursor update if (containerRef.current) containerRef.current.style.cursor = 'grabbing'; }, [scale, position]); useEffect(() => { const handlePointerMove = (e) => { if (!isDraggingRef.current) return; e.preventDefault(); const newX = e.clientX - dragStartRef.current.x; const newY = e.clientY - dragStartRef.current.y; // Use the functional state update so we always have the latest scale setPosition(prevPos => clampPosition(newX, newY, scale)); }; const handlePointerUp = (e) => { if (isDraggingRef.current) { isDraggingRef.current = false; if (containerRef.current) containerRef.current.style.cursor = scale > minScale ? 'grab' : 'zoom-in'; } }; window.addEventListener('pointermove', handlePointerMove, { passive: false }); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; }, [scale]); // Touch specific (for pinch-to-zoom) const handleTouchStart = (e) => { if (e.touches.length === 2) { isDraggingRef.current = false; // Cancel drag if zooming setInitialPinchDist(getDistance(e.touches)); setInitialScale(scale); } }; const handleTouchMove = (e) => { if (e.touches.length === 2 && initialPinchDist) { e.preventDefault(); // prevent native scroll const dist = getDistance(e.touches); const zoomCenter = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; const newScale = initialScale * (dist / initialPinchDist); handleZoom(newScale, zoomCenter.x, zoomCenter.y, true); } }; const handleTouchEnd = () => { setInitialPinchDist(null); }; const handleDoubleClick = () => { setScale(minScale); setPosition({ x: 0, y: 0 }); }; // Attach non-passive wheel event listener manually for correct preventDefault useEffect(() => { const el = containerRef.current; if (el) { el.addEventListener('wheel', handleWheel, { passive: false }); } return () => { if (el) el.removeEventListener('wheel', handleWheel); }; }, [scale, position]); return ReactDOM.createPortal(
{/* Zoom Controls */}
{/* Close Button */} {/* Image Wrapper */}
Masterplan Viewer minScale ? 'grab' : 'zoom-in', transition: isDraggingRef.current ? 'none' : 'transform 0.25s cubic-bezier(0.2, 0, 0, 1)' }} />
, document.body ); }; window.MasterPlanViewer = MasterPlanViewer;