'use client' import { useEffect, useRef, useCallback, useState } from 'react' import { useTheme } from '@/lib/ThemeContext' import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext' import type { EditorTool } from '@/app/worksheet-editor/types' // Fabric.js types declare const fabric: any // A4 dimensions in pixels at 96 DPI const A4_WIDTH = 794 // 210mm * 3.78 const A4_HEIGHT = 1123 // 297mm * 3.78 interface FabricCanvasProps { className?: string } export function FabricCanvas({ className = '' }: FabricCanvasProps) { const canvasRef = useRef(null) const containerRef = useRef(null) const { isDark } = useTheme() const [fabricLoaded, setFabricLoaded] = useState(false) const [fabricCanvas, setFabricCanvas] = useState(null) const { setCanvas, activeTool, setActiveTool, setSelectedObjects, zoom, showGrid, snapToGrid, gridSize, saveToHistory, document } = useWorksheet() // Load Fabric.js dynamically useEffect(() => { const loadFabric = async () => { if (typeof window !== 'undefined' && !(window as any).fabric) { const fabricModule = await import('fabric') // Fabric 6.x exports directly, not via .fabric ;(window as any).fabric = fabricModule } setFabricLoaded(true) } loadFabric() }, []) // Initialize canvas useEffect(() => { if (!fabricLoaded || !canvasRef.current || fabricCanvas) return const fabric = (window as any).fabric if (!fabric) return try { const canvas = new fabric.Canvas(canvasRef.current, { width: A4_WIDTH, height: A4_HEIGHT, backgroundColor: '#ffffff', selection: true, preserveObjectStacking: true, enableRetinaScaling: true, }) // Store canvas reference setFabricCanvas(canvas) setCanvas(canvas) return () => { canvas.dispose() setFabricCanvas(null) setCanvas(null) } } catch (error) { console.error('Failed to initialize Fabric.js canvas:', error) } }, [fabricLoaded, setCanvas]) // Save initial history entry after canvas is ready useEffect(() => { if (fabricCanvas && saveToHistory) { // Small delay to ensure canvas is fully initialized const timeout = setTimeout(() => { saveToHistory('initial') }, 100) return () => clearTimeout(timeout) } }, [fabricCanvas, saveToHistory]) // Handle selection events useEffect(() => { if (!fabricCanvas) return const handleSelection = () => { const activeObjects = fabricCanvas.getActiveObjects() setSelectedObjects(activeObjects) } const handleSelectionCleared = () => { setSelectedObjects([]) } fabricCanvas.on('selection:created', handleSelection) fabricCanvas.on('selection:updated', handleSelection) fabricCanvas.on('selection:cleared', handleSelectionCleared) return () => { fabricCanvas.off('selection:created', handleSelection) fabricCanvas.off('selection:updated', handleSelection) fabricCanvas.off('selection:cleared', handleSelectionCleared) } }, [fabricCanvas, setSelectedObjects]) // Handle object modifications useEffect(() => { if (!fabricCanvas) return const handleModified = () => { saveToHistory('object:modified') } fabricCanvas.on('object:modified', handleModified) fabricCanvas.on('object:added', handleModified) fabricCanvas.on('object:removed', handleModified) return () => { fabricCanvas.off('object:modified', handleModified) fabricCanvas.off('object:added', handleModified) fabricCanvas.off('object:removed', handleModified) } }, [fabricCanvas, saveToHistory]) // Handle zoom useEffect(() => { if (!fabricCanvas) return fabricCanvas.setZoom(zoom) fabricCanvas.setDimensions({ width: A4_WIDTH * zoom, height: A4_HEIGHT * zoom }) fabricCanvas.renderAll() }, [fabricCanvas, zoom]) // Draw grid useEffect(() => { if (!fabricCanvas) return // Remove existing grid const existingGrid = fabricCanvas.getObjects().filter((obj: any) => obj.isGrid) existingGrid.forEach((obj: any) => fabricCanvas.remove(obj)) if (showGrid) { const fabric = (window as any).fabric const gridSpacing = gridSize * 3.78 // Convert mm to pixels // Vertical lines for (let x = gridSpacing; x < A4_WIDTH; x += gridSpacing) { const line = new fabric.Line([x, 0, x, A4_HEIGHT], { stroke: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', strokeWidth: 0.5, selectable: false, evented: false, isGrid: true, }) fabricCanvas.add(line) fabricCanvas.sendObjectToBack(line) } // Horizontal lines for (let y = gridSpacing; y < A4_HEIGHT; y += gridSpacing) { const line = new fabric.Line([0, y, A4_WIDTH, y], { stroke: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', strokeWidth: 0.5, selectable: false, evented: false, isGrid: true, }) fabricCanvas.add(line) fabricCanvas.sendObjectToBack(line) } } fabricCanvas.renderAll() }, [fabricCanvas, showGrid, gridSize, isDark]) // Handle snap to grid useEffect(() => { if (!fabricCanvas) return if (snapToGrid) { const gridSpacing = gridSize * 3.78 fabricCanvas.on('object:moving', (e: any) => { const obj = e.target obj.set({ left: Math.round(obj.left / gridSpacing) * gridSpacing, top: Math.round(obj.top / gridSpacing) * gridSpacing }) }) } }, [fabricCanvas, snapToGrid, gridSize]) // Handle tool changes useEffect(() => { if (!fabricCanvas) return const fabric = (window as any).fabric // Reset drawing mode fabricCanvas.isDrawingMode = false fabricCanvas.selection = activeTool === 'select' // Handle canvas click based on tool const handleMouseDown = (e: any) => { if (e.target) return // Clicked on an object const pointer = fabricCanvas.getPointer(e.e) switch (activeTool) { case 'text': { const text = new fabric.IText('Text eingeben', { left: pointer.x, top: pointer.y, fontFamily: 'Arial', fontSize: 16, fill: isDark ? '#ffffff' : '#000000', }) fabricCanvas.add(text) fabricCanvas.setActiveObject(text) text.enterEditing() setActiveTool('select') break } case 'rectangle': { const rect = new fabric.Rect({ left: pointer.x, top: pointer.y, width: 100, height: 60, fill: 'transparent', stroke: isDark ? '#ffffff' : '#000000', strokeWidth: 2, }) fabricCanvas.add(rect) fabricCanvas.setActiveObject(rect) setActiveTool('select') break } case 'circle': { const circle = new fabric.Circle({ left: pointer.x, top: pointer.y, radius: 40, fill: 'transparent', stroke: isDark ? '#ffffff' : '#000000', strokeWidth: 2, }) fabricCanvas.add(circle) fabricCanvas.setActiveObject(circle) setActiveTool('select') break } case 'line': { const line = new fabric.Line([pointer.x, pointer.y, pointer.x + 100, pointer.y], { stroke: isDark ? '#ffffff' : '#000000', strokeWidth: 2, }) fabricCanvas.add(line) fabricCanvas.setActiveObject(line) setActiveTool('select') break } case 'arrow': { // Create arrow using path const arrowPath = `M ${pointer.x} ${pointer.y} L ${pointer.x + 80} ${pointer.y} L ${pointer.x + 70} ${pointer.y - 8} M ${pointer.x + 80} ${pointer.y} L ${pointer.x + 70} ${pointer.y + 8}` const arrow = new fabric.Path(arrowPath, { fill: 'transparent', stroke: isDark ? '#ffffff' : '#000000', strokeWidth: 2, }) fabricCanvas.add(arrow) fabricCanvas.setActiveObject(arrow) setActiveTool('select') break } } } fabricCanvas.on('mouse:down', handleMouseDown) return () => { fabricCanvas.off('mouse:down', handleMouseDown) } }, [fabricCanvas, activeTool, isDark, setActiveTool]) // Keyboard shortcuts useEffect(() => { if (!fabricCanvas) return const handleKeyDown = (e: KeyboardEvent) => { // Don't handle if typing in input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return // Delete selected objects if (e.key === 'Delete' || e.key === 'Backspace') { const activeObjects = fabricCanvas.getActiveObjects() if (activeObjects.length > 0) { activeObjects.forEach((obj: any) => { if (!obj.isGrid) { fabricCanvas.remove(obj) } }) fabricCanvas.discardActiveObject() fabricCanvas.renderAll() saveToHistory('delete') } } // Ctrl/Cmd shortcuts if (e.ctrlKey || e.metaKey) { switch (e.key.toLowerCase()) { case 'c': // Copy if (fabricCanvas.getActiveObject()) { fabricCanvas.getActiveObject().clone((cloned: any) => { (window as any)._clipboard = cloned }) } break case 'v': // Paste if ((window as any)._clipboard) { (window as any)._clipboard.clone((cloned: any) => { cloned.set({ left: cloned.left + 20, top: cloned.top + 20, }) fabricCanvas.add(cloned) fabricCanvas.setActiveObject(cloned) fabricCanvas.renderAll() saveToHistory('paste') }) } break case 'a': // Select all e.preventDefault() const objects = fabricCanvas.getObjects().filter((obj: any) => !obj.isGrid) const fabric = (window as any).fabric const selection = new fabric.ActiveSelection(objects, { canvas: fabricCanvas }) fabricCanvas.setActiveObject(selection) fabricCanvas.renderAll() break case 'd': // Duplicate e.preventDefault() if (fabricCanvas.getActiveObject()) { fabricCanvas.getActiveObject().clone((cloned: any) => { cloned.set({ left: cloned.left + 20, top: cloned.top + 20, }) fabricCanvas.add(cloned) fabricCanvas.setActiveObject(cloned) fabricCanvas.renderAll() saveToHistory('duplicate') }) } break } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [fabricCanvas, saveToHistory]) // Add image to canvas const addImage = useCallback((url: string) => { if (!fabricCanvas) return const fabric = (window as any).fabric fabric.Image.fromURL(url, (img: any) => { // Scale image to fit within canvas const maxWidth = A4_WIDTH * 0.8 const maxHeight = A4_HEIGHT * 0.5 const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1) img.set({ left: (A4_WIDTH - img.width * scale) / 2, top: (A4_HEIGHT - img.height * scale) / 2, scaleX: scale, scaleY: scale, }) fabricCanvas.add(img) fabricCanvas.setActiveObject(img) fabricCanvas.renderAll() saveToHistory('image:added') setActiveTool('select') }, { crossOrigin: 'anonymous' }) }, [fabricCanvas, saveToHistory, setActiveTool]) // Expose addImage to context useEffect(() => { if (fabricCanvas) { (fabricCanvas as any).addImage = addImage } }, [fabricCanvas, addImage]) // Background styling const canvasContainerStyle = isDark ? 'bg-slate-800 shadow-2xl' : 'bg-slate-200 shadow-xl' return (
) }