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