Files
house-plan-maker/apps/client/src/components/editor/context/EditorContext.tsx
T
alexei.dolgolyov 521ea5e85b feat: add outlet direction (horizontal/vertical), wall light styles, floor textures, and stretch ceiling
- Add configurable outlet direction (horizontal/vertical) stored in metadata
- Add wall light style variants (classic, pendant-globe, sconce-up, sconce-down)
- Add PBR floor textures including natural oak
- Add stretch ceiling offset support with DB migration
- Add furniture surface texture selection
- Add canvas theme colors utility for dark mode support
- Update projection views with improved rendering
- Add EN and RU translations for all new properties
2026-04-12 20:52:49 +03:00

926 lines
29 KiB
TypeScript

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, stretchCeiling: true },
selectedElectricalIndex: null,
selectedFurnitureIndex: null,
annotations: room.annotations ?? [],
furnitureProjectionIds: new Set(),
globalFurnitureOpacity: 1,
};
}
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,
annotations: room.annotations ?? state.annotations,
};
}
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_VIEW':
return { ...state, zoom: action.zoom, 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),
};
case 'TOGGLE_FURNITURE_PROJECTION': {
const next = new Set(state.furnitureProjectionIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return { ...state, furnitureProjectionIds: next };
}
case 'SET_GLOBAL_FURNITURE_OPACITY': {
const clamped = Math.min(1, Math.max(0, action.opacity));
return { ...state, globalFurnitureOpacity: clamped };
}
// ── Import ──
case 'SYNC_SAVE': {
// Build set of all new IDs so we can prune any selection that did not survive
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);
// Remap selected IDs through the id map (so freshly created server items
// stay selected). Fall back to the original id when no mapping is given.
const idMap = action.idMap;
const remappedSelection = new Set<string>();
for (const id of state.selectedIds) {
const next = idMap?.get(id) ?? id;
if (newIds.has(next)) remappedSelection.add(next);
}
// Use server annotations when provided; otherwise just remap attached ids
// for the existing client-only annotation list.
let remappedAnnotations = action.annotations
? [...action.annotations]
: state.annotations;
if (idMap) {
remappedAnnotations = remappedAnnotations.map((a) =>
a.attachedToId && idMap.has(a.attachedToId)
? { ...a, attachedToId: idMap.get(a.attachedToId)! }
: a,
);
}
return {
...state,
walls: action.walls,
openings: action.openings,
electricalItems: action.electricalItems,
furnitureItems: action.furnitureItems,
selectedIds: remappedSelection,
annotations: remappedAnnotations,
};
}
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 furnitureProjectionIds: ReadonlySet<string>;
readonly globalFurnitureOpacity: number;
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;
toggleFurnitureProjection(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;
toggleFurnitureProjection(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 }),
[],
);
const toggleFurnitureProjection = useCallback(
(id: string) => dispatch({ type: 'TOGGLE_FURNITURE_PROJECTION', 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,
furnitureProjectionIds: state.furnitureProjectionIds,
globalFurnitureOpacity: state.globalFurnitureOpacity,
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,
toggleFurnitureProjection,
copySelected,
pasteClipboard,
}),
[
state.room,
state.walls,
state.openings,
state.electricalItems,
state.furnitureItems,
state.annotations,
state.furnitureProjectionIds,
state.globalFurnitureOpacity,
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,
toggleFurnitureProjection,
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,
toggleFurnitureProjection,
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;
}