fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
434
studio-v2/components/worksheet-editor/FabricCanvas.tsx
Normal file
434
studio-v2/components/worksheet-editor/FabricCanvas.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user