feat: ImageLayoutEditor, arrow-key nav, multi-select bold, wider columns
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 32s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m52s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 18s

- New ImageLayoutEditor: SVG overlay on original scan with draggable
  column dividers, horizontal guidelines (margins/header/footer),
  double-click to add columns, x-button to delete
- GridTable: MIN_COL_WIDTH 40→80px for better readability
- Arrow up/down keys navigate between rows in the grid editor
- Ctrl+Click for multi-cell selection, Ctrl+B to toggle bold on selection
- getAdjacentCell works for cells that don't exist yet (new rows/cols)
- deleteColumn now merges x-boundaries correctly
- Session restore fix: grid_editor_result/structure_result in session GET
- Footer row 3-state cycle, auto-create cells for empty footer rows
- Grid save/build/GT-mark now advance current_step=11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-24 07:45:39 +01:00
parent 4e668660a7
commit 65f4ce1947
12 changed files with 1422 additions and 90 deletions

View File

@@ -7,20 +7,24 @@ interface GridTableProps {
zone: GridZone
layoutMetrics?: LayoutMetrics
selectedCell: string | null
selectedCells?: Set<string>
onSelectCell: (cellId: string) => void
onToggleCellSelection?: (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
onDeleteColumn?: (zoneIndex: number, colIndex: number) => void
onAddColumn?: (zoneIndex: number, afterColIndex: number) => void
onDeleteRow?: (zoneIndex: number, rowIndex: number) => void
onAddRow?: (zoneIndex: number, afterRowIndex: number) => void
}
/** Gutter width for row numbers (px). */
const ROW_NUM_WIDTH = 36
/** Minimum column width in px so columns remain usable. */
const MIN_COL_WIDTH = 40
const MIN_COL_WIDTH = 80
/** Minimum row height in px. */
const MIN_ROW_HEIGHT = 26
@@ -29,13 +33,17 @@ export function GridTable({
zone,
layoutMetrics,
selectedCell,
selectedCells,
onSelectCell,
onToggleCellSelection,
onCellTextChange,
onToggleColumnBold,
onToggleRowHeader,
onNavigate,
onDeleteColumn,
onAddColumn,
onDeleteRow,
onAddRow,
}: GridTableProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
@@ -113,12 +121,18 @@ export function GridTable({
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onNavigate(cellId, 'down')
} else if (e.key === 'ArrowUp' && e.altKey) {
} else if (e.key === 'ArrowUp') {
e.preventDefault()
onNavigate(cellId, 'up')
} else if (e.key === 'ArrowDown' && e.altKey) {
} else if (e.key === 'ArrowDown') {
e.preventDefault()
onNavigate(cellId, 'down')
} else if (e.key === 'ArrowLeft' && e.altKey) {
e.preventDefault()
onNavigate(cellId, 'left')
} else if (e.key === 'ArrowRight' && e.altKey) {
e.preventDefault()
onNavigate(cellId, 'right')
} else if (e.key === 'Escape') {
;(e.target as HTMLElement).blur()
}
@@ -323,7 +337,7 @@ export function GridTable({
<div key={row.index} style={{ display: 'contents' }}>
{/* Row number cell */}
<div
className={`relative sticky left-0 z-10 flex items-center justify-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 ${
className={`group/rowhdr relative sticky left-0 z-10 flex items-center justify-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'
: row.is_footer
@@ -332,11 +346,41 @@ export function GridTable({
}`}
style={{ height: `${rowH}px` }}
onClick={() => onToggleRowHeader(zone.zone_index, row.index)}
title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`}
title={`Zeile ${row.index + 1} — Klick: ${row.is_header ? 'Footer' : row.is_footer ? 'Normal' : 'Header'}`}
>
{row.index + 1}
{row.is_header && <span className="block text-[8px]">H</span>}
{row.is_footer && <span className="block text-[8px]">F</span>}
{/* Delete row button (visible on hover) */}
{onDeleteRow && zone.rows.length > 1 && (
<button
className="absolute top-0 left-0 w-4 h-4 flex items-center justify-center bg-red-500 text-white rounded-br text-[9px] leading-none opacity-0 group-hover/rowhdr:opacity-100 transition-opacity z-30"
onClick={(e) => {
e.stopPropagation()
if (confirm(`Zeile ${row.index + 1} loeschen?`)) {
onDeleteRow(zone.zone_index, row.index)
}
}}
title={`Zeile ${row.index + 1} loeschen`}
>
x
</button>
)}
{/* Add row button (visible on hover, below this row) */}
{onAddRow && (
<button
className="absolute -bottom-[7px] left-0 w-full h-[14px] flex items-center justify-center text-teal-500 opacity-0 group-hover/rowhdr:opacity-100 transition-opacity z-30 hover:bg-teal-100 dark:hover:bg-teal-900/40 rounded"
onClick={(e) => {
e.stopPropagation()
onAddRow(zone.zone_index, row.index)
}}
title={`Zeile nach ${row.index + 1} einfuegen`}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
)}
{/* Bottom-edge resize handle */}
<div
className="absolute bottom-0 left-0 w-full h-[4px] cursor-row-resize hover:bg-teal-400/40 z-20"
@@ -398,6 +442,7 @@ export function GridTable({
const isSelected = selectedCell === cellId
const isBold = col.bold || cell?.is_bold
const isLowConf = cell && cell.confidence > 0 && cell.confidence < 60
const isMultiSelected = selectedCells?.has(cellId)
const cellColor = getCellColor(cell)
// Show per-word colored display only when word_boxes
// match the cell text. Post-processing steps (e.g. 5h
@@ -417,9 +462,9 @@ export function GridTable({
key={col.index}
className={`relative border-b border-r border-gray-200 dark:border-gray-700 flex items-center ${
isSelected ? 'ring-2 ring-teal-500 ring-inset z-10' : ''
} ${isLowConf ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''} ${
row.is_header ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
}`}
} ${isMultiSelected ? 'bg-teal-50/60 dark:bg-teal-900/20' : ''} ${
isLowConf && !isMultiSelected ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''
} ${row.is_header && !isMultiSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
style={{ height: `${rowH}px` }}
>
{cellColor && (
@@ -433,9 +478,13 @@ export function GridTable({
{hasColoredWords && !isSelected ? (
<div
className={`w-full px-2 cursor-text truncate ${isBold ? 'font-bold' : 'font-normal'}`}
onClick={() => {
onSelectCell(cellId)
setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0)
onClick={(e) => {
if ((e.metaKey || e.ctrlKey) && onToggleCellSelection) {
onToggleCellSelection(cellId)
} else {
onSelectCell(cellId)
setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0)
}
}}
>
{cell!.word_boxes!.map((wb, i) => (
@@ -457,10 +506,14 @@ export function GridTable({
id={`cell-${cellId}`}
type="text"
value={cell?.text ?? ''}
onChange={(e) => {
if (cell) onCellTextChange(cellId, e.target.value)
}}
onChange={(e) => onCellTextChange(cellId, e.target.value)}
onFocus={() => onSelectCell(cellId)}
onClick={(e) => {
if ((e.metaKey || e.ctrlKey) && onToggleCellSelection) {
e.preventDefault()
onToggleCellSelection(cellId)
}
}}
onKeyDown={(e) => handleKeyDown(e, cellId)}
className={`w-full px-2 bg-transparent border-0 outline-none ${
isBold ? 'font-bold' : 'font-normal'