'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('loading') const [error, setError] = useState('') const [cells, setCells] = useState([]) const [gridCells, setGridCells] = useState([]) const [editedTexts, setEditedTexts] = useState>(new Map()) // Undo/Redo const [undoStack, setUndoStack] = useState([]) const [redoStack, setRedoStack] = useState([]) // Overlay state const [rows, setRows] = useState([]) 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('red') const [positioningMode, setPositioningMode] = useState('slide') const reconRef = useRef(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, } }