import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'; import { useBlocker } from 'react-router'; import { useTranslation } from 'react-i18next'; import type Konva from 'konva'; import { useEditor } from './context/EditorContext'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { boundingBox } from './utils/geometry'; import { normalizeAngleDegrees } from './utils/angle'; import { EditorCanvas } from './EditorCanvas'; import { EditorToolbar } from './EditorToolbar'; import { PropertiesPanel } from './PropertiesPanel'; import { ElectricalPalette } from './panels/ElectricalPalette'; import { FurniturePalette } from './panels/FurniturePalette'; import { CableLengthStatus } from './panels/CableLengthStatus'; import { ProjectionPanel } from './projection/ProjectionPanel'; import { ExportDialog } from './export/ExportDialog'; import { importRoomFromJson } from './export/roomFormat'; const Room3DView = lazy(() => import('./three/Room3DView').then((m) => ({ default: m.Room3DView })), ); import { getRoomFull, bulkUpdateWalls, batchSyncOpenings, batchSyncElectrical, batchSyncFurniture, batchSyncAnnotations, updateRoom, } from '../../api/client'; import type { CreateWallOpeningDto, UpdateWallOpeningDto, CreateElectricalItemDto, UpdateElectricalItemDto, CreateFurnitureItemDto, UpdateFurnitureItemDto, CreateAnnotationDto, UpdateAnnotationDto, } from '@house-plan-maker/shared'; import styles from './room-editor-layout.module.css'; const AUTO_SAVE_DELAY_MS = 5000; interface RoomEditorLayoutProps { readonly roomId: string; } export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { const { t } = useTranslation(); const { state, dispatch } = useEditor(); const [isSaving, setIsSaving] = useState(false); const [isAutoSaving, setIsAutoSaving] = useState(false); const [saveError, setSaveError] = useState(null); type ViewMode = '2d' | '3d' | 'projections'; const [viewMode, setViewMode] = useState('2d'); const [showExport, setShowExport] = useState(false); const canvasContainerRef = useRef(null); // Start as null so the initial render doesn't use a seed 800×600 size — // the Stage (and the auto-fit effect) only kicks in after the container // has been measured, avoiding the multi-frame resize flicker on open. const [canvasSize, setCanvasSize] = useState<{ width: number; height: number } | null>(null); // ── Dirty tracking ── const [isDirty, setIsDirty] = useState(false); const lastSavedRef = useRef({ walls: state.walls, openings: state.openings, electricalItems: state.electricalItems, furnitureItems: state.furnitureItems, room: state.room, }); // Mark dirty when state diverges from last saved snapshot useEffect(() => { const saved = lastSavedRef.current; const dirty = state.walls !== saved.walls || state.openings !== saved.openings || state.electricalItems !== saved.electricalItems || state.furnitureItems !== saved.furnitureItems || state.room.floorType !== saved.room.floorType || state.room.wallColor !== saved.room.wallColor || state.room.wallFinish !== saved.room.wallFinish || state.room.wallHeight !== saved.room.wallHeight || state.room.plinthHeight !== saved.room.plinthHeight || state.room.plinthThickness !== saved.room.plinthThickness || state.room.outletWidth !== saved.room.outletWidth || state.room.outletHeight !== saved.room.outletHeight || state.room.name !== saved.room.name; setIsDirty(dirty); }, [ state.walls, state.openings, state.electricalItems, state.furnitureItems, state.room.floorType, state.room.wallColor, state.room.wallFinish, state.room.wallHeight, state.room.plinthHeight, state.room.plinthThickness, state.room.outletWidth, state.room.outletHeight, state.room.name, state.room, ]); // Warn on browser close / refresh useEffect(() => { if (!isDirty) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [isDirty]); // Block in-app navigation via react-router const blocker = useBlocker(isDirty); useEffect(() => { if (blocker.state === 'blocked') { const leave = window.confirm( t('editor.unsavedChanges'), ); if (leave) { blocker.proceed(); } else { blocker.reset(); } } }, [blocker]); // ── Export refs ── const mainStageRef = useRef(null); const projectionStageMapRef = useRef>(new Map()); const threeCanvasRef = useRef(null); const handleMainStageRef = useCallback((stage: Konva.Stage | null) => { mainStageRef.current = stage; }, []); const handleProjectionStageRef = useCallback((wallId: string, stage: Konva.Stage | null) => { if (stage) { projectionStageMapRef.current.set(wallId, stage); } else { projectionStageMapRef.current.delete(wallId); } }, []); // ── Resize observer for canvas ── useEffect(() => { const container = canvasContainerRef.current; if (!container) return; const commitSize = (w: number, h: number): void => { const width = Math.floor(w); const height = Math.floor(h); if (width <= 0 || height <= 0) return; // Skip no-op updates so the auto-fit effect doesn't re-run on every // ResizeObserver tick that doesn't actually change the pixel size. setCanvasSize((prev) => { if (prev && prev.width === width && prev.height === height) return prev; return { width, height }; }); }; const observer = new ResizeObserver((entries) => { for (const entry of entries) { commitSize(entry.contentRect.width, entry.contentRect.height); } }); observer.observe(container); // Initial size const rect = container.getBoundingClientRect(); commitSize(rect.width, rect.height); return () => observer.disconnect(); }, []); // ── Auto-fit the room into the 2D canvas ── // Fires once the container has been measured and the room shape is // available, and again whenever either changes. Skips no-op reruns where // the canvas and room already match the last fit signature so we don't // flicker through multiple frames on open. const hasUserAdjustedViewRef = useRef(false); const lastFitSignatureRef = useRef(''); const lastDispatchedViewRef = useRef<{ zoom: number; panX: number; panY: number } | null>(null); useEffect(() => { if (viewMode !== '2d') return; if (!canvasSize) return; if (canvasSize.width <= 100 || canvasSize.height <= 100) return; if (state.room.shape.length === 0) return; const bbox = boundingBox(state.room.shape); const roomW = bbox.maxX - bbox.minX; const roomH = bbox.maxY - bbox.minY; if (roomW <= 0 || roomH <= 0) return; const signature = `${canvasSize.width}x${canvasSize.height}|${bbox.minX},${bbox.minY},${bbox.maxX},${bbox.maxY}`; // Already fit at this signature? Nothing to do. if (lastFitSignatureRef.current === signature) return; // User moved the camera → don't clobber their view until the room or // canvas actually changes dimensions (which gives a new signature). if (hasUserAdjustedViewRef.current && lastFitSignatureRef.current !== '') return; const padding = 80; const scaleX = (canvasSize.width - padding * 2) / roomW; const scaleY = (canvasSize.height - padding * 2) / roomH; const fitZoom = Math.min(scaleX, scaleY, 300); const centerX = (bbox.minX + bbox.maxX) / 2; const centerY = (bbox.minY + bbox.maxY) / 2; const panX = canvasSize.width / 2 - centerX * fitZoom; const panY = canvasSize.height / 2 - centerY * fitZoom; lastDispatchedViewRef.current = { zoom: fitZoom, panX, panY }; // Single atomic reducer pass — produces one new state, not two, so the // ZoomPanContext can't emit an intermediate (newZoom, oldPan) frame. dispatch({ type: 'SET_VIEW', zoom: fitZoom, offset: { x: panX, y: panY } }); lastFitSignatureRef.current = signature; }, [viewMode, canvasSize, state.room.shape, dispatch]); // Detect *manual* zoom/pan. Comparing against the values we just // dispatched prevents the auto-fit itself from flipping the flag. useEffect(() => { const last = lastDispatchedViewRef.current; if (!last) return; const EPS = 0.5; const cameFromAutoFit = Math.abs(state.zoom - last.zoom) < EPS && Math.abs(state.panOffset.x - last.panX) < EPS && Math.abs(state.panOffset.y - last.panY) < EPS; if (!cameFromAutoFit) { hasUserAdjustedViewRef.current = true; } }, [state.zoom, state.panOffset]); // ── Re-measure canvas when switching back to 2D view ── useEffect(() => { if (viewMode !== '2d') return; // Use requestAnimationFrame to measure after the style change is applied const rafId = requestAnimationFrame(() => { const container = canvasContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) }); } }); return () => cancelAnimationFrame(rafId); }, [viewMode]); // ── Save handler (batch-optimized) ── const isSavingRef = useRef(false); const handleSave = useCallback(async () => { if (isSavingRef.current) return; isSavingRef.current = true; setIsSaving(true); setSaveError(null); try { // 0. Save room-level properties (floor, wall color, heights, name) await updateRoom(roomId, { name: state.room.name, floorType: state.room.floorType, wallColor: state.room.wallColor, wallFinish: state.room.wallFinish, wallHeight: state.room.wallHeight, plinthHeight: state.room.plinthHeight, plinthThickness: state.room.plinthThickness, outletWidth: state.room.outletWidth, outletHeight: state.room.outletHeight, }); // 1. Save walls first (bulk replace) to get server-assigned wall IDs const wallDtos = state.walls.map((w) => ({ startX: w.startX, startY: w.startY, endX: w.endX, endY: w.endY, thickness: w.thickness, direction: w.direction, })); const serverWalls = await bulkUpdateWalls(roomId, wallDtos); // Build a map from local wall to server wall by matching coordinates const wallIdMap = new Map(); for (const localWall of state.walls) { const match = serverWalls.find( (sw) => Math.abs(sw.startX - localWall.startX) < 0.001 && Math.abs(sw.startY - localWall.startY) < 0.001 && Math.abs(sw.endX - localWall.endX) < 0.001 && Math.abs(sw.endY - localWall.endY) < 0.001, ); if (match) { wallIdMap.set(localWall.id, match.id); } } // 2. Fetch current server state once for diff computation const freshRoom = await getRoomFull(roomId); // 3. Compute openings — since bulkUpdateWalls CASCADE-deletes all openings, // we must re-create ALL openings with the new server wall IDs const openingsCreate: CreateWallOpeningDto[] = []; const openingsUpdate: { id: string; data: UpdateWallOpeningDto }[] = []; const openingsDelete: string[] = []; for (const opening of state.openings) { const serverWallId = wallIdMap.get(opening.wallId) ?? opening.wallId; openingsCreate.push({ wallId: serverWallId, type: opening.type, positionAlongWall: opening.positionAlongWall, width: opening.width, height: opening.height, elevationFromFloor: opening.elevationFromFloor, openDirection: opening.openDirection, positionAnchor: opening.positionAnchor, gridCols: opening.gridCols, gridRows: opening.gridRows, slopeDepth: opening.slopeDepth, frameThickness: opening.frameThickness, }); } // No updates or deletes needed — cascade already removed all server openings // 4. Electrical items — since wall IDs changed after bulk replace, // delete all existing and re-create with correct wall IDs const elecCreate: CreateElectricalItemDto[] = []; const elecUpdate: { id: string; data: UpdateElectricalItemDto }[] = []; const elecDelete: string[] = freshRoom.electricalItems.map((e) => e.id); for (const elec of state.electricalItems) { const serverWallId = elec.wallId ? (wallIdMap.get(elec.wallId) ?? elec.wallId) : null; elecCreate.push({ type: elec.type, x: elec.x, y: elec.y, wallId: serverWallId, elevationFromFloor: elec.elevationFromFloor, rotation: normalizeAngleDegrees(elec.rotation ?? 0), count: elec.count, positionAnchor: elec.positionAnchor, label: elec.label, metadata: elec.metadata, }); } // 5. Compute diffs for furniture const serverFurnIds = new Set(freshRoom.furnitureItems.map((f) => f.id)); const localFurnIds = new Set(state.furnitureItems.map((f) => f.id)); const furnCreate: CreateFurnitureItemDto[] = []; const furnUpdate: { id: string; data: UpdateFurnitureItemDto }[] = []; const furnDelete: string[] = []; for (const furn of state.furnitureItems) { if (furn.id.startsWith('local-')) { furnCreate.push({ type: furn.type, x: furn.x, y: furn.y, width: furn.width, depth: furn.depth, height: furn.height, rotation: furn.rotation, elevationFromFloor: furn.elevationFromFloor, label: furn.label, showProjection: furn.showProjection ?? false, opacity: furn.opacity ?? 1, positionAnchor: furn.positionAnchor, metadata: furn.metadata ?? null, }); } else if (serverFurnIds.has(furn.id)) { const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id); if (serverFurn) { const anchorChanged = serverFurn.positionAnchor.horizontal !== furn.positionAnchor.horizontal || serverFurn.positionAnchor.vertical !== furn.positionAnchor.vertical; const metadataChanged = JSON.stringify(serverFurn.metadata ?? null) !== JSON.stringify(furn.metadata ?? null); const hasChanges = serverFurn.x !== furn.x || serverFurn.y !== furn.y || serverFurn.width !== furn.width || serverFurn.depth !== furn.depth || serverFurn.height !== furn.height || serverFurn.rotation !== furn.rotation || serverFurn.elevationFromFloor !== furn.elevationFromFloor || serverFurn.label !== furn.label || (serverFurn.showProjection ?? false) !== (furn.showProjection ?? false) || (serverFurn.opacity ?? 1) !== (furn.opacity ?? 1) || anchorChanged || metadataChanged; if (hasChanges) { furnUpdate.push({ id: furn.id, data: { type: furn.type, x: furn.x, y: furn.y, width: furn.width, depth: furn.depth, height: furn.height, rotation: normalizeAngleDegrees(furn.rotation ?? 0), elevationFromFloor: furn.elevationFromFloor, label: furn.label, showProjection: furn.showProjection ?? false, opacity: furn.opacity ?? 1, positionAnchor: furn.positionAnchor, metadata: furn.metadata ?? null, }, }); } } } } for (const serverFurn of freshRoom.furnitureItems) { if (!localFurnIds.has(serverFurn.id)) { furnDelete.push(serverFurn.id); } } // 6. Execute the 3 element batch calls in parallel — responses contain // final server state. Annotations need to wait until after this so we // can remap their attachedToId through the new server-side ids. const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([ batchSyncOpenings(roomId, { create: openingsCreate, update: openingsUpdate, delete: openingsDelete, }), batchSyncElectrical(roomId, { create: elecCreate, update: elecUpdate, delete: elecDelete, }), batchSyncFurniture(roomId, { create: furnCreate, update: furnUpdate, delete: furnDelete, }), ]); // 7. Build an id map (old local id → new server id) so the reducer can // preserve the user's selection across the bulk-replace save flow. // The server batch endpoints return items in non-deterministic order, so // we match by content, then consume each match exactly once. const idMap = new Map(); for (const [localId, serverId] of wallIdMap) { idMap.set(localId, serverId); } const consumedOpenings = new Set(); for (const local of state.openings) { const localServerWallId = wallIdMap.get(local.wallId) ?? local.wallId; const match = syncedOpenings.find( (o) => !consumedOpenings.has(o.id) && o.wallId === localServerWallId && o.type === local.type && Math.abs(o.positionAlongWall - local.positionAlongWall) < 0.001 && Math.abs(o.width - local.width) < 0.001, ); if (match) { consumedOpenings.add(match.id); idMap.set(local.id, match.id); } } const consumedElectrical = new Set(); for (const local of state.electricalItems) { const localServerWallId = local.wallId ? (wallIdMap.get(local.wallId) ?? local.wallId) : null; const match = syncedElectrical.find( (e) => !consumedElectrical.has(e.id) && e.type === local.type && (e.wallId ?? null) === localServerWallId && Math.abs(e.x - local.x) < 0.001 && Math.abs(e.y - local.y) < 0.001, ); if (match) { consumedElectrical.add(match.id); idMap.set(local.id, match.id); } } const consumedFurniture = new Set(); for (const local of state.furnitureItems) { if (!local.id.startsWith('local-') && syncedFurniture.some((f) => f.id === local.id)) { idMap.set(local.id, local.id); consumedFurniture.add(local.id); continue; } const match = syncedFurniture.find( (f) => !consumedFurniture.has(f.id) && f.type === local.type && Math.abs(f.x - local.x) < 0.001 && Math.abs(f.y - local.y) < 0.001 && Math.abs(f.width - local.width) < 0.001 && Math.abs(f.depth - local.depth) < 0.001, ); if (match) { consumedFurniture.add(match.id); idMap.set(local.id, match.id); } } // 7b. Now that the id map is built, save annotations with attachedToId // remapped to the new server-side item ids. const serverAnnIds = new Set((freshRoom.annotations ?? []).map((a) => a.id)); const localAnnIds = new Set(state.annotations.map((a) => a.id)); const annCreate: CreateAnnotationDto[] = []; const annUpdate: { id: string; data: UpdateAnnotationDto }[] = []; const annDelete: string[] = []; for (const ann of state.annotations) { const remappedAttachedTo = ann.attachedToId ? (idMap.get(ann.attachedToId) ?? ann.attachedToId) : null; if (ann.id.startsWith('local-') || !serverAnnIds.has(ann.id)) { annCreate.push({ x: ann.x, y: ann.y, text: ann.text, fontSize: ann.fontSize, color: ann.color, attachedToId: remappedAttachedTo, projectionOffsetX: ann.projectionOffsetX, projectionOffsetY: ann.projectionOffsetY, }); } else { annUpdate.push({ id: ann.id, data: { x: ann.x, y: ann.y, text: ann.text, fontSize: ann.fontSize, color: ann.color, attachedToId: remappedAttachedTo, projectionOffsetX: ann.projectionOffsetX, projectionOffsetY: ann.projectionOffsetY, }, }); } } for (const id of serverAnnIds) { if (!localAnnIds.has(id)) annDelete.push(id); } const syncedAnnotations = await batchSyncAnnotations(roomId, { create: annCreate, update: annUpdate, delete: annDelete, }); // 8. Sync state with server-assigned IDs (single dispatch, no flicker) dispatch({ type: 'SYNC_SAVE', walls: serverWalls, openings: syncedOpenings, electricalItems: syncedElectrical, furnitureItems: syncedFurniture, annotations: syncedAnnotations, idMap, }); // Mark state as clean after successful save lastSavedRef.current = { walls: serverWalls, openings: syncedOpenings, electricalItems: syncedElectrical, furnitureItems: syncedFurniture, room: state.room, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : t('editor.error.load'); setSaveError(message); } finally { setIsSaving(false); isSavingRef.current = false; } }, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, state.annotations, state.room, dispatch]); // ── Auto-save with ref-based debounce ── const autoSaveTimerRef = useRef | null>(null); useEffect(() => { // Clear any existing timer when dirty state or saving state changes if (autoSaveTimerRef.current !== null) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } // Only start the timer if dirty and not currently saving if (!isDirty || isSavingRef.current) return; autoSaveTimerRef.current = setTimeout(() => { autoSaveTimerRef.current = null; // Guard: don't auto-save if a manual save is in progress if (isSavingRef.current) return; setIsAutoSaving(true); handleSave().finally(() => setIsAutoSaving(false)); }, AUTO_SAVE_DELAY_MS); return () => { if (autoSaveTimerRef.current !== null) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }; }, [isDirty, handleSave]); // ── Keyboard shortcuts ── useKeyboardShortcuts({ onSave: handleSave }); // ── Import handler ── const [importError, setImportError] = useState(null); const fileInputRef = useRef(null); const handleImportClick = useCallback(() => { setImportError(null); fileInputRef.current?.click(); }, []); const handleImportFile = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; // Reset the input so the same file can be re-selected event.target.value = ''; const reader = new FileReader(); reader.onload = () => { try { const text = reader.result as string; const imported = importRoomFromJson(text); const confirmed = window.confirm( `Import room "${imported.room.name}"? This will replace all current walls, openings, electrical items, and furniture.`, ); if (!confirmed) return; dispatch({ type: 'IMPORT_ROOM', room: imported.room, walls: imported.walls, openings: imported.openings, electricalItems: imported.electricalItems, furnitureItems: imported.furnitureItems, }); setImportError(null); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to import file'; setImportError(message); } }; reader.onerror = () => { setImportError('Failed to read the selected file.'); }; reader.readAsText(file); }, [dispatch], ); return (
{/* Toolbar */} {/* Hidden file input for JSON import */} setShowExport(true)} onImport={handleImportClick} /> {saveError && (
{t('editor.saveFailed', { error: saveError })}
)} {importError && (
{t('editor.importFailed', { error: importError })}
)} {/* Main area: palette + canvas + properties panel */}
{/* Overlay palettes (float over canvas, don't affect layout) */} {state.activeTool === 'electrical' && ( dispatch({ type: 'SET_ELECTRICAL_INDEX', index }) } /> )} {state.activeTool === 'furniture' && ( dispatch({ type: 'SET_FURNITURE_INDEX', index }) } /> )} {/* View mode toggle: 2D / 3D / Projections */}
{viewMode === '3d' && (
{ // Grab the R3F canvas element for 3D export if (el) { const canvas = el.querySelector('canvas'); threeCanvasRef.current = canvas; } else { threeCanvasRef.current = null; } }} > {t('editor.loading3D')}
}>
)}
{/* Only mount the Konva stage once the container has been measured — rendering at a seed 800×600 and then re-rendering at the real size causes a visible flicker on open. */} {canvasSize && ( )}
{viewMode === '2d' && ( )} {/* ProjectionPanel always mounted for export, hidden when not active */}
{/* Export Dialog */} setShowExport(false)} mainStageRef={mainStageRef} projectionStageRefs={projectionStageMapRef} threeCanvasRef={threeCanvasRef} is3DView={viewMode === '3d'} viewMode={viewMode} /> ); }