feat: add Excel-like grid editor for OCR overlay (Kombi mode step 6)
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 27s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 17s
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 27s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 17s
Backend: new grid_editor_api.py with build-grid endpoint that detects bordered boxes, splits page into zones, clusters columns/rows per zone from Kombi word positions. New DB column grid_editor_result JSONB. Frontend: GridEditor component with editable HTML tables per zone, column bold toggle, header row toggle, undo/redo, keyboard navigation (Tab/Enter/Arrow), image overlay verification, and save/load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
admin-lehrer/components/grid-editor/GridTable.tsx
Normal file
153
admin-lehrer/components/grid-editor/GridTable.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { GridZone } from './types'
|
||||
|
||||
interface GridTableProps {
|
||||
zone: GridZone
|
||||
selectedCell: string | null
|
||||
onSelectCell: (cellId: string) => void
|
||||
onCellTextChange: (cellId: string, text: string) => void
|
||||
onToggleColumnBold: (zoneIndex: number, colIndex: number) => void
|
||||
onToggleRowHeader: (zoneIndex: number, rowIndex: number) => void
|
||||
onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void
|
||||
}
|
||||
|
||||
export function GridTable({
|
||||
zone,
|
||||
selectedCell,
|
||||
onSelectCell,
|
||||
onCellTextChange,
|
||||
onToggleColumnBold,
|
||||
onToggleRowHeader,
|
||||
onNavigate,
|
||||
}: GridTableProps) {
|
||||
const tableRef = useRef<HTMLTableElement>(null)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, cellId: string) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
onNavigate(cellId, e.shiftKey ? 'left' : 'right')
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onNavigate(cellId, 'down')
|
||||
} else if (e.key === 'ArrowUp' && e.altKey) {
|
||||
e.preventDefault()
|
||||
onNavigate(cellId, 'up')
|
||||
} else if (e.key === 'ArrowDown' && e.altKey) {
|
||||
e.preventDefault()
|
||||
onNavigate(cellId, 'down')
|
||||
} else if (e.key === 'Escape') {
|
||||
;(e.target as HTMLElement).blur()
|
||||
}
|
||||
},
|
||||
[onNavigate],
|
||||
)
|
||||
|
||||
// Build row→col cell lookup
|
||||
const cellMap = new Map<string, (typeof zone.cells)[0]>()
|
||||
for (const cell of zone.cells) {
|
||||
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
|
||||
}
|
||||
|
||||
const isBoxZone = zone.zone_type === 'box'
|
||||
|
||||
return (
|
||||
<div className={`overflow-x-auto ${isBoxZone ? 'border-2 border-gray-400 dark:border-gray-500 rounded-lg' : ''}`}>
|
||||
{/* Zone label */}
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
isBoxZone
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
{isBoxZone ? 'Box' : 'Inhalt'} Zone {zone.zone_index}
|
||||
</span>
|
||||
<span>{zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen</span>
|
||||
</div>
|
||||
|
||||
<table ref={tableRef} className="w-full border-collapse text-sm">
|
||||
{/* Column headers */}
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number header */}
|
||||
<th className="w-8 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 font-normal border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" />
|
||||
{zone.columns.map((col) => (
|
||||
<th
|
||||
key={col.index}
|
||||
className={`px-2 py-1.5 text-xs font-medium border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
col.bold ? 'text-teal-700 dark:text-teal-300' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
onClick={() => onToggleColumnBold(zone.zone_index, col.index)}
|
||||
title={`Spalte ${col.index + 1} — Klick fuer Fett-Toggle`}
|
||||
>
|
||||
<div className="flex items-center gap-1 justify-center">
|
||||
<span>{col.label}</span>
|
||||
{col.bold && (
|
||||
<span className="text-[9px] px-1 py-0 rounded bg-teal-100 dark:bg-teal-900/40 text-teal-600 dark:text-teal-400">
|
||||
B
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{zone.rows.map((row) => (
|
||||
<tr key={row.index} className={row.is_header ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}>
|
||||
{/* Row number */}
|
||||
<td
|
||||
className={`w-8 px-1 py-1 text-center text-[10px] border-b border-r border-gray-200 dark:border-gray-700 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
row.is_header
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
onClick={() => onToggleRowHeader(zone.zone_index, row.index)}
|
||||
title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`}
|
||||
>
|
||||
{row.index + 1}
|
||||
{row.is_header && <span className="block text-[8px]">H</span>}
|
||||
</td>
|
||||
|
||||
{/* Cells */}
|
||||
{zone.columns.map((col) => {
|
||||
const cell = cellMap.get(`${row.index}_${col.index}`)
|
||||
const cellId = cell?.cell_id ?? `Z${zone.zone_index}_R${String(row.index).padStart(2, '0')}_C${col.index}`
|
||||
const isSelected = selectedCell === cellId
|
||||
const isBold = col.bold || cell?.is_bold
|
||||
const isLowConf = cell && cell.confidence > 0 && cell.confidence < 60
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.index}
|
||||
className={`border-b border-r border-gray-200 dark:border-gray-700 p-0 transition-shadow ${
|
||||
isSelected ? 'ring-2 ring-teal-500 ring-inset z-10 relative' : ''
|
||||
} ${isLowConf ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''}`}
|
||||
>
|
||||
<input
|
||||
id={`cell-${cellId}`}
|
||||
type="text"
|
||||
value={cell?.text ?? ''}
|
||||
onChange={(e) => {
|
||||
if (cell) onCellTextChange(cellId, e.target.value)
|
||||
}}
|
||||
onFocus={() => onSelectCell(cellId)}
|
||||
onKeyDown={(e) => handleKeyDown(e, cellId)}
|
||||
className={`w-full px-2 py-1.5 bg-transparent border-0 outline-none text-gray-800 dark:text-gray-200 ${
|
||||
isBold ? 'font-bold' : 'font-normal'
|
||||
} ${row.is_header ? 'text-base' : 'text-sm'}`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user