diff --git a/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg b/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg new file mode 100644 index 0000000..792150e Binary files /dev/null and b/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg differ diff --git a/frontend/src/components/canvas/FloorMapLayer.tsx b/frontend/src/components/canvas/FloorMapLayer.tsx index 2e925e0..26daf2a 100644 --- a/frontend/src/components/canvas/FloorMapLayer.tsx +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -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(null) + const wrapperRef = useRef(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 (
{ e.stopPropagation(); requestFloorMapEdit() }} > - {!locked && ( + {!locked && selected && ( <>
onResizeStart(e, new Set(['n','w']))} />
onResizeStart(e, new Set(['n']))} /> diff --git a/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx b/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx new file mode 100644 index 0000000..cce0973 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx @@ -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 }) =>
{children}
, + 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 = {}) { + 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() + expect(container.querySelector('img')).toBeNull() + setFloorMap({ enabled: false }) + rerender() + expect(container.querySelector('img')).toBeNull() + }) + + it('hides resize handles until the plan is selected, then shows them (unlocked)', () => { + setFloorMap() + render() + 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() + 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() + fireEvent.doubleClick(wrapper()) + expect(useCanvasStore.getState().floorMapEditNonce).toBe(1) + }) + + it('locked plan ignores double-click', () => { + setFloorMap({ locked: true }) + render() + fireEvent.doubleClick(wrapper()) + expect(useCanvasStore.getState().floorMapEditNonce).toBe(0) + }) + + it('sits at the bottom of the canvas (negative z-index)', () => { + setFloorMap() + render() + expect(wrapper().style.zIndex).toBe('-1') + }) +}) diff --git a/frontend/src/components/modals/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx index 27c0976..688f092 100644 --- a/frontend/src/components/modals/__tests__/DesignModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -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( + , + ) + // Canvas-side resize happened; reopen with a fresh key + larger dims. + const resized = { ...fm, width: 1200, height: 900 } + rerender( + , + ) + 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( + , + ) + const resized = { ...fm, width: 1200, height: 900 } + rerender( + , + ) + 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, diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index da5765b..758ec67 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -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