feat: complete house plan maker application
Full-featured house/apartment floor plan editor with: - Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas) - 2D room editor with walls, doors, windows, furniture, electrical elements - 3D room preview with Three.js (auto-hide nearest walls, bird's eye default) - Wall projection views with interactive drag (elevation, position) - Apartment floor plan view with room positioning - Copy/paste, alignment tools, measurement tool, annotations - Item-attached annotations with leader lines (visible on projections) - Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc - Floor type textures (wood, tile, concrete, laminate, herringbone) - Wall color picker for 3D view - Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit - Furniture elevation support (wall-mounted items) - Auto-save with dirty state tracking, batch save API - Rotation-aware collision detection (SAT/OBB) with 3D elevation check - Rotation-aware hit testing - i18n (English/Russian) with locale-aware number formatting - Dark mode with system preference detection - Undo/redo, keyboard shortcuts, scale bar - PDF/PNG/JSON export and JSON import - Focus trap modal, toast notifications, tooltips - Responsive layout with overlay palettes
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
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 { 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,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
CreateWallOpeningDto,
|
||||
UpdateWallOpeningDto,
|
||||
CreateElectricalItemDto,
|
||||
UpdateElectricalItemDto,
|
||||
CreateFurnitureItemDto,
|
||||
UpdateFurnitureItemDto,
|
||||
} 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);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
|
||||
|
||||
// ── Dirty tracking ──
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const lastSavedRef = useRef({
|
||||
walls: state.walls,
|
||||
openings: state.openings,
|
||||
electricalItems: state.electricalItems,
|
||||
furnitureItems: state.furnitureItems,
|
||||
});
|
||||
|
||||
// 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;
|
||||
setIsDirty(dirty);
|
||||
}, [state.walls, state.openings, state.electricalItems, state.furnitureItems]);
|
||||
|
||||
// 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 observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setCanvasSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
// Initial size
|
||||
const rect = container.getBoundingClientRect();
|
||||
setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// ── Center room in canvas on first mount ──
|
||||
const hasCenteredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasCenteredRef.current) 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;
|
||||
|
||||
// Fit room in canvas with some padding
|
||||
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;
|
||||
|
||||
dispatch({ type: 'SET_ZOOM', zoom: fitZoom });
|
||||
dispatch({ type: 'SET_PAN_OFFSET', offset: { x: panX, y: panY } });
|
||||
hasCenteredRef.current = true;
|
||||
}, [canvasSize, state.room.shape, dispatch]);
|
||||
|
||||
// ── 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 {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
// 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: elec.rotation,
|
||||
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,
|
||||
});
|
||||
} else if (serverFurnIds.has(furn.id)) {
|
||||
const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id);
|
||||
if (serverFurn) {
|
||||
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;
|
||||
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: furn.rotation,
|
||||
elevationFromFloor: furn.elevationFromFloor,
|
||||
label: furn.label,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const serverFurn of freshRoom.furnitureItems) {
|
||||
if (!localFurnIds.has(serverFurn.id)) {
|
||||
furnDelete.push(serverFurn.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Execute all 3 batch calls in parallel — responses contain final server state
|
||||
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. Sync state with server-assigned IDs (single dispatch, no flicker)
|
||||
dispatch({
|
||||
type: 'SYNC_SAVE',
|
||||
walls: serverWalls,
|
||||
openings: syncedOpenings,
|
||||
electricalItems: syncedElectrical,
|
||||
furnitureItems: syncedFurniture,
|
||||
});
|
||||
|
||||
// Mark state as clean after successful save
|
||||
lastSavedRef.current = {
|
||||
walls: serverWalls,
|
||||
openings: syncedOpenings,
|
||||
electricalItems: syncedElectrical,
|
||||
furnitureItems: syncedFurniture,
|
||||
};
|
||||
} 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, 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}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user