'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import dynamic from 'next/dynamic' import type { GridResult, GridCell, WordEntry } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' // Lazy-load Fabric.js canvas editor (SSR-incompatible) const FabricReconstructionCanvas = dynamic( () => import('./FabricReconstructionCanvas').then(m => ({ default: m.FabricReconstructionCanvas })), { ssr: false, loading: () =>
Editor wird geladen...
} ) type EditorMode = 'simple' | 'editor' interface StepReconstructionProps { sessionId: string | null onNext: () => void } interface EditableCell { cellId: string text: string originalText: string bboxPct: { x: number; y: number; w: number; h: number } colType: string rowIndex: number colIndex: number } type UndoAction = { cellId: string; oldText: string; newText: string } export function StepReconstruction({ sessionId, onNext }: StepReconstructionProps) { const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading') const [error, setError] = useState('') const [cells, setCells] = useState([]) const [gridCells, setGridCells] = useState([]) const [editorMode, setEditorMode] = useState('simple') const [editedTexts, setEditedTexts] = useState>(new Map()) const [zoom, setZoom] = useState(100) const [imageNaturalH, setImageNaturalH] = useState(0) const [showEmptyHighlight, setShowEmptyHighlight] = useState(true) // Undo/Redo stacks const [undoStack, setUndoStack] = useState([]) const [redoStack, setRedoStack] = useState([]) // (allCells removed — cells now contains all cells including empty ones) const containerRef = useRef(null) const imageRef = useRef(null) // Load session data on mount useEffect(() => { if (!sessionId) return loadSessionData() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) // Track image natural height for font scaling const handleImageLoad = useCallback(() => { if (imageRef.current) { setImageNaturalH(imageRef.current.naturalHeight) } }, []) const loadSessionData = async () => { if (!sessionId) return setStatus('loading') try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() const wordResult: GridResult | undefined = data.word_result if (!wordResult) { setError('Keine Worterkennungsdaten gefunden. Bitte zuerst Schritt 5 abschliessen.') setStatus('error') return } // Build editable cells from grid cells const rawGridCells: GridCell[] = wordResult.cells || [] setGridCells(rawGridCells) const allEditableCells: EditableCell[] = rawGridCells.map(c => ({ cellId: c.cell_id, text: c.text, originalText: c.text, bboxPct: c.bbox_pct, colType: c.col_type, rowIndex: c.row_index, colIndex: c.col_index, })) setCells(allEditableCells) setEditedTexts(new Map()) setUndoStack([]) setRedoStack([]) setStatus('ready') } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) setStatus('error') } } const handleTextChange = useCallback((cellId: string, newText: string) => { setEditedTexts(prev => { const oldText = prev.get(cellId) const cell = cells.find(c => c.cellId === cellId) const prevText = oldText ?? cell?.text ?? '' // Push to undo stack setUndoStack(stack => [...stack, { cellId, oldText: prevText, newText }]) setRedoStack([]) // Clear redo on new edit const next = new Map(prev) next.set(cellId, newText) return next }) }, [cells]) const undo = useCallback(() => { setUndoStack(stack => { if (stack.length === 0) return stack const action = stack[stack.length - 1] const newStack = stack.slice(0, -1) setRedoStack(rs => [...rs, action]) setEditedTexts(prev => { const next = new Map(prev) next.set(action.cellId, action.oldText) return next }) return newStack }) }, []) const redo = useCallback(() => { setRedoStack(stack => { if (stack.length === 0) return stack const action = stack[stack.length - 1] const newStack = stack.slice(0, -1) setUndoStack(us => [...us, action]) setEditedTexts(prev => { const next = new Map(prev) next.set(action.cellId, action.newText) return next }) return newStack }) }, []) const resetCell = useCallback((cellId: string) => { const cell = cells.find(c => c.cellId === cellId) if (!cell) return setEditedTexts(prev => { const next = new Map(prev) next.delete(cellId) return next }) }, [cells]) // Global keyboard shortcuts for undo/redo useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'z') { e.preventDefault() if (e.shiftKey) { redo() } else { undo() } } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [undo, redo]) const getDisplayText = useCallback((cell: EditableCell): string => { return editedTexts.get(cell.cellId) ?? cell.text }, [editedTexts]) const isEdited = useCallback((cell: EditableCell): boolean => { const edited = editedTexts.get(cell.cellId) return edited !== undefined && edited !== cell.originalText }, [editedTexts]) const changedCount = useMemo(() => { let count = 0 for (const cell of cells) { if (isEdited(cell)) count++ } return count }, [cells, isEdited]) // Identify empty required cells (EN or DE columns with no text) const emptyCellIds = useMemo(() => { const required = new Set(['column_en', 'column_de']) const ids = new Set() for (const cell of cells) { if (required.has(cell.colType) && !cell.text.trim()) { ids.add(cell.cellId) } } return ids }, [cells]) // Sort cells for tab navigation: by row, then by column const sortedCellIds = useMemo(() => { return [...cells] .sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex) .map(c => c.cellId) }, [cells]) const handleKeyDown = useCallback((e: React.KeyboardEvent, cellId: string) => { if (e.key === 'Tab') { e.preventDefault() const idx = sortedCellIds.indexOf(cellId) const nextIdx = e.shiftKey ? idx - 1 : idx + 1 if (nextIdx >= 0 && nextIdx < sortedCellIds.length) { const nextId = sortedCellIds[nextIdx] const el = document.getElementById(`cell-${nextId}`) el?.focus() } } }, [sortedCellIds]) const saveReconstruction = useCallback(async () => { if (!sessionId) return setStatus('saving') try { const cellUpdates = Array.from(editedTexts.entries()) .filter(([cellId, text]) => { const cell = cells.find(c => c.cellId === cellId) return cell && text !== cell.originalText }) .map(([cellId, text]) => ({ cell_id: cellId, text })) if (cellUpdates.length === 0) { // Nothing changed, just advance setStatus('saved') return } const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cells: cellUpdates }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.detail || `HTTP ${res.status}`) } setStatus('saved') } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) setStatus('error') } }, [sessionId, editedTexts, cells]) // Handler for Fabric.js editor cell changes const handleFabricCellsChanged = useCallback((updates: { cell_id: string; text: string }[]) => { for (const u of updates) { setEditedTexts(prev => { const next = new Map(prev) next.set(u.cell_id, u.text) return next }) } }, []) const dewarpedUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped` : '' const colTypeColor = (colType: string): string => { const colors: Record = { column_en: 'border-blue-400/40 focus:border-blue-500', column_de: 'border-green-400/40 focus:border-green-500', column_example: 'border-orange-400/40 focus:border-orange-500', column_text: 'border-purple-400/40 focus:border-purple-500', page_ref: 'border-cyan-400/40 focus:border-cyan-500', column_marker: 'border-gray-400/40 focus:border-gray-500', } return colors[colType] || 'border-gray-400/40 focus:border-gray-500' } // Font size based on image natural height (not container) scaled by zoom const getFontSize = useCallback((bboxH: number): number => { const baseH = imageNaturalH || 800 const px = (bboxH / 100) * baseH * 0.55 return Math.max(8, Math.min(18, px * (zoom / 100))) }, [imageNaturalH, zoom]) if (!sessionId) { return
Bitte zuerst eine Session auswaehlen.
} if (status === 'loading') { return (
Rekonstruktionsdaten werden geladen...
) } if (status === 'error') { return (
⚠️

Fehler

{error}

) } if (status === 'saved') { return (

Rekonstruktion gespeichert

{changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'}

) } return (
{/* Toolbar */}

Schritt 7: Rekonstruktion

{/* Mode toggle */}
{cells.length} Zellen · {changedCount} geaendert {emptyCellIds.size > 0 && showEmptyHighlight && ( · {emptyCellIds.size} leer )}
{/* Undo/Redo */}
{/* Empty field toggle */}
{/* Zoom controls */} {zoom}%
{/* Reconstruction canvas — Simple or Editor mode */} {editorMode === 'editor' && sessionId ? ( ) : (
{/* Background image at reduced opacity */} {/* eslint-disable-next-line @next/next/no-img-element */} Dewarped {/* Empty field markers */} {showEmptyHighlight && cells .filter(c => emptyCellIds.has(c.cellId)) .map(cell => (
))} {/* Editable text fields at bbox positions */} {cells.map((cell) => { const displayText = getDisplayText(cell) const edited = isEdited(cell) return (
handleTextChange(cell.cellId, e.target.value)} onKeyDown={(e) => handleKeyDown(e, cell.cellId)} className={`w-full h-full bg-transparent text-black dark:text-white border px-0.5 outline-none transition-colors ${ colTypeColor(cell.colType) } ${edited ? 'border-green-500 bg-green-50/30 dark:bg-green-900/20' : ''}`} style={{ fontSize: `${getFontSize(cell.bboxPct.h)}px`, lineHeight: '1', }} title={`${cell.cellId} (${cell.colType})`} /> {/* Per-cell reset button (X) — only shown for edited cells on hover */} {edited && ( )}
) })}
)} {/* Bottom action */}
) }