d8a914bf2a
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.
822 lines
29 KiB
TypeScript
822 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|