05ccebad8d
- Restore TS frontend source (canvas, tools, CRDT, components, services) - Keep 5 JSX components that TS code depends on (CADCanvas, LayerPanel, BlockLibrary, PluginRegistry, Toolbar) - Keep JS services (api.js, blockService.js) that components depend on - Fix vite/plugin-react version mismatch (downgrade to v4 for vite 6) - Add axios dependency - Skip tsc in build script (vite/esbuild handles transpilation) - Fix index.html lang=de, favicon path - Add vite proxy config for /api and /ws - Backend unchanged (already from deployed containers)
285 lines
10 KiB
React
285 lines
10 KiB
React
import React, { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 'react';
|
|
import { fabric } from 'fabric';
|
|
|
|
const CADCanvas = forwardRef(({ activeTool, setActiveTool, onCanvasReady }, ref) => {
|
|
const canvasEl = useRef(null);
|
|
const fabricRef = useRef(null);
|
|
const [zoom, setZoom] = useState(1);
|
|
const isDrawing = useRef(false);
|
|
const startPoint = useRef(null);
|
|
const tempShape = useRef(null);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getCanvas: () => fabricRef.current,
|
|
toJSON: () => fabricRef.current?.toJSON(),
|
|
loadFromJSON: (json) => {
|
|
if (fabricRef.current) {
|
|
fabricRef.current.loadFromJSON(json, fabricRef.current.renderAll.bind(fabricRef.current));
|
|
}
|
|
},
|
|
addSvgObject: (svgStr) => {
|
|
if (!fabricRef.current) return;
|
|
fabric.loadSVGFromString(svgStr, (objects, options) => {
|
|
const group = fabric.util.groupSVGElements(objects, options);
|
|
group.set({ left: 100, top: 100, scaleX: 0.5, scaleY: 0.5 });
|
|
fabricRef.current.add(group);
|
|
fabricRef.current.setActiveObject(group);
|
|
fabricRef.current.renderAll();
|
|
});
|
|
},
|
|
addObjects: (objects) => {
|
|
if (!fabricRef.current) return;
|
|
objects.forEach(obj => {
|
|
let shape;
|
|
if (obj.type === 'line') {
|
|
shape = new fabric.Line([obj.x1, obj.y1, obj.x2, obj.y2], { stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
|
|
} else if (obj.type === 'circle') {
|
|
shape = new fabric.Circle({ left: obj.left, top: obj.top, radius: obj.radius, fill: obj.fill || 'transparent', stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
|
|
} else if (obj.type === 'polygon') {
|
|
shape = new fabric.Polygon(obj.points.map(p => ({ x: p.x, y: p.y })), { fill: obj.fill || 'transparent', stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
|
|
} else if (obj.type === 'rect') {
|
|
shape = new fabric.Rect({ left: obj.left, top: obj.top, width: obj.width, height: obj.height, fill: obj.fill || 'transparent', stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
|
|
}
|
|
if (shape) fabricRef.current.add(shape);
|
|
});
|
|
fabricRef.current.renderAll();
|
|
},
|
|
// Get selected elements for block creation
|
|
getSelectedElements: () => {
|
|
if (!fabricRef.current) return [];
|
|
const activeObject = fabricRef.current.getActiveObject();
|
|
if (!activeObject) return [];
|
|
|
|
// If it's a group, return all objects in the group
|
|
if (activeObject.type === 'group') {
|
|
return activeObject.getObjects().map(obj => ({
|
|
id: obj.id || Date.now() + Math.random(),
|
|
type: obj.type,
|
|
...obj.toObject(['id', 'type', 'left', 'top', 'width', 'height', 'radius', 'x1', 'y1', 'x2', 'y2', 'points', 'fill', 'stroke', 'strokeWidth'])
|
|
}));
|
|
}
|
|
|
|
// If it's a single object, return it
|
|
return [{
|
|
id: activeObject.id || Date.now() + Math.random(),
|
|
type: activeObject.type,
|
|
...activeObject.toObject(['id', 'type', 'left', 'top', 'width', 'height', 'radius', 'x1', 'y1', 'x2', 'y2', 'points', 'fill', 'stroke', 'strokeWidth'])
|
|
}];
|
|
},
|
|
// Generate SVG data for selected elements
|
|
generateSvgData: () => {
|
|
if (!fabricRef.current) return '';
|
|
const activeObject = fabricRef.current.getActiveObject();
|
|
if (!activeObject) return '';
|
|
|
|
// Clone the object to avoid modifying the original
|
|
return activeObject.toSVG();
|
|
}
|
|
}));
|
|
|
|
// Initialize fabric canvas
|
|
useEffect(() => {
|
|
if (canvasEl.current && !fabricRef.current) {
|
|
fabricRef.current = new fabric.Canvas(canvasEl.current, {
|
|
width: canvasEl.current.parentElement.clientWidth,
|
|
height: canvasEl.current.parentElement.clientHeight,
|
|
backgroundColor: '#fff',
|
|
selection: true,
|
|
});
|
|
|
|
if (onCanvasReady) onCanvasReady(fabricRef.current);
|
|
|
|
const handleResize = () => {
|
|
if (canvasEl.current && fabricRef.current) {
|
|
fabricRef.current.setWidth(canvasEl.current.parentElement.clientWidth);
|
|
fabricRef.current.setHeight(canvasEl.current.parentElement.clientHeight);
|
|
fabricRef.current.renderAll();
|
|
}
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
if (fabricRef.current) {
|
|
fabricRef.current.dispose();
|
|
fabricRef.current = null;
|
|
}
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
// Zoom with mouse wheel
|
|
useEffect(() => {
|
|
if (!fabricRef.current) return;
|
|
const canvas = fabricRef.current;
|
|
const handleWheel = (opt) => {
|
|
const delta = opt.e.deltaY;
|
|
let zoom = canvas.getZoom();
|
|
zoom *= 0.999 ** delta;
|
|
if (zoom > 20) zoom = 20;
|
|
if (zoom < 0.01) zoom = 0.01;
|
|
canvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
|
|
opt.e.preventDefault();
|
|
opt.e.stopPropagation();
|
|
setZoom(Math.round(zoom * 100) / 100);
|
|
};
|
|
canvas.on('mouse:wheel', handleWheel);
|
|
return () => canvas.off('mouse:wheel', handleWheel);
|
|
}, []);
|
|
|
|
// Drawing tool behavior
|
|
useEffect(() => {
|
|
if (!fabricRef.current) return;
|
|
const canvas = fabricRef.current;
|
|
|
|
canvas.off('mouse:down');
|
|
canvas.off('mouse:move');
|
|
canvas.off('mouse:up');
|
|
|
|
if (activeTool === 'select') {
|
|
canvas.isDrawingMode = false;
|
|
canvas.selection = true;
|
|
canvas.forEachObject(obj => obj.selectable = true);
|
|
return;
|
|
}
|
|
|
|
canvas.selection = false;
|
|
canvas.forEachObject(obj => obj.selectable = false);
|
|
|
|
if (activeTool === 'freehand') {
|
|
canvas.isDrawingMode = true;
|
|
canvas.freeDrawingBrush.width = 2;
|
|
canvas.freeDrawingBrush.color = '#000';
|
|
return;
|
|
}
|
|
|
|
canvas.isDrawingMode = false;
|
|
|
|
canvas.on('mouse:down', (opt) => {
|
|
isDrawing.current = true;
|
|
const pointer = canvas.getPointer(opt.e);
|
|
startPoint.current = { x: pointer.x, y: pointer.y };
|
|
|
|
if (activeTool === 'line') {
|
|
tempShape.current = new fabric.Line(
|
|
[pointer.x, pointer.y, pointer.x, pointer.y],
|
|
{ stroke: '#000', strokeWidth: 2 }
|
|
);
|
|
} else if (activeTool === 'rect') {
|
|
tempShape.current = new fabric.Rect({
|
|
left: pointer.x,
|
|
top: pointer.y,
|
|
width: 0,
|
|
height: 0,
|
|
fill: 'transparent',
|
|
stroke: '#000',
|
|
strokeWidth: 2,
|
|
});
|
|
} else if (activeTool === 'circle') {
|
|
tempShape.current = new fabric.Circle({
|
|
left: pointer.x,
|
|
top: pointer.y,
|
|
radius: 0,
|
|
fill: 'transparent',
|
|
stroke: '#000',
|
|
strokeWidth: 2,
|
|
});
|
|
} else if (activeTool === 'text') {
|
|
const text = new fabric.IText('Text', {
|
|
left: pointer.x,
|
|
top: pointer.y,
|
|
fontSize: 20,
|
|
fill: '#000',
|
|
});
|
|
canvas.add(text);
|
|
canvas.setActiveObject(text);
|
|
isDrawing.current = false;
|
|
return;
|
|
} else if (activeTool === 'dimension') {
|
|
const line = new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], {
|
|
stroke: '#e74c3c', strokeWidth: 1, strokeDashArray: [5, 5]
|
|
});
|
|
const tick1 = new fabric.Line([pointer.x, pointer.y, pointer.x+10, pointer.y], { stroke: '#e74c3c', strokeWidth: 1 });
|
|
const tick2 = new fabric.Line([pointer.x+10, pointer.y, pointer.x+10, pointer.y+10], { stroke: '#e74c3c', strokeWidth: 1 });
|
|
const text = new fabric.Text('0', {
|
|
left: pointer.x, top: pointer.y - 20, fontSize: 12, fill: '#e74c3c',
|
|
});
|
|
const group = new fabric.Group([line, tick1, tick2, text], {
|
|
selectable: false, hasControls: false, hasBorders: false,
|
|
});
|
|
tempShape.current = { group, line, text, tick1, tick2, start: {x: pointer.x, y: pointer.y} };
|
|
canvas.add(group);
|
|
}
|
|
if (tempShape.current && !(activeTool === 'dimension')) {
|
|
canvas.add(tempShape.current);
|
|
}
|
|
});
|
|
|
|
canvas.on('mouse:move', (opt) => {
|
|
if (!isDrawing.current || !tempShape.current) return;
|
|
const pointer = canvas.getPointer(opt.e);
|
|
|
|
if (activeTool === 'line') {
|
|
tempShape.current.set({ x2: pointer.x, y2: pointer.y });
|
|
} else if (activeTool === 'rect') {
|
|
const start = startPoint.current;
|
|
let left = Math.min(start.x, pointer.x);
|
|
let top = Math.min(start.y, pointer.y);
|
|
let width = Math.abs(start.x - pointer.x);
|
|
let height = Math.abs(start.y - pointer.y);
|
|
tempShape.current.set({ left, top, width, height });
|
|
} else if (activeTool === 'circle') {
|
|
const start = startPoint.current;
|
|
const radius = Math.sqrt(
|
|
(pointer.x - start.x) ** 2 + (pointer.y - start.y) ** 2
|
|
) / 2;
|
|
tempShape.current.set({
|
|
left: start.x - radius,
|
|
top: start.y - radius,
|
|
radius: radius,
|
|
});
|
|
} else if (activeTool === 'dimension') {
|
|
const { group, line, text, tick1, tick2, start } = tempShape.current;
|
|
// Update line
|
|
line.set({ x2: pointer.x, y2: pointer.y });
|
|
// Compute distance
|
|
const dx = pointer.x - start.x;
|
|
const dy = pointer.y - start.y;
|
|
const dist = Math.sqrt(dx*dx + dy*dy).toFixed(1);
|
|
text.set({ text: dist });
|
|
// Update text position to middle of line
|
|
const midX = (start.x + pointer.x) / 2;
|
|
const midY = (start.y + pointer.y) / 2;
|
|
text.set({ left: midX - 15, top: midY - 20 });
|
|
// Update tick positions (simplified: put at start and end)
|
|
tick1.set({ x1: start.x, y1: start.y, x2: start.x+10, y2: start.y });
|
|
tick2.set({ x1: pointer.x, y1: pointer.y, x2: pointer.x-10, y2: pointer.y });
|
|
// Add corner tick lines (just simple markers)
|
|
// Refresh group
|
|
group.addWithUpdate();
|
|
}
|
|
canvas.renderAll();
|
|
});
|
|
|
|
canvas.on('mouse:up', () => {
|
|
if (isDrawing.current && activeTool === 'dimension' && tempShape.current) {
|
|
// Make dimension group selectable after placement
|
|
tempShape.current.group.set({ selectable: true, hasControls: false, hasBorders: true });
|
|
}
|
|
isDrawing.current = false;
|
|
tempShape.current = null;
|
|
});
|
|
}, [activeTool]);
|
|
|
|
return (
|
|
<div style={{ width: '100%', height: '100%' }}>
|
|
<canvas ref={canvasEl} />
|
|
<div style={{ position: 'absolute', bottom: 10, right: 10, background: 'rgba(255,255,255,0.8)', padding: '4px 8px', borderRadius: 4, fontSize: 12 }}>
|
|
Zoom: {Math.round(zoom * 100)}%
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default CADCanvas;
|