perf(editor): extract 2D draft cursor state to store + leaves — kill per-move panel re-render
The 2D build/edit tools republish the snapped cursor point AND the screen-space coordinate-badge position on every pointer move. Both lived in FloorplanPanel useState, so each move re-rendered the whole ~310ms panel (the badge fires on every pointermove while any build tool is active — the dominant culprit). Move both into a useFloorplanDraftPreview store written via getState() (no panel re-render); render the crosshair + live polygon-draft edge from a FloorplanDraftCursorLayer leaf and the coordinate badge from a FloorplanCursorIndicator leaf, each subscribing to the store. Same store+leaf pattern as the stair build preview. Slab/zone/ceiling drafts now have zero per-move panel setState. Wall/fence/roof draftEnd are follow-up slices. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6d5294d43f
commit
a769b45756
@@ -95,6 +95,7 @@ import useEditor, {
|
|||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
selectSiteFloorplanContext,
|
selectSiteFloorplanContext,
|
||||||
} from '../../store/use-editor'
|
} from '../../store/use-editor'
|
||||||
|
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
useActiveHandleDrag,
|
useActiveHandleDrag,
|
||||||
useEndpointReshape,
|
useEndpointReshape,
|
||||||
@@ -4592,6 +4593,129 @@ function FloorplanStairBuildPreviewLayer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leaf overlay for the cursor-following draft preview: the cursor crosshair plus
|
||||||
|
// the live polygon-draft edge (slab / zone / ceiling). Subscribes to
|
||||||
|
// `useFloorplanDraftPreview.cursorPoint` directly so a per-`grid:move` cursor
|
||||||
|
// update re-renders ONLY this tiny layer — never the (~120-220ms) FloorplanPanel.
|
||||||
|
// Everything else it needs is per-click panel state (the committed draft points)
|
||||||
|
// passed as props, so it re-renders on click via the parent and on move via the
|
||||||
|
// store. SVG mirrors the cursor-driven branches of `FloorplanDraftLayer`.
|
||||||
|
function FloorplanDraftCursorLayer({
|
||||||
|
activePolygonDraftPoints,
|
||||||
|
isPolygonDraftBuildActive,
|
||||||
|
cursorColor,
|
||||||
|
draftFill,
|
||||||
|
draftStroke,
|
||||||
|
polygonDraftStroke,
|
||||||
|
unitsPerPixel,
|
||||||
|
}: {
|
||||||
|
activePolygonDraftPoints: WallPlanPoint[]
|
||||||
|
isPolygonDraftBuildActive: boolean
|
||||||
|
cursorColor: string
|
||||||
|
draftFill: string
|
||||||
|
draftStroke: string
|
||||||
|
polygonDraftStroke: string | undefined
|
||||||
|
unitsPerPixel: number
|
||||||
|
}) {
|
||||||
|
const cursorPoint = useFloorplanDraftPreview((s) => s.cursorPoint)
|
||||||
|
const activeStroke = polygonDraftStroke ?? draftStroke
|
||||||
|
const strokeWidth = polygonDraftStroke ? FLOORPLAN_WALL_STROKE_WIDTH : '0.08'
|
||||||
|
|
||||||
|
const polygon = useMemo(() => {
|
||||||
|
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
||||||
|
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
||||||
|
|
||||||
|
const polyline = useMemo(() => {
|
||||||
|
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
||||||
|
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
||||||
|
|
||||||
|
const closingSegment = useMemo(() => {
|
||||||
|
const firstPoint = activePolygonDraftPoints[0]
|
||||||
|
if (
|
||||||
|
!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2) ||
|
||||||
|
!firstPoint
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x1: toSvgX(cursorPoint[0]),
|
||||||
|
y1: toSvgY(cursorPoint[1]),
|
||||||
|
x2: toSvgX(firstPoint[0]),
|
||||||
|
y2: toSvgY(firstPoint[1]),
|
||||||
|
}
|
||||||
|
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{polygon && <polygon fill={draftFill} fillOpacity={0.2} points={polygon} stroke="none" />}
|
||||||
|
|
||||||
|
{polyline && (
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
points={polyline}
|
||||||
|
stroke={activeStroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{closingSegment && (
|
||||||
|
<line
|
||||||
|
stroke={activeStroke}
|
||||||
|
strokeDasharray="0.16 0.1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeOpacity={0.75}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
x1={closingSegment.x1}
|
||||||
|
x2={closingSegment.x2}
|
||||||
|
y1={closingSegment.y1}
|
||||||
|
y2={closingSegment.y2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cursorPoint && (
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
cx={toSvgX(cursorPoint[0])}
|
||||||
|
cy={toSvgY(cursorPoint[1])}
|
||||||
|
fill={cursorColor}
|
||||||
|
fillOpacity={0.25}
|
||||||
|
r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX * unitsPerPixel}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={toSvgX(cursorPoint[0])}
|
||||||
|
cy={toSvgY(cursorPoint[1])}
|
||||||
|
fill={cursorColor}
|
||||||
|
fillOpacity={0.9}
|
||||||
|
r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX * unitsPerPixel}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// (tool / mode / colour) are per-interaction panel state passed through — they
|
||||||
|
// change rarely, never per move.
|
||||||
|
function FloorplanCursorIndicator(
|
||||||
|
props: Omit<ComponentProps<typeof Editor2dFloorplanCursorIndicatorOverlay>, 'cursorPosition'>,
|
||||||
|
) {
|
||||||
|
const cursorPosition = useFloorplanDraftPreview((s) => s.cursorPosition)
|
||||||
|
return <Editor2dFloorplanCursorIndicatorOverlay {...props} cursorPosition={cursorPosition} />
|
||||||
|
}
|
||||||
|
|
||||||
export function FloorplanPanel({
|
export function FloorplanPanel({
|
||||||
/**
|
/**
|
||||||
* Element to portal the compass button into. The 2D/3D navigation poses stay
|
* Element to portal the compass button into. The 2D/3D navigation poses stay
|
||||||
@@ -4751,8 +4875,33 @@ export function FloorplanPanel({
|
|||||||
const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
|
const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
|
||||||
unit === 'imperial' ? 'feet' : 'meters',
|
unit === 'imperial' ? 'feet' : 'meters',
|
||||||
)
|
)
|
||||||
const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
|
// The cursor point is the hottest 2D state — every build/edit tool republishes
|
||||||
const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
|
// it on `grid:move`. It lives in `useFloorplanDraftPreview` (not panel state)
|
||||||
|
// so a per-move update re-renders only `FloorplanDraftCursorLayer`, not this
|
||||||
|
// ~200ms panel. This shim keeps the `setCursorPoint(value)` /
|
||||||
|
// `setCursorPoint(prev => …)` call sites (and their snap-SFX side effects)
|
||||||
|
// unchanged while routing the write to the store; reads go through the store.
|
||||||
|
const setCursorPoint = useCallback(
|
||||||
|
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
||||||
|
const store = useFloorplanDraftPreview.getState()
|
||||||
|
const value = typeof next === 'function' ? next(store.cursorPoint) : next
|
||||||
|
store.setCursorPoint(value)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
// The coordinate-badge cursor position is set on every SVG `pointermove` while
|
||||||
|
// a build/select tool is active — the single hottest 2D update. It lives in
|
||||||
|
// `useFloorplanDraftPreview` (not panel state) so a move re-renders only the
|
||||||
|
// badge leaf, not this panel. Shim preserves the existing call sites (value +
|
||||||
|
// functional-updater forms) while routing the write to the store.
|
||||||
|
const setFloorplanCursorPosition = useCallback(
|
||||||
|
(next: SvgPoint | null | ((prev: SvgPoint | null) => SvgPoint | null)) => {
|
||||||
|
const store = useFloorplanDraftPreview.getState()
|
||||||
|
const value = typeof next === 'function' ? next(store.cursorPosition) : next
|
||||||
|
store.setCursorPosition(value)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
|
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
|
||||||
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
|
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
|
||||||
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
|
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
|
||||||
@@ -5867,37 +6016,9 @@ export function FloorplanPanel({
|
|||||||
slabDraftPoints,
|
slabDraftPoints,
|
||||||
zoneDraftPoints,
|
zoneDraftPoints,
|
||||||
])
|
])
|
||||||
const polygonDraftPolylinePoints = useMemo(() => {
|
// The cursor-following polygon-draft preview (slab / zone / ceiling) moved into
|
||||||
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) {
|
// `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store
|
||||||
return null
|
// so it re-renders per move without re-rendering this panel.
|
||||||
}
|
|
||||||
|
|
||||||
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
|
||||||
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
||||||
const polygonDraftPolygonPoints = useMemo(() => {
|
|
||||||
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
|
||||||
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
||||||
const polygonDraftClosingSegment = useMemo(() => {
|
|
||||||
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPoint = activePolygonDraftPoints[0]
|
|
||||||
if (!firstPoint) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x1: toSvgX(cursorPoint[0]),
|
|
||||||
y1: toSvgY(cursorPoint[1]),
|
|
||||||
x2: toSvgX(firstPoint[0]),
|
|
||||||
y2: toSvgY(firstPoint[1]),
|
|
||||||
}
|
|
||||||
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
||||||
|
|
||||||
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
|
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
|
||||||
|
|
||||||
@@ -6315,8 +6436,12 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
// While the cursor drives live geometry (items, drafts, moves), `fittedViewport` changes every
|
// While the cursor drives live geometry (items, drafts, moves), `fittedViewport` changes every
|
||||||
// pointermove. Syncing `viewport` here would call setState in a tight loop (max update depth).
|
// pointermove. Syncing `viewport` here would call setState in a tight loop (max update depth).
|
||||||
|
// `cursorPoint` now lives in the draft store; read it non-reactively (this
|
||||||
|
// effect only re-runs when `fittedViewport` / the other transient signals
|
||||||
|
// change, and the viewport never refits mid-draft because scene data is
|
||||||
|
// stable then — so a live store read is sufficient and correct).
|
||||||
const transientFloorplanFit =
|
const transientFloorplanFit =
|
||||||
cursorPoint != null ||
|
useFloorplanDraftPreview.getState().cursorPoint != null ||
|
||||||
movingNode != null ||
|
movingNode != null ||
|
||||||
endpointReshape != null ||
|
endpointReshape != null ||
|
||||||
isCurveReshape ||
|
isCurveReshape ||
|
||||||
@@ -6329,7 +6454,6 @@ export function FloorplanPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
cursorPoint,
|
|
||||||
endpointReshape,
|
endpointReshape,
|
||||||
fittedViewport,
|
fittedViewport,
|
||||||
isCurveReshape,
|
isCurveReshape,
|
||||||
@@ -10307,9 +10431,8 @@ export function FloorplanPanel({
|
|||||||
>
|
>
|
||||||
<FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
|
<FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
|
||||||
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
||||||
<Editor2dFloorplanCursorIndicatorOverlay
|
<FloorplanCursorIndicator
|
||||||
cursorColor={floorplanCursorColor}
|
cursorColor={floorplanCursorColor}
|
||||||
cursorPosition={floorplanCursorPosition}
|
|
||||||
floorplanSelectionTool={floorplanSelectionTool}
|
floorplanSelectionTool={floorplanSelectionTool}
|
||||||
indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X}
|
indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X}
|
||||||
indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y}
|
indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y}
|
||||||
@@ -10721,17 +10844,12 @@ export function FloorplanPanel({
|
|||||||
draftPolygonPoints={draftPolygonPoints}
|
draftPolygonPoints={draftPolygonPoints}
|
||||||
draftStroke={palette.draftStroke}
|
draftStroke={palette.draftStroke}
|
||||||
linearDraftSegment={fenceDraftSegment}
|
linearDraftSegment={fenceDraftSegment}
|
||||||
polygonDraftClosingSegment={polygonDraftClosingSegment}
|
// The cursor-following polygon-draft preview moved to
|
||||||
polygonDraftPolygonPoints={polygonDraftPolygonPoints}
|
// `FloorplanDraftCursorLayer` (reads the live cursor from the
|
||||||
polygonDraftPolylinePoints={polygonDraftPolylinePoints}
|
// draft store), so this shared layer no longer carries it.
|
||||||
polygonDraftStroke={
|
polygonDraftClosingSegment={null}
|
||||||
isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined
|
polygonDraftPolygonPoints={null}
|
||||||
}
|
polygonDraftPolylinePoints={null}
|
||||||
polygonDraftStrokeWidth={
|
|
||||||
isSlabBuildActive || isCeilingBuildActive
|
|
||||||
? FLOORPLAN_WALL_STROKE_WIDTH
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
unitsPerPixel={floorplanUnitsPerPixel}
|
unitsPerPixel={floorplanUnitsPerPixel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -10764,24 +10882,17 @@ export function FloorplanPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cursorPoint && (
|
<FloorplanDraftCursorLayer
|
||||||
<g>
|
activePolygonDraftPoints={activePolygonDraftPoints}
|
||||||
<circle
|
cursorColor={floorplanCursorColor}
|
||||||
cx={toSvgX(cursorPoint[0])}
|
draftFill={palette.draftFill}
|
||||||
cy={toSvgY(cursorPoint[1])}
|
draftStroke={palette.draftStroke}
|
||||||
fill={floorplanCursorColor}
|
isPolygonDraftBuildActive={isPolygonDraftBuildActive}
|
||||||
fillOpacity={0.25}
|
polygonDraftStroke={
|
||||||
r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX * floorplanUnitsPerPixel}
|
isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined
|
||||||
|
}
|
||||||
|
unitsPerPixel={floorplanUnitsPerPixel}
|
||||||
/>
|
/>
|
||||||
<circle
|
|
||||||
cx={toSvgX(cursorPoint[0])}
|
|
||||||
cy={toSvgY(cursorPoint[1])}
|
|
||||||
fill={floorplanCursorColor}
|
|
||||||
fillOpacity={0.9}
|
|
||||||
r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX * floorplanUnitsPerPixel}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeDraftAnchorPoint && (
|
{activeDraftAnchorPoint && (
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Ephemeral store for the 2D floor-plan's in-flight DRAFT preview state — the
|
||||||
|
// hot, per-pointer-move values every build/edit tool republishes on `grid:move`
|
||||||
|
// (the snapped cursor point today; wall/fence/roof draft endpoints as later
|
||||||
|
// slices land here). It exists so those per-move updates DON'T live in
|
||||||
|
// `FloorplanPanel`'s own `useState`: the panel is a ~10k-line component whose
|
||||||
|
// render costs ~120-220ms, so a `setState` per move made every 2D draft tool
|
||||||
|
// feel laggy. Producers write via `getState().setX(...)` (no panel re-render);
|
||||||
|
// the small overlay leaves subscribe and re-render alone. Same pattern that
|
||||||
|
// keeps stair / column / elevator placement smooth (`useStairBuildPreview`,
|
||||||
|
// `usePlacementPreview`).
|
||||||
|
//
|
||||||
|
// Editor-only. Producers clear on tool-inactive, commit, and unmount.
|
||||||
|
|
||||||
|
import type { WallPlanPoint } from '@pascal-app/core'
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
/** Screen-space (SVG-local px) cursor point — drives the coordinate badge. */
|
||||||
|
type SvgPoint = { x: number; y: number }
|
||||||
|
|
||||||
|
type FloorplanDraftPreviewState = {
|
||||||
|
/** Snapped plan-XZ point under the cursor; drives the crosshair + the
|
||||||
|
* cursor-following polygon-draft preview. `null` when idle. */
|
||||||
|
cursorPoint: WallPlanPoint | null
|
||||||
|
/** Screen-space cursor point driving the coordinate-indicator badge. Set on
|
||||||
|
* every SVG `pointermove` while a build/select tool is active, so it's the
|
||||||
|
* single hottest 2D update — keeping it out of panel state is what stops the
|
||||||
|
* panel re-rendering per move. `null` when idle. */
|
||||||
|
cursorPosition: SvgPoint | null
|
||||||
|
/** Set the snapped cursor point. No-ops (skips the store update, so
|
||||||
|
* subscribers don't re-render) when unchanged — `grid:move` fires far more
|
||||||
|
* often than the snapped cell actually changes. */
|
||||||
|
setCursorPoint(point: WallPlanPoint | null): void
|
||||||
|
/** Set the screen-space cursor point (deduped on x/y). */
|
||||||
|
setCursorPosition(point: SvgPoint | null): void
|
||||||
|
reset(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) => ({
|
||||||
|
cursorPoint: null,
|
||||||
|
cursorPosition: null,
|
||||||
|
setCursorPoint: (point) =>
|
||||||
|
set((state) => {
|
||||||
|
const prev = state.cursorPoint
|
||||||
|
if (!point && !prev) return state
|
||||||
|
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
|
||||||
|
return { cursorPoint: point }
|
||||||
|
}),
|
||||||
|
setCursorPosition: (point) =>
|
||||||
|
set((state) => {
|
||||||
|
const prev = state.cursorPosition
|
||||||
|
if (!point && !prev) return state
|
||||||
|
if (point && prev && prev.x === point.x && prev.y === point.y) return state
|
||||||
|
return { cursorPosition: point }
|
||||||
|
}),
|
||||||
|
reset: () =>
|
||||||
|
set((state) =>
|
||||||
|
state.cursorPoint === null && state.cursorPosition === null
|
||||||
|
? state
|
||||||
|
: { cursorPoint: null, cursorPosition: null },
|
||||||
|
),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user