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>
329 lines
9.6 KiB
TypeScript
329 lines
9.6 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { usePixelWordPositions } from './usePixelWordPositions'
|
|
import { useSlideWordPositions } from './useSlideWordPositions'
|
|
import type {
|
|
EditableCell,
|
|
GridCellCompat,
|
|
OverlayReconstructionState,
|
|
OverlayStatus,
|
|
PositioningMode,
|
|
RowItemCompat,
|
|
RowResultCompat,
|
|
TextColor,
|
|
UndoAction,
|
|
WordResultData,
|
|
} from './overlay-reconstruction-types'
|
|
import { KLAUSUR_API } from './overlay-reconstruction-types'
|
|
|
|
/**
|
|
* All state and logic for OverlayReconstruction, extracted as a custom hook.
|
|
*/
|
|
export function useOverlayReconstructionState(
|
|
sessionId: string | null,
|
|
wordResultOverride?: WordResultData,
|
|
): OverlayReconstructionState {
|
|
const [status, setStatus] = useState<OverlayStatus>('loading')
|
|
const [error, setError] = useState('')
|
|
const [cells, setCells] = useState<EditableCell[]>([])
|
|
const [gridCells, setGridCells] = useState<GridCellCompat[]>([])
|
|
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
|
|
|
|
// Undo/Redo
|
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([])
|
|
const [redoStack, setRedoStack] = useState<UndoAction[]>([])
|
|
|
|
// Overlay state
|
|
const [rows, setRows] = useState<RowItemCompat[]>([])
|
|
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
|
|
const [fontScale, setFontScale] = useState(0.7)
|
|
const [globalBold, setGlobalBold] = useState(false)
|
|
const [imageRotation, setImageRotation] = useState<0 | 180>(0)
|
|
const [textOpacity, setTextOpacity] = useState(100)
|
|
const [textColor, setTextColor] = useState<TextColor>('red')
|
|
const [positioningMode, setPositioningMode] = useState<PositioningMode>('slide')
|
|
const reconRef = useRef<HTMLDivElement>(null)
|
|
const [reconWidth, setReconWidth] = useState(0)
|
|
|
|
// Pixel-based word positions
|
|
const overlayImageUrl = sessionId
|
|
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
|
: ''
|
|
const clusterPositions = usePixelWordPositions(
|
|
overlayImageUrl,
|
|
gridCells as never[],
|
|
status === 'ready',
|
|
imageRotation,
|
|
)
|
|
const slidePositions = useSlideWordPositions(
|
|
overlayImageUrl,
|
|
gridCells as never[],
|
|
status === 'ready',
|
|
imageRotation,
|
|
)
|
|
const cellWordPositions = positioningMode === 'slide' ? slidePositions : clusterPositions
|
|
|
|
// Track container width
|
|
useEffect(() => {
|
|
const el = reconRef.current
|
|
if (!el) return
|
|
const obs = new ResizeObserver(entries => {
|
|
for (const entry of entries) setReconWidth(entry.contentRect.width)
|
|
})
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [status])
|
|
|
|
const applyWordResult = (wordResult: WordResultData) => {
|
|
const rawGridCells: GridCellCompat[] = wordResult.cells || []
|
|
setGridCells(rawGridCells)
|
|
|
|
const editableCells: EditableCell[] = rawGridCells.map(c => ({
|
|
cellId: c.cell_id,
|
|
text: c.text,
|
|
originalText: c.text,
|
|
bboxPct: c.bbox_pct,
|
|
colType: c.col_type,
|
|
rowIndex: c.row_index,
|
|
colIndex: c.col_index,
|
|
}))
|
|
setCells(editableCells)
|
|
setEditedTexts(new Map())
|
|
setUndoStack([])
|
|
setRedoStack([])
|
|
|
|
if (wordResult.image_width && wordResult.image_height) {
|
|
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
|
|
}
|
|
|
|
setStatus('ready')
|
|
}
|
|
|
|
const loadSessionData = useCallback(async () => {
|
|
if (!sessionId) return
|
|
setStatus('loading')
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json()
|
|
|
|
const wordResult = data.word_result
|
|
if (!wordResult) {
|
|
setError('Keine Worterkennungsdaten gefunden. Bitte zuerst den Woerter-Schritt abschliessen.')
|
|
setStatus('error')
|
|
return
|
|
}
|
|
|
|
applyWordResult(wordResult as WordResultData)
|
|
|
|
const rowResult: RowResultCompat | undefined = data.row_result
|
|
if (rowResult?.rows) setRows(rowResult.rows)
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
setStatus('error')
|
|
}
|
|
}, [sessionId])
|
|
|
|
// Load session data
|
|
useEffect(() => {
|
|
if (wordResultOverride) {
|
|
applyWordResult(wordResultOverride)
|
|
return
|
|
}
|
|
if (!sessionId) return
|
|
loadSessionData()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, wordResultOverride])
|
|
|
|
const handleTextChange = useCallback((cellId: string, newText: string) => {
|
|
setEditedTexts(prev => {
|
|
const oldText = prev.get(cellId)
|
|
const cell = cells.find(c => c.cellId === cellId)
|
|
const prevText = oldText ?? cell?.text ?? ''
|
|
|
|
setUndoStack(stack => [...stack, { cellId, oldText: prevText, newText }])
|
|
setRedoStack([])
|
|
|
|
const next = new Map(prev)
|
|
next.set(cellId, newText)
|
|
return next
|
|
})
|
|
}, [cells])
|
|
|
|
const undo = useCallback(() => {
|
|
setUndoStack(stack => {
|
|
if (stack.length === 0) return stack
|
|
const action = stack[stack.length - 1]
|
|
const newStack = stack.slice(0, -1)
|
|
setRedoStack(rs => [...rs, action])
|
|
setEditedTexts(prev => {
|
|
const next = new Map(prev)
|
|
next.set(action.cellId, action.oldText)
|
|
return next
|
|
})
|
|
return newStack
|
|
})
|
|
}, [])
|
|
|
|
const redo = useCallback(() => {
|
|
setRedoStack(stack => {
|
|
if (stack.length === 0) return stack
|
|
const action = stack[stack.length - 1]
|
|
const newStack = stack.slice(0, -1)
|
|
setUndoStack(us => [...us, action])
|
|
setEditedTexts(prev => {
|
|
const next = new Map(prev)
|
|
next.set(action.cellId, action.newText)
|
|
return next
|
|
})
|
|
return newStack
|
|
})
|
|
}, [])
|
|
|
|
const resetCell = useCallback((cellId: string) => {
|
|
setEditedTexts(prev => {
|
|
const next = new Map(prev)
|
|
next.delete(cellId)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
e.preventDefault()
|
|
if (e.shiftKey) redo()
|
|
else undo()
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handler)
|
|
return () => document.removeEventListener('keydown', handler)
|
|
}, [undo, redo])
|
|
|
|
const getDisplayText = useCallback((cell: EditableCell): string => {
|
|
return editedTexts.get(cell.cellId) ?? cell.text
|
|
}, [editedTexts])
|
|
|
|
const isEdited = useCallback((cell: EditableCell): boolean => {
|
|
const edited = editedTexts.get(cell.cellId)
|
|
return edited !== undefined && edited !== cell.originalText
|
|
}, [editedTexts])
|
|
|
|
const changedCount = useMemo(() => {
|
|
let count = 0
|
|
for (const cell of cells) {
|
|
if (isEdited(cell)) count++
|
|
}
|
|
return count
|
|
}, [cells, isEdited])
|
|
|
|
// Tab navigation
|
|
const sortedCellIds = useMemo(() => {
|
|
return [...cells]
|
|
.sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex)
|
|
.map(c => c.cellId)
|
|
}, [cells])
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent, cellId: string) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault()
|
|
const idx = sortedCellIds.indexOf(cellId)
|
|
const nextIdx = e.shiftKey ? idx - 1 : idx + 1
|
|
if (nextIdx >= 0 && nextIdx < sortedCellIds.length) {
|
|
const nextId = sortedCellIds[nextIdx]
|
|
const el = document.getElementById(`cell-${nextId}`)
|
|
el?.focus()
|
|
}
|
|
}
|
|
}, [sortedCellIds])
|
|
|
|
const saveReconstruction = useCallback(async () => {
|
|
if (!sessionId) return
|
|
setStatus('saving')
|
|
try {
|
|
const cellUpdates = Array.from(editedTexts.entries())
|
|
.filter(([cellId, text]) => {
|
|
const cell = cells.find(c => c.cellId === cellId)
|
|
return cell && text !== cell.originalText
|
|
})
|
|
.map(([cellId, text]) => ({ cell_id: cellId, text }))
|
|
|
|
if (cellUpdates.length === 0) {
|
|
setStatus('saved')
|
|
return
|
|
}
|
|
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ cells: cellUpdates }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
|
}
|
|
|
|
setStatus('saved')
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
setStatus('error')
|
|
}
|
|
}, [sessionId, editedTexts, cells])
|
|
|
|
// Median cell height for consistent font sizing (must be before early returns)
|
|
const medianCellHeightPx = useMemo(() => {
|
|
const imgWVal = imageNaturalSize?.w || 1
|
|
const imgHVal = imageNaturalSize?.h || 1
|
|
const cH = reconWidth * (imgHVal / imgWVal)
|
|
if (cells.length === 0 || cH === 0) return 40
|
|
const heights = cells.map(c => cH * (c.bboxPct.h / 100)).sort((a, b) => a - b)
|
|
const mid = Math.floor(heights.length / 2)
|
|
return heights.length % 2 === 0 ? (heights[mid - 1] + heights[mid]) / 2 : heights[mid]
|
|
}, [cells, reconWidth, imageNaturalSize])
|
|
|
|
return {
|
|
status,
|
|
error,
|
|
cells,
|
|
gridCells,
|
|
editedTexts,
|
|
undoStack,
|
|
redoStack,
|
|
rows,
|
|
imageNaturalSize,
|
|
fontScale,
|
|
globalBold,
|
|
imageRotation,
|
|
textOpacity,
|
|
textColor,
|
|
positioningMode,
|
|
changedCount,
|
|
sortedCellIds,
|
|
medianCellHeightPx,
|
|
cellWordPositions,
|
|
reconRef,
|
|
reconWidth,
|
|
|
|
setFontScale,
|
|
setGlobalBold,
|
|
setImageRotation,
|
|
setTextOpacity,
|
|
setTextColor,
|
|
setPositioningMode,
|
|
handleTextChange,
|
|
undo,
|
|
redo,
|
|
resetCell,
|
|
handleKeyDown,
|
|
getDisplayText,
|
|
isEdited,
|
|
saveReconstruction,
|
|
loadSessionData,
|
|
setError,
|
|
setImageNaturalSize,
|
|
}
|
|
}
|