'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { GridCell } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' // Column type → colour mapping const COL_TYPE_COLORS: Record = { column_en: '#3b82f6', // blue-500 column_de: '#22c55e', // green-500 column_example: '#f97316', // orange-500 column_text: '#a855f7', // purple-500 page_ref: '#06b6d4', // cyan-500 column_marker: '#6b7280', // gray-500 } interface FabricReconstructionCanvasProps { sessionId: string cells: GridCell[] onCellsChanged: (updates: { cell_id: string; text: string }[]) => void } // Fabric.js types (subset used here) interface FabricCanvas { add: (...objects: FabricObject[]) => FabricCanvas remove: (...objects: FabricObject[]) => FabricCanvas setBackgroundImage: (img: FabricImage, callback: () => void) => void renderAll: () => void getObjects: () => FabricObject[] dispose: () => void on: (event: string, handler: (e: FabricEvent) => void) => void setWidth: (w: number) => void setHeight: (h: number) => void getActiveObject: () => FabricObject | null discardActiveObject: () => FabricCanvas requestRenderAll: () => void setZoom: (z: number) => void getZoom: () => number } interface FabricObject { type?: string left?: number top?: number width?: number height?: number text?: string set: (props: Record) => FabricObject get: (prop: string) => unknown data?: Record selectable?: boolean on?: (event: string, handler: () => void) => void setCoords?: () => void } interface FabricImage extends FabricObject { width?: number height?: number scaleX?: number scaleY?: number } interface FabricEvent { target?: FabricObject e?: MouseEvent } // eslint-disable-next-line @typescript-eslint/no-explicit-any type FabricModule = any export function FabricReconstructionCanvas({ sessionId, cells, onCellsChanged, }: FabricReconstructionCanvasProps) { const canvasElRef = useRef(null) const fabricRef = useRef(null) const fabricModuleRef = useRef(null) const [ready, setReady] = useState(false) const [opacity, setOpacity] = useState(30) const [zoom, setZoom] = useState(100) const [selectedCell, setSelectedCell] = useState(null) const [error, setError] = useState('') // Undo/Redo const undoStackRef = useRef<{ cellId: string; oldText: string; newText: string }[]>([]) const redoStackRef = useRef<{ cellId: string; oldText: string; newText: string }[]>([]) // ---- Initialise Fabric.js ---- useEffect(() => { let disposed = false async function init() { try { const fabricModule = await import('fabric') if (disposed) return fabricModuleRef.current = fabricModule const canvasEl = canvasElRef.current if (!canvasEl) return // Load background image first to get dimensions const imgUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped` const bgImg = await fabricModule.FabricImage.fromURL(imgUrl, { crossOrigin: 'anonymous' }) as FabricImage if (disposed) return const imgW = (bgImg.width || 800) * (bgImg.scaleX || 1) const imgH = (bgImg.height || 600) * (bgImg.scaleY || 1) bgImg.set({ opacity: opacity / 100, selectable: false, evented: false } as Record) const canvas = new fabricModule.Canvas(canvasEl, { width: imgW, height: imgH, selection: true, preserveObjectStacking: true, backgroundImage: bgImg, }) as unknown as FabricCanvas fabricRef.current = canvas canvas.renderAll() // Add cell objects addCellObjects(canvas, fabricModule, cells, imgW, imgH) // Listen for text changes canvas.on('object:modified', (e: FabricEvent) => { if (e.target?.data?.cellId) { const cellId = e.target.data.cellId as string const newText = (e.target.text || '') as string onCellsChanged([{ cell_id: cellId, text: newText }]) } }) // Selection tracking canvas.on('selection:created', (e: FabricEvent) => { if (e.target?.data?.cellId) setSelectedCell(e.target.data.cellId as string) }) canvas.on('selection:updated', (e: FabricEvent) => { if (e.target?.data?.cellId) setSelectedCell(e.target.data.cellId as string) }) canvas.on('selection:cleared', () => setSelectedCell(null)) setReady(true) } catch (err) { if (!disposed) setError(err instanceof Error ? err.message : 'Fabric.js konnte nicht geladen werden') } } init() return () => { disposed = true fabricRef.current?.dispose() fabricRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) function addCellObjects( canvas: FabricCanvas, fabricModule: FabricModule, gridCells: GridCell[], imgW: number, imgH: number, ) { for (const cell of gridCells) { const color = COL_TYPE_COLORS[cell.col_type] || '#6b7280' const x = (cell.bbox_pct.x / 100) * imgW const y = (cell.bbox_pct.y / 100) * imgH const w = (cell.bbox_pct.w / 100) * imgW const h = (cell.bbox_pct.h / 100) * imgH const fontSize = Math.max(8, Math.min(18, h * 0.55)) const textObj = new fabricModule.IText(cell.text || '', { left: x, top: y, width: w, fontSize, fontFamily: 'monospace', fill: '#000000', backgroundColor: `${color}22`, padding: 2, editable: true, selectable: true, lockScalingFlip: true, data: { cellId: cell.cell_id, colType: cell.col_type, rowIndex: cell.row_index, colIndex: cell.col_index, originalText: cell.text, }, }) // Border colour matches column type textObj.set({ borderColor: color, cornerColor: color, cornerSize: 6, transparentCorners: false, } as Record) canvas.add(textObj) } canvas.renderAll() } // ---- Opacity slider ---- const handleOpacityChange = useCallback((val: number) => { setOpacity(val) const canvas = fabricRef.current if (!canvas) return // Fabric v6: backgroundImage is a direct property on the canvas const bgImg = (canvas as unknown as { backgroundImage?: FabricObject }).backgroundImage if (bgImg) { bgImg.set({ opacity: val / 100 }) canvas.renderAll() } }, []) // ---- Zoom ---- const handleZoomChange = useCallback((val: number) => { setZoom(val) const canvas = fabricRef.current if (!canvas) return ;(canvas as unknown as { zoom: number }).zoom = val / 100 canvas.requestRenderAll() }, []) // ---- Undo / Redo via keyboard ---- useEffect(() => { const handler = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.key !== 'z') return e.preventDefault() const canvas = fabricRef.current if (!canvas) return if (e.shiftKey) { // Redo const action = redoStackRef.current.pop() if (!action) return undoStackRef.current.push(action) const obj = canvas.getObjects().find( (o: FabricObject) => o.data?.cellId === action.cellId ) if (obj) { obj.set({ text: action.newText } as Record) canvas.renderAll() onCellsChanged([{ cell_id: action.cellId, text: action.newText }]) } } else { // Undo const action = undoStackRef.current.pop() if (!action) return redoStackRef.current.push(action) const obj = canvas.getObjects().find( (o: FabricObject) => o.data?.cellId === action.cellId ) if (obj) { obj.set({ text: action.oldText } as Record) canvas.renderAll() onCellsChanged([{ cell_id: action.cellId, text: action.oldText }]) } } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [onCellsChanged]) // ---- Delete selected cell (via context-menu or Delete key) ---- useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key !== 'Delete' && e.key !== 'Backspace') return // Only delete if not currently editing text inside an IText const canvas = fabricRef.current if (!canvas) return const active = canvas.getActiveObject() if (!active) return // If the IText is in editing mode, let the keypress pass through if ((active as unknown as Record).isEditing) return e.preventDefault() canvas.remove(active) canvas.discardActiveObject() canvas.renderAll() } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, []) // ---- Export helpers ---- const handleExportPdf = useCallback(() => { window.open( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/export/pdf`, '_blank' ) }, [sessionId]) const handleExportDocx = useCallback(() => { window.open( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/export/docx`, '_blank' ) }, [sessionId]) if (error) { return (

Fabric.js Editor konnte nicht geladen werden:

{error}

) } return (
{/* Toolbar */}
{/* Opacity slider */}
{/* Zoom */}
{/* Selected cell info */} {selectedCell && ( Zelle: {selectedCell} )}
{/* Export buttons */}
{/* Canvas */}
{!ready && (
Canvas wird geladen...
)}
{/* Legend */}
{Object.entries(COL_TYPE_COLORS).map(([type, color]) => ( {type.replace('column_', '').replace('page_', '')} ))} Doppelklick = Text bearbeiten | Delete = Zelle entfernen | Cmd+Z = Undo
) }