'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import dynamic from 'next/dynamic' import type { GridResult, GridCell, ColumnResult, RowResult, PageZone, PageRegion, RowItem } from '@/app/(admin)/ai/ocr-pipeline/types' import { usePixelWordPositions } from './usePixelWordPositions' 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' | 'overlay' 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([]) const containerRef = useRef(null) const imageRef = useRef(null) // Overlay mode state const [isParentWithBoxes, setIsParentWithBoxes] = useState(false) const [mergedGridCells, setMergedGridCells] = useState([]) const [parentColumns, setParentColumns] = useState([]) const [parentRows, setParentRows] = useState([]) const [parentZones, setParentZones] = useState([]) const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null) const [fontScale, setFontScale] = useState(0.7) const [globalBold, setGlobalBold] = useState(false) const [imageRotation, setImageRotation] = useState<0 | 180>(0) const reconRef = useRef(null) const [reconWidth, setReconWidth] = useState(0) // Pixel-based word positions for overlay mode const overlayImageUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` : '' const cellWordPositions = usePixelWordPositions( overlayImageUrl, mergedGridCells, editorMode === 'overlay', imageRotation, ) // Track reconstruction container width for font size calculation useEffect(() => { const el = reconRef.current if (!el) return const obs = new ResizeObserver(entries => { for (const entry of entries) setReconWidth(entry.contentRect.width) }) obs.observe(el) return () => obs.disconnect() }, [editorMode]) // 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([]) // Check for parent with boxes (sub-sessions + zones) const columnResult: ColumnResult | undefined = data.column_result const rowResult: RowResult | undefined = data.row_result const subSessions: { id: string; box_index: number }[] = data.sub_sessions || [] const zones: PageZone[] = columnResult?.zones || [] const hasBoxes = subSessions.length > 0 && zones.some(z => z.zone_type === 'box') setIsParentWithBoxes(hasBoxes) if (hasBoxes) setImageRotation(180) // Default: rotate for correct pixel matching if (columnResult?.columns) setParentColumns(columnResult.columns) if (rowResult?.rows) setParentRows(rowResult.rows) if (zones.length > 0) setParentZones(zones) // Store image dimensions if (wordResult.image_width && wordResult.image_height) { setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height }) } if (hasBoxes) { // Default to overlay mode for parent sessions with boxes setEditorMode('overlay') // Load sub-sessions and merge cells const imgW = wordResult.image_width || 1 const imgH = wordResult.image_height || 1 const allMergedCells: GridCell[] = [...rawGridCells] for (const sub of subSessions) { try { const subRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sub.id}`) if (!subRes.ok) continue const subData = await subRes.json() const subWordResult: GridResult | undefined = subData.word_result if (!subWordResult?.cells) continue // Find the box zone for this sub-session const boxZone = zones.find(z => z.zone_type === 'box') if (!boxZone?.box) continue const box = boxZone.box // Box coordinates are in pixels, convert to pct const boxXPct = (box.x / imgW) * 100 const boxYPct = (box.y / imgH) * 100 const boxWPct = (box.width / imgW) * 100 const boxHPct = (box.height / imgH) * 100 // Convert sub-session cell coordinates to parent coordinates for (const subCell of subWordResult.cells) { if (!subCell.bbox_pct) continue const parentCellX = boxXPct + (subCell.bbox_pct.x / 100) * boxWPct const parentCellY = boxYPct + (subCell.bbox_pct.y / 100) * boxHPct const parentCellW = (subCell.bbox_pct.w / 100) * boxWPct const parentCellH = (subCell.bbox_pct.h / 100) * boxHPct allMergedCells.push({ ...subCell, cell_id: `sub_${sub.id}_${subCell.cell_id}`, bbox_pct: { x: parentCellX, y: parentCellY, w: parentCellW, h: parentCellH, }, bbox_px: { x: Math.round(parentCellX / 100 * imgW), y: Math.round(parentCellY / 100 * imgH), w: Math.round(parentCellW / 100 * imgW), h: Math.round(parentCellH / 100 * imgH), }, }) } } catch { // Skip failing sub-sessions } } setMergedGridCells(allMergedCells) // Also add merged cells as editable cells const mergedEditableCells: EditableCell[] = allMergedCells.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(mergedEditableCells) } else { setMergedGridCells(rawGridCells) } 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/cropped` : '' 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]) // Box zones in percent for clamping cell positions in overlay mode const boxZonesPct = useMemo(() => parentZones .filter(z => z.zone_type === 'box' && z.box) .map(z => { const imgH = imageNaturalSize?.h || 1 return { topPct: (z.box!.y / imgH) * 100, bottomPct: ((z.box!.y + z.box!.height) / imgH) * 100, } }), [parentZones, imageNaturalSize] ) 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.'}

) } // Clamp cell positions so they don't overlap with box zones const adjustCellForBoxZones = ( bboxPct: { x: number; y: number; w: number; h: number }, cellId: string, ): { x: number; y: number; w: number; h: number } => { // Sub-session cells (inside box) → no adjustment if (cellId.startsWith('sub_')) return bboxPct if (boxZonesPct.length === 0) return bboxPct const cellTop = bboxPct.y const cellBottom = bboxPct.y + bboxPct.h const cellCenter = cellTop + bboxPct.h / 2 for (const { topPct, bottomPct } of boxZonesPct) { // Cell ABOVE box: clamp height so bottom doesn't exceed box top if (cellCenter < topPct && cellBottom > topPct) { return { ...bboxPct, h: topPct - cellTop } } // Cell BELOW box: push top down to box bottom if (cellCenter > bottomPct && cellTop < bottomPct) { const newY = bottomPct return { ...bboxPct, y: newY, h: cellBottom - newY } } } return bboxPct } // Overlay rendering helper const renderOverlayMode = () => { const imgW = imageNaturalSize?.w || 1 const imgH = imageNaturalSize?.h || 1 const aspect = imgH / imgW const containerH = reconWidth * aspect return (
{/* Left: Original image */}
Originalbild
{/* eslint-disable-next-line @next/next/no-img-element */} Original { const img = e.target as HTMLImageElement setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }) }} />
{/* Right: Reconstructed table overlay */}
Rekonstruktion ({cells.length} Zellen)
{/* Column lines */} {parentColumns .filter(c => !['header', 'footer'].includes(c.type)) .map((col, i) => (
))} {/* Row lines */} {parentRows.map((row, i) => (
))} {/* Box zone highlight */} {parentZones .filter(z => z.zone_type === 'box' && z.box) .map((z, i) => { const box = z.box! return (
) })} {/* Pixel-positioned words / editable inputs */} {cells.map((cell) => { const displayText = getDisplayText(cell) const edited = isEdited(cell) const wordPos = cellWordPositions.get(cell.cellId) const adjBbox = adjustCellForBoxZones(cell.bboxPct, cell.cellId) const cellHeightPx = containerH * (adjBbox.h / 100) // Pixel-analysed: render word-groups at detected positions as inputs if (wordPos && wordPos.length > 0) { return wordPos.map((wp, i) => { const autoFontPx = cellHeightPx * wp.fontRatio * fontScale const fs = Math.max(6, autoFontPx) // For multi-group cells, only the first group is the primary input // Show as span (read-only positioned) — editing happens at cell level if (wordPos.length > 1) { return ( {wp.text} ) } // Single group: render as editable input at pixel position return (
handleTextChange(cell.cellId, e.target.value)} onKeyDown={(e) => handleKeyDown(e, cell.cellId)} className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${ edited ? 'bg-green-50/30' : '' }`} style={{ fontSize: `${fs}px`, fontWeight: globalBold ? 'bold' : (cell.colType === 'column_en' ? 'bold' : 'normal'), fontFamily: "'Liberation Sans', Arial, sans-serif", lineHeight: '1', color: '#1a1a1a', }} title={`${cell.cellId} (${cell.colType})`} /> {edited && ( )}
) }) } // Multi-group cell with pixel positions: already handled above // Fallback: no pixel data — single input at cell bbox if (!cell.text) return null const fontSize = Math.max(6, cellHeightPx * fontScale) return (
handleTextChange(cell.cellId, e.target.value)} onKeyDown={(e) => handleKeyDown(e, cell.cellId)} className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${ edited ? 'bg-green-50/30' : '' }`} style={{ fontSize: `${fontSize}px`, fontWeight: globalBold ? 'bold' : 'normal', fontFamily: "'Liberation Sans', Arial, sans-serif", lineHeight: '1', color: '#1a1a1a', }} title={`${cell.cellId} (${cell.colType})`} /> {edited && ( )}
) })}
) } return (
{/* Toolbar */}

Schritt 7: Rekonstruktion

{/* Mode toggle */}
{isParentWithBoxes && ( )}
{cells.length} Zellen · {changedCount} geaendert {emptyCellIds.size > 0 && showEmptyHighlight && ( · {emptyCellIds.size} leer )}
{/* Undo/Redo */}
{/* Overlay-specific toolbar */} {editorMode === 'overlay' && ( <>
)} {/* Non-overlay controls */} {editorMode !== 'overlay' && ( <> {/* Empty field toggle */}
{/* Zoom controls */} {zoom}%
)}
{/* Reconstruction canvas */} {editorMode === 'overlay' ? ( renderOverlayMode() ) : 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 */}
) }