Files
breakpilot-lehrer/studio-v2/components/worksheet-editor/FabricCanvas.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

435 lines
12 KiB
TypeScript

'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<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { isDark } = useTheme()
const [fabricLoaded, setFabricLoaded] = useState(false)
const [fabricCanvas, setFabricCanvas] = useState<any>(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 (
<div
ref={containerRef}
className={`flex items-center justify-center overflow-auto p-8 ${className}`}
style={{ minHeight: '100%' }}
>
<div className={`rounded-lg overflow-hidden ${canvasContainerStyle}`}>
<canvas ref={canvasRef} id="worksheet-canvas" />
</div>
</div>
)
}