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(); 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(); 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(); 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, id: string): ReadonlySet { const next = new Set(set); next.add(id); return next; } function removeFromSet(set: ReadonlySet, id: string): ReadonlySet { 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(); 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 = ( 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(null); // ── Selection Context ── interface SelectionContextValue { readonly selectedIds: ReadonlySet; 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(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; 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(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(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(); 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( () => ({ 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( () => ({ 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( () => ({ 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( () => ({ 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 ( {children} ); } // ── 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; }