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 38s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 19s
When a cell has colored words (red !, blue phonetics), render each word as a separate span with its own color instead of coloring the entire input text with the first non-black color found. Switches to editable input on cell selection (click). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
8.7 KiB
TypeScript
205 lines
8.7 KiB
TypeScript
'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)
|
|
}
|
|
|
|
/** Dominant non-black color from a cell's word_boxes, or null. */
|
|
const getCellColor = (cell: (typeof zone.cells)[0] | undefined): string | null => {
|
|
if (!cell?.word_boxes?.length) return null
|
|
for (const wb of cell.word_boxes) {
|
|
if (wb.color_name && wb.color_name !== 'black' && wb.color) {
|
|
return wb.color
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
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
|
|
const cellColor = getCellColor(cell)
|
|
const hasColoredWords = cell?.word_boxes?.some(
|
|
(wb) => wb.color_name && wb.color_name !== 'black',
|
|
) ?? false
|
|
|
|
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' : ''}`}
|
|
>
|
|
<div className="flex items-center">
|
|
{cellColor && (
|
|
<span
|
|
className="flex-shrink-0 w-1.5 h-full min-h-[28px] rounded-l-sm"
|
|
style={{ backgroundColor: cellColor }}
|
|
title={`Farbe: ${cell?.word_boxes?.find(wb => wb.color_name !== 'black')?.color_name}`}
|
|
/>
|
|
)}
|
|
{/* Per-word colored display when not editing */}
|
|
{hasColoredWords && !isSelected ? (
|
|
<div
|
|
className={`w-full px-2 py-1.5 cursor-text truncate ${
|
|
isBold ? 'font-bold' : 'font-normal'
|
|
} ${row.is_header ? 'text-base' : 'text-sm'}`}
|
|
onClick={() => {
|
|
onSelectCell(cellId)
|
|
setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0)
|
|
}}
|
|
>
|
|
{cell!.word_boxes!.map((wb, i) => (
|
|
<span
|
|
key={i}
|
|
style={
|
|
wb.color_name && wb.color_name !== 'black'
|
|
? { color: wb.color }
|
|
: undefined
|
|
}
|
|
>
|
|
{wb.text}
|
|
{i < cell!.word_boxes!.length - 1 ? ' ' : ''}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<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 ${
|
|
isBold ? 'font-bold' : 'font-normal'
|
|
} ${row.is_header ? 'text-base' : 'text-sm'}`}
|
|
spellCheck={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|