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>
435 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|