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