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>
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
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,
|
|
layoutMetrics,
|
|
selectedCell,
|
|
selectedCells,
|
|
onSelectCell,
|
|
onToggleCellSelection,
|
|
onCellTextChange,
|
|
onToggleColumnBold,
|
|
onToggleRowHeader,
|
|
onNavigate,
|
|
onDeleteColumn,
|
|
onAddColumn,
|
|
onDeleteRow,
|
|
onAddRow,
|
|
onSetCellColor,
|
|
}: GridTableProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [containerWidth, setContainerWidth] = useState(0)
|
|
const [colorMenu, setColorMenu] = useState<{ cellId: string; x: number; y: number } | null>(null)
|
|
|
|
// ----------------------------------------------------------------
|
|
// Observe container width for scaling
|
|
// ----------------------------------------------------------------
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
const ro = new ResizeObserver(([entry]) => {
|
|
setContainerWidth(entry.contentRect.width)
|
|
})
|
|
ro.observe(el)
|
|
return () => ro.disconnect()
|
|
}, [])
|
|
|
|
// ----------------------------------------------------------------
|
|
// Compute column widths from OCR measurements
|
|
// ----------------------------------------------------------------
|
|
const [colWidthOverrides, setColWidthOverrides] = useState<number[] | null>(null)
|
|
|
|
const columnWidthsPx = zone.columns.map((col) => col.x_max_px - col.x_min_px)
|
|
const totalColWidthPx = columnWidthsPx.reduce((sum, w) => sum + w, 0)
|
|
const zoneWidthPx = totalColWidthPx > 0
|
|
? totalColWidthPx
|
|
: (zone.bbox_px.w || layoutMetrics?.page_width_px || 1)
|
|
const scale = containerWidth > 0 ? (containerWidth - ROW_NUM_WIDTH) / zoneWidthPx : 1
|
|
|
|
const effectiveColWidths = (colWidthOverrides ?? columnWidthsPx).map(
|
|
(w) => Math.max(MIN_COL_WIDTH, w * scale),
|
|
)
|
|
|
|
// ----------------------------------------------------------------
|
|
// Row height
|
|
// ----------------------------------------------------------------
|
|
const avgRowHeightPx = layoutMetrics?.avg_row_height_px ?? 30
|
|
const [rowHeightOverrides, setRowHeightOverrides] = useState<Map<number, number>>(new Map())
|
|
|
|
const computeRowHeight = (rowIndex: number, isHeader: boolean): number => {
|
|
if (rowHeightOverrides.has(rowIndex)) {
|
|
return rowHeightOverrides.get(rowIndex)!
|
|
}
|
|
return getRowHeight(zone, rowIndex, isHeader, avgRowHeightPx, scale)
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Font size from layout metrics
|
|
// ----------------------------------------------------------------
|
|
const baseFontSize = layoutMetrics?.font_size_suggestion_px
|
|
? Math.max(11, layoutMetrics.font_size_suggestion_px * scale)
|
|
: 13
|
|
|
|
// ----------------------------------------------------------------
|
|
// Keyboard navigation
|
|
// ----------------------------------------------------------------
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent, cellId: string) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault()
|
|
onNavigate(cellId, e.shiftKey ? 'left' : 'right')
|
|
} else if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
onNavigate(cellId, 'down')
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
onNavigate(cellId, 'up')
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
onNavigate(cellId, 'down')
|
|
} else if (e.key === 'ArrowLeft' && e.altKey) {
|
|
e.preventDefault()
|
|
onNavigate(cellId, 'left')
|
|
} else if (e.key === 'ArrowRight' && e.altKey) {
|
|
e.preventDefault()
|
|
onNavigate(cellId, 'right')
|
|
} else if (e.key === 'Escape') {
|
|
;(e.target as HTMLElement).blur()
|
|
}
|
|
},
|
|
[onNavigate],
|
|
)
|
|
|
|
// ----------------------------------------------------------------
|
|
// Cell lookup
|
|
// ----------------------------------------------------------------
|
|
const cellMap = new Map<string, GridEditorCell>()
|
|
for (const cell of zone.cells) {
|
|
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Column resize (drag)
|
|
// ----------------------------------------------------------------
|
|
const handleColResizeStart = useCallback(
|
|
(colIndex: number, startX: number) => {
|
|
const baseWidths = colWidthOverrides ?? [...columnWidthsPx]
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const deltaPx = (e.clientX - startX) / scale
|
|
const newWidths = [...baseWidths]
|
|
newWidths[colIndex] = Math.max(20, baseWidths[colIndex] + deltaPx)
|
|
if (colIndex + 1 < newWidths.length) {
|
|
newWidths[colIndex + 1] = Math.max(20, baseWidths[colIndex + 1] - deltaPx)
|
|
}
|
|
setColWidthOverrides(newWidths)
|
|
}
|
|
|
|
const handleMouseUp = () => {
|
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
document.removeEventListener('mouseup', handleMouseUp)
|
|
document.body.style.cursor = ''
|
|
document.body.style.userSelect = ''
|
|
}
|
|
|
|
document.body.style.cursor = 'col-resize'
|
|
document.body.style.userSelect = 'none'
|
|
document.addEventListener('mousemove', handleMouseMove)
|
|
document.addEventListener('mouseup', handleMouseUp)
|
|
},
|
|
[colWidthOverrides, columnWidthsPx, scale],
|
|
)
|
|
|
|
// ----------------------------------------------------------------
|
|
// Row resize (drag)
|
|
// ----------------------------------------------------------------
|
|
const handleRowResizeStart = useCallback(
|
|
(rowIndex: number, startY: number, currentHeight: number) => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const delta = e.clientY - startY
|
|
const newH = Math.max(MIN_ROW_HEIGHT, currentHeight + delta)
|
|
setRowHeightOverrides((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(rowIndex, newH)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleMouseUp = () => {
|
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
document.removeEventListener('mouseup', handleMouseUp)
|
|
document.body.style.cursor = ''
|
|
document.body.style.userSelect = ''
|
|
}
|
|
|
|
document.body.style.cursor = 'row-resize'
|
|
document.body.style.userSelect = 'none'
|
|
document.addEventListener('mousemove', handleMouseMove)
|
|
document.addEventListener('mouseup', handleMouseUp)
|
|
},
|
|
[],
|
|
)
|
|
|
|
const isBoxZone = zone.zone_type === 'box'
|
|
const numCols = zone.columns.length
|
|
|
|
const gridTemplateCols = `${ROW_NUM_WIDTH}px ${effectiveColWidths.map((w) => `${w.toFixed(1)}px`).join(' ')}`
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`overflow-x-auto ${isBoxZone ? 'border-2 border-gray-400 dark:border-gray-500 rounded-lg' : ''}`}
|
|
>
|
|
{/* Zone label */}
|
|
<div className="flex items-center gap-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
|
isBoxZone
|
|
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800'
|
|
: 'bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700'
|
|
}`}
|
|
>
|
|
{isBoxZone ? 'Box' : 'Inhalt'} Zone {zone.zone_index}
|
|
</span>
|
|
<span>
|
|
{zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen
|
|
</span>
|
|
</div>
|
|
|
|
{/* CSS Grid */}
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: gridTemplateCols,
|
|
fontFamily: "var(--font-noto-sans, 'Noto Sans'), 'Inter', system-ui, sans-serif",
|
|
fontSize: `${baseFontSize}px`,
|
|
}}
|
|
>
|
|
{/* Header: row-number corner */}
|
|
<div className="sticky left-0 z-10 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" />
|
|
|
|
{/* Header: column labels */}
|
|
{zone.columns.map((col, ci) => (
|
|
<GridTableColumnHeader
|
|
key={col.index}
|
|
col={col}
|
|
colIndex={ci}
|
|
numCols={numCols}
|
|
zoneIndex={zone.zone_index}
|
|
onToggleColumnBold={onToggleColumnBold}
|
|
onDeleteColumn={onDeleteColumn}
|
|
onAddColumn={onAddColumn}
|
|
onColResizeStart={handleColResizeStart}
|
|
/>
|
|
))}
|
|
|
|
{/* Data rows */}
|
|
{zone.rows.map((row) => {
|
|
const rowH = computeRowHeight(row.index, row.is_header)
|
|
const isSpanning = zone.cells.some(
|
|
(c) => c.row_index === row.index && c.col_type === 'spanning_header',
|
|
)
|
|
|
|
return (
|
|
<div key={row.index} style={{ display: 'contents' }}>
|
|
{/* Row number cell */}
|
|
<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 ? (
|
|
<>
|
|
{zone.cells
|
|
.filter((c) => c.row_index === row.index && c.col_type === 'spanning_header')
|
|
.sort((a, b) => a.col_index - b.col_index)
|
|
.map((spanCell) => {
|
|
const colspan = spanCell.colspan || numCols
|
|
const cellId = spanCell.cell_id
|
|
const isSelected = selectedCell === cellId
|
|
const cellColor = getCellColor(spanCell)
|
|
const gridColStart = spanCell.col_index + 2
|
|
const gridColEnd = gridColStart + colspan
|
|
return (
|
|
<div
|
|
key={cellId}
|
|
className={`border-b border-r border-gray-200 dark:border-gray-700 bg-blue-50/50 dark:bg-blue-900/10 flex items-center ${
|
|
isSelected ? 'ring-2 ring-teal-500 ring-inset z-10' : ''
|
|
}`}
|
|
style={{ gridColumn: `${gridColStart} / ${gridColEnd}`, height: `${rowH}px` }}
|
|
>
|
|
{cellColor && (
|
|
<span className="flex-shrink-0 w-1.5 self-stretch rounded-l-sm" style={{ backgroundColor: cellColor }} />
|
|
)}
|
|
<input
|
|
id={`cell-${cellId}`}
|
|
type="text"
|
|
value={spanCell.text}
|
|
onChange={(e) => onCellTextChange(cellId, e.target.value)}
|
|
onFocus={() => onSelectCell(cellId)}
|
|
onKeyDown={(e) => handleKeyDown(e, cellId)}
|
|
className="w-full px-3 py-1 bg-transparent border-0 outline-none text-center"
|
|
style={{ color: cellColor || undefined }}
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
) : (
|
|
zone.columns.map((col) => {
|
|
const cell = cellMap.get(`${row.index}_${col.index}`)
|
|
return (
|
|
<GridTableCell
|
|
key={col.index}
|
|
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}
|
|
/>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Color context menu (right-click) */}
|
|
{colorMenu && onSetCellColor && (
|
|
<GridTableColorMenu
|
|
cellId={colorMenu.cellId}
|
|
x={colorMenu.x}
|
|
y={colorMenu.y}
|
|
onSetCellColor={onSetCellColor}
|
|
onClose={() => setColorMenu(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|