feat: Text-Overlay Rekonstruktion in StepLlmReview
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 30s
CI / test-go-edu-search (push) Successful in 33s
CI / test-python-klausur (push) Failing after 2m13s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 24s
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 30s
CI / test-go-edu-search (push) Successful in 33s
CI / test-python-klausur (push) Failing after 2m13s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 24s
Neuer Overlay-Modus zeigt OCR-Text per bbox_pct ueber weissem Hintergrund neben dem Originalbild. Steuerelemente fuer Schriftgroesse, Einrueckung und Bold. Inline-Editing per contentEditable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { GridResult, WordEntry, ColumnMeta } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { GridCell, GridResult, WordEntry, ColumnMeta } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
@@ -83,9 +83,29 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
|||||||
// Image
|
// Image
|
||||||
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
|
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
|
||||||
|
|
||||||
|
// Overlay view state
|
||||||
|
const [viewMode, setViewMode] = useState<'table' | 'overlay'>('table')
|
||||||
|
const [fontScale, setFontScale] = useState(0.7)
|
||||||
|
const [leftPaddingPct, setLeftPaddingPct] = useState(0)
|
||||||
|
const [globalBold, setGlobalBold] = useState(false)
|
||||||
|
const [cells, setCells] = useState<GridCell[]>([])
|
||||||
|
const reconRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [reconWidth, setReconWidth] = useState(0)
|
||||||
|
|
||||||
const tableRef = useRef<HTMLDivElement>(null)
|
const tableRef = useRef<HTMLDivElement>(null)
|
||||||
const activeRowRef = useRef<HTMLTableRowElement>(null)
|
const activeRowRef = useRef<HTMLTableRowElement>(null)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}, [viewMode])
|
||||||
|
|
||||||
// Load session data on mount
|
// Load session data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
@@ -111,6 +131,7 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
|||||||
const entries = wordResult.vocab_entries || wordResult.entries || []
|
const entries = wordResult.vocab_entries || wordResult.entries || []
|
||||||
setVocabEntries(entries)
|
setVocabEntries(entries)
|
||||||
setColumnsUsed(wordResult.columns_used || [])
|
setColumnsUsed(wordResult.columns_used || [])
|
||||||
|
setCells(wordResult.cells || [])
|
||||||
|
|
||||||
// Check if LLM review was already run
|
// Check if LLM review was already run
|
||||||
const llmReview = wordResult.llm_review
|
const llmReview = wordResult.llm_review
|
||||||
@@ -383,6 +404,22 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
|||||||
|
|
||||||
const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0
|
const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0
|
||||||
|
|
||||||
|
/** Handle inline edit of a cell in the overlay */
|
||||||
|
const handleCellEdit = (cellId: string, rowIndex: number, newText: string | null) => {
|
||||||
|
if (newText === null) return
|
||||||
|
setCells(prev => prev.map(c => c.cell_id === cellId ? { ...c, text: newText } : c))
|
||||||
|
// Also update vocabEntries if this cell maps to a known field
|
||||||
|
const cell = cells.find(c => c.cell_id === cellId)
|
||||||
|
if (cell) {
|
||||||
|
const field = COL_TYPE_TO_FIELD[cell.col_type]
|
||||||
|
if (field) {
|
||||||
|
setVocabEntries(prev => prev.map((e, i) =>
|
||||||
|
i === rowIndex ? { ...e, [field]: newText } : e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ready / Running / Done: 2-column layout ---
|
// --- Ready / Running / Done: 2-column layout ---
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -439,8 +476,66 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 2-column layout: Image + Table */}
|
{/* View mode toggle */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-l-lg border transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-teal-600 text-white border-teal-600'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tabelle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('overlay')}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-r-lg border transition-colors ${
|
||||||
|
viewMode === 'overlay'
|
||||||
|
? 'bg-teal-600 text-white border-teal-600'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Overlay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay toolbar */}
|
||||||
|
{viewMode === 'overlay' && (
|
||||||
|
<div className="flex items-center gap-4 flex-wrap bg-gray-50 dark:bg-gray-800/50 rounded-lg px-3 py-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Schrift
|
||||||
|
<input
|
||||||
|
type="range" min={30} max={120} value={Math.round(fontScale * 100)}
|
||||||
|
onChange={e => setFontScale(Number(e.target.value) / 100)}
|
||||||
|
className="w-24 h-1 accent-teal-600"
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right font-mono">{Math.round(fontScale * 100)}%</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Einrueckung
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={20} step={0.5} value={leftPaddingPct}
|
||||||
|
onChange={e => setLeftPaddingPct(Number(e.target.value))}
|
||||||
|
className="w-24 h-1 accent-teal-600"
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right font-mono">{leftPaddingPct}%</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setGlobalBold(b => !b)}
|
||||||
|
className={`px-2 py-1 text-xs rounded border transition-colors font-bold ${
|
||||||
|
globalBold
|
||||||
|
? 'bg-teal-600 text-white border-teal-600'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2-column layout: Image + Table/Overlay */}
|
||||||
|
<div className={`grid gap-4 ${viewMode === 'overlay' ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
||||||
{/* Left: Dewarped Image with highlight overlay */}
|
{/* Left: Dewarped Image with highlight overlay */}
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
@@ -472,93 +567,145 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Full vocabulary table */}
|
{/* Right: Table or Overlay */}
|
||||||
<div className="col-span-2" ref={tableRef}>
|
<div className={viewMode === 'table' ? 'col-span-2' : 'col-span-1'} ref={tableRef}>
|
||||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
{viewMode === 'table' ? (
|
||||||
{columnsUsed.length === 1 && columnsUsed[0]?.type === 'column_text' ? 'Tabelle' : 'Vokabeltabelle'} ({vocabEntries.length} Eintraege)
|
<>
|
||||||
</div>
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
{columnsUsed.length === 1 && columnsUsed[0]?.type === 'column_text' ? 'Tabelle' : 'Vokabeltabelle'} ({vocabEntries.length} Eintraege)
|
||||||
<div className="max-h-[70vh] overflow-y-auto">
|
</div>
|
||||||
<table className="w-full text-sm">
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<thead className="sticky top-0 z-10">
|
<div className="max-h-[70vh] overflow-y-auto">
|
||||||
<tr className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<table className="w-full text-sm">
|
||||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium w-10">#</th>
|
<thead className="sticky top-0 z-10">
|
||||||
{columnsUsed.length > 0 ? (
|
<tr className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
columnsUsed.map((col, i) => {
|
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium w-10">#</th>
|
||||||
const field = COL_TYPE_TO_FIELD[col.type]
|
|
||||||
if (!field) return null
|
|
||||||
return (
|
|
||||||
<th key={i} className={`px-2 py-2 text-left font-medium ${COL_TYPE_COLOR[col.type] || 'text-gray-500 dark:text-gray-400'}`}>
|
|
||||||
{FIELD_LABELS[field] || field}
|
|
||||||
</th>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">EN</th>
|
|
||||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">DE</th>
|
|
||||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">Beispiel</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<th className="px-2 py-2 text-center text-gray-500 dark:text-gray-400 font-medium w-16">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{vocabEntries.map((entry, idx) => {
|
|
||||||
const rowStatus = getRowStatus(idx)
|
|
||||||
const rowChanges = correctedMap.get(idx)
|
|
||||||
|
|
||||||
const rowBg = {
|
|
||||||
pending: '',
|
|
||||||
active: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
||||||
reviewed: '',
|
|
||||||
corrected: 'bg-teal-50/50 dark:bg-teal-900/10',
|
|
||||||
skipped: 'bg-gray-50 dark:bg-gray-800/50',
|
|
||||||
}[rowStatus]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={idx}
|
|
||||||
ref={rowStatus === 'active' ? activeRowRef : undefined}
|
|
||||||
className={`border-b border-gray-100 dark:border-gray-700/50 ${rowBg} ${
|
|
||||||
rowStatus === 'active' ? 'ring-1 ring-yellow-400 ring-inset' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-2 py-1.5 text-gray-400 font-mono text-xs">{idx}</td>
|
|
||||||
{columnsUsed.length > 0 ? (
|
{columnsUsed.length > 0 ? (
|
||||||
columnsUsed.map((col, i) => {
|
columnsUsed.map((col, i) => {
|
||||||
const field = COL_TYPE_TO_FIELD[col.type]
|
const field = COL_TYPE_TO_FIELD[col.type]
|
||||||
if (!field) return null
|
if (!field) return null
|
||||||
const text = (entry as Record<string, unknown>)[field] as string || ''
|
|
||||||
return (
|
return (
|
||||||
<td key={i} className="px-2 py-1.5 text-xs">
|
<th key={i} className={`px-2 py-2 text-left font-medium ${COL_TYPE_COLOR[col.type] || 'text-gray-500 dark:text-gray-400'}`}>
|
||||||
<CellContent text={text} field={field} rowChanges={rowChanges} />
|
{FIELD_LABELS[field] || field}
|
||||||
</td>
|
</th>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<td className="px-2 py-1.5">
|
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">EN</th>
|
||||||
<CellContent text={entry.english} field="english" rowChanges={rowChanges} />
|
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">DE</th>
|
||||||
</td>
|
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">Beispiel</th>
|
||||||
<td className="px-2 py-1.5">
|
|
||||||
<CellContent text={entry.german} field="german" rowChanges={rowChanges} />
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-xs">
|
|
||||||
<CellContent text={entry.example} field="example" rowChanges={rowChanges} />
|
|
||||||
</td>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<td className="px-2 py-1.5 text-center">
|
<th className="px-2 py-2 text-center text-gray-500 dark:text-gray-400 font-medium w-16">Status</th>
|
||||||
<StatusIcon status={rowStatus} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{vocabEntries.map((entry, idx) => {
|
||||||
|
const rowStatus = getRowStatus(idx)
|
||||||
|
const rowChanges = correctedMap.get(idx)
|
||||||
|
|
||||||
|
const rowBg = {
|
||||||
|
pending: '',
|
||||||
|
active: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
reviewed: '',
|
||||||
|
corrected: 'bg-teal-50/50 dark:bg-teal-900/10',
|
||||||
|
skipped: 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
}[rowStatus]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
ref={rowStatus === 'active' ? activeRowRef : undefined}
|
||||||
|
className={`border-b border-gray-100 dark:border-gray-700/50 ${rowBg} ${
|
||||||
|
rowStatus === 'active' ? 'ring-1 ring-yellow-400 ring-inset' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5 text-gray-400 font-mono text-xs">{idx}</td>
|
||||||
|
{columnsUsed.length > 0 ? (
|
||||||
|
columnsUsed.map((col, i) => {
|
||||||
|
const field = COL_TYPE_TO_FIELD[col.type]
|
||||||
|
if (!field) return null
|
||||||
|
const text = (entry as Record<string, unknown>)[field] as string || ''
|
||||||
|
return (
|
||||||
|
<td key={i} className="px-2 py-1.5 text-xs">
|
||||||
|
<CellContent text={text} field={field} rowChanges={rowChanges} />
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<CellContent text={entry.english} field="english" rowChanges={rowChanges} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<CellContent text={entry.german} field="german" rowChanges={rowChanges} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-xs">
|
||||||
|
<CellContent text={entry.example} field="example" rowChanges={rowChanges} />
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<StatusIcon status={rowStatus} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Text-Rekonstruktion ({cells.filter(c => c.text).length} Zellen)
|
||||||
|
</div>
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-white">
|
||||||
|
<div
|
||||||
|
ref={reconRef}
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
aspectRatio: imageNaturalSize ? `${imageNaturalSize.w} / ${imageNaturalSize.h}` : '3 / 4',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.map(cell => {
|
||||||
|
if (!cell.bbox_pct || !cell.text) return null
|
||||||
|
const aspect = imageNaturalSize ? imageNaturalSize.h / imageNaturalSize.w : 4 / 3
|
||||||
|
const containerH = reconWidth * aspect
|
||||||
|
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
|
||||||
|
const fontSize = Math.max(6, cellHeightPx * fontScale)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={cell.cell_id}
|
||||||
|
className="absolute leading-none overflow-hidden"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
style={{
|
||||||
|
left: `${cell.bbox_pct.x}%`,
|
||||||
|
top: `${cell.bbox_pct.y}%`,
|
||||||
|
width: `${cell.bbox_pct.w}%`,
|
||||||
|
height: `${cell.bbox_pct.h}%`,
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
|
||||||
|
paddingLeft: `${leftPaddingPct}%`,
|
||||||
|
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
onBlur={(e) => handleCellEdit(cell.cell_id, cell.row_index, e.currentTarget.textContent)}
|
||||||
|
>
|
||||||
|
{cell.text}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user