import React, { useRef, useEffect, useState } from 'react'; import type { CanvasAreaProps, ViewMode } from '../types/ui.types'; import type { CADElement } from '../types/cad.types'; import type { ToolType } from '../types/cad.types'; import type { UserCursor } from '../crdt'; import { RenderEngine } from '../canvas/RenderEngine'; import { ZoomPanController } from '../canvas/ZoomPanController'; import { InteractionEngine } from '../interaction'; import { SnapEngine } from '../canvas/SnapEngine'; import { SelectionEngine } from '../canvas/SelectionEngine'; import { SpatialIndex } from '../canvas/SpatialIndex'; import { LayerManager } from '../canvas/LayerManager'; import { GroupManager } from '../tools/modification/GroupTool'; const CanvasArea: React.FC = ({ cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, polarEnabled, activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors, zoomCommand, groups, }) => { const canvasRef = useRef(null); const zoomPanRef = useRef(null); const renderEngineRef = useRef(null); const interactionRef = useRef(null); const spatialIndexRef = useRef(null); const layerManagerRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const zoomPan = new ZoomPanController(canvas); const spatialIndex = new SpatialIndex(); const layerManager = new LayerManager(); const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager); const snapEngine = new SnapEngine(); const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); const interaction = new InteractionEngine( canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager, ); zoomPanRef.current = zoomPan; renderEngineRef.current = renderEngine; interactionRef.current = interaction; spatialIndexRef.current = spatialIndex; layerManagerRef.current = layerManager; interaction.setCallbacks({ onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange, }); interaction.attach(); return () => { interaction.detach(); zoomPanRef.current = null; renderEngineRef.current = null; interactionRef.current = null; spatialIndexRef.current = null; layerManagerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Update callbacks when they change (avoids stale closures) useEffect(() => { const interaction = interactionRef.current; if (!interaction) return; interaction.setCallbacks({ onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange]); // Resize canvas to fill container useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const container = canvas.parentElement; if (!container) return; const resize = () => { const w = container.clientWidth; const h = container.clientHeight; if (w > 0 && h > 0) { canvas.width = w; canvas.height = h; renderEngineRef.current?.render(); } }; resize(); const ro = new ResizeObserver(resize); ro.observe(container); window.addEventListener('resize', resize); return () => { ro.disconnect(); window.removeEventListener('resize', resize); }; }, []); // Sync elements to interaction engine + spatial index + render useEffect(() => { const interaction = interactionRef.current; const spatialIndex = spatialIndexRef.current; const renderEngine = renderEngineRef.current; if (!interaction || !spatialIndex || !renderEngine) return; interaction.setElements(elements); spatialIndex.clear(); spatialIndex.bulkInsert(elements); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [elements]); // Sync layers to LayerManager + render useEffect(() => { const renderEngine = renderEngineRef.current; const layerManager = layerManagerRef.current; if (!renderEngine) return; if (layerManager) { layerManager.clear(); layers.forEach(l => layerManager.addLayer(l)); } renderEngine.setLayers(layers); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layers]); // Sync blocks to RenderEngine useEffect(() => { const renderEngine = renderEngineRef.current; if (!renderEngine || !blocks) return; renderEngine.setBlockDefinitions(blocks); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [blocks]); // Sync groups to RenderEngine and InteractionEngine useEffect(() => { const renderEngine = renderEngineRef.current; const interaction = interactionRef.current; if (!renderEngine) return; renderEngine.setGroups(groups ?? []); renderEngine.render(); // InteractionEngine uses GroupManager from App — we pass it via a simple adapter if (interaction && groups) { const gm = new GroupManager(); gm.fromJSON(groups); interaction.setGroupManager(gm); } else if (interaction) { interaction.setGroupManager(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups]); // Sync active layer to LayerManager useEffect(() => { const layerManager = layerManagerRef.current; if (!layerManager || !activeLayerId) return; layerManager.setActiveLayer(activeLayerId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeLayerId, layers]); // Sync grid/snap/ortho toggles useEffect(() => { const renderEngine = renderEngineRef.current; const interaction = interactionRef.current; if (!renderEngine || !interaction) return; renderEngine.setOptions({ showGrid: gridEnabled }); renderEngine.setOptions({ showSnapPoints: snapEnabled }); renderEngine.setOptions({ showOrtho: orthoEnabled }); interaction.setSnapEnabled(snapEnabled); interaction.setOrthoEnabled(orthoEnabled); interaction.setPolarEnabled(polarEnabled); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled]); // Sync active tool useEffect(() => { const interaction = interactionRef.current; if (!interaction) return; interaction.setTool(activeTool as ToolType); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTool]); // Sync selected template to interaction engine useEffect(() => { const interaction = interactionRef.current; if (!interaction) return; interaction.setSelectedTemplate(selectedTemplate ?? null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedTemplate]); // Compute screen positions for remote cursors const [cursorScreenPositions, setCursorScreenPositions] = useState>([]); useEffect(() => { const zoomPan = zoomPanRef.current; if (!zoomPan || !remoteCursors || remoteCursors.length === 0) { setCursorScreenPositions([]); return; } const positions = remoteCursors .filter((c) => c.visible) .map((cursor) => { const screen = zoomPan.worldToScreen(cursor.x, cursor.y); return { cursor, sx: screen.x, sy: screen.y }; }); setCursorScreenPositions(positions); // eslint-disable-next-line react-hooks/exhaustive-deps }, [remoteCursors]); // Re-render cursor overlay when canvas renders (zoom/pan changes) useEffect(() => { const renderEngine = renderEngineRef.current; const zoomPan = zoomPanRef.current; if (!renderEngine || !zoomPan || !remoteCursors || remoteCursors.length === 0) return; const origRender = renderEngine.render.bind(renderEngine); renderEngine.render = () => { origRender(); const positions = remoteCursors .filter((c) => c.visible) .map((cursor) => { const screen = zoomPan.worldToScreen(cursor.x, cursor.y); return { cursor, sx: screen.x, sy: screen.y }; }); setCursorScreenPositions(positions); }; return () => { renderEngine.render = origRender; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [remoteCursors]); const handleZoomIn = () => { const zoomPan = zoomPanRef.current; const canvas = canvasRef.current; if (zoomPan && canvas) { zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 1.2); renderEngineRef.current?.render(); } onZoomIn(); }; const handleZoomOut = () => { const zoomPan = zoomPanRef.current; const canvas = canvasRef.current; if (zoomPan && canvas) { zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 0.8); renderEngineRef.current?.render(); } onZoomOut(); }; const handleZoomFit = () => { const interaction = interactionRef.current; if (interaction) { interaction.zoomFit(); } onZoomFit(); }; // Handle zoom commands from App (ribbon/menu actions) useEffect(() => { if (!zoomCommand) return; const zoomPan = zoomPanRef.current; const canvas = canvasRef.current; if (!zoomPan || !canvas) return; switch (zoomCommand) { case 'in': zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 1.2); break; case 'out': zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 0.8); break; case 'fit': zoomPan.zoomFit(elements); break; case '100': zoomPan.reset(); break; } renderEngineRef.current?.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [zoomCommand]); return (
X{cursorPos.x.toFixed(3)} Y{cursorPos.y.toFixed(3)} m
{ e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }} onDrop={(e) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const sx = e.clientX - rect.left; const sy = e.clientY - rect.top; const zoomPan = zoomPanRef.current; if (!zoomPan) return; const world = zoomPan.screenToWorld(sx, sy); // Project block drop const blockId = e.dataTransfer.getData('text/block-id'); if (blockId && onBlockDrop) { onBlockDrop(blockId, world.x, world.y); return; } // Global block drop — parse block_data and create element(s) const globalBlockData = e.dataTransfer.getData('text/global-block-data'); if (globalBlockData) { try { const parsed = JSON.parse(globalBlockData); if (parsed.type === 'svg' && parsed.svg) { // SVG block — create a block_instance element const svgEl: CADElement = { id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, type: 'block_instance', layerId: 'layer-0', x: world.x, y: world.y, width: 100, height: 100, properties: { blockId: `svg_${Date.now()}`, svgData: parsed.svg, scale: 1 }, }; onElementCreated(svgEl); } else if (Array.isArray(parsed)) { // Array of elements — create them with offset parsed.forEach((el: CADElement, i: number) => { onElementCreated({ ...el, id: `el_${Date.now()}_${i}`, x: (el.x ?? 0) + world.x, y: (el.y ?? 0) + world.y, }); }); } else if (parsed.type) { // Single element onElementCreated({ ...parsed, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, x: world.x, y: world.y, }); } } catch { // Invalid block data — ignore } } }} /> {cursorScreenPositions.map(({ cursor, sx, sy }) => (
{cursor.userName}
))}
100%
); }; export default CanvasArea;