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:
2026-04-05 22:34:03 +03:00
parent b84807bbdb
commit af8b9fe00f
188 changed files with 35795 additions and 0 deletions
@@ -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>
);
}