'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import dynamic from 'next/dynamic' import { usePixelWordPositions } from './usePixelWordPositions' import type { EditableCell, UndoAction, StepReconstructionProps } from './StepReconstructionTypes' import { KLAUSUR_API } from './StepReconstructionTypes' import { useReconstructionData } from './useReconstructionData' import { ReconstructionToolbar } from './ReconstructionToolbar' import { ReconstructionOverlay } from './ReconstructionOverlay' import { ReconstructionSimpleView } from './ReconstructionSimpleView' // Lazy-load Fabric.js canvas editor (SSR-incompatible) const FabricReconstructionCanvas = dynamic( () => import('./FabricReconstructionCanvas').then(m => ({ default: m.FabricReconstructionCanvas })), { ssr: false, loading: () =>
Editor wird geladen...
} ) export function StepReconstruction({ sessionId, onNext }: StepReconstructionProps) { const [editedTexts, setEditedTexts] = useState>(new Map()) const [zoom, setZoom] = useState(100) const [imageNaturalH, setImageNaturalH] = useState(0) const [showEmptyHighlight, setShowEmptyHighlight] = useState(true) const [fontScale, setFontScale] = useState(0.7) const [globalBold, setGlobalBold] = useState(false) const [showStructure, setShowStructure] = useState(true) const [undoStack, setUndoStack] = useState([]) const [redoStack, setRedoStack] = useState([]) const imageRef = useRef(null) const resetEditing = useCallback(() => { setEditedTexts(new Map()) setUndoStack([]) setRedoStack([]) }, []) const data = useReconstructionData(sessionId, resetEditing) // Pixel-based word positions for overlay mode const overlayImageUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` : '' const cellWordPositions = usePixelWordPositions( overlayImageUrl, data.mergedGridCells, data.editorMode === 'overlay', data.imageRotation, ) // Track image natural dimensions for font scaling and structure layer const handleImageLoad = useCallback(() => { if (imageRef.current) { setImageNaturalH(imageRef.current.naturalHeight) if (!data.imageNaturalSize) { data.setImageNaturalSize({ w: imageRef.current.naturalWidth, h: imageRef.current.naturalHeight }) } } }, [data]) // --- Cell editing callbacks --- const handleTextChange = useCallback((cellId: string, newText: string) => { setEditedTexts(prev => { const oldText = prev.get(cellId) const cell = data.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 }) }, [data.cells]) const undo = useCallback(() => { setUndoStack(stack => { if (stack.length === 0) return stack const action = stack[stack.length - 1] setRedoStack(rs => [...rs, action]) setEditedTexts(prev => { const next = new Map(prev) next.set(action.cellId, action.oldText) return next }) return stack.slice(0, -1) }) }, []) const redo = useCallback(() => { setRedoStack(stack => { if (stack.length === 0) return stack const action = stack[stack.length - 1] setUndoStack(us => [...us, action]) setEditedTexts(prev => { const next = new Map(prev) next.set(action.cellId, action.newText) return next }) return stack.slice(0, -1) }) }, []) const resetCell = useCallback((cellId: string) => { const cell = data.cells.find(c => c.cellId === cellId) if (!cell) return setEditedTexts(prev => { const next = new Map(prev); next.delete(cellId); return next }) }, [data.cells]) // Global keyboard shortcuts for undo/redo 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 data.cells) { if (isEdited(cell)) count++ } return count }, [data.cells, isEdited]) const emptyCellIds = useMemo(() => { const required = new Set(['column_en', 'column_de']) const ids = new Set() for (const cell of data.cells) { if (required.has(cell.colType) && !cell.text.trim()) ids.add(cell.cellId) } return ids }, [data.cells]) const sortedCellIds = useMemo(() => { return [...data.cells] .sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex) .map(c => c.cellId) }, [data.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) { document.getElementById(`cell-${sortedCellIds[nextIdx]}`)?.focus() } } }, [sortedCellIds]) const saveReconstruction = useCallback(async () => { if (!sessionId) return data.setStatus('saving') try { const cellUpdates = Array.from(editedTexts.entries()) .filter(([cellId, text]) => { const cell = data.cells.find(c => c.cellId === cellId) return cell && text !== cell.originalText }) .map(([cellId, text]) => ({ cell_id: cellId, text })) if (cellUpdates.length === 0) { data.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 d = await res.json().catch(() => ({})) throw new Error(d.detail || `HTTP ${res.status}`) } data.setStatus('saved') } catch (e: unknown) { data.setError(e instanceof Error ? e.message : String(e)) data.setStatus('error') } }, [sessionId, editedTexts, data]) const handleFabricCellsChanged = useCallback((updates: { cell_id: string; text: string }[]) => { for (const u of updates) { setEditedTexts(prev => { const next = new Map(prev); next.set(u.cell_id, u.text); return next }) } }, []) const boxZonesPct = useMemo(() => data.parentZones .filter(z => z.zone_type === 'box' && z.box) .map(z => { const imgH = data.imageNaturalSize?.h || 1 return { topPct: (z.box!.y / imgH) * 100, bottomPct: ((z.box!.y + z.box!.height) / imgH) * 100, } }), [data.parentZones, data.imageNaturalSize] ) const dewarpedUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` : '' const hasStructureElements = data.structureBoxes.length > 0 || data.structureGraphics.length > 0 // --- Status screens --- if (!sessionId) { return
Bitte zuerst eine Session auswaehlen.
} if (data.status === 'loading') { return (
Rekonstruktionsdaten werden geladen...
) } if (data.status === 'error') { return (
⚠️

Fehler

{data.error}

) } if (data.status === 'saved') { return (

Rekonstruktion gespeichert

{changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'}

) } // --- Main rendering --- return (
{data.editorMode === 'overlay' ? ( ) : data.editorMode === 'editor' && sessionId ? ( ) : ( )}
) }