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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user