[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)

Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:28:57 +02:00
parent 9ba420fa91
commit b681ddb131
251 changed files with 30016 additions and 25037 deletions

View File

@@ -1,44 +1,14 @@
'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
import type { GridEditorCell } from './types'
import { MIN_COL_WIDTH, MIN_ROW_HEIGHT, ROW_NUM_WIDTH } from './gridTableConstants'
import type { GridTableProps } from './gridTableConstants'
import { getCellColor, getRowHeight } from './gridTableUtils'
import { GridTableColumnHeader } from './GridTableColumnHeader'
import { GridTableRowHeader } from './GridTableRowHeader'
import { GridTableCell } from './GridTableCell'
import { GridTableColorMenu } from './GridTableColorMenu'
export function GridTable({
zone,
@@ -77,10 +47,6 @@ export function GridTable({
// ----------------------------------------------------------------
// 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)
@@ -95,31 +61,16 @@ export function GridTable({
)
// ----------------------------------------------------------------
// Compute row heights from OCR measurements
// Row height
// ----------------------------------------------------------------
const avgRowHeightPx = layoutMetrics?.avg_row_height_px ?? 30
const [rowHeightOverrides, setRowHeightOverrides] = useState<Map<number, number>>(new Map())
const getRowHeight = (rowIndex: number, isHeader: boolean): number => {
const computeRowHeight = (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)
return getRowHeight(zone, rowIndex, isHeader, avgRowHeightPx, scale)
}
// ----------------------------------------------------------------
@@ -162,26 +113,11 @@ export function GridTable({
// ----------------------------------------------------------------
// Cell lookup
// ----------------------------------------------------------------
const cellMap = new Map<string, (typeof zone.cells)[0]>()
const cellMap = new Map<string, GridEditorCell>()
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)
// ----------------------------------------------------------------
@@ -193,7 +129,6 @@ export function GridTable({
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)
}
@@ -248,7 +183,6 @@ export function GridTable({
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 (
@@ -272,9 +206,7 @@ export function GridTable({
</span>
</div>
{/* ============================================================ */}
{/* CSS Grid — column headers */}
{/* ============================================================ */}
{/* CSS Grid */}
<div
style={{
display: 'grid',
@@ -286,72 +218,24 @@ export function GridTable({
{/* 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 */}
{/* Header: column labels */}
{zone.columns.map((col, ci) => (
<div
<GridTableColumnHeader
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>
col={col}
colIndex={ci}
numCols={numCols}
zoneIndex={zone.zone_index}
onToggleColumnBold={onToggleColumnBold}
onDeleteColumn={onDeleteColumn}
onAddColumn={onAddColumn}
onColResizeStart={handleColResizeStart}
/>
))}
{/* ============================================================ */}
{/* Data rows */}
{/* ============================================================ */}
{/* Data rows */}
{zone.rows.map((row) => {
const rowH = getRowHeight(row.index, row.is_header)
const rowH = computeRowHeight(row.index, row.is_header)
const isSpanning = zone.cells.some(
(c) => c.row_index === row.index && c.col_type === 'spanning_header',
)
@@ -359,60 +243,16 @@ export function GridTable({
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>
<GridTableRowHeader
row={row}
zoneIndex={zone.zone_index}
rowCount={zone.rows.length}
rowH={rowH}
onToggleRowHeader={onToggleRowHeader}
onDeleteRow={onDeleteRow}
onAddRow={onAddRow}
onRowResizeStart={handleRowResizeStart}
/>
{/* Cells — spanning header or normal columns */}
{isSpanning ? (
@@ -456,143 +296,24 @@ export function GridTable({
) : (
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
<GridTableCell
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`,
...(cell?.box_region?.bg_hex ? {
backgroundColor: `${cell.box_region.bg_hex}12`,
borderLeft: cell.box_region.border ? `3px solid ${cell.box_region.bg_hex}60` : undefined,
} : {}),
}}
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 */}
{(() => {
const cellText = cell?.text ?? ''
const isMultiLine = cellText.includes('\n')
if (hasColoredWords && !isSelected) {
return (
<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>
)
}
if (isMultiLine) {
return (
<textarea
id={`cell-${cellId}`}
value={cellText}
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={cellText.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}
/>
)
}
return (
<input
id={`cell-${cellId}`}
type="text"
value={cellText}
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>
cell={cell}
col={col}
row={row}
zone={zone}
rowH={rowH}
selectedCell={selectedCell}
selectedCells={selectedCells}
onSelectCell={onSelectCell}
onToggleCellSelection={onToggleCellSelection}
onCellTextChange={onCellTextChange}
onNavigate={onNavigate}
onSetCellColor={onSetCellColor}
onOpenColorMenu={(cellId, x, y) => setColorMenu({ cellId, x, y })}
handleKeyDown={handleKeyDown}
/>
)
})
)}
@@ -603,49 +324,13 @@ export function GridTable({
{/* 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>
<GridTableColorMenu
cellId={colorMenu.cellId}
x={colorMenu.x}
y={colorMenu.y}
onSetCellColor={onSetCellColor}
onClose={() => setColorMenu(null)}
/>
)}
</div>
)

View File

@@ -0,0 +1,169 @@
'use client'
import type { GridColumn, GridEditorCell, GridRow } from './types'
import { getCellColor } from './gridTableUtils'
interface GridTableCellProps {
cell: GridEditorCell | undefined
col: GridColumn
row: GridRow
zone: { zone_index: number }
rowH: number
selectedCell: string | null
selectedCells?: Set<string>
onSelectCell: (cellId: string) => void
onToggleCellSelection?: (cellId: string) => void
onCellTextChange: (cellId: string, text: string) => void
onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void
onSetCellColor?: (cellId: string, color: string | null | undefined) => void
onOpenColorMenu: (cellId: string, x: number, y: number) => void
handleKeyDown: (e: React.KeyboardEvent, cellId: string) => void
}
export function GridTableCell({
cell,
col,
row,
zone,
rowH,
selectedCell,
selectedCells,
onSelectCell,
onToggleCellSelection,
onCellTextChange,
onNavigate,
onSetCellColor,
onOpenColorMenu,
handleKeyDown,
}: GridTableCellProps) {
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 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)
const cellText = cell?.text ?? ''
const isMultiLine = cellText.includes('\n')
return (
<div
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`,
...(cell?.box_region?.bg_hex ? {
backgroundColor: `${cell.box_region.bg_hex}12`,
borderLeft: cell.box_region.border ? `3px solid ${cell.box_region.bg_hex}60` : undefined,
} : {}),
}}
onContextMenu={(e) => {
if (onSetCellColor) {
e.preventDefault()
onOpenColorMenu(cellId, e.clientX, 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>
) : isMultiLine ? (
<textarea
id={`cell-${cellId}`}
value={cellText}
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={cellText.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={cellText}
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>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { COLOR_OPTIONS } from './gridTableConstants'
interface GridTableColorMenuProps {
cellId: string
x: number
y: number
onSetCellColor: (cellId: string, color: string | null | undefined) => void
onClose: () => void
}
export function GridTableColorMenu({
cellId,
x,
y,
onSetCellColor,
onClose,
}: GridTableColorMenuProps) {
return (
<div
className="fixed inset-0 z-50"
onClick={onClose}
onContextMenu={(e) => { e.preventDefault(); onClose() }}
>
<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: x, top: 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(cellId, opt.value)
onClose()
}}
>
{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(cellId, undefined)
onClose()
}}
>
Farbe zuruecksetzen (OCR)
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import type { GridColumn } from './types'
interface GridTableColumnHeaderProps {
col: GridColumn
colIndex: number
numCols: number
zoneIndex: number
onToggleColumnBold: (zoneIndex: number, colIndex: number) => void
onDeleteColumn?: (zoneIndex: number, colIndex: number) => void
onAddColumn?: (zoneIndex: number, afterColIndex: number) => void
onColResizeStart: (colIndex: number, startX: number) => void
}
export function GridTableColumnHeader({
col,
colIndex,
numCols,
zoneIndex,
onToggleColumnBold,
onDeleteColumn,
onAddColumn,
onColResizeStart,
}: GridTableColumnHeaderProps) {
return (
<div
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(zoneIndex, 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(zoneIndex, 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(zoneIndex, 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 */}
{colIndex < 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()
onColResizeStart(colIndex, e.clientX)
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import type { GridRow } from './types'
interface GridTableRowHeaderProps {
row: GridRow
zoneIndex: number
rowCount: number
rowH: number
onToggleRowHeader: (zoneIndex: number, rowIndex: number) => void
onDeleteRow?: (zoneIndex: number, rowIndex: number) => void
onAddRow?: (zoneIndex: number, afterRowIndex: number) => void
onRowResizeStart: (rowIndex: number, startY: number, currentHeight: number) => void
}
export function GridTableRowHeader({
row,
zoneIndex,
rowCount,
rowH,
onToggleRowHeader,
onDeleteRow,
onAddRow,
onRowResizeStart,
}: GridTableRowHeaderProps) {
return (
<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(zoneIndex, 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 && rowCount > 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(zoneIndex, 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(zoneIndex, 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()
onRowResizeStart(row.index, e.clientY, rowH)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import type { StructuredGrid } from './types'
export const KLAUSUR_API = '/klausur-api'
export const MAX_UNDO = 50
export interface GridEditorState {
grid: StructuredGrid | null
loading: boolean
saving: boolean
error: string | null
dirty: boolean
selectedCell: string | null
selectedZone: number | null
}
export type IpaMode = 'auto' | 'all' | 'de' | 'en' | 'none'
export type SyllableMode = 'auto' | 'all' | 'de' | 'en' | 'none'

View File

@@ -0,0 +1,38 @@
import type { GridZone, LayoutMetrics } from './types'
export 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. */
export 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). */
export const ROW_NUM_WIDTH = 36
/** Minimum column width in px so columns remain usable. */
export const MIN_COL_WIDTH = 80
/** Minimum row height in px. */
export const MIN_ROW_HEIGHT = 26

View File

@@ -0,0 +1,43 @@
import type { GridEditorCell, GridZone } from './types'
import { MIN_ROW_HEIGHT } from './gridTableConstants'
/** Dominant non-black color from a cell's word_boxes, or null.
* `color_override` takes priority when set. */
export function getCellColor(cell: GridEditorCell | 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
}
/** Compute the rendered height for a row in px. */
export function getRowHeight(
zone: GridZone,
rowIndex: number,
isHeader: boolean,
avgRowHeightPx: number,
scale: number,
): number {
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)
}

View File

@@ -1,21 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { StructuredGrid, GridZone, LayoutDividers } from './types'
import { useCallback, useRef, useState } from 'react'
import type { StructuredGrid } from './types'
import { MAX_UNDO } from './gridEditorTypes'
import type { IpaMode, SyllableMode } from './gridEditorTypes'
import { useGridEditorApi } from './useGridEditorApi'
import { useGridEditorActions } from './useGridEditorActions'
import { useGridEditorLayout } from './useGridEditorLayout'
const KLAUSUR_API = '/klausur-api'
const MAX_UNDO = 50
export interface GridEditorState {
grid: StructuredGrid | null
loading: boolean
saving: boolean
error: string | null
dirty: boolean
selectedCell: string | null
selectedZone: number | null
}
export type IpaMode = 'auto' | 'all' | 'de' | 'en' | 'none'
export type SyllableMode = 'auto' | 'all' | 'de' | 'en' | 'none'
// Re-export types so existing imports keep working
export type { GridEditorState, IpaMode, SyllableMode } from './gridEditorTypes'
export function useGridEditor(sessionId: string | null) {
const [grid, setGrid] = useState<StructuredGrid | null>(null)
@@ -41,7 +33,7 @@ export function useGridEditor(sessionId: string | null) {
const undoStack = useRef<string[]>([])
const redoStack = useRef<string[]>([])
const pushUndo = useCallback((zones: GridZone[]) => {
const pushUndo = useCallback((zones: StructuredGrid['zones']) => {
undoStack.current.push(JSON.stringify(zones))
if (undoStack.current.length > MAX_UNDO) {
undoStack.current.shift()
@@ -50,878 +42,58 @@ export function useGridEditor(sessionId: string | null) {
}, [])
// ------------------------------------------------------------------
// Load / Build
// API calls (build, load, save, rerunOcr)
// ------------------------------------------------------------------
const buildGrid = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
params.set('enhance', String(ocrEnhance))
if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols))
if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf))
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
{ method: 'POST' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
undoStack.current = []
redoStack.current = []
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [sessionId, ipaMode, syllableMode, ocrEnhance, ocrMaxCols, ocrMinConf])
/** Re-run OCR with current quality settings, then rebuild grid */
const rerunOcr = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
params.set('enhance', String(ocrEnhance))
if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols))
if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf))
params.set('vision_fusion', String(visionFusion))
if (documentCategory) params.set('doc_category', documentCategory)
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/rerun-ocr-and-build-grid?${params}`,
{ method: 'POST' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
undoStack.current = []
redoStack.current = []
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [sessionId, ipaMode, syllableMode, ocrEnhance, ocrMaxCols, ocrMinConf, visionFusion, documentCategory])
const loadGrid = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError(null)
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/grid-editor`,
)
if (res.status === 404) {
// No grid yet — build it with current modes
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
params.set('enhance', String(ocrEnhance))
if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols))
if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf))
const buildRes = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
{ method: 'POST' },
)
if (buildRes.ok) {
const data: StructuredGrid = await buildRes.json()
setGrid(data)
setDirty(false)
}
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
undoStack.current = []
redoStack.current = []
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
// Only depends on sessionId — mode changes are handled by the
// separate useEffect below, not by re-triggering loadGrid.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// Auto-rebuild when IPA or syllable mode changes (skip initial mount).
// We call the API directly with the new values instead of going through
// the buildGrid callback, which may still close over stale state due to
// React's asynchronous state batching.
const mountedRef = useRef(false)
useEffect(() => {
if (!mountedRef.current) {
// Skip the first trigger (component mount) — don't rebuild yet
mountedRef.current = true
return
}
if (!sessionId) return
const rebuild = async () => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
{ method: 'POST' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
rebuild()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ipaMode, syllableMode])
const { buildGrid, rerunOcr, loadGrid, saveGrid } = useGridEditorApi({
sessionId,
ipaMode,
syllableMode,
ocrEnhance,
ocrMaxCols,
ocrMinConf,
visionFusion,
documentCategory,
setGrid,
setLoading,
setSaving,
setError,
setDirty,
grid,
undoStack,
redoStack,
})
// ------------------------------------------------------------------
// Save
// Cell editing, formatting, multi-select
// ------------------------------------------------------------------
const saveGrid = useCallback(async () => {
if (!sessionId || !grid) return
setSaving(true)
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/save-grid`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(grid),
},
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
setDirty(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}, [sessionId, grid])
const {
updateCellText,
setCellColor,
toggleColumnBold,
toggleRowHeader,
autoCorrectColumnPatterns,
selectedCells,
toggleCellSelection,
clearCellSelection,
toggleSelectedBold,
} = useGridEditorActions({ grid, setGrid, setDirty, pushUndo })
// ------------------------------------------------------------------
// Cell editing
// Column/row management, layout dividers
// ------------------------------------------------------------------
const updateCellText = useCallback(
(cellId: string, newText: string) => {
if (!grid) return
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
// Check if cell exists
const existing = zone.cells.find((c) => c.cell_id === cellId)
if (existing) {
return {
...zone,
cells: zone.cells.map((cell) =>
cell.cell_id === cellId ? { ...cell, text: newText } : cell,
),
}
}
// Cell doesn't exist — create it if the cellId belongs to this zone
// cellId format: Z{zone}_R{row}_C{col}
const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/)
if (!match || parseInt(match[1]) !== zone.zone_index) return zone
const rowIndex = parseInt(match[2])
const colIndex = parseInt(match[3])
const col = zone.columns.find((c) => c.index === colIndex)
const newCell = {
cell_id: cellId,
zone_index: zone.zone_index,
row_index: rowIndex,
col_index: colIndex,
col_type: col?.label ?? '',
text: newText,
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}
return { ...zone, cells: [...zone.cells, newCell] }
}),
}
})
setDirty(true)
},
[grid, pushUndo],
)
// ------------------------------------------------------------------
// Column formatting
// ------------------------------------------------------------------
const toggleColumnBold = useCallback(
(zoneIndex: number, colIndex: number) => {
if (!grid) return
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
if (zone.zone_index !== zoneIndex) return zone
const col = zone.columns.find((c) => c.index === colIndex)
const newBold = col ? !col.bold : true
return {
...zone,
columns: zone.columns.map((c) =>
c.index === colIndex ? { ...c, bold: newBold } : c,
),
cells: zone.cells.map((cell) =>
cell.col_index === colIndex
? { ...cell, is_bold: newBold }
: cell,
),
}
}),
}
})
setDirty(true)
},
[grid, pushUndo],
)
// ------------------------------------------------------------------
// Row formatting
// ------------------------------------------------------------------
const toggleRowHeader = useCallback(
(zoneIndex: number, rowIndex: number) => {
if (!grid) return
pushUndo(grid.zones)
// Cycle: normal → header → footer → normal
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
if (zone.zone_index !== zoneIndex) return zone
return {
...zone,
rows: zone.rows.map((r) => {
if (r.index !== rowIndex) return r
if (!r.is_header && !r.is_footer) {
return { ...r, is_header: true, is_footer: false }
} else if (r.is_header) {
return { ...r, is_header: false, is_footer: true }
} else {
return { ...r, is_header: false, is_footer: false }
}
}),
}
}),
}
})
setDirty(true)
},
[grid, pushUndo],
)
// ------------------------------------------------------------------
// Column management
// ------------------------------------------------------------------
const deleteColumn = useCallback(
(zoneIndex: number, colIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone || zone.columns.length <= 1) return // keep at least 1 column
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
const deletedCol = z.columns.find((c) => c.index === colIndex)
const newColumns = z.columns
.filter((c) => c.index !== colIndex)
.map((c, i) => {
const result = { ...c, index: i, label: `column_${i + 1}` }
// Merge x-boundary: previous column absorbs deleted column's space
if (deletedCol) {
if (c.index === colIndex - 1) {
result.x_max_pct = deletedCol.x_max_pct
result.x_max_px = deletedCol.x_max_px
} else if (colIndex === 0 && c.index === 1) {
result.x_min_pct = deletedCol.x_min_pct
result.x_min_px = deletedCol.x_min_px
}
}
return result
})
const newCells = z.cells
.filter((c) => c.col_index !== colIndex)
.map((c) => {
const newCI = c.col_index > colIndex ? c.col_index - 1 : c.col_index
return {
...c,
col_index: newCI,
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
}
})
return { ...z, columns: newColumns, cells: newCells }
}),
summary: {
...prev.summary,
total_columns: prev.summary.total_columns - 1,
total_cells: prev.zones.reduce(
(sum, z) =>
sum +
(z.zone_index === zoneIndex
? z.cells.filter((c) => c.col_index !== colIndex).length
: z.cells.length),
0,
),
},
}
})
setDirty(true)
},
[grid, pushUndo],
)
const addColumn = useCallback(
(zoneIndex: number, afterColIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone) return
pushUndo(grid.zones)
const newColIndex = afterColIndex + 1
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
// Shift existing columns
const shiftedCols = z.columns.map((c) =>
c.index > afterColIndex ? { ...c, index: c.index + 1, label: `column_${c.index + 2}` } : c,
)
// Insert new column
const refCol = z.columns.find((c) => c.index === afterColIndex) || z.columns[z.columns.length - 1]
const newCol = {
index: newColIndex,
label: `column_${newColIndex + 1}`,
x_min_px: refCol.x_max_px,
x_max_px: refCol.x_max_px + (refCol.x_max_px - refCol.x_min_px),
x_min_pct: refCol.x_max_pct,
x_max_pct: Math.min(100, refCol.x_max_pct + (refCol.x_max_pct - refCol.x_min_pct)),
bold: false,
}
const allCols = [...shiftedCols, newCol].sort((a, b) => a.index - b.index)
// Shift existing cells and create empty cells for new column
const shiftedCells = z.cells.map((c) => {
if (c.col_index > afterColIndex) {
const newCI = c.col_index + 1
return {
...c,
col_index: newCI,
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
}
}
return c
})
// Create empty cells for each row
const newCells = z.rows.map((row) => ({
cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`,
zone_index: zoneIndex,
row_index: row.index,
col_index: newColIndex,
col_type: `column_${newColIndex + 1}`,
text: '',
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}))
return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] }
}),
summary: {
...prev.summary,
total_columns: prev.summary.total_columns + 1,
total_cells: prev.summary.total_cells + (zone?.rows.length ?? 0),
},
}
})
setDirty(true)
},
[grid, pushUndo],
)
// ------------------------------------------------------------------
// Row management
// ------------------------------------------------------------------
const deleteRow = useCallback(
(zoneIndex: number, rowIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone || zone.rows.length <= 1) return // keep at least 1 row
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
const newRows = z.rows
.filter((r) => r.index !== rowIndex)
.map((r, i) => ({ ...r, index: i }))
const newCells = z.cells
.filter((c) => c.row_index !== rowIndex)
.map((c) => {
const newRI = c.row_index > rowIndex ? c.row_index - 1 : c.row_index
return {
...c,
row_index: newRI,
cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`,
}
})
return { ...z, rows: newRows, cells: newCells }
}),
summary: {
...prev.summary,
total_rows: prev.summary.total_rows - 1,
total_cells: prev.zones.reduce(
(sum, z) =>
sum +
(z.zone_index === zoneIndex
? z.cells.filter((c) => c.row_index !== rowIndex).length
: z.cells.length),
0,
),
},
}
})
setDirty(true)
},
[grid, pushUndo],
)
const addRow = useCallback(
(zoneIndex: number, afterRowIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone) return
pushUndo(grid.zones)
const newRowIndex = afterRowIndex + 1
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
// Shift existing rows
const shiftedRows = z.rows.map((r) =>
r.index > afterRowIndex ? { ...r, index: r.index + 1 } : r,
)
// Insert new row
const refRow = z.rows.find((r) => r.index === afterRowIndex) || z.rows[z.rows.length - 1]
const newRow = {
index: newRowIndex,
y_min_px: refRow.y_max_px,
y_max_px: refRow.y_max_px + (refRow.y_max_px - refRow.y_min_px),
y_min_pct: refRow.y_max_pct,
y_max_pct: Math.min(100, refRow.y_max_pct + (refRow.y_max_pct - refRow.y_min_pct)),
is_header: false,
is_footer: false,
}
const allRows = [...shiftedRows, newRow].sort((a, b) => a.index - b.index)
// Shift existing cells
const shiftedCells = z.cells.map((c) => {
if (c.row_index > afterRowIndex) {
const newRI = c.row_index + 1
return {
...c,
row_index: newRI,
cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`,
}
}
return c
})
// Create empty cells for each column
const newCells = z.columns.map((col) => ({
cell_id: `Z${zoneIndex}_R${String(newRowIndex).padStart(2, '0')}_C${col.index}`,
zone_index: zoneIndex,
row_index: newRowIndex,
col_index: col.index,
col_type: col.label,
text: '',
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}))
return { ...z, rows: allRows, cells: [...shiftedCells, ...newCells] }
}),
summary: {
...prev.summary,
total_rows: prev.summary.total_rows + 1,
total_cells: prev.summary.total_cells + (zone?.columns.length ?? 0),
},
}
})
setDirty(true)
},
[grid, pushUndo],
)
// ------------------------------------------------------------------
// Layout editing (image overlay)
// ------------------------------------------------------------------
/** Capture current state for undo — call once at drag start. */
const commitUndoPoint = useCallback(() => {
if (!grid) return
pushUndo(grid.zones)
}, [grid, pushUndo])
/** Move a column boundary. boundaryIndex 0 = left edge of col 0, etc. */
const updateColumnDivider = useCallback(
(zoneIndex: number, boundaryIndex: number, newXPct: number) => {
if (!grid) return
setGrid((prev) => {
if (!prev) return prev
const imgW = prev.image_width || 1
const newPx = Math.round((newXPct / 100) * imgW)
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
return {
...z,
columns: z.columns.map((col) => {
// Right edge of the column before this boundary
if (col.index === boundaryIndex - 1) {
return { ...col, x_max_pct: newXPct, x_max_px: newPx }
}
// Left edge of the column at this boundary
if (col.index === boundaryIndex) {
return { ...col, x_min_pct: newXPct, x_min_px: newPx }
}
return col
}),
}
}),
}
})
setDirty(true)
},
[grid],
)
/** Update horizontal layout guidelines (margins, header, footer). */
const updateLayoutHorizontals = useCallback(
(horizontals: LayoutDividers['horizontals']) => {
if (!grid) return
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
layout_dividers: {
...(prev.layout_dividers || { horizontals: {} }),
horizontals,
},
}
})
setDirty(true)
},
[grid],
)
/** Split a column at a given x percentage, creating a new column. */
const splitColumnAt = useCallback(
(zoneIndex: number, xPct: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone) return
const sorted = [...zone.columns].sort((a, b) => a.index - b.index)
const targetCol = sorted.find((c) => c.x_min_pct <= xPct && c.x_max_pct >= xPct)
if (!targetCol) return
pushUndo(grid.zones)
const newColIndex = targetCol.index + 1
const imgW = grid.image_width || 1
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
const leftCol = {
...targetCol,
x_max_pct: xPct,
x_max_px: Math.round((xPct / 100) * imgW),
}
const rightCol = {
index: newColIndex,
label: `column_${newColIndex + 1}`,
x_min_pct: xPct,
x_max_pct: targetCol.x_max_pct,
x_min_px: Math.round((xPct / 100) * imgW),
x_max_px: targetCol.x_max_px,
bold: false,
}
const updatedCols = z.columns.map((c) => {
if (c.index === targetCol.index) return leftCol
if (c.index > targetCol.index) return { ...c, index: c.index + 1, label: `column_${c.index + 2}` }
return c
})
const allCols = [...updatedCols, rightCol].sort((a, b) => a.index - b.index)
const shiftedCells = z.cells.map((c) => {
if (c.col_index > targetCol.index) {
const newCI = c.col_index + 1
return {
...c,
col_index: newCI,
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
}
}
return c
})
const newCells = z.rows.map((row) => ({
cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`,
zone_index: zoneIndex,
row_index: row.index,
col_index: newColIndex,
col_type: `column_${newColIndex + 1}`,
text: '',
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}))
return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] }
}),
summary: {
...prev.summary,
total_columns: prev.summary.total_columns + 1,
total_cells: prev.summary.total_cells + (zone.rows.length),
},
}
})
setDirty(true)
},
[grid, pushUndo],
)
// ------------------------------------------------------------------
// Column pattern auto-correction
// ------------------------------------------------------------------
/**
* Detect dominant prefix+number patterns per column and complete
* partial matches. E.g. if 3+ cells read "p.70", "p.71", etc.,
* a cell reading ".65" is corrected to "p.65".
* Returns the number of corrections made.
*/
const autoCorrectColumnPatterns = useCallback(() => {
if (!grid) return 0
pushUndo(grid.zones)
let totalFixed = 0
const numberPattern = /^(.+?)(\d+)\s*$/
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
// Group cells by column
const cellsByCol = new Map<number, { cell: (typeof zone.cells)[0]; idx: number }[]>()
zone.cells.forEach((cell, idx) => {
const arr = cellsByCol.get(cell.col_index) || []
arr.push({ cell, idx })
cellsByCol.set(cell.col_index, arr)
})
const newCells = [...zone.cells]
for (const [, colEntries] of cellsByCol) {
// Count prefix occurrences
const prefixCounts = new Map<string, number>()
for (const { cell } of colEntries) {
const m = cell.text.trim().match(numberPattern)
if (m) {
prefixCounts.set(m[1], (prefixCounts.get(m[1]) || 0) + 1)
}
}
// Find dominant prefix (>= 3 occurrences)
let dominantPrefix = ''
let maxCount = 0
for (const [prefix, count] of prefixCounts) {
if (count >= 3 && count > maxCount) {
dominantPrefix = prefix
maxCount = count
}
}
if (!dominantPrefix) continue
// Fix partial matches — entries that are just [.?\s*]NUMBER
for (const { cell, idx } of colEntries) {
const text = cell.text.trim()
if (!text || text.startsWith(dominantPrefix)) continue
const numMatch = text.match(/^[.\s]*(\d+)\s*$/)
if (numMatch) {
newCells[idx] = { ...newCells[idx], text: `${dominantPrefix}${numMatch[1]}` }
totalFixed++
}
}
}
return { ...zone, cells: newCells }
}),
}
})
if (totalFixed > 0) setDirty(true)
return totalFixed
}, [grid, pushUndo])
// ------------------------------------------------------------------
// Multi-select & bulk formatting
// ------------------------------------------------------------------
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set())
const toggleCellSelection = useCallback(
(cellId: string) => {
setSelectedCells((prev) => {
const next = new Set(prev)
if (next.has(cellId)) next.delete(cellId)
else next.add(cellId)
return next
})
},
[],
)
const clearCellSelection = useCallback(() => {
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
pushUndo(grid.zones)
// Determine if we're turning bold on or off (majority rule)
const cells = grid.zones.flatMap((z) => z.cells)
const selectedArr = cells.filter((c) => selectedCells.has(c.cell_id))
const boldCount = selectedArr.filter((c) => c.is_bold).length
const newBold = boldCount < selectedArr.length / 2
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => ({
...zone,
cells: zone.cells.map((cell) =>
selectedCells.has(cell.cell_id) ? { ...cell, is_bold: newBold } : cell,
),
})),
}
})
setDirty(true)
setSelectedCells(new Set())
}, [grid, selectedCells, pushUndo])
const {
deleteColumn,
addColumn,
deleteRow,
addRow,
commitUndoPoint,
updateColumnDivider,
updateLayoutHorizontals,
splitColumnAt,
} = useGridEditorLayout({ grid, setGrid, setDirty, pushUndo })
// ------------------------------------------------------------------
// Undo / Redo

View File

@@ -0,0 +1,322 @@
import { useCallback, useState } from 'react'
import type { StructuredGrid, GridZone } from './types'
interface ActionDeps {
grid: StructuredGrid | null
setGrid: React.Dispatch<React.SetStateAction<StructuredGrid | null>>
setDirty: React.Dispatch<React.SetStateAction<boolean>>
pushUndo: (zones: GridZone[]) => void
}
export function useGridEditorActions(deps: ActionDeps) {
const { grid, setGrid, setDirty, pushUndo } = deps
// ------------------------------------------------------------------
// Multi-select state
// ------------------------------------------------------------------
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set())
const toggleCellSelection = useCallback(
(cellId: string) => {
setSelectedCells((prev) => {
const next = new Set(prev)
if (next.has(cellId)) next.delete(cellId)
else next.add(cellId)
return next
})
},
[],
)
const clearCellSelection = useCallback(() => {
setSelectedCells(new Set())
}, [])
// ------------------------------------------------------------------
// Cell editing
// ------------------------------------------------------------------
const updateCellText = useCallback(
(cellId: string, newText: string) => {
if (!grid) return
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
// Check if cell exists
const existing = zone.cells.find((c) => c.cell_id === cellId)
if (existing) {
return {
...zone,
cells: zone.cells.map((cell) =>
cell.cell_id === cellId ? { ...cell, text: newText } : cell,
),
}
}
// Cell doesn't exist — create it if the cellId belongs to this zone
// cellId format: Z{zone}_R{row}_C{col}
const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/)
if (!match || parseInt(match[1]) !== zone.zone_index) return zone
const rowIndex = parseInt(match[2])
const colIndex = parseInt(match[3])
const col = zone.columns.find((c) => c.index === colIndex)
const newCell = {
cell_id: cellId,
zone_index: zone.zone_index,
row_index: rowIndex,
col_index: colIndex,
col_type: col?.label ?? '',
text: newText,
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}
return { ...zone, cells: [...zone.cells, newCell] }
}),
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
// ------------------------------------------------------------------
// Cell color
// ------------------------------------------------------------------
/**
* 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, setGrid, setDirty],
)
// ------------------------------------------------------------------
// Column formatting
// ------------------------------------------------------------------
const toggleColumnBold = useCallback(
(zoneIndex: number, colIndex: number) => {
if (!grid) return
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
if (zone.zone_index !== zoneIndex) return zone
const col = zone.columns.find((c) => c.index === colIndex)
const newBold = col ? !col.bold : true
return {
...zone,
columns: zone.columns.map((c) =>
c.index === colIndex ? { ...c, bold: newBold } : c,
),
cells: zone.cells.map((cell) =>
cell.col_index === colIndex
? { ...cell, is_bold: newBold }
: cell,
),
}
}),
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
// ------------------------------------------------------------------
// Row formatting
// ------------------------------------------------------------------
const toggleRowHeader = useCallback(
(zoneIndex: number, rowIndex: number) => {
if (!grid) return
pushUndo(grid.zones)
// Cycle: normal -> header -> footer -> normal
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
if (zone.zone_index !== zoneIndex) return zone
return {
...zone,
rows: zone.rows.map((r) => {
if (r.index !== rowIndex) return r
if (!r.is_header && !r.is_footer) {
return { ...r, is_header: true, is_footer: false }
} else if (r.is_header) {
return { ...r, is_header: false, is_footer: true }
} else {
return { ...r, is_header: false, is_footer: false }
}
}),
}
}),
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
// ------------------------------------------------------------------
// Column pattern auto-correction
// ------------------------------------------------------------------
/**
* Detect dominant prefix+number patterns per column and complete
* partial matches. E.g. if 3+ cells read "p.70", "p.71", etc.,
* a cell reading ".65" is corrected to "p.65".
* Returns the number of corrections made.
*/
const autoCorrectColumnPatterns = useCallback(() => {
if (!grid) return 0
pushUndo(grid.zones)
let totalFixed = 0
const numberPattern = /^(.+?)(\d+)\s*$/
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => {
// Group cells by column
const cellsByCol = new Map<number, { cell: (typeof zone.cells)[0]; idx: number }[]>()
zone.cells.forEach((cell, idx) => {
const arr = cellsByCol.get(cell.col_index) || []
arr.push({ cell, idx })
cellsByCol.set(cell.col_index, arr)
})
const newCells = [...zone.cells]
for (const [, colEntries] of cellsByCol) {
// Count prefix occurrences
const prefixCounts = new Map<string, number>()
for (const { cell } of colEntries) {
const m = cell.text.trim().match(numberPattern)
if (m) {
prefixCounts.set(m[1], (prefixCounts.get(m[1]) || 0) + 1)
}
}
// Find dominant prefix (>= 3 occurrences)
let dominantPrefix = ''
let maxCount = 0
for (const [prefix, count] of prefixCounts) {
if (count >= 3 && count > maxCount) {
dominantPrefix = prefix
maxCount = count
}
}
if (!dominantPrefix) continue
// Fix partial matches — entries that are just [.?\s*]NUMBER
for (const { cell, idx } of colEntries) {
const text = cell.text.trim()
if (!text || text.startsWith(dominantPrefix)) continue
const numMatch = text.match(/^[.\s]*(\d+)\s*$/)
if (numMatch) {
newCells[idx] = { ...newCells[idx], text: `${dominantPrefix}${numMatch[1]}` }
totalFixed++
}
}
}
return { ...zone, cells: newCells }
}),
}
})
if (totalFixed > 0) setDirty(true)
return totalFixed
}, [grid, pushUndo, setGrid, setDirty])
// ------------------------------------------------------------------
// Multi-select bulk formatting
// ------------------------------------------------------------------
/** Toggle bold on all selected cells (and their columns). */
const toggleSelectedBold = useCallback(() => {
if (!grid || selectedCells.size === 0) return
pushUndo(grid.zones)
// Determine if we're turning bold on or off (majority rule)
const cells = grid.zones.flatMap((z) => z.cells)
const selectedArr = cells.filter((c) => selectedCells.has(c.cell_id))
const boldCount = selectedArr.filter((c) => c.is_bold).length
const newBold = boldCount < selectedArr.length / 2
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((zone) => ({
...zone,
cells: zone.cells.map((cell) =>
selectedCells.has(cell.cell_id) ? { ...cell, is_bold: newBold } : cell,
),
})),
}
})
setDirty(true)
setSelectedCells(new Set())
}, [grid, selectedCells, pushUndo, setGrid, setDirty])
return {
// Cell editing
updateCellText,
setCellColor,
// Column formatting
toggleColumnBold,
// Row formatting
toggleRowHeader,
// Pattern correction
autoCorrectColumnPatterns,
// Multi-select
selectedCells,
toggleCellSelection,
clearCellSelection,
toggleSelectedBold,
}
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useRef } from 'react'
import type { StructuredGrid } from './types'
import type { IpaMode, SyllableMode } from './gridEditorTypes'
import { KLAUSUR_API } from './gridEditorTypes'
interface ApiDeps {
sessionId: string | null
ipaMode: IpaMode
syllableMode: SyllableMode
ocrEnhance: boolean
ocrMaxCols: number
ocrMinConf: number
visionFusion: boolean
documentCategory: string
setGrid: React.Dispatch<React.SetStateAction<StructuredGrid | null>>
setLoading: React.Dispatch<React.SetStateAction<boolean>>
setSaving: React.Dispatch<React.SetStateAction<boolean>>
setError: React.Dispatch<React.SetStateAction<string | null>>
setDirty: React.Dispatch<React.SetStateAction<boolean>>
grid: StructuredGrid | null
undoStack: React.MutableRefObject<string[]>
redoStack: React.MutableRefObject<string[]>
}
export function useGridEditorApi(deps: ApiDeps) {
const {
sessionId,
ipaMode,
syllableMode,
ocrEnhance,
ocrMaxCols,
ocrMinConf,
visionFusion,
documentCategory,
setGrid,
setLoading,
setSaving,
setError,
setDirty,
grid,
undoStack,
redoStack,
} = deps
// ------------------------------------------------------------------
// Build grid
// ------------------------------------------------------------------
const buildGrid = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
params.set('enhance', String(ocrEnhance))
if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols))
if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf))
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
{ method: 'POST' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
undoStack.current = []
redoStack.current = []
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [sessionId, ipaMode, syllableMode, ocrEnhance, ocrMaxCols, ocrMinConf, setGrid, setLoading, setError, setDirty, undoStack, redoStack])
// ------------------------------------------------------------------
// Re-run OCR with current quality settings, then rebuild grid
// ------------------------------------------------------------------
const rerunOcr = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
params.set('enhance', String(ocrEnhance))
if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols))
if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf))
params.set('vision_fusion', String(visionFusion))
if (documentCategory) params.set('doc_category', documentCategory)
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/rerun-ocr-and-build-grid?${params}`,
{ method: 'POST' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
undoStack.current = []
redoStack.current = []
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [sessionId, ipaMode, syllableMode, ocrEnhance, ocrMaxCols, ocrMinConf, visionFusion, documentCategory, setGrid, setLoading, setError, setDirty, undoStack, redoStack])
// ------------------------------------------------------------------
// Load grid (with auto-build fallback on 404)
// ------------------------------------------------------------------
const loadGrid = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError(null)
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/grid-editor`,
)
if (res.status === 404) {
// No grid yet — build it with current modes
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
params.set('enhance', String(ocrEnhance))
if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols))
if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf))
const buildRes = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
{ method: 'POST' },
)
if (buildRes.ok) {
const data: StructuredGrid = await buildRes.json()
setGrid(data)
setDirty(false)
}
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
undoStack.current = []
redoStack.current = []
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
// Only depends on sessionId — mode changes are handled by the
// separate useEffect below, not by re-triggering loadGrid.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// ------------------------------------------------------------------
// Auto-rebuild when IPA or syllable mode changes (skip initial mount)
// ------------------------------------------------------------------
const mountedRef = useRef(false)
useEffect(() => {
if (!mountedRef.current) {
// Skip the first trigger (component mount) — don't rebuild yet
mountedRef.current = true
return
}
if (!sessionId) return
const rebuild = async () => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set('ipa_mode', ipaMode)
params.set('syllable_mode', syllableMode)
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
{ method: 'POST' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const data: StructuredGrid = await res.json()
setGrid(data)
setDirty(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
rebuild()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ipaMode, syllableMode])
// ------------------------------------------------------------------
// Save grid
// ------------------------------------------------------------------
const saveGrid = useCallback(async () => {
if (!sessionId || !grid) return
setSaving(true)
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/save-grid`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(grid),
},
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
setDirty(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}, [sessionId, grid, setSaving, setError, setDirty])
return {
buildGrid,
rerunOcr,
loadGrid,
saveGrid,
}
}

View File

@@ -0,0 +1,431 @@
import { useCallback } from 'react'
import type { StructuredGrid, GridZone, LayoutDividers } from './types'
interface LayoutDeps {
grid: StructuredGrid | null
setGrid: React.Dispatch<React.SetStateAction<StructuredGrid | null>>
setDirty: React.Dispatch<React.SetStateAction<boolean>>
pushUndo: (zones: GridZone[]) => void
}
export function useGridEditorLayout(deps: LayoutDeps) {
const { grid, setGrid, setDirty, pushUndo } = deps
// ------------------------------------------------------------------
// Column management
// ------------------------------------------------------------------
const deleteColumn = useCallback(
(zoneIndex: number, colIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone || zone.columns.length <= 1) return // keep at least 1 column
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
const deletedCol = z.columns.find((c) => c.index === colIndex)
const newColumns = z.columns
.filter((c) => c.index !== colIndex)
.map((c, i) => {
const result = { ...c, index: i, label: `column_${i + 1}` }
// Merge x-boundary: previous column absorbs deleted column's space
if (deletedCol) {
if (c.index === colIndex - 1) {
result.x_max_pct = deletedCol.x_max_pct
result.x_max_px = deletedCol.x_max_px
} else if (colIndex === 0 && c.index === 1) {
result.x_min_pct = deletedCol.x_min_pct
result.x_min_px = deletedCol.x_min_px
}
}
return result
})
const newCells = z.cells
.filter((c) => c.col_index !== colIndex)
.map((c) => {
const newCI = c.col_index > colIndex ? c.col_index - 1 : c.col_index
return {
...c,
col_index: newCI,
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
}
})
return { ...z, columns: newColumns, cells: newCells }
}),
summary: {
...prev.summary,
total_columns: prev.summary.total_columns - 1,
total_cells: prev.zones.reduce(
(sum, z) =>
sum +
(z.zone_index === zoneIndex
? z.cells.filter((c) => c.col_index !== colIndex).length
: z.cells.length),
0,
),
},
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
const addColumn = useCallback(
(zoneIndex: number, afterColIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone) return
pushUndo(grid.zones)
const newColIndex = afterColIndex + 1
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
// Shift existing columns
const shiftedCols = z.columns.map((c) =>
c.index > afterColIndex ? { ...c, index: c.index + 1, label: `column_${c.index + 2}` } : c,
)
// Insert new column
const refCol = z.columns.find((c) => c.index === afterColIndex) || z.columns[z.columns.length - 1]
const newCol = {
index: newColIndex,
label: `column_${newColIndex + 1}`,
x_min_px: refCol.x_max_px,
x_max_px: refCol.x_max_px + (refCol.x_max_px - refCol.x_min_px),
x_min_pct: refCol.x_max_pct,
x_max_pct: Math.min(100, refCol.x_max_pct + (refCol.x_max_pct - refCol.x_min_pct)),
bold: false,
}
const allCols = [...shiftedCols, newCol].sort((a, b) => a.index - b.index)
// Shift existing cells and create empty cells for new column
const shiftedCells = z.cells.map((c) => {
if (c.col_index > afterColIndex) {
const newCI = c.col_index + 1
return {
...c,
col_index: newCI,
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
}
}
return c
})
// Create empty cells for each row
const newCells = z.rows.map((row) => ({
cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`,
zone_index: zoneIndex,
row_index: row.index,
col_index: newColIndex,
col_type: `column_${newColIndex + 1}`,
text: '',
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}))
return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] }
}),
summary: {
...prev.summary,
total_columns: prev.summary.total_columns + 1,
total_cells: prev.summary.total_cells + (zone?.rows.length ?? 0),
},
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
// ------------------------------------------------------------------
// Row management
// ------------------------------------------------------------------
const deleteRow = useCallback(
(zoneIndex: number, rowIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone || zone.rows.length <= 1) return // keep at least 1 row
pushUndo(grid.zones)
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
const newRows = z.rows
.filter((r) => r.index !== rowIndex)
.map((r, i) => ({ ...r, index: i }))
const newCells = z.cells
.filter((c) => c.row_index !== rowIndex)
.map((c) => {
const newRI = c.row_index > rowIndex ? c.row_index - 1 : c.row_index
return {
...c,
row_index: newRI,
cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`,
}
})
return { ...z, rows: newRows, cells: newCells }
}),
summary: {
...prev.summary,
total_rows: prev.summary.total_rows - 1,
total_cells: prev.zones.reduce(
(sum, z) =>
sum +
(z.zone_index === zoneIndex
? z.cells.filter((c) => c.row_index !== rowIndex).length
: z.cells.length),
0,
),
},
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
const addRow = useCallback(
(zoneIndex: number, afterRowIndex: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone) return
pushUndo(grid.zones)
const newRowIndex = afterRowIndex + 1
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
// Shift existing rows
const shiftedRows = z.rows.map((r) =>
r.index > afterRowIndex ? { ...r, index: r.index + 1 } : r,
)
// Insert new row
const refRow = z.rows.find((r) => r.index === afterRowIndex) || z.rows[z.rows.length - 1]
const newRow = {
index: newRowIndex,
y_min_px: refRow.y_max_px,
y_max_px: refRow.y_max_px + (refRow.y_max_px - refRow.y_min_px),
y_min_pct: refRow.y_max_pct,
y_max_pct: Math.min(100, refRow.y_max_pct + (refRow.y_max_pct - refRow.y_min_pct)),
is_header: false,
is_footer: false,
}
const allRows = [...shiftedRows, newRow].sort((a, b) => a.index - b.index)
// Shift existing cells
const shiftedCells = z.cells.map((c) => {
if (c.row_index > afterRowIndex) {
const newRI = c.row_index + 1
return {
...c,
row_index: newRI,
cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`,
}
}
return c
})
// Create empty cells for each column
const newCells = z.columns.map((col) => ({
cell_id: `Z${zoneIndex}_R${String(newRowIndex).padStart(2, '0')}_C${col.index}`,
zone_index: zoneIndex,
row_index: newRowIndex,
col_index: col.index,
col_type: col.label,
text: '',
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}))
return { ...z, rows: allRows, cells: [...shiftedCells, ...newCells] }
}),
summary: {
...prev.summary,
total_rows: prev.summary.total_rows + 1,
total_cells: prev.summary.total_cells + (zone?.columns.length ?? 0),
},
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
// ------------------------------------------------------------------
// Layout divider editing (image overlay)
// ------------------------------------------------------------------
/** Capture current state for undo — call once at drag start. */
const commitUndoPoint = useCallback(() => {
if (!grid) return
pushUndo(grid.zones)
}, [grid, pushUndo])
/** Move a column boundary. boundaryIndex 0 = left edge of col 0, etc. */
const updateColumnDivider = useCallback(
(zoneIndex: number, boundaryIndex: number, newXPct: number) => {
if (!grid) return
setGrid((prev) => {
if (!prev) return prev
const imgW = prev.image_width || 1
const newPx = Math.round((newXPct / 100) * imgW)
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
return {
...z,
columns: z.columns.map((col) => {
// Right edge of the column before this boundary
if (col.index === boundaryIndex - 1) {
return { ...col, x_max_pct: newXPct, x_max_px: newPx }
}
// Left edge of the column at this boundary
if (col.index === boundaryIndex) {
return { ...col, x_min_pct: newXPct, x_min_px: newPx }
}
return col
}),
}
}),
}
})
setDirty(true)
},
[grid, setGrid, setDirty],
)
/** Update horizontal layout guidelines (margins, header, footer). */
const updateLayoutHorizontals = useCallback(
(horizontals: LayoutDividers['horizontals']) => {
if (!grid) return
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
layout_dividers: {
...(prev.layout_dividers || { horizontals: {} }),
horizontals,
},
}
})
setDirty(true)
},
[grid, setGrid, setDirty],
)
/** Split a column at a given x percentage, creating a new column. */
const splitColumnAt = useCallback(
(zoneIndex: number, xPct: number) => {
if (!grid) return
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
if (!zone) return
const sorted = [...zone.columns].sort((a, b) => a.index - b.index)
const targetCol = sorted.find((c) => c.x_min_pct <= xPct && c.x_max_pct >= xPct)
if (!targetCol) return
pushUndo(grid.zones)
const newColIndex = targetCol.index + 1
const imgW = grid.image_width || 1
setGrid((prev) => {
if (!prev) return prev
return {
...prev,
zones: prev.zones.map((z) => {
if (z.zone_index !== zoneIndex) return z
const leftCol = {
...targetCol,
x_max_pct: xPct,
x_max_px: Math.round((xPct / 100) * imgW),
}
const rightCol = {
index: newColIndex,
label: `column_${newColIndex + 1}`,
x_min_pct: xPct,
x_max_pct: targetCol.x_max_pct,
x_min_px: Math.round((xPct / 100) * imgW),
x_max_px: targetCol.x_max_px,
bold: false,
}
const updatedCols = z.columns.map((c) => {
if (c.index === targetCol.index) return leftCol
if (c.index > targetCol.index) return { ...c, index: c.index + 1, label: `column_${c.index + 2}` }
return c
})
const allCols = [...updatedCols, rightCol].sort((a, b) => a.index - b.index)
const shiftedCells = z.cells.map((c) => {
if (c.col_index > targetCol.index) {
const newCI = c.col_index + 1
return {
...c,
col_index: newCI,
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
}
}
return c
})
const newCells = z.rows.map((row) => ({
cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`,
zone_index: zoneIndex,
row_index: row.index,
col_index: newColIndex,
col_type: `column_${newColIndex + 1}`,
text: '',
confidence: 0,
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
word_boxes: [],
ocr_engine: 'manual',
is_bold: false,
}))
return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] }
}),
summary: {
...prev.summary,
total_columns: prev.summary.total_columns + 1,
total_cells: prev.summary.total_cells + (zone.rows.length),
},
}
})
setDirty(true)
},
[grid, pushUndo, setGrid, setDirty],
)
return {
deleteColumn,
addColumn,
deleteRow,
addRow,
commitUndoPoint,
updateColumnDivider,
updateLayoutHorizontals,
splitColumnAt,
}
}