feat: floor plan selection handles, dbl-click edit, bottom z-index
- Resize handles only render when the plan is selected (click to select, click outside to deselect); previously always shown when unlocked. - Double-clicking an unlocked plan opens the canvas edit modal (via a floorMapEditNonce signal the Sidebar watches). - Floor plan always sits at the bottom of the canvas (z-index -1), behind nodes and edges, locked or not. - Remount the edit modal on every open (key bump) so it re-seeds from the current floor plan; fixes Save clobbering a canvas-side resize/move with stale modal dimensions. - Drop the no-op history snapshot on floor-plan edits (floorMap isn't part of undo history).
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
|
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
@@ -17,20 +17,40 @@ interface ResizeState {
|
|||||||
* ViewportPortal) so it pans and zooms together with the nodes. Position and
|
* ViewportPortal) so it pans and zooms together with the nodes. Position and
|
||||||
* size are stored in flow coordinates.
|
* size are stored in flow coordinates.
|
||||||
*
|
*
|
||||||
* When unlocked it sits above the nodes so it can be grabbed/resized; when
|
* It always sits at the bottom of the canvas (behind nodes and edges). When
|
||||||
* locked it drops behind everything to act as a static background.
|
* unlocked it can still be grabbed/resized in areas not covered by a node;
|
||||||
|
* resize handles appear only while it is selected. Double-clicking an unlocked
|
||||||
|
* plan opens its edit modal.
|
||||||
*/
|
*/
|
||||||
export function FloorMapLayer() {
|
export function FloorMapLayer() {
|
||||||
const floorMap = useCanvasStore((s) => s.floorMap)
|
const floorMap = useCanvasStore((s) => s.floorMap)
|
||||||
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
|
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
|
||||||
|
const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit)
|
||||||
const { screenToFlowPosition } = useReactFlow()
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
const zoom = useStore((s) => s.transform[2])
|
const zoom = useStore((s) => s.transform[2])
|
||||||
|
|
||||||
const resizeRef = useRef<ResizeState | null>(null)
|
const resizeRef = useRef<ResizeState | null>(null)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selected, setSelected] = useState(false)
|
||||||
|
|
||||||
|
const locked = floorMap?.locked ?? false
|
||||||
|
|
||||||
|
// While selected (and unlocked), deselect on any click outside the plan.
|
||||||
|
// A locked plan can't be selected, and handles/edit are gated on !locked, so
|
||||||
|
// a residual selection is harmless.
|
||||||
|
useEffect(() => {
|
||||||
|
if (locked || !selected) return
|
||||||
|
const onDocDown = (ev: MouseEvent) => {
|
||||||
|
if (!wrapperRef.current?.contains(ev.target as Node)) setSelected(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDocDown)
|
||||||
|
}, [selected, locked])
|
||||||
|
|
||||||
const onDragStart = useCallback((e: React.MouseEvent) => {
|
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
if (!floorMap) return
|
if (!floorMap) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
setSelected(true)
|
||||||
const startX = e.clientX
|
const startX = e.clientX
|
||||||
const startY = e.clientY
|
const startY = e.clientY
|
||||||
const origPosX = floorMap.posX
|
const origPosX = floorMap.posX
|
||||||
@@ -96,7 +116,7 @@ export function FloorMapLayer() {
|
|||||||
|
|
||||||
if (!floorMap || !floorMap.enabled) return null
|
if (!floorMap || !floorMap.enabled) return null
|
||||||
|
|
||||||
const { imageData, posX, posY, width, height, opacity, locked } = floorMap
|
const { imageData, posX, posY, width, height, opacity } = floorMap
|
||||||
|
|
||||||
// Handles live in flow space, so counter-scale by zoom to keep a ~constant
|
// Handles live in flow space, so counter-scale by zoom to keep a ~constant
|
||||||
// on-screen size regardless of the current zoom level.
|
// on-screen size regardless of the current zoom level.
|
||||||
@@ -115,6 +135,7 @@ export function FloorMapLayer() {
|
|||||||
return (
|
return (
|
||||||
<ViewportPortal>
|
<ViewportPortal>
|
||||||
<div
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: posX,
|
left: posX,
|
||||||
@@ -122,13 +143,13 @@ export function FloorMapLayer() {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
opacity,
|
opacity,
|
||||||
// Unlocked → above nodes so it can be grabbed. Locked → behind
|
// Always at the bottom of the canvas, behind nodes and edges.
|
||||||
// everything as a static background.
|
zIndex: -1,
|
||||||
zIndex: locked ? -1 : 4,
|
|
||||||
pointerEvents: locked ? 'none' : 'auto',
|
pointerEvents: locked ? 'none' : 'auto',
|
||||||
cursor: locked ? 'default' : 'move',
|
cursor: locked ? 'default' : 'move',
|
||||||
}}
|
}}
|
||||||
onMouseDown={locked ? undefined : onDragStart}
|
onMouseDown={locked ? undefined : onDragStart}
|
||||||
|
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={imageData}
|
src={imageData}
|
||||||
@@ -142,7 +163,7 @@ export function FloorMapLayer() {
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!locked && (
|
{!locked && selected && (
|
||||||
<>
|
<>
|
||||||
<div style={{ ...hs, cursor: 'nw-resize', top: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
|
<div style={{ ...hs, cursor: 'nw-resize', top: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
|
||||||
<div style={{ ...hs, cursor: 'n-resize', top: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
|
<div style={{ ...hs, cursor: 'n-resize', top: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { FloorMapLayer } from '../FloorMapLayer'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { FloorMapConfig } from '@/types'
|
||||||
|
|
||||||
|
// Stub React Flow: render the portal inline, and give the layer a 1x zoom and
|
||||||
|
// an identity screen→flow projection so it can mount without a provider.
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
ViewportPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
useReactFlow: () => ({ screenToFlowPosition: (p: { x: number; y: number }) => p }),
|
||||||
|
useStore: (sel: (s: { transform: number[] }) => unknown) => sel({ transform: [0, 0, 1] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const BASE: FloorMapConfig = {
|
||||||
|
imageData: '/api/v1/media/abc.png',
|
||||||
|
posX: 0, posY: 0, width: 800, height: 600,
|
||||||
|
opacity: 0.8, locked: false, enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFloorMap(patch: Partial<FloorMapConfig> = {}) {
|
||||||
|
useCanvasStore.setState({ floorMap: { ...BASE, ...patch }, floorMapEditNonce: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapper() {
|
||||||
|
return screen.getByAltText('Floor plan').parentElement as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCount(root: HTMLElement) {
|
||||||
|
return Array.from(root.querySelectorAll('div')).filter((d) =>
|
||||||
|
(d.getAttribute('style') ?? '').includes('resize'),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FloorMapLayer', () => {
|
||||||
|
beforeEach(() => useCanvasStore.setState({ floorMap: null, floorMapEditNonce: 0 }))
|
||||||
|
|
||||||
|
it('renders nothing when there is no plan or it is disabled', () => {
|
||||||
|
const { container, rerender } = render(<FloorMapLayer />)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
setFloorMap({ enabled: false })
|
||||||
|
rerender(<FloorMapLayer />)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides resize handles until the plan is selected, then shows them (unlocked)', () => {
|
||||||
|
setFloorMap()
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
expect(handleCount(wrapper())).toBe(0)
|
||||||
|
|
||||||
|
fireEvent.mouseDown(wrapper())
|
||||||
|
expect(handleCount(wrapper())).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never shows handles and is non-interactive when locked', () => {
|
||||||
|
setFloorMap({ locked: true })
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
const w = wrapper()
|
||||||
|
fireEvent.mouseDown(w)
|
||||||
|
expect(handleCount(w)).toBe(0)
|
||||||
|
expect(w.style.pointerEvents).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('double-click on an unlocked plan requests the edit modal', () => {
|
||||||
|
setFloorMap()
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
fireEvent.doubleClick(wrapper())
|
||||||
|
expect(useCanvasStore.getState().floorMapEditNonce).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locked plan ignores double-click', () => {
|
||||||
|
setFloorMap({ locked: true })
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
fireEvent.doubleClick(wrapper())
|
||||||
|
expect(useCanvasStore.getState().floorMapEditNonce).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sits at the bottom of the canvas (negative z-index)', () => {
|
||||||
|
setFloorMap()
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
expect(wrapper().style.zIndex).toBe('-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -157,6 +157,43 @@ describe('DesignModal', () => {
|
|||||||
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
|
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: reopening the edit modal after a canvas-side resize must not
|
||||||
|
// save stale dimensions. Sidebar bumps the modal `key` on every open so it
|
||||||
|
// remounts and re-seeds from the current floor plan.
|
||||||
|
it('re-seeds width/height when remounted with a new key (reopen after resize)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON }
|
||||||
|
const { rerender } = render(
|
||||||
|
<DesignModal key="k1" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={fm} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
// Canvas-side resize happened; reopen with a fresh key + larger dims.
|
||||||
|
const resized = { ...fm, width: 1200, height: 900 }
|
||||||
|
rerender(
|
||||||
|
<DesignModal key="k2" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={resized} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 1200, height: 900 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps stale dimensions when reopened without remount (why the key bump matters)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON }
|
||||||
|
const { rerender } = render(
|
||||||
|
<DesignModal key="same" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={fm} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
const resized = { ...fm, width: 1200, height: 900 }
|
||||||
|
rerender(
|
||||||
|
<DesignModal key="same" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={resized} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
// Same key → no remount → local state still the original 800×600.
|
||||||
|
expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 800, height: 600 })
|
||||||
|
})
|
||||||
|
|
||||||
it('submits floorMap: null when shown but no image was chosen', () => {
|
it('submits floorMap: null when shown but no image was chosen', () => {
|
||||||
const { onSubmit } = renderModal({
|
const { onSubmit } = renderModal({
|
||||||
showFloorMap: true,
|
showFloorMap: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
@@ -39,7 +39,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||||
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
|
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
|
||||||
const { nodes, hasUnsavedChanges, floorMap, setFloorMap, snapshotHistory } = useCanvasStore()
|
// Bumped on every open so the modal remounts and re-seeds its local state from
|
||||||
|
// the current floor plan — otherwise a reopen keeps stale width/height/lock
|
||||||
|
// and Save would clobber canvas-side resize/move.
|
||||||
|
const [openSeq, setOpenSeq] = useState(0)
|
||||||
|
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
|
||||||
|
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
|
||||||
|
|
||||||
|
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
|
||||||
|
setOpenSeq((s) => s + 1)
|
||||||
|
setDesignModal(m)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
|
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
|
||||||
if (!designModal) return
|
if (!designModal) return
|
||||||
@@ -57,16 +67,16 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
}
|
}
|
||||||
// Floor plan is canvas data attached to the active design. `undefined`
|
// Floor plan is canvas data attached to the active design. `undefined`
|
||||||
// means the section wasn't shown → leave it untouched. Applied to the
|
// means the section wasn't shown → leave it untouched. Applied to the
|
||||||
// store and persisted on the next explicit canvas Save.
|
// store and persisted on the next explicit canvas Save. Not pushed to
|
||||||
|
// undo history (floorMap isn't part of HistoryEntry).
|
||||||
if (data.floorMap !== undefined) {
|
if (data.floorMap !== undefined) {
|
||||||
snapshotHistory()
|
|
||||||
setFloorMap(data.floorMap)
|
setFloorMap(data.floorMap)
|
||||||
}
|
}
|
||||||
setDesignModal(null)
|
setDesignModal(null)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
|
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
|
||||||
}
|
}
|
||||||
}, [designModal, addDesign, updateDesign, setFloorMap, snapshotHistory])
|
}, [designModal, addDesign, updateDesign, setFloorMap])
|
||||||
|
|
||||||
const handleDesignDelete = useCallback(async (d: Design) => {
|
const handleDesignDelete = useCallback(async (d: Design) => {
|
||||||
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
||||||
@@ -96,6 +106,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
|
|
||||||
const isActiveEdit = designModal?.mode === 'edit' && designModal.design?.id === activeDesignId
|
const isActiveEdit = designModal?.mode === 'edit' && designModal.design?.id === activeDesignId
|
||||||
|
|
||||||
|
// Double-click on the floor plan (canvas) asks to edit the active canvas.
|
||||||
|
useEffect(() => {
|
||||||
|
if (floorMapEditNonce === 0) return
|
||||||
|
const active = designs.find((d) => d.id === activeDesignId)
|
||||||
|
if (active) openDesignModal({ mode: 'edit', design: active })
|
||||||
|
// Only react to the nonce bump, not to design/active changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [floorMapEditNonce])
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
@@ -160,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<button
|
<button
|
||||||
aria-label={`Edit ${d.name}`}
|
aria-label={`Edit ${d.name}`}
|
||||||
title="Edit canvas"
|
title="Edit canvas"
|
||||||
onClick={() => { setDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }}
|
onClick={() => { openDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }}
|
||||||
className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
@@ -179,7 +198,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
})}
|
})}
|
||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }}
|
onClick={() => { openDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer"
|
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<PlusCircle size={14} />
|
<PlusCircle size={14} />
|
||||||
@@ -273,7 +292,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
{!collapsed && <VersionBadge />}
|
{!collapsed && <VersionBadge />}
|
||||||
|
|
||||||
<DesignModal
|
<DesignModal
|
||||||
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'}
|
key={`${designModal?.mode === 'edit' ? designModal.design?.id : 'create'}-${openSeq}`}
|
||||||
open={!!designModal}
|
open={!!designModal}
|
||||||
onClose={() => setDesignModal(null)}
|
onClose={() => setDesignModal(null)}
|
||||||
onSubmit={handleDesignSubmit}
|
onSubmit={handleDesignSubmit}
|
||||||
|
|||||||
@@ -1337,5 +1337,12 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
useCanvasStore.getState().updateFloorMap({ posX: 5 })
|
useCanvasStore.getState().updateFloorMap({ posX: 5 })
|
||||||
expect(useCanvasStore.getState().floorMap).toBeNull()
|
expect(useCanvasStore.getState().floorMap).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requestFloorMapEdit bumps the nonce each call', () => {
|
||||||
|
const start = useCanvasStore.getState().floorMapEditNonce
|
||||||
|
useCanvasStore.getState().requestFloorMapEdit()
|
||||||
|
useCanvasStore.getState().requestFloorMapEdit()
|
||||||
|
expect(useCanvasStore.getState().floorMapEditNonce).toBe(start + 2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface CanvasState {
|
|||||||
floorMap: FloorMapConfig | null
|
floorMap: FloorMapConfig | null
|
||||||
setFloorMap: (config: FloorMapConfig | null) => void
|
setFloorMap: (config: FloorMapConfig | null) => void
|
||||||
updateFloorMap: (patch: Partial<FloorMapConfig>) => void
|
updateFloorMap: (patch: Partial<FloorMapConfig>) => void
|
||||||
|
// Bumped when the user double-clicks the floor plan on the canvas, asking the
|
||||||
|
// Sidebar to open the active canvas's edit modal (floor plan section).
|
||||||
|
floorMapEditNonce: number
|
||||||
|
requestFloorMapEdit: () => void
|
||||||
|
|
||||||
// History
|
// History
|
||||||
past: HistoryEntry[]
|
past: HistoryEntry[]
|
||||||
@@ -103,6 +107,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
serviceStatuses: {},
|
serviceStatuses: {},
|
||||||
floorMap: null,
|
floorMap: null,
|
||||||
|
floorMapEditNonce: 0,
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
|
|
||||||
past: [],
|
past: [],
|
||||||
@@ -760,6 +765,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
requestFloorMapEdit: () => set((s) => ({ floorMapEditNonce: s.floorMapEditNonce + 1 })),
|
||||||
|
|
||||||
loadCanvas: (nodes, edges) => {
|
loadCanvas: (nodes, edges) => {
|
||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
const parents = nodes.filter((n) => !n.parentId)
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
|
|||||||
Reference in New Issue
Block a user