feat(grid-editor): add manual cell color control via right-click menu
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 23s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m53s
CI / test-python-agent-core (push) Successful in 13s
CI / test-nodejs-website (push) Successful in 15s

Users can now right-click any cell to set text color (red, green, blue,
orange, purple, black) or remove the color bar without changing text.
A "reset" option restores the OCR-detected color. This enables accurate
Ground Truth marking when OCR assigns colors to wrong cells.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-24 08:51:18 +01:00
parent d54814fa70
commit 9d34c5201e
4 changed files with 116 additions and 4 deletions

View File

@@ -18,8 +18,19 @@ interface GridTableProps {
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
@@ -44,9 +55,11 @@ export function GridTable({
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
@@ -148,9 +161,13 @@ export function GridTable({
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
}
/** Dominant non-black color from a cell's word_boxes, or null. */
/** 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?.word_boxes?.length) return 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
@@ -450,9 +467,11 @@ export function GridTable({
// plain input when they diverge.
const wbText = cell?.word_boxes?.map((wb) => wb.text).join(' ') ?? ''
const textMatches = !cell?.text || wbText === cell.text
// Color bar only when word_boxes still match edited text
const cellColor = textMatches ? getCellColor(cell) : null
// 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',
@@ -467,6 +486,12 @@ export function GridTable({
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
@@ -519,6 +544,7 @@ export function GridTable({
className={`w-full px-2 bg-transparent border-0 outline-none ${
isBold ? 'font-bold' : 'font-normal'
}`}
style={{ color: cellColor || undefined }}
spellCheck={false}
/>
)}
@@ -530,6 +556,53 @@ export function GridTable({
)
})}
</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>
)
}

View File

@@ -112,6 +112,8 @@ export interface GridEditorCell {
word_boxes: OcrWordBox[]
ocr_engine: string
is_bold: boolean
/** Manual color override: hex string or null to clear. */
color_override?: string | null
}
/** Layout dividers for the visual column/margin editor on the original image. */

View File

@@ -752,6 +752,39 @@ export function useGridEditor(sessionId: string | null) {
setSelectedCells(new Set())
}, [])
/**
* Set a manual color override on a cell.
* - hex string (e.g. "#dc2626"): force text color
* - null: force no color (clear bar)
* - undefined: remove override, restore OCR-detected color
*/
const setCellColor = useCallback(
(cellId: string, color: string | null | undefined) => {
if (!grid) return
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => ({
...zone,
cells: zone.cells.map((cell) => {
if (cell.cell_id !== cellId) return cell
if (color === undefined) {
// Remove override entirely — restore OCR behavior
const { color_override: _, ...rest } = cell
return rest
}
return { ...cell, color_override: color }
}),
})),
}
})
setDirty(true)
},
[grid, pushUndo],
)
/** Toggle bold on all selected cells (and their columns). */
const toggleSelectedBold = useCallback(() => {
if (!grid || selectedCells.size === 0) return
@@ -881,5 +914,6 @@ export function useGridEditor(sessionId: string | null) {
clearCellSelection,
toggleSelectedBold,
autoCorrectColumnPatterns,
setCellColor,
}
}

View File

@@ -56,6 +56,7 @@ export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewPro
clearCellSelection,
toggleSelectedBold,
autoCorrectColumnPatterns,
setCellColor,
} = useGridEditor(sessionId)
const [showImage, setShowImage] = useState(true)
@@ -399,6 +400,7 @@ export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewPro
onAddColumn={addColumn}
onDeleteRow={deleteRow}
onAddRow={addRow}
onSetCellColor={setCellColor}
/>
</div>
))}
@@ -438,6 +440,7 @@ export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewPro
<span>Pfeiltasten: Navigation</span>
<span>Ctrl+Klick: Mehrfachauswahl</span>
<span>Ctrl+B: Fett</span>
<span>Rechtsklick: Farbe</span>
<span>Ctrl+Z/Y: Undo/Redo</span>
<span>Ctrl+S: Speichern</span>
</div>