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'; import { formatCoordinate } from '../utils/format'; import { InteractionDispatcher } from '../interaction/dispatcher'; import { logger } from '../utils/logger'; import { CADDocument } from '../kernel/document/CADDocument'; import { YjsDocument } from '../crdt/YjsDocument'; import { setActiveDocument, getActiveDocument } from '../kernel/document/documentService'; import { pluginRegistry } from '../plugins'; 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, externalSelectionIds, selectedTemplate, bgConfig, remoteCursors, zoomCommand, groups, unit = 'mm', gridSize = 20, scaleFactor = 1, canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true, }) => { const canvasRef = useRef(null); const zoomPanRef = useRef(null); const renderEngineRef = useRef(null); const interactionRef = useRef(null); const dispatcherRef = useRef(null); /** Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */ const announcedV2Ids = useRef>(new Set()); const spatialIndexRef = useRef(null); const layerManagerRef = useRef(null); const selectionEngineRef = 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, ); // ── Task A2/A4: V2-Tool-Dispatcher hinter Feature-Flag ── // Flag VITE_TOOL_DISPATCHER=v2: aktiviert den InteractionDispatcher UND // verdrahtet ihn mit einem lokalen CADDocument + SelectionBridge (Task A4.1). if (import.meta.env.VITE_TOOL_DISPATCHER === 'v2') { const ydoc = new YjsDocument(); ydoc.data.layers.set('layer-0', { id: 'layer-0', name: 'Standard', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, } as never); const v2Doc = new CADDocument(ydoc); // App-Level-Zugriffspunkt für HistoryPanel/Tastenkürzel (Task A4.11): setActiveDocument(v2Doc); // Committete V2-Elemente fließen über den etablierten Kanal in UI/State: v2Doc.onChanged(() => { for (const el of v2Doc.getAllElements()) { if (!announcedV2Ids.current.has(el.id)) { announcedV2Ids.current.add(el.id); onElementCreated(el); } } }); dispatcherRef.current = new InteractionDispatcher(canvas, { zoomPan, setStatus: (msg) => { /* StatusBar-Bridge folgt in A4 */ }, setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null), getDoc: () => v2Doc, getSelectionBridge: () => ({ getIds: () => Array.from(selectionEngine.getSelectedIds()), setIds: (ids) => selectionEngine.selectByIds(ids, false), hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol), }), }); logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)'); } zoomPanRef.current = zoomPan; renderEngineRef.current = renderEngine; interactionRef.current = interaction; spatialIndexRef.current = spatialIndex; layerManagerRef.current = layerManager; selectionEngineRef.current = selectionEngine; interaction.setCallbacks({ onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange, }); interaction.attach(); return () => { interaction.detach(); dispatcherRef.current?.destroy(); dispatcherRef.current = null; setActiveDocument(null); zoomPanRef.current = null; renderEngineRef.current = null; interactionRef.current = null; spatialIndexRef.current = null; layerManagerRef.current = null; selectionEngineRef.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 external selection (from tree panel) to SelectionEngine const prevExternalSelectionRef = useRef([]); useEffect(() => { const selectionEngine = selectionEngineRef.current; const renderEngine = renderEngineRef.current; if (!selectionEngine || !renderEngine) return; if (!externalSelectionIds || externalSelectionIds.length === 0) return; // Avoid feedback loop: skip if selection is already identical const currentIds = Array.from(selectionEngine.getSelectedIds()).sort(); const incomingIds = [...externalSelectionIds].sort(); const same = currentIds.length === incomingIds.length && currentIds.every((id, i) => id === incomingIds[i]); if (same) return; // Avoid re-processing the same external selection we just applied const prevIds = prevExternalSelectionRef.current; const sameAsPrev = prevIds.length === incomingIds.length && prevIds.every((id, i) => id === incomingIds[i]); if (sameAsPrev) return; prevExternalSelectionRef.current = incomingIds; selectionEngine.selectByIds(externalSelectionIds, false); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [externalSelectionIds]); // 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 }); renderEngine.setOptions({ gridSize, unit, scaleFactor, showGridLabels: true }); renderEngine.setOptions({ canvasBgColor, gridColor, rulerEnabled }); interaction.setSnapEnabled(snapEnabled); interaction.setOrthoEnabled(orthoEnabled); interaction.setPolarEnabled(polarEnabled); interaction.setUnits(unit, scaleFactor); interaction.setGridSize(gridSize); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled]); // Sync active tool useEffect(() => { const interaction = interactionRef.current; if (!interaction) return; interaction.setTool(activeTool as ToolType); // Task A4.1: V2-Werkzeuge zusätzlich an den Dispatcher routen (Flag aktiv). // Legacy bleibt parallel aktiv für noch nicht migrierte Tools. dispatcherRef.current?.setActive( pluginRegistry.getToolV2(activeTool) ? activeTool : null, ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTool]); // Task A4.11: Globale Undo/Redo-Shortcuts wirken nur auf das V2-Dokument, // wenn es registriert ist (= Flag aktiv). Legacy-History bleibt unberührt. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey)) return; const doc = getActiveDocument(); if (!doc) return; // Flag aus → Legacy-Verhalten unangetastet const key = e.key.toLowerCase(); if (key === 'z' && !e.shiftKey) { e.preventDefault(); if (doc.undo()) logger.info('V2 undo'); } else if (key === 'y' || (key === 'z' && e.shiftKey)) { e.preventDefault(); if (doc.redo()) logger.info('V2 redo'); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); // 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{formatCoordinate(cursorPos.x, unit, scaleFactor)} Y{formatCoordinate(cursorPos.y, unit, scaleFactor)} {unit}
{ 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;