'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { GridZone, LayoutMetrics } from './types' interface GridTableProps { zone: GridZone layoutMetrics?: LayoutMetrics 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 } /** 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 /** Minimum row height in px. */ const MIN_ROW_HEIGHT = 26 export function GridTable({ zone, layoutMetrics, selectedCell, onSelectCell, onCellTextChange, onToggleColumnBold, onToggleRowHeader, onNavigate, }: GridTableProps) { const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) // ---------------------------------------------------------------- // Observe container width for scaling // ---------------------------------------------------------------- useEffect(() => { const el = containerRef.current if (!el) return const ro = new ResizeObserver(([entry]) => { setContainerWidth(entry.contentRect.width) }) ro.observe(el) return () => ro.disconnect() }, []) // ---------------------------------------------------------------- // Compute column widths from OCR measurements // ---------------------------------------------------------------- const zoneWidthPx = zone.bbox_px.w || layoutMetrics?.page_width_px || 1 const scale = containerWidth > 0 ? (containerWidth - ROW_NUM_WIDTH) / zoneWidthPx : 1 // Column widths in original pixels, then scaled to container const [colWidthOverrides, setColWidthOverrides] = useState(null) const columnWidthsPx = zone.columns.map((col) => col.x_max_px - col.x_min_px) const effectiveColWidths = (colWidthOverrides ?? columnWidthsPx).map( (w) => Math.max(MIN_COL_WIDTH, w * scale), ) // ---------------------------------------------------------------- // Compute row heights from OCR measurements // ---------------------------------------------------------------- const avgRowHeightPx = layoutMetrics?.avg_row_height_px ?? 30 const [rowHeightOverrides, setRowHeightOverrides] = useState>(new Map()) const getRowHeight = (rowIndex: number, isHeader: boolean): number => { if (rowHeightOverrides.has(rowIndex)) { return rowHeightOverrides.get(rowIndex)! } const row = zone.rows.find((r) => r.index === rowIndex) if (!row) return Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale) if (isHeader) { // Headers keep their measured height const measuredH = row.y_max_px - row.y_min_px return Math.max(MIN_ROW_HEIGHT, measuredH * scale) } // Content rows use average for uniformity return Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale) } // ---------------------------------------------------------------- // Font size from layout metrics // ---------------------------------------------------------------- const baseFontSize = layoutMetrics?.font_size_suggestion_px ? Math.max(11, layoutMetrics.font_size_suggestion_px * scale) : 13 // ---------------------------------------------------------------- // Keyboard navigation // ---------------------------------------------------------------- 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], ) // ---------------------------------------------------------------- // Cell lookup // ---------------------------------------------------------------- const cellMap = new Map() 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 } // ---------------------------------------------------------------- // Column resize (drag) // ---------------------------------------------------------------- const handleColResizeStart = useCallback( (colIndex: number, startX: number) => { const baseWidths = colWidthOverrides ?? [...columnWidthsPx] const handleMouseMove = (e: MouseEvent) => { const deltaPx = (e.clientX - startX) / scale const newWidths = [...baseWidths] newWidths[colIndex] = Math.max(20, baseWidths[colIndex] + deltaPx) // Steal from next column to keep total constant if (colIndex + 1 < newWidths.length) { newWidths[colIndex + 1] = Math.max(20, baseWidths[colIndex + 1] - deltaPx) } setColWidthOverrides(newWidths) } const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) document.body.style.cursor = '' document.body.style.userSelect = '' } document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [colWidthOverrides, columnWidthsPx, scale], ) // ---------------------------------------------------------------- // Row resize (drag) // ---------------------------------------------------------------- const handleRowResizeStart = useCallback( (rowIndex: number, startY: number, currentHeight: number) => { const handleMouseMove = (e: MouseEvent) => { const delta = e.clientY - startY const newH = Math.max(MIN_ROW_HEIGHT, currentHeight + delta) setRowHeightOverrides((prev) => { const next = new Map(prev) next.set(rowIndex, newH) return next }) } const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) document.body.style.cursor = '' document.body.style.userSelect = '' } document.body.style.cursor = 'row-resize' document.body.style.userSelect = 'none' document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [], ) const isBoxZone = zone.zone_type === 'box' const numCols = zone.columns.length // CSS Grid template for columns: row-number gutter + proportional columns const gridTemplateCols = `${ROW_NUM_WIDTH}px ${effectiveColWidths.map((w) => `${w.toFixed(1)}px`).join(' ')}` return (
{/* Zone label */}
{isBoxZone ? 'Box' : 'Inhalt'} Zone {zone.zone_index} {zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen
{/* ============================================================ */} {/* CSS Grid — column headers */} {/* ============================================================ */}
{/* Header: row-number corner */}
{/* Header: column labels with resize handles */} {zone.columns.map((col, ci) => (
onToggleColumnBold(zone.zone_index, col.index)} title={`Spalte ${col.index + 1} — Klick fuer Fett-Toggle`} >
{col.label} {col.bold && ( B )}
{/* Right-edge resize handle */} {ci < numCols - 1 && (
{ e.stopPropagation() handleColResizeStart(ci, e.clientX) }} /> )}
))} {/* ============================================================ */} {/* Data rows */} {/* ============================================================ */} {zone.rows.map((row) => { const rowH = getRowHeight(row.index, row.is_header) const isSpanning = zone.cells.some( (c) => c.row_index === row.index && c.col_type === 'spanning_header', ) return (
{/* Row number cell */}
onToggleRowHeader(zone.zone_index, row.index)} title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`} > {row.index + 1} {row.is_header && H} {/* Bottom-edge resize handle */}
{ e.stopPropagation() handleRowResizeStart(row.index, e.clientY, rowH) }} />
{/* Cells — spanning header or normal columns */} {isSpanning ? (
{(() => { const spanCell = zone.cells.find( (c) => c.row_index === row.index && c.col_type === 'spanning_header', ) if (!spanCell) return null const cellId = spanCell.cell_id const isSelected = selectedCell === cellId const cellColor = getCellColor(spanCell) return (
{cellColor && ( )} onCellTextChange(cellId, e.target.value)} onFocus={() => onSelectCell(cellId)} onKeyDown={(e) => handleKeyDown(e, cellId)} className={`w-full px-3 py-1 bg-transparent border-0 outline-none text-center ${ isSelected ? 'ring-2 ring-teal-500 ring-inset rounded' : '' }`} style={{ color: cellColor || undefined }} spellCheck={false} />
) })()}
) : ( 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 (
{cellColor && ( wb.color_name !== 'black')?.color_name}`} /> )} {/* Per-word colored display when not editing */} {hasColoredWords && !isSelected ? (
{ onSelectCell(cellId) setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0) }} > {cell!.word_boxes!.map((wb, i) => ( {wb.text} {i < cell!.word_boxes!.length - 1 ? ' ' : ''} ))}
) : ( { if (cell) onCellTextChange(cellId, e.target.value) }} onFocus={() => onSelectCell(cellId)} onKeyDown={(e) => handleKeyDown(e, cellId)} className={`w-full px-2 bg-transparent border-0 outline-none ${ isBold ? 'font-bold' : 'font-normal' }`} spellCheck={false} /> )}
) }) )}
) })}
) }