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.
This commit is contained in:
2026-04-08 12:27:57 +03:00
parent aa8a874348
commit d8a914bf2a
116 changed files with 7324 additions and 1114 deletions
@@ -5,6 +5,7 @@ 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';
@@ -24,6 +25,8 @@ import {
batchSyncOpenings,
batchSyncElectrical,
batchSyncFurniture,
batchSyncAnnotations,
updateRoom,
} from '../../api/client';
import type {
CreateWallOpeningDto,
@@ -32,6 +35,8 @@ import type {
UpdateElectricalItemDto,
CreateFurnitureItemDto,
UpdateFurnitureItemDto,
CreateAnnotationDto,
UpdateAnnotationDto,
} from '@house-plan-maker/shared';
import styles from './room-editor-layout.module.css';
@@ -51,7 +56,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
const [viewMode, setViewMode] = useState<ViewMode>('2d');
const [showExport, setShowExport] = useState(false);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
// 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);
@@ -60,6 +68,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
openings: state.openings,
electricalItems: state.electricalItems,
furnitureItems: state.furnitureItems,
room: state.room,
});
// Mark dirty when state diverges from last saved snapshot
@@ -69,9 +78,33 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
state.walls !== saved.walls ||
state.openings !== saved.openings ||
state.electricalItems !== saved.electricalItems ||
state.furnitureItems !== saved.furnitureItems;
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.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(() => {
@@ -123,10 +156,21 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
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) {
const { width, height } = entry.contentRect;
setCanvasSize({ width: Math.floor(width), height: Math.floor(height) });
commitSize(entry.contentRect.width, entry.contentRect.height);
}
});
@@ -134,15 +178,23 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
// Initial size
const rect = container.getBoundingClientRect();
setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
commitSize(rect.width, rect.height);
return () => observer.disconnect();
}, []);
// ── Center room in canvas on first mount ──
const hasCenteredRef = useRef(false);
// ── 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 (hasCenteredRef.current) return;
if (viewMode !== '2d') return;
if (!canvasSize) return;
if (canvasSize.width <= 100 || canvasSize.height <= 100) return;
if (state.room.shape.length === 0) return;
@@ -151,7 +203,14 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
const roomH = bbox.maxY - bbox.minY;
if (roomW <= 0 || roomH <= 0) return;
// Fit room in canvas with some padding
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;
@@ -162,10 +221,27 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
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]);
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(() => {
@@ -192,6 +268,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
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,
@@ -237,6 +326,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
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
@@ -255,7 +349,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
y: elec.y,
wallId: serverWallId,
elevationFromFloor: elec.elevationFromFloor,
rotation: elec.rotation,
rotation: normalizeAngleDegrees(elec.rotation ?? 0),
count: elec.count,
positionAnchor: elec.positionAnchor,
label: elec.label,
metadata: elec.metadata,
});
}
@@ -280,10 +377,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
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 ||
@@ -292,7 +398,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
serverFurn.height !== furn.height ||
serverFurn.rotation !== furn.rotation ||
serverFurn.elevationFromFloor !== furn.elevationFromFloor ||
serverFurn.label !== furn.label;
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,
@@ -303,9 +413,13 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
width: furn.width,
depth: furn.depth,
height: furn.height,
rotation: furn.rotation,
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,
},
});
}
@@ -319,7 +433,9 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
}
}
// 6. Execute all 3 batch calls in parallel — responses contain final server state
// 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,
@@ -338,13 +454,132 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
}),
]);
// 7. Sync state with server-assigned IDs (single dispatch, no flicker)
// 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
@@ -353,6 +588,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
openings: syncedOpenings,
electricalItems: syncedElectrical,
furnitureItems: syncedFurniture,
room: state.room,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('editor.error.load');
@@ -361,7 +597,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
setIsSaving(false);
isSavingRef.current = false;
}
}, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, dispatch]);
}, [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);
@@ -544,11 +780,16 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
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}
/>
{/* 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} />