Files
house-plan-maker/apps/client/src/components/editor/RoomEditorLayout.tsx
T
alexei.dolgolyov d8a914bf2a feat: editor improvements and collapsible sidebars
Add collapse/expand toggle for the AppShell navigation sidebar and the
editor properties panel (both persisted to localStorage). Bundles other
in-progress editor work including position anchors, outlet sizing, PBR
textures, window slope/frame depth, curtain metadata, and various 2D/3D
rendering tweaks.
2026-04-08 12:27:57 +03:00

822 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null>(null);
type ViewMode = '2d' | '3d' | 'projections';
const [viewMode, setViewMode] = useState<ViewMode>('2d');
const [showExport, setShowExport] = useState(false);
const canvasContainerRef = useRef<HTMLDivElement>(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<Konva.Stage | null>(null);
const projectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
const threeCanvasRef = useRef<HTMLCanvasElement | null>(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<string>('');
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<string, string>();
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<string, string>();
for (const [localId, serverId] of wallIdMap) {
idMap.set(localId, serverId);
}
const consumedOpenings = new Set<string>();
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<string>();
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<string>();
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<ReturnType<typeof setTimeout> | 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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImportClick = useCallback(() => {
setImportError(null);
fileInputRef.current?.click();
}, []);
const handleImportFile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={styles.layout}>
{/* Toolbar */}
{/* Hidden file input for JSON import */}
<input
ref={fileInputRef}
type="file"
accept=".json"
style={{ display: 'none' }}
onChange={handleImportFile}
/>
<EditorToolbar
onSave={handleSave}
isSaving={isSaving}
isDirty={isDirty}
isAutoSaving={isAutoSaving}
onExport={() => setShowExport(true)}
onImport={handleImportClick}
/>
{saveError && (
<div className={styles.saveError}>
{t('editor.saveFailed', { error: saveError })}
</div>
)}
{importError && (
<div className={styles.saveError}>
{t('editor.importFailed', { error: importError })}
</div>
)}
{/* Main area: palette + canvas + properties panel */}
<div className={styles.main}>
<div className={styles.canvasArea}>
{/* Overlay palettes (float over canvas, don't affect layout) */}
{state.activeTool === 'electrical' && (
<ElectricalPalette
selectedIndex={state.selectedElectricalIndex}
onSelect={(index) =>
dispatch({ type: 'SET_ELECTRICAL_INDEX', index })
}
/>
)}
{state.activeTool === 'furniture' && (
<FurniturePalette
selectedIndex={state.selectedFurnitureIndex}
onSelect={(index) =>
dispatch({ type: 'SET_FURNITURE_INDEX', index })
}
/>
)}
{/* View mode toggle: 2D / 3D / Projections */}
<div className={styles.viewToggle}>
<button
className={[styles.viewToggleBtn, viewMode === '2d' ? styles.viewToggleBtnActive : ''].join(' ')}
onClick={() => setViewMode('2d')}
>
{t('toolbar.view2D')}
</button>
<button
className={[styles.viewToggleBtn, viewMode === '3d' ? styles.viewToggleBtnActive : ''].join(' ')}
onClick={() => setViewMode('3d')}
>
{t('toolbar.view3D')}
</button>
<button
className={[styles.viewToggleBtn, viewMode === 'projections' ? styles.viewToggleBtnActive : ''].join(' ')}
onClick={() => setViewMode('projections')}
>
{t('toolbar.viewProjections')}
</button>
</div>
{viewMode === '3d' && (
<div
className={styles.canvasContainer}
ref={(el) => {
// Grab the R3F canvas element for 3D export
if (el) {
const canvas = el.querySelector('canvas');
threeCanvasRef.current = canvas;
} else {
threeCanvasRef.current = null;
}
}}
>
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
<Room3DView />
</Suspense>
</div>
)}
<div
ref={canvasContainerRef}
className={styles.canvasContainer}
style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined}
>
{/* 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 && (
<EditorCanvas
width={canvasSize.width}
height={canvasSize.height}
onStageRef={handleMainStageRef}
/>
)}
</div>
{viewMode === '2d' && (
<CableLengthStatus electricalItems={state.electricalItems} />
)}
{/* ProjectionPanel always mounted for export, hidden when not active */}
<div style={viewMode !== 'projections' ? { position: 'absolute', width: '800px', height: '400px', overflow: 'hidden', pointerEvents: 'none', opacity: 0, zIndex: -1 } : { display: 'contents' }}>
<ProjectionPanel
fullView={viewMode === 'projections'}
onStageRef={handleProjectionStageRef}
/>
</div>
</div>
<PropertiesPanel />
</div>
{/* Export Dialog */}
<ExportDialog
open={showExport}
onClose={() => setShowExport(false)}
mainStageRef={mainStageRef}
projectionStageRefs={projectionStageMapRef}
threeCanvasRef={threeCanvasRef}
is3DView={viewMode === '3d'}
viewMode={viewMode}
/>
</div>
);
}