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,879 @@
import {
createContext,
useContext,
useReducer,
useCallback,
useMemo,
useRef,
type ReactNode,
} from 'react';
import type {
RoomFull,
Wall,
WallOpening,
ElectricalItem,
FurnitureItem,
Point,
Annotation,
} from '@house-plan-maker/shared';
import type { EditorState, EditorAction, EditorToolType, LayerVisibility, PastePayload } from '../types';
import { DEFAULT_ZOOM, DEFAULT_GRID_SIZE } from '../types';
import { wallsFromShape } from '../utils/wallUtils';
import { generateLocalId } from '../utils/geometry';
// ── Reducer ──
function assignWallDirection(w: { startX: number; startY: number; endX: number; endY: number }, roomWidth: number, roomHeight: number): Wall['direction'] {
const dx = w.endX - w.startX;
const dy = w.endY - w.startY;
const isHorizontal = Math.abs(dx) > Math.abs(dy);
if (isHorizontal) {
return w.startY < roomHeight / 2 ? 'NORTH' : 'SOUTH';
}
return w.startX < roomWidth / 2 ? 'WEST' : 'EAST';
}
function generateWallsFromShape(room: RoomFull): readonly Wall[] {
return wallsFromShape(room.shape).map((w) => ({
...w,
id: generateLocalId(),
roomId: room.id,
direction: assignWallDirection(w, room.width ?? 0, room.height ?? 0),
}));
}
function wallsMatchShape(existingWalls: readonly Wall[], shape: readonly Point[]): boolean {
const shapeWalls = wallsFromShape(shape);
if (existingWalls.length !== shapeWalls.length) return false;
return shapeWalls.every((sw, i) => {
const ew = existingWalls[i];
return ew &&
Math.abs(ew.startX - sw.startX) < 0.001 &&
Math.abs(ew.startY - sw.startY) < 0.001 &&
Math.abs(ew.endX - sw.endX) < 0.001 &&
Math.abs(ew.endY - sw.endY) < 0.001;
});
}
function createInitialState(room: RoomFull): EditorState {
// Auto-generate walls if none exist or if they don't match the room shape
const existingMatch = room.walls.length > 0 && wallsMatchShape(room.walls, room.shape);
const walls = existingMatch ? room.walls : generateWallsFromShape(room);
return {
room,
walls,
openings: existingMatch ? room.openings : [],
electricalItems: room.electricalItems,
furnitureItems: room.furnitureItems,
selectedIds: new Set(),
activeTool: 'select',
zoom: DEFAULT_ZOOM,
panOffset: { x: 0, y: 0 },
gridSize: DEFAULT_GRID_SIZE,
gridVisible: true,
snapEnabled: true,
snapGranularity: DEFAULT_GRID_SIZE,
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
selectedElectricalIndex: null,
selectedFurnitureIndex: null,
annotations: [],
};
}
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case 'SET_ROOM': {
const room = action.room;
const existingMatch = room.walls.length > 0 && wallsMatchShape(room.walls, room.shape);
const walls = existingMatch ? room.walls : generateWallsFromShape(room);
return {
...state,
room,
walls,
openings: existingMatch ? room.openings : [],
electricalItems: room.electricalItems,
furnitureItems: room.furnitureItems,
};
}
case 'UPDATE_ROOM_PROPS':
return { ...state, room: { ...state.room, ...action.props } };
case 'SET_WALLS':
return { ...state, walls: action.walls };
case 'UPDATE_WALL':
return {
...state,
walls: state.walls.map((w) =>
w.id === action.wall.id ? action.wall : w,
),
};
case 'ADD_OPENING':
return { ...state, openings: [...state.openings, action.opening] };
case 'UPDATE_OPENING':
return {
...state,
openings: state.openings.map((o) =>
o.id === action.opening.id ? action.opening : o,
),
};
case 'REMOVE_OPENING':
return {
...state,
openings: state.openings.filter((o) => o.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'ADD_ELECTRICAL':
return {
...state,
electricalItems: [...state.electricalItems, action.item],
};
case 'UPDATE_ELECTRICAL':
return {
...state,
electricalItems: state.electricalItems.map((i) =>
i.id === action.item.id ? action.item : i,
),
};
case 'REMOVE_ELECTRICAL':
return {
...state,
electricalItems: state.electricalItems.filter((i) => i.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'ADD_FURNITURE':
return {
...state,
furnitureItems: [...state.furnitureItems, action.item],
};
case 'UPDATE_FURNITURE':
return {
...state,
furnitureItems: state.furnitureItems.map((i) =>
i.id === action.item.id ? action.item : i,
),
};
case 'REMOVE_FURNITURE':
return {
...state,
furnitureItems: state.furnitureItems.filter((i) => i.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'SET_SELECTED':
return { ...state, selectedIds: action.ids };
case 'ADD_TO_SELECTION':
return {
...state,
selectedIds: addToSet(state.selectedIds, action.id),
};
case 'REMOVE_FROM_SELECTION':
return {
...state,
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'CLEAR_SELECTION':
return { ...state, selectedIds: new Set() };
case 'SET_TOOL':
return { ...state, activeTool: action.tool, selectedIds: new Set() };
case 'SET_ZOOM':
return { ...state, zoom: action.zoom };
case 'SET_PAN_OFFSET':
return { ...state, panOffset: action.offset };
case 'SET_GRID_SIZE':
return { ...state, gridSize: action.gridSize };
case 'TOGGLE_GRID':
return { ...state, gridVisible: !state.gridVisible };
case 'TOGGLE_SNAP':
return { ...state, snapEnabled: !state.snapEnabled };
case 'SET_SNAP_GRANULARITY':
return { ...state, snapGranularity: action.granularity };
case 'TOGGLE_LAYER':
return {
...state,
layerVisibility: {
...state.layerVisibility,
[action.layer]: !state.layerVisibility[action.layer],
},
};
case 'SET_ELECTRICAL_INDEX':
return { ...state, selectedElectricalIndex: action.index };
case 'SET_FURNITURE_INDEX':
return { ...state, selectedFurnitureIndex: action.index };
case 'DELETE_SELECTED': {
return {
...state,
openings: state.openings.filter((o) => !state.selectedIds.has(o.id)),
electricalItems: state.electricalItems.filter((e) => !state.selectedIds.has(e.id)),
furnitureItems: state.furnitureItems.filter((f) => !state.selectedIds.has(f.id)),
annotations: state.annotations.filter((a) => !state.selectedIds.has(a.id)),
selectedIds: new Set(),
};
}
case 'SELECT_ALL': {
const allIds = new Set<string>();
for (const o of state.openings) allIds.add(o.id);
for (const e of state.electricalItems) allIds.add(e.id);
for (const f of state.furnitureItems) allIds.add(f.id);
for (const a of state.annotations) allIds.add(a.id);
return { ...state, selectedIds: allIds };
}
// ── Clipboard ──
case 'COPY_SELECTED':
// No state change — clipboard is handled via ref in the provider
return state;
case 'PASTE_CLIPBOARD': {
const { items } = action;
return {
...state,
openings: [...state.openings, ...items.openings],
electricalItems: [...state.electricalItems, ...items.electricalItems],
furnitureItems: [...state.furnitureItems, ...items.furnitureItems],
annotations: [...state.annotations, ...items.annotations],
selectedIds: items.newSelectedIds,
};
}
// ── Alignment ──
case 'ALIGN_SELECTED': {
if (state.selectedIds.size < 2) return state;
return applyAlignment(state, action.alignment);
}
// ── Annotations ──
case 'ADD_ANNOTATION':
return { ...state, annotations: [...state.annotations, action.annotation] };
case 'UPDATE_ANNOTATION':
return {
...state,
annotations: state.annotations.map((a) =>
a.id === action.annotation.id ? action.annotation : a,
),
};
case 'REMOVE_ANNOTATION':
return {
...state,
annotations: state.annotations.filter((a) => a.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
// ── Import ──
case 'SYNC_SAVE': {
// Build set of all new IDs to prune stale selections
const newIds = new Set<string>();
for (const w of action.walls) newIds.add(w.id);
for (const o of action.openings) newIds.add(o.id);
for (const e of action.electricalItems) newIds.add(e.id);
for (const f of action.furnitureItems) newIds.add(f.id);
// Keep only selected IDs that still exist in the new data
const prunedSelection = new Set<string>();
for (const id of state.selectedIds) {
if (newIds.has(id)) prunedSelection.add(id);
}
return {
...state,
walls: action.walls,
openings: action.openings,
electricalItems: action.electricalItems,
furnitureItems: action.furnitureItems,
selectedIds: prunedSelection,
};
}
case 'IMPORT_ROOM':
return {
...state,
room: {
...state.room,
name: action.room.name,
shape: action.room.shape,
wallHeight: action.room.wallHeight,
plinthHeight: action.room.plinthHeight,
plinthThickness: action.room.plinthThickness,
},
walls: action.walls,
openings: action.openings,
electricalItems: action.electricalItems,
furnitureItems: action.furnitureItems,
annotations: [],
selectedIds: new Set(),
};
default:
return state;
}
}
function addToSet(set: ReadonlySet<string>, id: string): ReadonlySet<string> {
const next = new Set(set);
next.add(id);
return next;
}
function removeFromSet(set: ReadonlySet<string>, id: string): ReadonlySet<string> {
if (!set.has(id)) return set;
const next = new Set(set);
next.delete(id);
return next;
}
// ── Alignment helpers ──
interface PositionedItem {
readonly id: string;
readonly x: number;
readonly y: number;
}
function getSelectedPositionedItems(state: EditorState): readonly PositionedItem[] {
const items: PositionedItem[] = [];
for (const id of state.selectedIds) {
const elec = state.electricalItems.find((e) => e.id === id);
if (elec) { items.push({ id: elec.id, x: elec.x, y: elec.y }); continue; }
const furn = state.furnitureItems.find((f) => f.id === id);
if (furn) { items.push({ id: furn.id, x: furn.x, y: furn.y }); continue; }
const ann = state.annotations.find((a) => a.id === id);
if (ann) { items.push({ id: ann.id, x: ann.x, y: ann.y }); continue; }
}
return items;
}
function applyAlignment(state: EditorState, alignment: string): EditorState {
const items = getSelectedPositionedItems(state);
if (items.length < 2) return state;
const offsets = new Map<string, { dx: number; dy: number }>();
switch (alignment) {
case 'left': {
const minX = Math.min(...items.map((i) => i.x));
for (const item of items) offsets.set(item.id, { dx: minX - item.x, dy: 0 });
break;
}
case 'right': {
const maxX = Math.max(...items.map((i) => i.x));
for (const item of items) offsets.set(item.id, { dx: maxX - item.x, dy: 0 });
break;
}
case 'top': {
const minY = Math.min(...items.map((i) => i.y));
for (const item of items) offsets.set(item.id, { dx: 0, dy: minY - item.y });
break;
}
case 'bottom': {
const maxY = Math.max(...items.map((i) => i.y));
for (const item of items) offsets.set(item.id, { dx: 0, dy: maxY - item.y });
break;
}
case 'center-h': {
const avgX = items.reduce((s, i) => s + i.x, 0) / items.length;
for (const item of items) offsets.set(item.id, { dx: avgX - item.x, dy: 0 });
break;
}
case 'center-v': {
const avgY = items.reduce((s, i) => s + i.y, 0) / items.length;
for (const item of items) offsets.set(item.id, { dx: 0, dy: avgY - item.y });
break;
}
case 'distribute-h': {
const sorted = [...items].sort((a, b) => a.x - b.x);
if (sorted.length < 3) break;
const minX = sorted[0].x;
const maxX = sorted[sorted.length - 1].x;
const step = (maxX - minX) / (sorted.length - 1);
for (let i = 0; i < sorted.length; i++) {
offsets.set(sorted[i].id, { dx: minX + step * i - sorted[i].x, dy: 0 });
}
break;
}
case 'distribute-v': {
const sorted = [...items].sort((a, b) => a.y - b.y);
if (sorted.length < 3) break;
const minY = sorted[0].y;
const maxY = sorted[sorted.length - 1].y;
const step = (maxY - minY) / (sorted.length - 1);
for (let i = 0; i < sorted.length; i++) {
offsets.set(sorted[i].id, { dx: 0, dy: minY + step * i - sorted[i].y });
}
break;
}
}
if (offsets.size === 0) return state;
const applyOffset = <T extends { readonly id: string; readonly x: number; readonly y: number }>(
list: readonly T[],
): readonly T[] =>
list.map((item) => {
const o = offsets.get(item.id);
if (!o || (o.dx === 0 && o.dy === 0)) return item;
return { ...item, x: item.x + o.dx, y: item.y + o.dy };
});
return {
...state,
electricalItems: applyOffset(state.electricalItems),
furnitureItems: applyOffset(state.furnitureItems),
annotations: applyOffset(state.annotations),
};
}
// ── ZoomPan Context ──
interface ZoomPanContextValue {
readonly zoom: number;
readonly panOffset: Point;
setZoom(zoom: number): void;
setPanOffset(offset: Point): void;
}
const ZoomPanContext = createContext<ZoomPanContextValue | null>(null);
// ── Selection Context ──
interface SelectionContextValue {
readonly selectedIds: ReadonlySet<string>;
readonly activeTool: EditorToolType;
readonly dispatch: (action: EditorAction) => void;
setTool(tool: EditorToolType): void;
selectElement(id: string, addToSelection?: boolean): void;
clearSelection(): void;
deleteSelected(): void;
selectAll(): void;
}
const SelectionContext = createContext<SelectionContextValue | null>(null);
// ── SceneData Context ──
interface SceneDataContextValue {
readonly room: RoomFull;
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly annotations: readonly Annotation[];
readonly gridSize: number;
readonly gridVisible: boolean;
readonly snapEnabled: boolean;
readonly snapGranularity: number;
readonly layerVisibility: LayerVisibility;
readonly selectedElectricalIndex: number | null;
readonly selectedFurnitureIndex: number | null;
readonly dispatch: (action: EditorAction) => void;
setWalls(walls: readonly Wall[]): void;
updateWall(wall: Wall): void;
addOpening(opening: WallOpening): void;
updateOpening(opening: WallOpening): void;
removeOpening(id: string): void;
addElectrical(item: ElectricalItem): void;
updateElectrical(item: ElectricalItem): void;
removeElectrical(id: string): void;
addFurniture(item: FurnitureItem): void;
updateFurniture(item: FurnitureItem): void;
removeFurniture(id: string): void;
addAnnotation(annotation: Annotation): void;
updateAnnotation(annotation: Annotation): void;
removeAnnotation(id: string): void;
copySelected(): void;
pasteClipboard(): void;
}
const SceneDataContext = createContext<SceneDataContextValue | null>(null);
// ── Legacy combined context (for useEditor backward compat) ──
interface EditorContextValue {
readonly state: EditorState;
readonly dispatch: (action: EditorAction) => void;
setTool(tool: EditorToolType): void;
setZoom(zoom: number): void;
setPanOffset(offset: Point): void;
selectElement(id: string, addToSelection?: boolean): void;
clearSelection(): void;
deleteSelected(): void;
selectAll(): void;
addOpening(opening: WallOpening): void;
updateOpening(opening: WallOpening): void;
removeOpening(id: string): void;
setWalls(walls: readonly Wall[]): void;
updateWall(wall: Wall): void;
addElectrical(item: ElectricalItem): void;
updateElectrical(item: ElectricalItem): void;
removeElectrical(id: string): void;
addFurniture(item: FurnitureItem): void;
updateFurniture(item: FurnitureItem): void;
removeFurniture(id: string): void;
addAnnotation(annotation: Annotation): void;
updateAnnotation(annotation: Annotation): void;
removeAnnotation(id: string): void;
copySelected(): void;
pasteClipboard(): void;
}
const EditorContext = createContext<EditorContextValue | null>(null);
// ── Provider ──
interface EditorProviderProps {
readonly room: RoomFull;
readonly children: ReactNode;
}
export function EditorProvider({ room, children }: EditorProviderProps) {
const [state, dispatch] = useReducer(editorReducer, room, createInitialState);
// ── Stable callbacks (dispatch never changes) ──
const setTool = useCallback(
(tool: EditorToolType) => dispatch({ type: 'SET_TOOL', tool }),
[],
);
const setZoom = useCallback(
(zoom: number) => dispatch({ type: 'SET_ZOOM', zoom }),
[],
);
const setPanOffset = useCallback(
(offset: Point) => dispatch({ type: 'SET_PAN_OFFSET', offset }),
[],
);
const selectElement = useCallback(
(id: string, addToSelection = false) => {
if (addToSelection) {
dispatch({ type: 'ADD_TO_SELECTION', id });
} else {
dispatch({ type: 'SET_SELECTED', ids: new Set([id]) });
}
},
[],
);
const clearSelection = useCallback(
() => dispatch({ type: 'CLEAR_SELECTION' }),
[],
);
// Task 6: dispatch-only callbacks — no closure over state arrays
const deleteSelected = useCallback(
() => dispatch({ type: 'DELETE_SELECTED' }),
[],
);
const selectAll = useCallback(
() => dispatch({ type: 'SELECT_ALL' }),
[],
);
const addOpening = useCallback(
(opening: WallOpening) => dispatch({ type: 'ADD_OPENING', opening }),
[],
);
const updateOpening = useCallback(
(opening: WallOpening) => dispatch({ type: 'UPDATE_OPENING', opening }),
[],
);
const removeOpening = useCallback(
(id: string) => dispatch({ type: 'REMOVE_OPENING', id }),
[],
);
const setWalls = useCallback(
(walls: readonly Wall[]) => dispatch({ type: 'SET_WALLS', walls }),
[],
);
const updateWall = useCallback(
(wall: Wall) => dispatch({ type: 'UPDATE_WALL', wall }),
[],
);
const addElectrical = useCallback(
(item: ElectricalItem) => dispatch({ type: 'ADD_ELECTRICAL', item }),
[],
);
const updateElectrical = useCallback(
(item: ElectricalItem) => dispatch({ type: 'UPDATE_ELECTRICAL', item }),
[],
);
const removeElectrical = useCallback(
(id: string) => dispatch({ type: 'REMOVE_ELECTRICAL', id }),
[],
);
const addFurniture = useCallback(
(item: FurnitureItem) => dispatch({ type: 'ADD_FURNITURE', item }),
[],
);
const updateFurniture = useCallback(
(item: FurnitureItem) => dispatch({ type: 'UPDATE_FURNITURE', item }),
[],
);
const removeFurniture = useCallback(
(id: string) => dispatch({ type: 'REMOVE_FURNITURE', id }),
[],
);
const addAnnotation = useCallback(
(annotation: Annotation) => dispatch({ type: 'ADD_ANNOTATION', annotation }),
[],
);
const updateAnnotation = useCallback(
(annotation: Annotation) => dispatch({ type: 'UPDATE_ANNOTATION', annotation }),
[],
);
const removeAnnotation = useCallback(
(id: string) => dispatch({ type: 'REMOVE_ANNOTATION', id }),
[],
);
// ── Clipboard (ref-based so copy reads current state without closures) ──
const clipboardRef = useRef<{
openings: readonly WallOpening[];
electricalItems: readonly ElectricalItem[];
furnitureItems: readonly FurnitureItem[];
annotations: readonly Annotation[];
}>({ openings: [], electricalItems: [], furnitureItems: [], annotations: [] });
const stateRef = useRef(state);
stateRef.current = state;
const copySelected = useCallback(() => {
const s = stateRef.current;
const ids = s.selectedIds;
if (ids.size === 0) return;
clipboardRef.current = {
openings: s.openings.filter((o) => ids.has(o.id)).map((o) => ({ ...o })),
electricalItems: s.electricalItems.filter((e) => ids.has(e.id)).map((e) => ({ ...e })),
furnitureItems: s.furnitureItems.filter((f) => ids.has(f.id)).map((f) => ({ ...f })),
annotations: s.annotations.filter((a) => ids.has(a.id)).map((a) => ({ ...a })),
};
}, []);
const pasteClipboard = useCallback(() => {
const cb = clipboardRef.current;
const hasItems =
cb.openings.length > 0 ||
cb.electricalItems.length > 0 ||
cb.furnitureItems.length > 0 ||
cb.annotations.length > 0;
if (!hasItems) return;
const PASTE_OFFSET = 0.2;
const newSelectedIds = new Set<string>();
const newOpenings = cb.openings.map((o) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...o, id: newId, positionAlongWall: o.positionAlongWall + PASTE_OFFSET };
});
const newElectrical = cb.electricalItems.map((e) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...e, id: newId, x: e.x + PASTE_OFFSET, y: e.y + PASTE_OFFSET };
});
const newFurniture = cb.furnitureItems.map((f) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...f, id: newId, x: f.x + PASTE_OFFSET, y: f.y + PASTE_OFFSET };
});
const newAnnotations = cb.annotations.map((a) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...a, id: newId, x: a.x + PASTE_OFFSET, y: a.y + PASTE_OFFSET };
});
const items: PastePayload = {
openings: newOpenings,
electricalItems: newElectrical,
furnitureItems: newFurniture,
annotations: newAnnotations,
newSelectedIds,
};
dispatch({ type: 'PASTE_CLIPBOARD', items });
}, []);
// ── ZoomPan context value (changes only on zoom/panOffset) ──
const zoomPanValue = useMemo<ZoomPanContextValue>(
() => ({ zoom: state.zoom, panOffset: state.panOffset, setZoom, setPanOffset }),
[state.zoom, state.panOffset, setZoom, setPanOffset],
);
// ── Selection context value (changes only on selection/tool) ──
const selectionValue = useMemo<SelectionContextValue>(
() => ({
selectedIds: state.selectedIds,
activeTool: state.activeTool,
dispatch,
setTool,
selectElement,
clearSelection,
deleteSelected,
selectAll,
}),
[state.selectedIds, state.activeTool, setTool, selectElement, clearSelection, deleteSelected, selectAll],
);
// ── SceneData context value (changes only on data edits) ──
const sceneDataValue = useMemo<SceneDataContextValue>(
() => ({
room: state.room,
walls: state.walls,
openings: state.openings,
electricalItems: state.electricalItems,
furnitureItems: state.furnitureItems,
annotations: state.annotations,
gridSize: state.gridSize,
gridVisible: state.gridVisible,
snapEnabled: state.snapEnabled,
snapGranularity: state.snapGranularity,
layerVisibility: state.layerVisibility,
selectedElectricalIndex: state.selectedElectricalIndex,
selectedFurnitureIndex: state.selectedFurnitureIndex,
dispatch,
setWalls,
updateWall,
addOpening,
updateOpening,
removeOpening,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
}),
[
state.room,
state.walls,
state.openings,
state.electricalItems,
state.furnitureItems,
state.annotations,
state.gridSize,
state.gridVisible,
state.snapEnabled,
state.snapGranularity,
state.layerVisibility,
state.selectedElectricalIndex,
state.selectedFurnitureIndex,
setWalls,
updateWall,
addOpening,
updateOpening,
removeOpening,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
],
);
// ── Legacy combined value ──
const legacyValue = useMemo<EditorContextValue>(
() => ({
state,
dispatch,
setTool,
setZoom,
setPanOffset,
selectElement,
clearSelection,
deleteSelected,
selectAll,
addOpening,
updateOpening,
removeOpening,
setWalls,
updateWall,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
}),
[
state,
setTool,
setZoom,
setPanOffset,
selectElement,
clearSelection,
deleteSelected,
selectAll,
addOpening,
updateOpening,
removeOpening,
setWalls,
updateWall,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
],
);
return (
<EditorContext.Provider value={legacyValue}>
<ZoomPanContext.Provider value={zoomPanValue}>
<SelectionContext.Provider value={selectionValue}>
<SceneDataContext.Provider value={sceneDataValue}>
{children}
</SceneDataContext.Provider>
</SelectionContext.Provider>
</ZoomPanContext.Provider>
</EditorContext.Provider>
);
}
// ── Hooks ──
/** Legacy hook -- combines all contexts. Use granular hooks for optimal re-render perf. */
export function useEditor(): EditorContextValue {
const ctx = useContext(EditorContext);
if (!ctx) {
throw new Error('useEditor must be used within an EditorProvider');
}
return ctx;
}
/** Granular hook for zoom/pan state (60fps changes). */
export function useZoomPan(): ZoomPanContextValue {
const ctx = useContext(ZoomPanContext);
if (!ctx) {
throw new Error('useZoomPan must be used within an EditorProvider');
}
return ctx;
}
/** Granular hook for selection/tool state. */
export function useSelection(): SelectionContextValue {
const ctx = useContext(SelectionContext);
if (!ctx) {
throw new Error('useSelection must be used within an EditorProvider');
}
return ctx;
}
/** Granular hook for scene data (walls, openings, items, grid, layers). */
export function useSceneData(): SceneDataContextValue {
const ctx = useContext(SceneDataContext);
if (!ctx) {
throw new Error('useSceneData must be used within an EditorProvider');
}
return ctx;
}
@@ -0,0 +1,106 @@
import {
createContext,
useContext,
useCallback,
useRef,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { EditorCommand } from '../types';
interface UndoRedoContextValue {
/** Execute a command and push it onto the undo stack. */
execute(command: EditorCommand): void;
/** Undo the last command. */
undo(): void;
/** Redo the last undone command. */
redo(): void;
/** Whether there are commands to undo. */
readonly canUndo: boolean;
/** Whether there are commands to redo. */
readonly canRedo: boolean;
}
const UndoRedoContext = createContext<UndoRedoContextValue | null>(null);
const MAX_UNDO_STACK = 100;
interface UndoRedoProviderProps {
readonly children: ReactNode;
/** Called whenever the stack changes so the parent can re-render. */
readonly onStackChange?: () => void;
}
export function UndoRedoProvider({ children, onStackChange }: UndoRedoProviderProps) {
const undoStackRef = useRef<EditorCommand[]>([]);
const redoStackRef = useRef<EditorCommand[]>([]);
// State counter to trigger re-renders when stack changes
const [version, setVersion] = useState(0);
const notifyChange = useCallback(() => {
setVersion((v) => v + 1);
onStackChange?.();
}, [onStackChange]);
const execute = useCallback(
(command: EditorCommand) => {
command.execute();
undoStackRef.current = [...undoStackRef.current, command].slice(
-MAX_UNDO_STACK,
);
redoStackRef.current = [];
notifyChange();
},
[notifyChange],
);
const undo = useCallback(() => {
const stack = undoStackRef.current;
if (stack.length === 0) return;
const command = stack[stack.length - 1];
command.undo();
undoStackRef.current = stack.slice(0, -1);
redoStackRef.current = [...redoStackRef.current, command];
notifyChange();
}, [notifyChange]);
const redo = useCallback(() => {
const stack = redoStackRef.current;
if (stack.length === 0) return;
const command = stack[stack.length - 1];
command.execute();
redoStackRef.current = stack.slice(0, -1);
undoStackRef.current = [...undoStackRef.current, command];
notifyChange();
}, [notifyChange]);
const canUndo = undoStackRef.current.length > 0;
const canRedo = redoStackRef.current.length > 0;
const value = useMemo<UndoRedoContextValue>(
() => ({
execute,
undo,
redo,
canUndo,
canRedo,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[execute, undo, redo, version],
);
return (
<UndoRedoContext.Provider value={value}>{children}</UndoRedoContext.Provider>
);
}
export function useUndoRedo(): UndoRedoContextValue {
const ctx = useContext(UndoRedoContext);
if (!ctx) {
throw new Error('useUndoRedo must be used within an UndoRedoProvider');
}
return ctx;
}