/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Measure tool panel UI (measurement list, controls) */ import React, { useCallback, useState, useEffect } from 'react'; import { X, Trash2, Ruler, ChevronDown, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useViewerStore, type Measurement } from '@/store'; import { MeasurementOverlays } from './MeasurementVisuals'; import { formatDistance } from './formatDistance'; import { useDraggablePanel } from '@/hooks/useDraggablePanel'; export function MeasureOverlay() { const measurements = useViewerStore((s) => s.measurements); const pendingMeasurePoint = useViewerStore((s) => s.pendingMeasurePoint); const activeMeasurement = useViewerStore((s) => s.activeMeasurement); const snapTarget = useViewerStore((s) => s.snapTarget); const snapVisualization = useViewerStore((s) => s.snapVisualization); const snapEnabled = useViewerStore((s) => s.snapEnabled); const measurementConstraintEdge = useViewerStore((s) => s.measurementConstraintEdge); const toggleSnap = useViewerStore((s) => s.toggleSnap); const deleteMeasurement = useViewerStore((s) => s.deleteMeasurement); const clearMeasurements = useViewerStore((s) => s.clearMeasurements); const setActiveTool = useViewerStore((s) => s.setActiveTool); const projectToScreen = useViewerStore((s) => s.cameraCallbacks.projectToScreen); // Track cursor position in ref (no re-renders on mouse move) const cursorPosRef = React.useRef<{ x: number; y: number } | null>(null); // Only update snap indicator position when snap target changes (not on every cursor move) const [snapIndicatorPos, setSnapIndicatorPos] = useState<{ x: number; y: number } | null>(null); // Panel collapsed by default for minimal UI const [isPanelCollapsed, setIsPanelCollapsed] = useState(true); // Ref to the overlay container for coordinate conversion const overlayRef = React.useRef(null); // Update cursor position in ref (no re-renders) useEffect(() => { const handleMouseMove = (e: MouseEvent) => { // Convert page coords to overlay-relative coords for consistent SVG positioning const container = overlayRef.current?.parentElement; if (container) { const rect = container.getBoundingClientRect(); cursorPosRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; } else { cursorPosRef.current = { x: e.clientX, y: e.clientY }; } }; window.addEventListener('mousemove', handleMouseMove); return () => { window.removeEventListener('mousemove', handleMouseMove); }; }, []); // Update snap indicator position when snap target changes // Cursor position is stored in ref (no re-renders on mouse move) // Snap target changes already trigger re-renders, so indicator will update frequently enough useEffect(() => { if (snapTarget && cursorPosRef.current) { setSnapIndicatorPos(cursorPosRef.current); } else { setSnapIndicatorPos(null); } }, [snapTarget]); const handleClear = useCallback(() => { clearMeasurements(); }, [clearMeasurements]); const handleDeleteMeasurement = useCallback((id: string) => { deleteMeasurement(id); }, [deleteMeasurement]); const togglePanel = useCallback(() => { setIsPanelCollapsed(prev => !prev); }, []); const handleClose = useCallback(() => { setActiveTool('select'); }, [setActiveTool]); // Calculate total distance const totalDistance = measurements.reduce((sum, m) => sum + m.distance, 0); const panelRef = React.useRef(null); const drag = useDraggablePanel(panelRef); return ( <> {/* Hidden ref element for coordinate calculation */}
{/* Compact Measure Tool Panel */}
{/* Header: grip drags (issue #1107), title button collapses. */}
{measurements.length > 0 && ( )}
{/* Expandable content */} {!isPanelCollapsed && (
{measurements.length > 0 ? (
{measurements.map((m, i) => ( ))} {measurements.length > 1 && (
Total {formatDistance(totalDistance)}
)}
) : (
No measurements
)}
)}
{/* Instruction hint - brutalist style with snap-colored shadow */}
{activeMeasurement ? 'Release to complete' : 'Drag to measure'}
{/* Snap toggle - brutalist style */}
{/* Render measurement lines, labels, and snap indicators */} ); } interface MeasurementItemProps { measurement: Measurement; index: number; onDelete: (id: string) => void; } function MeasurementItem({ measurement, index, onDelete }: MeasurementItemProps) { return (
#{index + 1} {formatDistance(measurement.distance)}
); }