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:
Pouzor
2026-07-03 00:03:16 +02:00
parent 6160090919
commit bdde03cdfa
7 changed files with 190 additions and 16 deletions
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 { useCanvasStore } from '@/stores/canvasStore'
@@ -17,20 +17,40 @@ interface ResizeState {
* ViewportPortal) so it pans and zooms together with the nodes. Position and
* size are stored in flow coordinates.
*
* When unlocked it sits above the nodes so it can be grabbed/resized; when
* locked it drops behind everything to act as a static background.
* It always sits at the bottom of the canvas (behind nodes and edges). When
* 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() {
const floorMap = useCanvasStore((s) => s.floorMap)
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit)
const { screenToFlowPosition } = useReactFlow()
const zoom = useStore((s) => s.transform[2])
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) => {
if (!floorMap) return
e.stopPropagation()
setSelected(true)
const startX = e.clientX
const startY = e.clientY
const origPosX = floorMap.posX
@@ -96,7 +116,7 @@ export function FloorMapLayer() {
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
// on-screen size regardless of the current zoom level.
@@ -115,6 +135,7 @@ export function FloorMapLayer() {
return (
<ViewportPortal>
<div
ref={wrapperRef}
style={{
position: 'absolute',
left: posX,
@@ -122,13 +143,13 @@ export function FloorMapLayer() {
width,
height,
opacity,
// Unlocked → above nodes so it can be grabbed. Locked → behind
// everything as a static background.
zIndex: locked ? -1 : 4,
// Always at the bottom of the canvas, behind nodes and edges.
zIndex: -1,
pointerEvents: locked ? 'none' : 'auto',
cursor: locked ? 'default' : 'move',
}}
onMouseDown={locked ? undefined : onDragStart}
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
>
<img
src={imageData}
@@ -142,7 +163,7 @@ export function FloorMapLayer() {
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: '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()
})
// 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', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
+27 -8
View File
@@ -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 { Logo } from '@/components/ui/Logo'
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 [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
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) => {
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`
// 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) {
snapshotHistory()
setFloorMap(data.floorMap)
}
setDesignModal(null)
} catch {
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) => {
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
// 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 onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
@@ -160,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<button
aria-label={`Edit ${d.name}`}
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"
>
<Pencil size={12} />
@@ -179,7 +198,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
})}
<div className="border-t border-border" />
<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"
>
<PlusCircle size={14} />
@@ -273,7 +292,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{!collapsed && <VersionBadge />}
<DesignModal
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'}
key={`${designModal?.mode === 'edit' ? designModal.design?.id : 'create'}-${openSeq}`}
open={!!designModal}
onClose={() => setDesignModal(null)}
onSubmit={handleDesignSubmit}
@@ -1337,5 +1337,12 @@ describe('canvasStore — custom style apply', () => {
useCanvasStore.getState().updateFloorMap({ posX: 5 })
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)
})
})
})
+7
View File
@@ -38,6 +38,10 @@ interface CanvasState {
floorMap: FloorMapConfig | null
setFloorMap: (config: FloorMapConfig | null) => 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
past: HistoryEntry[]
@@ -103,6 +107,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
scanEventTs: 0,
serviceStatuses: {},
floorMap: null,
floorMapEditNonce: 0,
fitViewPending: false,
past: [],
@@ -760,6 +765,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: true,
})),
requestFloorMapEdit: () => set((s) => ({ floorMapEditNonce: s.floorMapEditNonce + 1 })),
loadCanvas: (nodes, edges) => {
// React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId)