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 { 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,
+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 { 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)
})
}) })
}) })
+7
View File
@@ -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)