Files
breakpilot-lehrer/admin-lehrer/components/ocr-pipeline/StepWordRecognition.tsx
Benjamin Admin 8a60f4bf30
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 36s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m12s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
fix: Overlay-Zellen ohne _heal_row_gaps positionieren (skip_heal_gaps)
_heal_row_gaps verschiebt Zell-Positionen nach Entfernung von Artefakt-Zeilen,
was im Overlay zu sichtbarem Versatz fuehrt (z.B. 23px bei "badge").
Neuer skip_heal_gaps Parameter in build_cell_grid_v2 und words-Endpoint
behaelt die exakten Zeilen-Positionen bei.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:59:50 +01:00

914 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GridResult, GridCell, WordEntry, WordGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types'
const KLAUSUR_API = '/klausur-api'
/** Render text with \n as line breaks */
function MultilineText({ text }: { text: string }) {
if (!text) return <span className="text-gray-300 dark:text-gray-600">&mdash;</span>
const lines = text.split('\n')
if (lines.length === 1) return <>{text}</>
return <>{lines.map((line, i) => (
<span key={i}>{line}{i < lines.length - 1 && <br />}</span>
))}</>
}
/** Column type → human-readable header */
function colTypeLabel(colType: string): string {
const labels: Record<string, string> = {
column_en: 'English',
column_de: 'Deutsch',
column_example: 'Example',
column_text: 'Text',
column_marker: 'Marker',
page_ref: 'Seite',
}
return labels[colType] || colType.replace('column_', '')
}
/** Column type → color class */
function colTypeColor(colType: string): string {
const colors: Record<string, string> = {
column_en: 'text-blue-600 dark:text-blue-400',
column_de: 'text-green-600 dark:text-green-400',
column_example: 'text-orange-600 dark:text-orange-400',
column_text: 'text-purple-600 dark:text-purple-400',
column_marker: 'text-gray-500 dark:text-gray-400',
}
return colors[colType] || 'text-gray-600 dark:text-gray-400'
}
interface StepWordRecognitionProps {
sessionId: string | null
onNext: () => void
goToStep: (step: number) => void
/** Skip _heal_row_gaps in cell grid (better overlay positioning) */
skipHealGaps?: boolean
}
export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps = false }: StepWordRecognitionProps) {
const [gridResult, setGridResult] = useState<GridResult | null>(null)
const [detecting, setDetecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [gtNotes, setGtNotes] = useState('')
const [gtSaved, setGtSaved] = useState(false)
// Step-through labeling state
const [activeIndex, setActiveIndex] = useState(0)
const [editedEntries, setEditedEntries] = useState<WordEntry[]>([])
const [editedCells, setEditedCells] = useState<GridCell[]>([])
const [mode, setMode] = useState<'overview' | 'labeling'>('overview')
const [ocrEngine, setOcrEngine] = useState<'auto' | 'tesseract' | 'rapid'>('auto')
const [usedEngine, setUsedEngine] = useState<string>('')
const [pronunciation, setPronunciation] = useState<'british' | 'american'>('british')
// Streaming progress state
const [streamProgress, setStreamProgress] = useState<{ current: number; total: number } | null>(null)
const enRef = useRef<HTMLInputElement>(null)
const tableEndRef = useRef<HTMLDivElement>(null)
const isVocab = gridResult?.layout === 'vocab'
useEffect(() => {
if (!sessionId) return
// Always run fresh detection — word-lookup is fast (~0.03s)
// and avoids stale cached results from previous pipeline versions.
runAutoDetection()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
const applyGridResult = (data: GridResult) => {
setGridResult(data)
setUsedEngine(data.ocr_engine || '')
if (data.layout === 'vocab' && data.entries) {
initEntries(data.entries)
}
if (data.cells) {
setEditedCells(data.cells.map(c => ({ ...c, status: c.status || 'pending' })))
}
}
const initEntries = (entries: WordEntry[]) => {
setEditedEntries(entries.map(e => ({ ...e, status: e.status || 'pending' })))
setActiveIndex(0)
}
const runAutoDetection = useCallback(async (engine?: string) => {
if (!sessionId) return
const eng = engine || ocrEngine
setDetecting(true)
setError(null)
setStreamProgress(null)
setEditedCells([])
setEditedEntries([])
setGridResult(null)
try {
// Retry once if initial request fails (e.g. after container restart,
// session cache may not be warm yet when navigating via wizard)
let res: Response | null = null
for (let attempt = 0; attempt < 2; attempt++) {
res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/words?stream=true&engine=${eng}&pronunciation=${pronunciation}${skipHealGaps ? '&skip_heal_gaps=true' : ''}`,
{ method: 'POST' },
)
if (res.ok) break
if (attempt === 0 && (res.status === 400 || res.status === 404)) {
// Wait briefly for cache to warm up, then retry
await new Promise(r => setTimeout(r, 2000))
continue
}
break
}
if (!res || !res.ok) {
const err = await res?.json().catch(() => ({ detail: res?.statusText })) || { detail: 'Worterkennung fehlgeschlagen' }
throw new Error(err.detail || 'Worterkennung fehlgeschlagen')
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let streamLayout: string | null = null
let streamColumnsUsed: GridResult['columns_used'] = []
let streamGridShape: GridResult['grid_shape'] | null = null
let streamCells: GridCell[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Parse SSE events (separated by \n\n)
while (buffer.includes('\n\n')) {
const idx = buffer.indexOf('\n\n')
const chunk = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 2)
if (!chunk.startsWith('data: ')) continue
const dataStr = chunk.slice(6) // strip "data: "
let event: any
try {
event = JSON.parse(dataStr)
} catch {
continue
}
if (event.type === 'meta') {
streamLayout = event.layout || 'generic'
streamGridShape = event.grid_shape || null
// Show partial grid result so UI renders structure
setGridResult(prev => ({
...prev,
layout: event.layout || 'generic',
grid_shape: event.grid_shape,
columns_used: [],
cells: [],
summary: { total_cells: event.grid_shape?.total_cells || 0, non_empty_cells: 0, low_confidence: 0 },
duration_seconds: 0,
ocr_engine: '',
} as GridResult))
}
if (event.type === 'columns') {
streamColumnsUsed = event.columns_used || []
setGridResult(prev => prev ? { ...prev, columns_used: streamColumnsUsed } : prev)
}
if (event.type === 'cell') {
const cell: GridCell = { ...event.cell, status: 'pending' }
streamCells = [...streamCells, cell]
setEditedCells(streamCells)
setStreamProgress(event.progress)
// Auto-scroll table to bottom
setTimeout(() => tableEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 16)
}
if (event.type === 'complete') {
// Build final GridResult
const finalResult: GridResult = {
cells: streamCells,
grid_shape: streamGridShape || { rows: 0, cols: 0, total_cells: streamCells.length },
columns_used: streamColumnsUsed,
layout: streamLayout || 'generic',
image_width: 0,
image_height: 0,
duration_seconds: event.duration_seconds || 0,
ocr_engine: event.ocr_engine || '',
summary: event.summary || {},
}
// If vocab: apply post-processed entries from complete event
if (event.vocab_entries) {
finalResult.entries = event.vocab_entries
finalResult.vocab_entries = event.vocab_entries
finalResult.entry_count = event.vocab_entries.length
}
applyGridResult(finalResult)
setUsedEngine(event.ocr_engine || '')
setStreamProgress(null)
}
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setDetecting(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, ocrEngine, pronunciation])
const handleGroundTruth = useCallback(async (isCorrect: boolean) => {
if (!sessionId) return
const gt: WordGroundTruth = {
is_correct: isCorrect,
corrected_entries: isCorrect ? undefined : (isVocab ? editedEntries : undefined),
notes: gtNotes || undefined,
}
try {
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/words`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(gt),
})
setGtSaved(true)
} catch (e) {
console.error('Ground truth save failed:', e)
}
}, [sessionId, gtNotes, editedEntries, isVocab])
// Vocab mode: update entry field
const updateEntry = (index: number, field: 'english' | 'german' | 'example', value: string) => {
setEditedEntries(prev => prev.map((e, i) =>
i === index ? { ...e, [field]: value, status: 'edited' as const } : e
))
}
// Generic mode: update cell text
const updateCell = (cellId: string, value: string) => {
setEditedCells(prev => prev.map(c =>
c.cell_id === cellId ? { ...c, text: value, status: 'edited' as const } : c
))
}
// Step-through: confirm current row (always cell-based)
const confirmEntry = () => {
const rowCells = getRowCells(activeIndex)
const cellIds = new Set(rowCells.map(c => c.cell_id))
setEditedCells(prev => prev.map(c =>
cellIds.has(c.cell_id) ? { ...c, status: c.status === 'edited' ? 'edited' : 'confirmed' } : c
))
const maxIdx = getUniqueRowCount() - 1
if (activeIndex < maxIdx) {
setActiveIndex(activeIndex + 1)
}
}
// Step-through: skip current row
const skipEntry = () => {
const rowCells = getRowCells(activeIndex)
const cellIds = new Set(rowCells.map(c => c.cell_id))
setEditedCells(prev => prev.map(c =>
cellIds.has(c.cell_id) ? { ...c, status: 'skipped' as const } : c
))
const maxIdx = getUniqueRowCount() - 1
if (activeIndex < maxIdx) {
setActiveIndex(activeIndex + 1)
}
}
// Helper: get unique row indices from cells
const getUniqueRowCount = () => {
if (!editedCells.length) return 0
return new Set(editedCells.map(c => c.row_index)).size
}
// Helper: get cells for a given row index (by position in sorted unique rows)
const getRowCells = (rowPosition: number) => {
const uniqueRows = [...new Set(editedCells.map(c => c.row_index))].sort((a, b) => a - b)
const rowIdx = uniqueRows[rowPosition]
return editedCells.filter(c => c.row_index === rowIdx)
}
// Focus english input when active entry changes in labeling mode
useEffect(() => {
if (mode === 'labeling' && enRef.current) {
enRef.current.focus()
}
}, [activeIndex, mode])
// Keyboard shortcuts in labeling mode
useEffect(() => {
if (mode !== 'labeling') return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmEntry()
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.preventDefault()
skipEntry()
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
e.preventDefault()
if (activeIndex > 0) setActiveIndex(activeIndex - 1)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, activeIndex, editedEntries, editedCells])
if (!sessionId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-5xl mb-4">🔤</div>
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
Schritt 5: Worterkennung
</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-md">
Bitte zuerst Schritte 1-4 abschliessen.
</p>
</div>
)
}
const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
const confColor = (conf: number) => {
if (conf >= 70) return 'text-green-600 dark:text-green-400'
if (conf >= 50) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
const statusBadge = (status?: string) => {
const map: Record<string, string> = {
pending: 'bg-gray-100 dark:bg-gray-700 text-gray-500',
confirmed: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
edited: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
skipped: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400',
}
return map[status || 'pending'] || map.pending
}
const summary = gridResult?.summary
const columnsUsed = gridResult?.columns_used || []
const gridShape = gridResult?.grid_shape
// Counts for labeling progress (always cell-based)
const confirmedRowIds = new Set(
editedCells.filter(c => c.status === 'confirmed' || c.status === 'edited').map(c => c.row_index)
)
const confirmedCount = confirmedRowIds.size
const totalCount = getUniqueRowCount()
// Group cells by row for generic table display
const cellsByRow: Map<number, GridCell[]> = new Map()
for (const cell of editedCells) {
const existing = cellsByRow.get(cell.row_index) || []
existing.push(cell)
cellsByRow.set(cell.row_index, existing)
}
const sortedRowIndices = [...cellsByRow.keys()].sort((a, b) => a - b)
return (
<div className="space-y-4">
{/* Loading with streaming progress */}
{detecting && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400 text-sm">
<div className="animate-spin w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full" />
{streamProgress
? `Zelle ${streamProgress.current}/${streamProgress.total} erkannt...`
: 'Worterkennung startet...'}
</div>
{streamProgress && streamProgress.total > 0 && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-teal-500 h-1.5 rounded-full transition-all duration-150"
style={{ width: `${(streamProgress.current / streamProgress.total) * 100}%` }}
/>
</div>
)}
</div>
)}
{/* Layout badge + Mode toggle */}
{gridResult && (
<div className="flex items-center gap-2">
{/* Layout badge */}
<span className={`px-2 py-0.5 rounded text-[10px] uppercase font-semibold ${
isVocab
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{isVocab ? 'Vokabel-Layout' : 'Generisch'}
</span>
{gridShape && (
<span className="text-[10px] text-gray-400">
{gridShape.rows}×{gridShape.cols} = {gridShape.total_cells} Zellen
</span>
)}
<div className="flex-1" />
<button
onClick={() => setMode('overview')}
className={`px-3 py-1.5 text-xs rounded-lg font-medium transition-colors ${
mode === 'overview'
? 'bg-teal-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Uebersicht
</button>
<button
onClick={() => setMode('labeling')}
className={`px-3 py-1.5 text-xs rounded-lg font-medium transition-colors ${
mode === 'labeling'
? 'bg-teal-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Labeling ({confirmedCount}/{totalCount})
</button>
</div>
)}
{/* Overview mode */}
{mode === 'overview' && (
<>
{/* Images: overlay vs clean */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Mit Grid-Overlay
</div>
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
{gridResult ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${overlayUrl}?t=${Date.now()}`}
alt="Wort-Overlay"
className="w-full h-auto"
/>
) : (
<div className="aspect-[3/4] flex items-center justify-center text-gray-400 text-sm">
{detecting ? 'Erkenne Woerter...' : 'Keine Daten'}
</div>
)}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Entzerrtes Bild
</div>
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={dewarpedUrl}
alt="Entzerrt"
className="w-full h-auto"
/>
</div>
</div>
</div>
{/* Result summary (only after streaming completes) */}
{gridResult && summary && !detecting && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Ergebnis: {summary.non_empty_cells}/{summary.total_cells} Zellen mit Text
({sortedRowIndices.length} Zeilen, {columnsUsed.length} Spalten)
</h4>
<span className="text-xs text-gray-400">
{gridResult.duration_seconds}s
</span>
</div>
{/* Summary badges */}
<div className="flex gap-2 flex-wrap">
<span className="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
Zellen: {summary.non_empty_cells}/{summary.total_cells}
</span>
{columnsUsed.map((col, i) => (
<span key={i} className={`px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 ${colTypeColor(col.type)}`}>
C{col.index}: {colTypeLabel(col.type)}
</span>
))}
{summary.low_confidence > 0 && (
<span className="px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
Unsicher: {summary.low_confidence}
</span>
)}
</div>
{/* Entry/Cell table */}
<div className="max-h-80 overflow-y-auto">
{/* Unified dynamic table — columns driven by columns_used */}
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white dark:bg-gray-800">
<tr className="text-left text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
<th className="py-1 pr-2 w-12">Zeile</th>
{columnsUsed.map((col, i) => (
<th key={i} className={`py-1 pr-2 ${colTypeColor(col.type)}`}>
{colTypeLabel(col.type)}
</th>
))}
<th className="py-1 w-12 text-right">Conf</th>
</tr>
</thead>
<tbody>
{sortedRowIndices.map((rowIdx, posIdx) => {
const rowCells = cellsByRow.get(rowIdx) || []
const avgConf = rowCells.length
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
: 0
return (
<tr
key={rowIdx}
className={`border-b dark:border-gray-700/50 ${
posIdx === activeIndex ? 'bg-teal-50 dark:bg-teal-900/20' : ''
}`}
onClick={() => { setActiveIndex(posIdx); setMode('labeling') }}
>
<td className="py-1 pr-2 text-gray-400 font-mono text-[10px]">
R{String(rowIdx).padStart(2, '0')}
</td>
{columnsUsed.map((col) => {
const cell = rowCells.find(c => c.col_index === col.index)
return (
<td key={col.index} className="py-1 pr-2 font-mono text-gray-700 dark:text-gray-300 cursor-pointer">
<MultilineText text={cell?.text || ''} />
</td>
)
})}
<td className={`py-1 text-right font-mono ${confColor(avgConf)}`}>
{avgConf}%
</td>
</tr>
)
})}
</tbody>
</table>
<div ref={tableEndRef} />
</div>
</div>
)}
{/* Streaming cell table (shown while detecting, before complete) */}
{detecting && editedCells.length > 0 && !gridResult?.summary?.non_empty_cells && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Live: {editedCells.length} Zellen erkannt...
</h4>
<div className="max-h-80 overflow-y-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white dark:bg-gray-800">
<tr className="text-left text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
<th className="py-1 pr-2 w-12">Zelle</th>
{columnsUsed.map((col, i) => (
<th key={i} className={`py-1 pr-2 ${colTypeColor(col.type)}`}>
{colTypeLabel(col.type)}
</th>
))}
<th className="py-1 w-12 text-right">Conf</th>
</tr>
</thead>
<tbody>
{(() => {
const liveByRow: Map<number, GridCell[]> = new Map()
for (const cell of editedCells) {
const existing = liveByRow.get(cell.row_index) || []
existing.push(cell)
liveByRow.set(cell.row_index, existing)
}
const liveSorted = [...liveByRow.keys()].sort((a, b) => a - b)
return liveSorted.map(rowIdx => {
const rowCells = liveByRow.get(rowIdx) || []
const avgConf = rowCells.length
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
: 0
return (
<tr key={rowIdx} className="border-b dark:border-gray-700/50 animate-fade-in">
<td className="py-1 pr-2 text-gray-400 font-mono text-[10px]">
R{String(rowIdx).padStart(2, '0')}
</td>
{columnsUsed.map((col) => {
const cell = rowCells.find(c => c.col_index === col.index)
return (
<td key={col.index} className="py-1 pr-2 font-mono text-gray-700 dark:text-gray-300">
<MultilineText text={cell?.text || ''} />
</td>
)
})}
<td className={`py-1 text-right font-mono ${confColor(avgConf)}`}>
{avgConf}%
</td>
</tr>
)
})
})()}
</tbody>
</table>
<div ref={tableEndRef} />
</div>
</div>
)}
</>
)}
{/* Labeling mode */}
{mode === 'labeling' && editedCells.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{/* Left 2/3: Image with highlighted active row */}
<div className="col-span-2">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Zeile {activeIndex + 1} von {getUniqueRowCount()}
</div>
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900 relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${overlayUrl}?t=${Date.now()}`}
alt="Wort-Overlay"
className="w-full h-auto"
/>
{/* Highlight overlay for active row */}
{(() => {
const rowCells = getRowCells(activeIndex)
return rowCells.map(cell => (
<div
key={cell.cell_id}
className="absolute border-2 border-yellow-400 bg-yellow-400/10 pointer-events-none"
style={{
left: `${cell.bbox_pct.x}%`,
top: `${cell.bbox_pct.y}%`,
width: `${cell.bbox_pct.w}%`,
height: `${cell.bbox_pct.h}%`,
}}
/>
))
})()}
</div>
</div>
{/* Right 1/3: Editable fields */}
<div className="space-y-3">
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => setActiveIndex(Math.max(0, activeIndex - 1))}
disabled={activeIndex === 0}
className="px-2 py-1 text-xs border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-30"
>
Zurueck
</button>
<span className="text-xs text-gray-500">
{activeIndex + 1} / {getUniqueRowCount()}
</span>
<button
onClick={() => setActiveIndex(Math.min(
getUniqueRowCount() - 1,
activeIndex + 1
))}
disabled={activeIndex >= getUniqueRowCount() - 1}
className="px-2 py-1 text-xs border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-30"
>
Weiter
</button>
</div>
{/* Status badge */}
<div className="flex items-center gap-2">
{(() => {
const rowCells = getRowCells(activeIndex)
const avgConf = rowCells.length
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
: 0
return (
<span className={`text-xs font-mono ${confColor(avgConf)}`}>
{avgConf}% Konfidenz
</span>
)
})()}
</div>
{/* Editable fields — one per column, driven by columns_used */}
<div className="space-y-2">
{(() => {
const rowCells = getRowCells(activeIndex)
return columnsUsed.map((col, colIdx) => {
const cell = rowCells.find(c => c.col_index === col.index)
if (!cell) return null
return (
<div key={col.index}>
<div className="flex items-center gap-1 mb-0.5">
<label className={`text-[10px] font-medium ${colTypeColor(col.type)}`}>
{colTypeLabel(col.type)}
</label>
<span className="text-[9px] text-gray-400">{cell.cell_id}</span>
</div>
{/* Cell crop */}
<div className="border rounded dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900 h-10 relative mb-1">
<CellCrop imageUrl={dewarpedUrl} bbox={cell.bbox_pct} />
</div>
<textarea
ref={colIdx === 0 ? enRef as any : undefined}
rows={Math.max(1, (cell.text || '').split('\n').length)}
value={cell.text || ''}
onChange={(e) => updateCell(cell.cell_id, e.target.value)}
className="w-full px-2 py-1.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 font-mono resize-none"
/>
</div>
)
})
})()}
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
onClick={confirmEntry}
className="flex-1 px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium"
>
Bestaetigen (Enter)
</button>
<button
onClick={skipEntry}
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600"
>
Skip
</button>
</div>
{/* Shortcuts hint */}
<div className="text-[10px] text-gray-400 space-y-0.5">
<div>Enter = Bestaetigen & weiter</div>
<div>Ctrl+Down = Ueberspringen</div>
<div>Ctrl+Up = Zurueck</div>
</div>
{/* Row list (compact) */}
<div className="border-t dark:border-gray-700 pt-2 mt-2">
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400 mb-1">
Alle Zeilen
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5">
{sortedRowIndices.map((rowIdx, posIdx) => {
const rowCells = cellsByRow.get(rowIdx) || []
const textParts = rowCells.filter(c => c.text).map(c => c.text.replace(/\n/g, ' '))
return (
<div
key={rowIdx}
onClick={() => setActiveIndex(posIdx)}
className={`flex items-center gap-1 px-2 py-1 rounded text-[10px] cursor-pointer transition-colors ${
posIdx === activeIndex
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<span className="w-6 text-right text-gray-400 font-mono">R{String(rowIdx).padStart(2, '0')}</span>
<span className="truncate text-gray-600 dark:text-gray-400 font-mono">
{textParts.join(' \u2192 ') || '\u2014'}
</span>
</div>
)
})}
</div>
</div>
</div>
</div>
)}
{/* Controls */}
{gridResult && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
{/* OCR Engine selector */}
<select
value={ocrEngine}
onChange={(e) => setOcrEngine(e.target.value as 'auto' | 'tesseract' | 'rapid')}
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<option value="auto">Auto (RapidOCR wenn verfuegbar)</option>
<option value="rapid">RapidOCR (ONNX)</option>
<option value="tesseract">Tesseract</option>
</select>
{/* Pronunciation selector (only for vocab) */}
{isVocab && (
<select
value={pronunciation}
onChange={(e) => setPronunciation(e.target.value as 'british' | 'american')}
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<option value="british">Britisch (RP)</option>
<option value="american">Amerikanisch</option>
</select>
)}
<button
onClick={() => runAutoDetection()}
disabled={detecting}
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-50"
>
Erneut erkennen
</button>
{/* Show which engine was used */}
{usedEngine && (
<span className={`px-2 py-0.5 rounded text-[10px] uppercase font-semibold ${
usedEngine === 'rapid'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{usedEngine}
</span>
)}
<button
onClick={() => goToStep(3)}
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-700"
>
Zeilen korrigieren (Step 4)
</button>
<div className="flex-1" />
{/* Ground truth */}
{!gtSaved ? (
<>
<input
type="text"
placeholder="Notizen (optional)"
value={gtNotes}
onChange={(e) => setGtNotes(e.target.value)}
className="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 w-48"
/>
<button
onClick={() => handleGroundTruth(true)}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Korrekt
</button>
<button
onClick={() => handleGroundTruth(false)}
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Fehlerhaft
</button>
</>
) : (
<span className="text-xs text-green-600 dark:text-green-400">
Ground Truth gespeichert
</span>
)}
<button
onClick={onNext}
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium"
>
Weiter
</button>
</div>
</div>
)}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
</div>
)
}
/**
* CellCrop: Shows a cropped portion of the dewarped image based on percent bbox.
* Uses CSS background-image + background-position for efficient cropping.
*/
function CellCrop({ imageUrl, bbox }: { imageUrl: string; bbox: { x: number; y: number; w: number; h: number } }) {
// Scale factor: how much to zoom into the cell
const scaleX = 100 / bbox.w
const scaleY = 100 / bbox.h
const scale = Math.min(scaleX, scaleY, 8) // Cap zoom at 8x
return (
<div
className="w-full h-full"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: `${scale * 100}%`,
backgroundPosition: `${-bbox.x * scale}% ${-bbox.y * scale}%`,
backgroundRepeat: 'no-repeat',
}}
/>
)
}