'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { GridResult, GridCell, RowResult, RowItem } from '@/app/(admin)/ai/ocr-overlay/types' import { usePixelWordPositions } from './usePixelWordPositions' import { useSlideWordPositions } from './useSlideWordPositions' const KLAUSUR_API = '/klausur-api' interface OverlayReconstructionProps { sessionId: string | null onNext: () => void /** When set, use this data directly instead of fetching from the session API. */ wordResultOverride?: { cells: GridCell[]; image_width: number; image_height: number; [key: string]: unknown } } 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 OverlayReconstruction({ sessionId, onNext, wordResultOverride }: OverlayReconstructionProps) { const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading') const [error, setError] = useState('') const [cells, setCells] = useState([]) const [gridCells, setGridCells] = useState([]) const [editedTexts, setEditedTexts] = useState>(new Map()) // Undo/Redo const [undoStack, setUndoStack] = useState([]) const [redoStack, setRedoStack] = useState([]) // Overlay state const [rows, setRows] = 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 [textOpacity, setTextOpacity] = useState(100) const [textColor, setTextColor] = useState<'red' | 'blue' | 'black'>('red') const [positioningMode, setPositioningMode] = useState<'cluster' | 'slide'>('slide') const reconRef = useRef(null) const [reconWidth, setReconWidth] = useState(0) // Pixel-based word positions (both algorithms run, toggle selects which to use) const overlayImageUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` : '' const clusterPositions = usePixelWordPositions( overlayImageUrl, gridCells, status === 'ready', imageRotation, ) const slidePositions = useSlideWordPositions( overlayImageUrl, gridCells, status === 'ready', imageRotation, ) const cellWordPositions = positioningMode === 'slide' ? slidePositions : clusterPositions // Track container width 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() }, [status]) // Load session data useEffect(() => { if (wordResultOverride) { applyWordResult(wordResultOverride) return } if (!sessionId) return loadSessionData() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, wordResultOverride]) const applyWordResult = (wordResult: { cells: GridCell[]; image_width: number; image_height: number; [key: string]: unknown }) => { const rawGridCells: GridCell[] = wordResult.cells || [] setGridCells(rawGridCells) const editableCells: 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(editableCells) setEditedTexts(new Map()) setUndoStack([]) setRedoStack([]) if (wordResult.image_width && wordResult.image_height) { setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height }) } setStatus('ready') } 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 den Woerter-Schritt abschliessen.') setStatus('error') return } applyWordResult(wordResult as unknown as { cells: GridCell[]; image_width: number; image_height: number }) // Load rows const rowResult: RowResult | undefined = data.row_result if (rowResult?.rows) setRows(rowResult.rows) } 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 ?? '' setUndoStack(stack => [...stack, { cellId, oldText: prevText, newText }]) setRedoStack([]) 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) => { setEditedTexts(prev => { const next = new Map(prev) next.delete(cellId) return next }) }, []) // Keyboard shortcuts 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]) // Tab navigation 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) { 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]) const dewarpedUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` : '' // Compute median cell height (in px) for consistent font sizing // Must be before early returns (Rules of Hooks) const medianCellHeightPx = useMemo(() => { const imgWVal = imageNaturalSize?.w || 1 const imgHVal = imageNaturalSize?.h || 1 const cH = reconWidth * (imgHVal / imgWVal) if (cells.length === 0 || cH === 0) return 40 const heights = cells.map(c => cH * (c.bboxPct.h / 100)).sort((a, b) => a - b) const mid = Math.floor(heights.length / 2) return heights.length % 2 === 0 ? (heights[mid - 1] + heights[mid]) / 2 : heights[mid] }, [cells, reconWidth, imageNaturalSize]) if (!sessionId) { return
Bitte zuerst eine Session auswaehlen.
} if (status === 'loading') { return (
Overlay-Daten werden geladen...
) } if (status === 'error') { return (
⚠️

Fehler

{error}

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

Overlay gespeichert

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

) } const imgW = imageNaturalSize?.w || 1 const imgH = imageNaturalSize?.h || 1 const containerH = reconWidth * (imgH / imgW) return (
{/* Toolbar */}

Overlay-Rekonstruktion

{cells.length} Zellen · {changedCount} geaendert
{/* Undo/Redo */}
{/* Font scale */}
{/* Positioning mode toggle */}
{/* Text color */} {(['red', 'blue', 'black'] as const).map(c => (
{/* True overlay: text layer on top of original image */}
{/* Background: original image */} {/* eslint-disable-next-line @next/next/no-img-element */} Original { const img = e.target as HTMLImageElement setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }) }} /> {/* Text overlay layer */}
{/* Row lines */} {rows.map((row, i) => (
))} {/* Pixel-positioned words / editable inputs */} {cells.map((cell) => { const displayText = getDisplayText(cell) const edited = isEdited(cell) const wordPos = cellWordPositions.get(cell.cellId) const bboxPct = cell.bboxPct const colorValue = textColor === 'black' ? '#1a1a1a' : textColor // Pixel-analysed: render word-groups at detected positions if (wordPos && wordPos.length > 0) { return wordPos.map((wp, i) => { const autoFontPx = medianCellHeightPx * wp.fontRatio * fontScale const fs = Math.max(6, autoFontPx) if (wordPos.length > 1) { return ( {wp.text} ) } 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' : 'normal', fontFamily: "'Liberation Sans', Arial, sans-serif", lineHeight: '1', color: colorValue, }} title={`${cell.cellId} (${cell.colType})`} /> {edited && ( )}
) }) } // Fallback: no pixel data — single input at cell bbox if (!cell.text) return null const fontSize = Math.max(6, medianCellHeightPx * 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: colorValue, }} title={`${cell.cellId} (${cell.colType})`} /> {edited && ( )}
) })}
{/* Bottom action */}
) }