Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 32s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 31s
CI / test-nodejs-website (push) Successful in 33s
Backend (unified_grid.py):
- build_unified_grid(): merges content + box zones into one zone
- Dominant row height from median of content row spacings
- Full-width boxes: rows integrated directly
- Partial-width boxes: extra rows inserted when box has more text
lines than standard rows fit (e.g., 7 lines in 5-row height)
- Box-origin cells tagged with source_zone_type + box_region metadata
Backend (grid_editor_api.py):
- POST /sessions/{id}/build-unified-grid → persists as unified_grid_result
- GET /sessions/{id}/unified-grid → retrieve persisted result
Frontend:
- GridEditorCell: added source_zone_type, box_region fields
- GridTable: box-origin cells get tinted background + left border
- StepAnsicht: split-view with original image (left) + editable
unified GridTable (right). Auto-builds on first load.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
191 lines
6.9 KiB
TypeScript
191 lines
6.9 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* StepAnsicht — Unified Grid View.
|
||
*
|
||
* Left: Original scan with OCR word overlay
|
||
* Right: Unified grid (single zone, boxes integrated) rendered via GridTable
|
||
*/
|
||
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
||
import { GridTable } from '@/components/grid-editor/GridTable'
|
||
import type { GridZone } from '@/components/grid-editor/types'
|
||
|
||
const KLAUSUR_API = '/klausur-api'
|
||
|
||
interface StepAnsichtProps {
|
||
sessionId: string | null
|
||
onNext: () => void
|
||
}
|
||
|
||
export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
||
const gridEditor = useGridEditor(sessionId)
|
||
const {
|
||
loading, error, selectedCell, setSelectedCell,
|
||
updateCellText, toggleColumnBold, toggleRowHeader,
|
||
getAdjacentCell, deleteColumn, addColumn, deleteRow, addRow,
|
||
commitUndoPoint, selectedCells, toggleCellSelection,
|
||
clearCellSelection, toggleSelectedBold, setCellColor,
|
||
saveGrid, saving, dirty, undo, redo, canUndo, canRedo,
|
||
} = gridEditor
|
||
|
||
const [unifiedGrid, setUnifiedGrid] = useState<any>(null)
|
||
const [building, setBuilding] = useState(false)
|
||
const [buildError, setBuildError] = useState<string | null>(null)
|
||
const leftRef = useRef<HTMLDivElement>(null)
|
||
const [leftHeight, setLeftHeight] = useState(600)
|
||
const [showGrid, setShowGrid] = useState(false)
|
||
|
||
// Build unified grid
|
||
const buildUnified = useCallback(async () => {
|
||
if (!sessionId) return
|
||
setBuilding(true)
|
||
setBuildError(null)
|
||
try {
|
||
const res = await fetch(
|
||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-unified-grid`,
|
||
{ method: 'POST' },
|
||
)
|
||
if (!res.ok) {
|
||
const d = await res.json().catch(() => ({}))
|
||
throw new Error(d.detail || `HTTP ${res.status}`)
|
||
}
|
||
const data = await res.json()
|
||
setUnifiedGrid(data)
|
||
} catch (e) {
|
||
setBuildError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setBuilding(false)
|
||
}
|
||
}, [sessionId])
|
||
|
||
// Load unified grid on mount (or build if missing)
|
||
useEffect(() => {
|
||
if (!sessionId) return
|
||
;(async () => {
|
||
try {
|
||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/unified-grid`)
|
||
if (res.ok) {
|
||
setUnifiedGrid(await res.json())
|
||
} else {
|
||
// Not built yet — build it
|
||
buildUnified()
|
||
}
|
||
} catch {
|
||
buildUnified()
|
||
}
|
||
})()
|
||
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Track left panel height for sync
|
||
useEffect(() => {
|
||
if (!leftRef.current) return
|
||
const ro = new ResizeObserver(([e]) => setLeftHeight(e.contentRect.height))
|
||
ro.observe(leftRef.current)
|
||
return () => ro.disconnect()
|
||
}, [])
|
||
|
||
const unifiedZone: GridZone | null = unifiedGrid?.zones?.[0] ?? null
|
||
|
||
if (loading || building) {
|
||
return (
|
||
<div className="flex items-center justify-center py-16">
|
||
<div className="w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
||
<span className="ml-3 text-gray-500">{building ? 'Baue Unified Grid...' : 'Lade...'}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Ansicht — Unified Grid</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
Alle Inhalte in einem Grid. Boxen sind integriert (farbig markiert).
|
||
{unifiedGrid && (
|
||
<span className="ml-2 font-mono text-xs">
|
||
{unifiedGrid.summary?.total_rows} Zeilen × {unifiedGrid.summary?.total_columns} Spalten
|
||
{unifiedGrid.dominant_row_h && ` · Zeilenhöhe: ${Math.round(unifiedGrid.dominant_row_h)}px`}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={buildUnified}
|
||
disabled={building}
|
||
className="px-3 py-1.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-xs font-medium disabled:opacity-50"
|
||
>
|
||
{building ? 'Baut...' : 'Neu aufbauen'}
|
||
</button>
|
||
<button onClick={onNext} className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium">
|
||
Weiter →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{(error || buildError) && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
|
||
{error || buildError}
|
||
</div>
|
||
)}
|
||
|
||
{/* Split view */}
|
||
<div className="flex gap-2">
|
||
{/* LEFT: Original + OCR overlay */}
|
||
<div ref={leftRef} className="w-1/3 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900 flex-shrink-0">
|
||
<div className="px-2 py-1 bg-black/60 text-white text-[10px] font-medium">Original + OCR</div>
|
||
{sessionId && (
|
||
<img
|
||
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`}
|
||
alt="Original + OCR"
|
||
className="w-full h-auto"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* RIGHT: Unified Grid Table */}
|
||
<div className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto bg-white dark:bg-gray-900" style={{ maxHeight: `${Math.max(600, leftHeight)}px` }}>
|
||
<div className="px-2 py-1 bg-teal-600/80 text-white text-[10px] font-medium sticky top-0 z-20">
|
||
Unified Grid
|
||
{unifiedGrid?.is_unified && (
|
||
<span className="ml-2 opacity-70">
|
||
({unifiedGrid.summary?.total_rows}×{unifiedGrid.summary?.total_columns})
|
||
</span>
|
||
)}
|
||
</div>
|
||
{unifiedZone ? (
|
||
<GridTable
|
||
zone={unifiedZone}
|
||
selectedCell={selectedCell}
|
||
selectedCells={selectedCells}
|
||
onSelectCell={setSelectedCell}
|
||
onCellTextChange={updateCellText}
|
||
onToggleColumnBold={toggleColumnBold}
|
||
onToggleRowHeader={toggleRowHeader}
|
||
onNavigate={(cellId, dir) => {
|
||
const next = getAdjacentCell(cellId, dir)
|
||
if (next) setSelectedCell(next)
|
||
}}
|
||
onDeleteColumn={deleteColumn}
|
||
onAddColumn={addColumn}
|
||
onDeleteRow={deleteRow}
|
||
onAddRow={addRow}
|
||
onToggleCellSelection={toggleCellSelection}
|
||
onSetCellColor={setCellColor}
|
||
/>
|
||
) : (
|
||
<div className="p-8 text-center text-gray-400">
|
||
<p>Kein Unified Grid verfügbar.</p>
|
||
<button onClick={buildUnified} className="mt-2 text-teal-600 text-sm">Jetzt aufbauen</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|