2026-06-26 10:50:24 +02:00
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|
|
|
|
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
|
|
|
|
polarEnabled,
|
2026-06-27 14:12:37 +02:00
|
|
|
activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged,
|
2026-06-26 10:50:24 +02:00
|
|
|
onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors,
|
|
|
|
|
}) => {
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
|
|
|
|
const renderEngineRef = useRef<RenderEngine | null>(null);
|
|
|
|
|
const interactionRef = useRef<InteractionEngine | null>(null);
|
|
|
|
|
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
2026-06-26 20:31:50 +02:00
|
|
|
const layerManagerRef = useRef<LayerManager | null>(null);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-06-26 20:31:50 +02:00
|
|
|
layerManagerRef.current = layerManager;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-06-26 20:31:50 +02:00
|
|
|
layerManagerRef.current = null;
|
2026-06-26 10:50:24 +02:00
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-26 18:57:09 +02:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
// 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;
|
2026-06-26 20:31:50 +02:00
|
|
|
const layerManager = layerManagerRef.current;
|
2026-06-26 10:50:24 +02:00
|
|
|
if (!renderEngine) return;
|
2026-06-26 20:31:50 +02:00
|
|
|
if (layerManager) {
|
|
|
|
|
layerManager.clear();
|
|
|
|
|
layers.forEach(l => layerManager.addLayer(l));
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
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]);
|
|
|
|
|
|
2026-06-27 14:12:37 +02:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
// 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<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
|
|
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="canvas-area" id="canvas-area" aria-label="CAD Zeichenfläche">
|
|
|
|
|
<div className="canvas-coords" role="status" aria-live="polite">
|
|
|
|
|
<span><span className="canvas-coords-label">X</span><span className="canvas-coords-val" id="coord-x">{cursorPos.x.toFixed(3)}</span></span>
|
|
|
|
|
<span><span className="canvas-coords-label">Y</span><span className="canvas-coords-val" id="coord-y">{cursorPos.y.toFixed(3)}</span></span>
|
|
|
|
|
<span style={{ color: 'var(--color-text-faint)' }}>m</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<canvas
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
className="canvas-svg"
|
|
|
|
|
role="img"
|
|
|
|
|
aria-label="CAD Zeichenfläche"
|
|
|
|
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
|
|
|
|
|
onDrop={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const blockId = e.dataTransfer.getData('text/block-id');
|
|
|
|
|
if (!blockId || !onBlockDrop) return;
|
|
|
|
|
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);
|
|
|
|
|
onBlockDrop(blockId, world.x, world.y);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{cursorScreenPositions.map(({ cursor, sx, sy }) => (
|
|
|
|
|
<div
|
|
|
|
|
key={cursor.userId}
|
|
|
|
|
className="remote-cursor"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
left: `${sx}px`,
|
|
|
|
|
top: `${sy}px`,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
zIndex: 1000,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
width: '12px',
|
|
|
|
|
height: '12px',
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
backgroundColor: cursor.color,
|
|
|
|
|
border: '2px solid white',
|
|
|
|
|
boxShadow: '0 0 4px rgba(0,0,0,0.5)',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
left: '16px',
|
|
|
|
|
top: '8px',
|
|
|
|
|
backgroundColor: cursor.color,
|
|
|
|
|
color: 'white',
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontFamily: 'sans-serif',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.4)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{cursor.userName}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<div className="canvas-toolbar" role="toolbar" aria-label="Canvas-Werkzeuge">
|
|
|
|
|
<button className="canvas-toolbar-btn" title="Herauszoomen (-)" aria-label="Herauszoomen" onClick={handleZoomOut}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<span className="canvas-zoom-display" id="zoom-display">100%</span>
|
|
|
|
|
<button className="canvas-toolbar-btn" title="Hineinzoomen (+)" aria-label="Hineinzoomen" onClick={handleZoomIn}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button className="canvas-toolbar-btn" title="Zoom anpassen (F)" aria-label="Zoom anpassen" onClick={handleZoomFit}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div className="canvas-toolbar-divider"></div>
|
|
|
|
|
<button className={`canvas-toolbar-btn${gridEnabled ? ' active' : ''}`} title="Grid ein/aus (F7)" aria-label="Grid umschalten" aria-pressed={gridEnabled} onClick={onToggleGrid}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button className={`canvas-toolbar-btn${orthoEnabled ? ' active' : ''}`} title="Ortho-Modus (F8)" aria-label="Ortho-Modus umschalten" aria-pressed={orthoEnabled} onClick={onToggleOrtho}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M3 21l18-18"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button className={`canvas-toolbar-btn${snapEnabled ? ' active' : ''}`} title="Snap ein/aus (F3)" aria-label="Snap umschalten" aria-pressed={snapEnabled} onClick={onToggleSnap}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M2 12h4M18 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8"/><circle cx="12" cy="12" r="3"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div className="canvas-toolbar-divider"></div>
|
|
|
|
|
<button className="canvas-toolbar-btn" title="Vorherige Ansicht" aria-label="Vorherige Ansicht">
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default CanvasArea;
|