perf(editor): move 2D marquee + reference-scale draft out of panel state
The last two hot per-pointer-move 2D states still lived in FloorplanPanel's useState, re-rendering the ~10k-line panel on every move: - marquee (box-select): the whole drag struct moves to a dedicated use-floorplan-marquee store; down/move/up/cancel read+write it via getState() (panel holds nothing), and a FloorplanMarqueeOverlay leaf subscribes to the moving corner and renders the rect alone. Drops the 3 bounds memos + the useState. - reference-scale: the rubber-band's moving end was always equal to the shared cursorPoint (written every move anyway), so drop the `cursor` field from the draft and read it from useFloorplanDraftPreview in a new FloorplanReferenceScaleDraftLine leaf. The draft now carries only the per-click guide + start anchor, so it no longer re-renders the panel. Closes out the 2D edition perf pass — every build/edit/select hot path now writes a store, not panel state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
84cdf4519d
commit
0fb3604586
@@ -98,6 +98,7 @@ import useEditor, {
|
|||||||
selectSiteFloorplanContext,
|
selectSiteFloorplanContext,
|
||||||
} from '../../store/use-editor'
|
} from '../../store/use-editor'
|
||||||
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
|
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
|
||||||
|
import { useFloorplanMarquee } from '../../store/use-floorplan-marquee'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
useActiveHandleDrag,
|
useActiveHandleDrag,
|
||||||
useEndpointReshape,
|
useEndpointReshape,
|
||||||
@@ -368,14 +369,6 @@ type FloorplanSelectionBounds = {
|
|||||||
maxY: number
|
maxY: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FloorplanMarqueeState = {
|
|
||||||
pointerId: number
|
|
||||||
startClientX: number
|
|
||||||
startClientY: number
|
|
||||||
startPlanPoint: WallPlanPoint
|
|
||||||
currentPlanPoint: WallPlanPoint
|
|
||||||
}
|
|
||||||
|
|
||||||
type LinkedWallSnapshot = {
|
type LinkedWallSnapshot = {
|
||||||
id: WallNode['id']
|
id: WallNode['id']
|
||||||
start: WallPlanPoint
|
start: WallPlanPoint
|
||||||
@@ -436,10 +429,13 @@ type GuideTransformDraft = {
|
|||||||
|
|
||||||
type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches'
|
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 = {
|
type ReferenceScaleDraft = {
|
||||||
guideId: GuideNode['id']
|
guideId: GuideNode['id']
|
||||||
start: WallPlanPoint | null
|
start: WallPlanPoint | null
|
||||||
cursor: WallPlanPoint | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingReferenceScale = {
|
type PendingReferenceScale = {
|
||||||
@@ -3404,16 +3400,11 @@ function FloorplanReferenceScaleLayer({
|
|||||||
unitsPerPixel={unitsPerPixel}
|
unitsPerPixel={unitsPerPixel}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{draft?.start && draft.cursor && (
|
{draft?.start && (
|
||||||
<FloorplanReferenceScaleLine
|
<FloorplanReferenceScaleDraftLine
|
||||||
end={draft.cursor}
|
|
||||||
isDraft
|
|
||||||
label={`Ref ${formatMeasurement(
|
|
||||||
Math.hypot(draft.cursor[0] - draft.start[0], draft.cursor[1] - draft.start[1]),
|
|
||||||
unit,
|
|
||||||
)}`}
|
|
||||||
palette={palette}
|
palette={palette}
|
||||||
start={draft.start}
|
start={draft.start}
|
||||||
|
unit={unit}
|
||||||
unitsPerPixel={unitsPerPixel}
|
unitsPerPixel={unitsPerPixel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -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 (
|
||||||
|
<FloorplanReferenceScaleLine
|
||||||
|
end={cursor}
|
||||||
|
isDraft
|
||||||
|
label={`Ref ${formatMeasurement(
|
||||||
|
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
|
||||||
|
unit,
|
||||||
|
)}`}
|
||||||
|
palette={palette}
|
||||||
|
start={start}
|
||||||
|
unitsPerPixel={unitsPerPixel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FloorplanGuideSelectionOverlay({
|
function FloorplanGuideSelectionOverlay({
|
||||||
guide,
|
guide,
|
||||||
isDarkMode,
|
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 (
|
||||||
|
<FloorplanMarqueeLayer
|
||||||
|
bounds={bounds}
|
||||||
|
cursorColor={cursorColor}
|
||||||
|
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
|
||||||
|
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Thin subscriber wrapper for the coordinate-badge overlay: reads the hot
|
// Thin subscriber wrapper for the coordinate-badge overlay: reads the hot
|
||||||
// screen-space cursor position from the draft store so a per-`pointermove`
|
// screen-space cursor position from the draft store so a per-`pointermove`
|
||||||
// update re-renders only the badge, not FloorplanPanel. The remaining props
|
// 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 setGuideLocked = useEditor((s) => s.setGuideLocked)
|
||||||
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
|
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
|
||||||
const clearGuideUi = useEditor((s) => s.clearGuideUi)
|
const clearGuideUi = useEditor((s) => s.clearGuideUi)
|
||||||
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [shiftPressed, setShiftPressed] = useState(false)
|
const [shiftPressed, setShiftPressed] = useState(false)
|
||||||
const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
|
const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
|
||||||
const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
|
const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
|
||||||
@@ -6026,35 +6082,6 @@ export function FloorplanPanel({
|
|||||||
() => new Set([...selectedIds, ...previewSelectedIds]),
|
() => new Set([...selectedIds, ...previewSelectedIds]),
|
||||||
[previewSelectedIds, selectedIds],
|
[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(() => {
|
const siteVertexHandles = useMemo(() => {
|
||||||
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
|
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
|
||||||
return []
|
return []
|
||||||
@@ -6901,7 +6928,6 @@ export function FloorplanPanel({
|
|||||||
setReferenceScaleDraft({
|
setReferenceScaleDraft({
|
||||||
guideId: guide.id,
|
guideId: guide.id,
|
||||||
start: null,
|
start: null,
|
||||||
cursor: null,
|
|
||||||
})
|
})
|
||||||
setPendingReferenceScale(null)
|
setPendingReferenceScale(null)
|
||||||
setMode('select')
|
setMode('select')
|
||||||
@@ -8769,17 +8795,11 @@ export function FloorplanPanel({
|
|||||||
if (referenceScaleDraft) {
|
if (referenceScaleDraft) {
|
||||||
emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event)
|
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) =>
|
setCursorPoint((previousPoint) =>
|
||||||
previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
|
previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
|
||||||
)
|
)
|
||||||
setReferenceScaleDraft((currentDraft) =>
|
|
||||||
currentDraft
|
|
||||||
? {
|
|
||||||
...currentDraft,
|
|
||||||
cursor: planPoint,
|
|
||||||
}
|
|
||||||
: currentDraft,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9444,7 +9464,6 @@ export function FloorplanPanel({
|
|||||||
setReferenceScaleDraft({
|
setReferenceScaleDraft({
|
||||||
...referenceScaleDraft,
|
...referenceScaleDraft,
|
||||||
start: planPoint,
|
start: planPoint,
|
||||||
cursor: planPoint,
|
|
||||||
})
|
})
|
||||||
setCursorPoint(planPoint)
|
setCursorPoint(planPoint)
|
||||||
return
|
return
|
||||||
@@ -10258,7 +10277,7 @@ export function FloorplanPanel({
|
|||||||
setCursorPoint(snappedPoint)
|
setCursorPoint(snappedPoint)
|
||||||
floorplanMarqueeSnapPointRef.current = snappedPoint
|
floorplanMarqueeSnapPointRef.current = snappedPoint
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
setFloorplanMarqueeState({
|
useFloorplanMarquee.getState().begin({
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startClientX: event.clientX,
|
startClientX: event.clientX,
|
||||||
startClientY: event.clientY,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10296,8 +10316,8 @@ export function FloorplanPanel({
|
|||||||
setCursorPoint(snappedPoint)
|
setCursorPoint(snappedPoint)
|
||||||
|
|
||||||
const dragDistance = Math.hypot(
|
const dragDistance = Math.hypot(
|
||||||
event.clientX - floorplanMarqueeState.startClientX,
|
event.clientX - marquee.startClientX,
|
||||||
event.clientY - floorplanMarqueeState.startClientY,
|
event.clientY - marquee.startClientY,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -10310,37 +10330,22 @@ export function FloorplanPanel({
|
|||||||
floorplanMarqueeSnapPointRef.current = snappedPoint
|
floorplanMarqueeSnapPointRef.current = snappedPoint
|
||||||
|
|
||||||
if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) {
|
if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) {
|
||||||
const bounds = getFloorplanSelectionBounds(
|
const bounds = getFloorplanSelectionBounds(marquee.startPlanPoint, snappedPoint)
|
||||||
floorplanMarqueeState.startPlanPoint,
|
|
||||||
snappedPoint,
|
|
||||||
)
|
|
||||||
syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds))
|
syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds))
|
||||||
} else {
|
} else {
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
}
|
}
|
||||||
|
|
||||||
setFloorplanMarqueeState((currentState) => {
|
// Advances the moving corner in the marquee store — re-renders only the
|
||||||
if (!currentState || currentState.pointerId !== event.pointerId) {
|
// marquee overlay leaf, never this panel.
|
||||||
return currentState
|
useFloorplanMarquee.getState().setCurrent(snappedPoint)
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
currentPlanPoint: snappedPoint,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[
|
[getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, syncPreviewSelectedIds],
|
||||||
floorplanMarqueeState,
|
|
||||||
getFloorplanSelectionIdsInBounds,
|
|
||||||
getPlanPointFromClientPoint,
|
|
||||||
syncPreviewSelectedIds,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMarqueePointerUp = useCallback(
|
const handleMarqueePointerUp = useCallback(
|
||||||
(event: ReactPointerEvent<SVGRectElement>) => {
|
(event: ReactPointerEvent<SVGRectElement>) => {
|
||||||
const marqueeState = floorplanMarqueeState
|
const marqueeState = useFloorplanMarquee.getState().drag
|
||||||
if (!marqueeState || marqueeState.pointerId !== event.pointerId) {
|
if (!marqueeState || marqueeState.pointerId !== event.pointerId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -10376,13 +10381,12 @@ export function FloorplanPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
setFloorplanMarqueeState(null)
|
useFloorplanMarquee.getState().reset()
|
||||||
floorplanMarqueeSnapPointRef.current = null
|
floorplanMarqueeSnapPointRef.current = null
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
addFloorplanSelection,
|
addFloorplanSelection,
|
||||||
commitFloorplanSelection,
|
commitFloorplanSelection,
|
||||||
floorplanMarqueeState,
|
|
||||||
getFloorplanHitIdAtPoint,
|
getFloorplanHitIdAtPoint,
|
||||||
getFloorplanSelectionIdsInBounds,
|
getFloorplanSelectionIdsInBounds,
|
||||||
getPlanPointFromClientPoint,
|
getPlanPointFromClientPoint,
|
||||||
@@ -10393,7 +10397,7 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
const handleMarqueePointerCancel = useCallback(
|
const handleMarqueePointerCancel = useCallback(
|
||||||
(event: ReactPointerEvent<SVGRectElement>) => {
|
(event: ReactPointerEvent<SVGRectElement>) => {
|
||||||
if (floorplanMarqueeState?.pointerId !== event.pointerId) {
|
if (useFloorplanMarquee.getState().drag?.pointerId !== event.pointerId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10401,18 +10405,18 @@ export function FloorplanPanel({
|
|||||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
setFloorplanMarqueeState(null)
|
useFloorplanMarquee.getState().reset()
|
||||||
setFloorplanCursorPosition(null)
|
setFloorplanCursorPosition(null)
|
||||||
floorplanMarqueeSnapPointRef.current = null
|
floorplanMarqueeSnapPointRef.current = null
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
setCursorPoint(null)
|
setCursorPoint(null)
|
||||||
},
|
},
|
||||||
[floorplanMarqueeState?.pointerId, syncPreviewSelectedIds],
|
[syncPreviewSelectedIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMarqueeSelectionToolActive) {
|
if (!isMarqueeSelectionToolActive) {
|
||||||
setFloorplanMarqueeState(null)
|
useFloorplanMarquee.getState().reset()
|
||||||
floorplanMarqueeSnapPointRef.current = null
|
floorplanMarqueeSnapPointRef.current = null
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
if (mode === 'select') {
|
if (mode === 'select') {
|
||||||
@@ -10995,12 +10999,7 @@ export function FloorplanPanel({
|
|||||||
the alignment guides. */}
|
the alignment guides. */}
|
||||||
<FloorplanSnapBeaconLayer />
|
<FloorplanSnapBeaconLayer />
|
||||||
|
|
||||||
<FloorplanMarqueeLayer
|
<FloorplanMarqueeOverlay cursorColor={palette.cursor} />
|
||||||
bounds={visibleSvgMarqueeBounds}
|
|
||||||
cursorColor={palette.cursor}
|
|
||||||
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
|
|
||||||
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* This shared layer now carries only the per-CLICK draft anchors
|
{/* This shared layer now carries only the per-CLICK draft anchors
|
||||||
(reference-scale start + committed polygon vertices). The
|
(reference-scale start + committed polygon vertices). The
|
||||||
|
|||||||
@@ -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<FloorplanMarqueeState>((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
|
||||||
Reference in New Issue
Block a user