Files
breakpilot-lehrer/admin-lehrer/components/grid-editor/GridTable.tsx
Benjamin Admin 92e4021898
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 31s
CI / test-go-edu-search (push) Successful in 42s
CI / test-python-klausur (push) Failing after 2m43s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 39s
Fix GridTable JSX syntax error in colspan rendering
Mismatched closing tags from previous colspan edit caused webpack
build failure. Cleaned up spanning cell map() return structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:52:26 +02:00

639 lines
29 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
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
onSetCellColor?: (cellId: string, color: string | null | undefined) => void
}
/** Color palette for the right-click cell color menu. */
const COLOR_OPTIONS: { label: string; value: string | null }[] = [
{ label: 'Rot', value: '#dc2626' },
{ label: 'Gruen', value: '#16a34a' },
{ label: 'Blau', value: '#2563eb' },
{ label: 'Orange', value: '#ea580c' },
{ label: 'Lila', value: '#9333ea' },
{ label: 'Schwarz', value: null },
]
/** Gutter width for row numbers (px). */
const ROW_NUM_WIDTH = 36
/** Minimum column width in px so columns remain usable. */
const MIN_COL_WIDTH = 80
/** Minimum row height in px. */
const MIN_ROW_HEIGHT = 26
export function GridTable({
zone,
layoutMetrics,
selectedCell,
selectedCells,
onSelectCell,
onToggleCellSelection,
onCellTextChange,
onToggleColumnBold,
onToggleRowHeader,
onNavigate,
onDeleteColumn,
onAddColumn,
onDeleteRow,
onAddRow,
onSetCellColor,
}: GridTableProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [colorMenu, setColorMenu] = useState<{ cellId: string; x: number; y: number } | null>(null)
// ----------------------------------------------------------------
// 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)
// Multi-line cells (containing \n): expand height based on line count
const rowCells = zone.cells.filter((c) => c.row_index === rowIndex)
const maxLines = Math.max(1, ...rowCells.map((c) => (c.text ?? '').split('\n').length))
if (maxLines > 1) {
const lineH = Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale)
return lineH * maxLines
}
if (isHeader) {
const measuredH = row.y_max_px - row.y_min_px
return Math.max(MIN_ROW_HEIGHT, measuredH * scale)
}
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.preventDefault()
onNavigate(cellId, 'up')
} 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()
}
},
[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.
* `color_override` takes priority when set. */
const getCellColor = (cell: (typeof zone.cells)[0] | undefined): string | null => {
if (!cell) return null
// Manual override: explicit color or null (= "clear color bar")
if (cell.color_override !== undefined) return cell.color_override ?? 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 + delete/add */}
{zone.columns.map((col, ci) => (
<div
key={col.index}
className={`group/colhdr 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>
{/* Delete column button (visible on hover) */}
{onDeleteColumn && numCols > 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/colhdr:opacity-100 transition-opacity z-30"
onClick={(e) => {
e.stopPropagation()
if (confirm(`Spalte "${col.label}" loeschen?`)) {
onDeleteColumn(zone.zone_index, col.index)
}
}}
title={`Spalte "${col.label}" loeschen`}
>
x
</button>
)}
{/* Add column button — small icon at bottom-right, below resize handle */}
{onAddColumn && (
<button
className="absolute -right-[7px] -bottom-[7px] w-[14px] h-[14px] flex items-center justify-center text-teal-500 opacity-0 group-hover/colhdr:opacity-100 transition-opacity z-30 hover:bg-teal-100 dark:hover:bg-teal-900/40 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
onClick={(e) => {
e.stopPropagation()
onAddColumn(zone.zone_index, col.index)
}}
title={`Spalte nach "${col.label}" einfuegen`}
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
)}
{/* Right-edge resize handle — wide grab area, highest z-index */}
{ci < numCols - 1 && (
<div
className="absolute top-0 -right-[4px] w-[9px] h-full cursor-col-resize hover:bg-teal-400/40 z-40"
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={`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
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-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: ${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"
onMouseDown={(e) => {
e.stopPropagation()
handleRowResizeStart(row.index, e.clientY, rowH)
}}
/>
</div>
{/* Cells — spanning header or normal columns */}
{isSpanning ? (
<>
{zone.cells
.filter((c) => c.row_index === row.index && c.col_type === 'spanning_header')
.sort((a, b) => a.col_index - b.col_index)
.map((spanCell) => {
const colspan = spanCell.colspan || numCols
const cellId = spanCell.cell_id
const isSelected = selectedCell === cellId
const cellColor = getCellColor(spanCell)
const gridColStart = spanCell.col_index + 2
const gridColEnd = gridColStart + colspan
return (
<div
key={cellId}
className={`border-b border-r border-gray-200 dark:border-gray-700 bg-blue-50/50 dark:bg-blue-900/10 flex items-center ${
isSelected ? 'ring-2 ring-teal-500 ring-inset z-10' : ''
}`}
style={{ gridColumn: `${gridColStart} / ${gridColEnd}`, height: `${rowH}px` }}
>
{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"
style={{ color: cellColor || undefined }}
spellCheck={false}
/>
</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 isMultiSelected = selectedCells?.has(cellId)
// Show per-word colored display only when word_boxes
// match the cell text. Post-processing steps (e.g. 5h
// slash-IPA → bracket conversion) modify cell.text but
// not individual word_boxes, so we fall back to the
// plain input when they diverge.
const wbText = cell?.word_boxes?.map((wb) => wb.text).join(' ') ?? ''
const textMatches = !cell?.text || wbText === cell.text
// Color: prefer manual override, else word_boxes when text matches
const hasOverride = cell?.color_override !== undefined
const cellColor = hasOverride ? getCellColor(cell) : (textMatches ? getCellColor(cell) : null)
const hasColoredWords =
!hasOverride &&
textMatches &&
(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' : ''
} ${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` }}
onContextMenu={(e) => {
if (onSetCellColor) {
e.preventDefault()
setColorMenu({ cellId, x: e.clientX, y: e.clientY })
}
}}
>
{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={(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) => (
<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>
) : (
{(cell?.text ?? '').includes('\n') ? (
<textarea
id={`cell-${cellId}`}
value={cell?.text ?? ''}
onChange={(e) => onCellTextChange(cellId, e.target.value)}
onFocus={() => onSelectCell(cellId)}
onClick={(e) => {
if ((e.metaKey || e.ctrlKey) && onToggleCellSelection) {
e.preventDefault()
onToggleCellSelection(cellId)
}
}}
onKeyDown={(e) => {
if (e.key === 'Tab') {
e.preventDefault()
onNavigate(cellId, e.shiftKey ? 'left' : 'right')
}
}}
rows={(cell?.text ?? '').split('\n').length}
className={`w-full px-2 bg-transparent border-0 outline-none resize-none ${
isBold ? 'font-bold' : 'font-normal'
}`}
style={{ color: cellColor || undefined }}
spellCheck={false}
/>
) : (
<input
id={`cell-${cellId}`}
type="text"
value={cell?.text ?? ''}
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'
}`}
style={{ color: cellColor || undefined }}
spellCheck={false}
/>
)}
)}
</div>
)
})
)}
</div>
)
})}
</div>
{/* Color context menu (right-click) */}
{colorMenu && onSetCellColor && (
<div
className="fixed inset-0 z-50"
onClick={() => setColorMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setColorMenu(null) }}
>
<div
className="absolute bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[140px]"
style={{ left: colorMenu.x, top: colorMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-1 text-[10px] text-gray-400 dark:text-gray-500 font-medium uppercase tracking-wider">
Textfarbe
</div>
{COLOR_OPTIONS.map((opt) => (
<button
key={opt.label}
className="w-full px-3 py-1.5 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
onClick={() => {
onSetCellColor(colorMenu.cellId, opt.value)
setColorMenu(null)
}}
>
{opt.value ? (
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: opt.value }} />
) : (
<span className="w-3 h-3 rounded-full flex-shrink-0 border border-gray-300 dark:border-gray-600" />
)}
<span>{opt.label}</span>
</button>
))}
<div className="border-t border-gray-100 dark:border-gray-700 mt-1 pt-1">
<button
className="w-full px-3 py-1.5 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400"
onClick={() => {
onSetCellColor(colorMenu.cellId, undefined)
setColorMenu(null)
}}
>
Farbe zuruecksetzen (OCR)
</button>
</div>
</div>
</div>
)}
</div>
)
}