Files
breakpilot-lehrer/admin-lehrer/components/grid-editor/GridTable.tsx
Benjamin Admin fd79d5e4fa fix: prevent grid table overflow when union columns exceed zone bbox
When union columns from multiple content zones are applied, column
boundaries can span wider than any single zone's bbox. Using
zone.bbox_px.w as the scale reference caused the total scaled width
to exceed the container, pushing the table off-screen.

Now uses the actual total column width sum as the scale reference,
guaranteeing columns always fit within the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:43:00 +01:00

437 lines
18 KiB
TypeScript

'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<HTMLDivElement>(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
// ----------------------------------------------------------------
// Use the actual total column span as reference width — NOT zone.bbox_px.w.
// When union columns are applied across content zones, column boundaries
// can extend beyond the zone's bbox, causing overflow if we scale by
// the smaller zone width.
const [colWidthOverrides, setColWidthOverrides] = useState<number[] | null>(null)
const columnWidthsPx = zone.columns.map((col) => col.x_max_px - col.x_min_px)
const totalColWidthPx = columnWidthsPx.reduce((sum, w) => sum + w, 0)
const zoneWidthPx = totalColWidthPx > 0
? totalColWidthPx
: (zone.bbox_px.w || layoutMetrics?.page_width_px || 1)
const scale = containerWidth > 0 ? (containerWidth - ROW_NUM_WIDTH) / zoneWidthPx : 1
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<Map<number, number>>(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<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
}
// ----------------------------------------------------------------
// 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 (
<div
ref={containerRef}
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>
{/* ============================================================ */}
{/* CSS Grid — column headers */}
{/* ============================================================ */}
<div
style={{
display: 'grid',
gridTemplateColumns: gridTemplateCols,
fontFamily: "var(--font-noto-sans, 'Noto Sans'), 'Inter', system-ui, sans-serif",
fontSize: `${baseFontSize}px`,
}}
>
{/* Header: row-number corner */}
<div className="sticky left-0 z-10 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" />
{/* Header: column labels with resize handles */}
{zone.columns.map((col, ci) => (
<div
key={col.index}
className={`relative 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 truncate">
<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>
{/* Right-edge resize handle */}
{ci < numCols - 1 && (
<div
className="absolute top-0 right-0 w-[5px] h-full cursor-col-resize hover:bg-teal-400/40 z-20"
onMouseDown={(e) => {
e.stopPropagation()
handleColResizeStart(ci, e.clientX)
}}
/>
)}
</div>
))}
{/* ============================================================ */}
{/* 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 (
<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 ${
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'
}`}
style={{ height: `${rowH}px` }}
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>}
{/* 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"
onMouseDown={(e) => {
e.stopPropagation()
handleRowResizeStart(row.index, e.clientY, rowH)
}}
/>
</div>
{/* Cells — spanning header or normal columns */}
{isSpanning ? (
<div
className="border-b border-r border-gray-200 dark:border-gray-700 bg-blue-50/50 dark:bg-blue-900/10 flex items-center"
style={{
gridColumn: `2 / ${numCols + 2}`,
height: `${rowH}px`,
}}
>
{(() => {
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 (
<div className="flex items-center w-full">
{cellColor && (
<span
className="flex-shrink-0 w-1.5 self-stretch rounded-l-sm"
style={{ backgroundColor: cellColor }}
/>
)}
<input
id={`cell-${cellId}`}
type="text"
value={spanCell.text}
onChange={(e) => 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}
/>
</div>
)
})()}
</div>
) : (
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 (
<div
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' : ''
}`}
style={{ height: `${rowH}px` }}
>
{cellColor && (
<span
className="flex-shrink-0 w-1.5 self-stretch 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 cursor-text truncate ${isBold ? 'font-bold' : 'font-normal'}`}
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 bg-transparent border-0 outline-none ${
isBold ? 'font-bold' : 'font-normal'
}`}
spellCheck={false}
/>
)}
</div>
)
})
)}
</div>
)
})}
</div>
</div>
)
}