diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 311a4562..ca9c9ab5 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -98,6 +98,7 @@ import useEditor, { selectSiteFloorplanContext, } from '../../store/use-editor' import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview' +import { useFloorplanMarquee } from '../../store/use-floorplan-marquee' import useInteractionScope, { useActiveHandleDrag, useEndpointReshape, @@ -368,14 +369,6 @@ type FloorplanSelectionBounds = { maxY: number } -type FloorplanMarqueeState = { - pointerId: number - startClientX: number - startClientY: number - startPlanPoint: WallPlanPoint - currentPlanPoint: WallPlanPoint -} - type LinkedWallSnapshot = { id: WallNode['id'] start: WallPlanPoint @@ -436,10 +429,13 @@ type GuideTransformDraft = { type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' +// The in-flight reference-scale measurement. Only the per-CLICK fields live +// here (guide + start anchor); the rubber-band's moving END is the shared +// `useFloorplanDraftPreview.cursorPoint` (set on every move anyway), so it +// never re-renders the panel — `FloorplanReferenceScaleDraftLine` reads it. type ReferenceScaleDraft = { guideId: GuideNode['id'] start: WallPlanPoint | null - cursor: WallPlanPoint | null } type PendingReferenceScale = { @@ -3404,16 +3400,11 @@ function FloorplanReferenceScaleLayer({ unitsPerPixel={unitsPerPixel} /> ))} - {draft?.start && draft.cursor && ( - )} @@ -3421,6 +3412,41 @@ function FloorplanReferenceScaleLayer({ ) } +// The live reference-scale rubber-band — split out of the layer so it can +// subscribe to the shared cursor store for its moving END. A per-move cursor +// update re-renders ONLY this line, never FloorplanPanel; the START anchor + +// render config arrive as props (set per click, never per move). +function FloorplanReferenceScaleDraftLine({ + palette, + start, + unit, + unitsPerPixel, +}: { + palette: FloorplanPalette + start: WallPlanPoint + unit: 'metric' | 'imperial' + unitsPerPixel: number +}) { + const cursor = useFloorplanDraftPreview((s) => s.cursorPoint) + if (!cursor) { + return null + } + + return ( + + ) +} + function FloorplanGuideSelectionOverlay({ guide, isDarkMode, @@ -4706,6 +4732,39 @@ function FloorplanDraftCursorLayer({ ) } +// Leaf overlay for the marquee (box-select) rectangle. Subscribes to the +// marquee store's moving corner so a per-move drag re-renders ONLY this layer, +// never the (~120-220ms) FloorplanPanel. The bounds math is pure (the +// module-scope `getFloorplanSelectionBounds` / `toSvgSelectionBounds`); the +// cursor colour is the one bit of panel config, passed as a prop. +function FloorplanMarqueeOverlay({ cursorColor }: { cursorColor: string }) { + const drag = useFloorplanMarquee((s) => s.drag) + const bounds = useMemo(() => { + if (!drag) { + return null + } + const dragDistance = Math.hypot( + drag.currentPlanPoint[0] - drag.startPlanPoint[0], + drag.currentPlanPoint[1] - drag.startPlanPoint[1], + ) + if (dragDistance <= 0) { + return null + } + return toSvgSelectionBounds( + getFloorplanSelectionBounds(drag.startPlanPoint, drag.currentPlanPoint), + ) + }, [drag]) + + return ( + + ) +} + // Thin subscriber wrapper for the coordinate-badge overlay: reads the hot // screen-space cursor position from the draft store so a per-`pointermove` // update re-renders only the badge, not FloorplanPanel. The remaining props @@ -5154,9 +5213,6 @@ export function FloorplanPanel({ const setGuideLocked = useEditor((s) => s.setGuideLocked) const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) const clearGuideUi = useEditor((s) => s.clearGuideUi) - const [floorplanMarqueeState, setFloorplanMarqueeState] = useState( - null, - ) const [shiftPressed, setShiftPressed] = useState(false) const [rotationModifierPressed, setRotationModifierPressed] = useState(false) const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) @@ -6026,35 +6082,6 @@ export function FloorplanPanel({ () => new Set([...selectedIds, ...previewSelectedIds]), [previewSelectedIds, selectedIds], ) - const activeMarqueeBounds = useMemo(() => { - if (!floorplanMarqueeState) { - return null - } - - return getFloorplanSelectionBounds( - floorplanMarqueeState.startPlanPoint, - floorplanMarqueeState.currentPlanPoint, - ) - }, [floorplanMarqueeState]) - const visibleMarqueeBounds = useMemo(() => { - if (!(floorplanMarqueeState && activeMarqueeBounds)) { - return null - } - - const dragDistance = Math.hypot( - floorplanMarqueeState.currentPlanPoint[0] - floorplanMarqueeState.startPlanPoint[0], - floorplanMarqueeState.currentPlanPoint[1] - floorplanMarqueeState.startPlanPoint[1], - ) - - return dragDistance > 0 ? activeMarqueeBounds : null - }, [activeMarqueeBounds, floorplanMarqueeState]) - const visibleSvgMarqueeBounds = useMemo(() => { - if (!visibleMarqueeBounds) { - return null - } - - return toSvgSelectionBounds(visibleMarqueeBounds) - }, [visibleMarqueeBounds]) const siteVertexHandles = useMemo(() => { if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) { return [] @@ -6901,7 +6928,6 @@ export function FloorplanPanel({ setReferenceScaleDraft({ guideId: guide.id, start: null, - cursor: null, }) setPendingReferenceScale(null) setMode('select') @@ -8769,17 +8795,11 @@ export function FloorplanPanel({ if (referenceScaleDraft) { emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event) + // The rubber-band's moving end IS this cursor point — the draft-line + // leaf reads it from the store, so no per-move panel-state write. setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint, ) - setReferenceScaleDraft((currentDraft) => - currentDraft - ? { - ...currentDraft, - cursor: planPoint, - } - : currentDraft, - ) return } @@ -9444,7 +9464,6 @@ export function FloorplanPanel({ setReferenceScaleDraft({ ...referenceScaleDraft, start: planPoint, - cursor: planPoint, }) setCursorPoint(planPoint) return @@ -10258,7 +10277,7 @@ export function FloorplanPanel({ setCursorPoint(snappedPoint) floorplanMarqueeSnapPointRef.current = snappedPoint syncPreviewSelectedIds([]) - setFloorplanMarqueeState({ + useFloorplanMarquee.getState().begin({ pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, @@ -10281,7 +10300,8 @@ export function FloorplanPanel({ }) } - if (floorplanMarqueeState?.pointerId !== event.pointerId) { + const marquee = useFloorplanMarquee.getState().drag + if (marquee?.pointerId !== event.pointerId) { return } @@ -10296,8 +10316,8 @@ export function FloorplanPanel({ setCursorPoint(snappedPoint) const dragDistance = Math.hypot( - event.clientX - floorplanMarqueeState.startClientX, - event.clientY - floorplanMarqueeState.startClientY, + event.clientX - marquee.startClientX, + event.clientY - marquee.startClientY, ) if ( @@ -10310,37 +10330,22 @@ export function FloorplanPanel({ floorplanMarqueeSnapPointRef.current = snappedPoint if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) { - const bounds = getFloorplanSelectionBounds( - floorplanMarqueeState.startPlanPoint, - snappedPoint, - ) + const bounds = getFloorplanSelectionBounds(marquee.startPlanPoint, snappedPoint) syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds)) } else { syncPreviewSelectedIds([]) } - setFloorplanMarqueeState((currentState) => { - if (!currentState || currentState.pointerId !== event.pointerId) { - return currentState - } - - return { - ...currentState, - currentPlanPoint: snappedPoint, - } - }) + // Advances the moving corner in the marquee store — re-renders only the + // marquee overlay leaf, never this panel. + useFloorplanMarquee.getState().setCurrent(snappedPoint) }, - [ - floorplanMarqueeState, - getFloorplanSelectionIdsInBounds, - getPlanPointFromClientPoint, - syncPreviewSelectedIds, - ], + [getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, syncPreviewSelectedIds], ) const handleMarqueePointerUp = useCallback( (event: ReactPointerEvent) => { - const marqueeState = floorplanMarqueeState + const marqueeState = useFloorplanMarquee.getState().drag if (!marqueeState || marqueeState.pointerId !== event.pointerId) { return } @@ -10376,13 +10381,12 @@ export function FloorplanPanel({ } syncPreviewSelectedIds([]) - setFloorplanMarqueeState(null) + useFloorplanMarquee.getState().reset() floorplanMarqueeSnapPointRef.current = null }, [ addFloorplanSelection, commitFloorplanSelection, - floorplanMarqueeState, getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, @@ -10393,7 +10397,7 @@ export function FloorplanPanel({ const handleMarqueePointerCancel = useCallback( (event: ReactPointerEvent) => { - if (floorplanMarqueeState?.pointerId !== event.pointerId) { + if (useFloorplanMarquee.getState().drag?.pointerId !== event.pointerId) { return } @@ -10401,18 +10405,18 @@ export function FloorplanPanel({ event.currentTarget.releasePointerCapture(event.pointerId) } - setFloorplanMarqueeState(null) + useFloorplanMarquee.getState().reset() setFloorplanCursorPosition(null) floorplanMarqueeSnapPointRef.current = null syncPreviewSelectedIds([]) setCursorPoint(null) }, - [floorplanMarqueeState?.pointerId, syncPreviewSelectedIds], + [syncPreviewSelectedIds], ) useEffect(() => { if (!isMarqueeSelectionToolActive) { - setFloorplanMarqueeState(null) + useFloorplanMarquee.getState().reset() floorplanMarqueeSnapPointRef.current = null syncPreviewSelectedIds([]) if (mode === 'select') { @@ -10995,12 +10999,7 @@ export function FloorplanPanel({ the alignment guides. */} - + {/* This shared layer now carries only the per-CLICK draft anchors (reference-scale start + committed polygon vertices). The diff --git a/packages/editor/src/store/use-floorplan-marquee.ts b/packages/editor/src/store/use-floorplan-marquee.ts new file mode 100644 index 00000000..023bef65 --- /dev/null +++ b/packages/editor/src/store/use-floorplan-marquee.ts @@ -0,0 +1,50 @@ +// Ephemeral store for the 2D floor-plan marquee (box-select) drag — the hot, +// per-pointer-move rectangle the select tool republishes on every move. It +// lives here, not in `FloorplanPanel`'s `useState`, for the same reason as +// `useFloorplanDraftPreview`: the panel is a ~10k-line component whose render +// costs ~120-220ms, so a `setState` per move made dragging a selection box +// re-render the whole panel. Producers write via `getState()` (no panel +// re-render); the small marquee overlay leaf subscribes and re-renders alone. +// +// The whole drag struct lives here (not just the moving corner) so the +// pointer-move / -up handlers read it non-reactively via `getState()` and the +// panel never subscribes. Editor-only; reset on pointer-up / cancel / +// tool-inactive. + +import type { WallPlanPoint } from '@pascal-app/core' +import { create } from 'zustand' + +export type FloorplanMarqueeDrag = { + pointerId: number + startClientX: number + startClientY: number + startPlanPoint: WallPlanPoint + /** Moving corner under the cursor — the only field that changes per move. */ + currentPlanPoint: WallPlanPoint +} + +type FloorplanMarqueeState = { + drag: FloorplanMarqueeDrag | null + begin(drag: FloorplanMarqueeDrag): void + /** Advance the moving corner. No-ops (skips the store update, so the overlay + * doesn't re-render) when the snapped point is unchanged or no drag is open. */ + setCurrent(point: WallPlanPoint): void + reset(): void +} + +export const useFloorplanMarquee = create((set) => ({ + drag: null, + begin: (drag) => set({ drag }), + setCurrent: (point) => + set((state) => { + const prev = state.drag + if (!prev) return state + if (prev.currentPlanPoint[0] === point[0] && prev.currentPlanPoint[1] === point[1]) { + return state + } + return { drag: { ...prev, currentPlanPoint: point } } + }), + reset: () => set((state) => (state.drag === null ? state : { drag: null })), +})) + +export default useFloorplanMarquee