import { useCallback, useRef, useState } from 'react' import type { StructuredGrid, GridZone } from './types' const KLAUSUR_API = '/klausur-api' const MAX_UNDO = 50 export interface GridEditorState { grid: StructuredGrid | null loading: boolean saving: boolean error: string | null dirty: boolean selectedCell: string | null selectedZone: number | null } export function useGridEditor(sessionId: string | null) { const [grid, setGrid] = useState(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [dirty, setDirty] = useState(false) const [selectedCell, setSelectedCell] = useState(null) const [selectedZone, setSelectedZone] = useState(null) // Undo/redo stacks store serialized zone arrays const undoStack = useRef([]) const redoStack = useRef([]) const pushUndo = useCallback((zones: GridZone[]) => { undoStack.current.push(JSON.stringify(zones)) if (undoStack.current.length > MAX_UNDO) { undoStack.current.shift() } redoStack.current = [] }, []) // ------------------------------------------------------------------ // Load / Build // ------------------------------------------------------------------ const buildGrid = useCallback(async () => { if (!sessionId) return setLoading(true) setError(null) try { const res = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid`, { method: 'POST' }, ) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.detail || `HTTP ${res.status}`) } const data: StructuredGrid = await res.json() setGrid(data) setDirty(false) undoStack.current = [] redoStack.current = [] } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [sessionId]) const loadGrid = useCallback(async () => { if (!sessionId) return setLoading(true) setError(null) try { const res = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/grid-editor`, ) if (res.status === 404) { // No grid yet — build it await buildGrid() return } if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.detail || `HTTP ${res.status}`) } const data: StructuredGrid = await res.json() setGrid(data) setDirty(false) undoStack.current = [] redoStack.current = [] } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [sessionId, buildGrid]) // ------------------------------------------------------------------ // Save // ------------------------------------------------------------------ const saveGrid = useCallback(async () => { if (!sessionId || !grid) return setSaving(true) try { const res = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/save-grid`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(grid), }, ) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.detail || `HTTP ${res.status}`) } setDirty(false) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } }, [sessionId, grid]) // ------------------------------------------------------------------ // Cell editing // ------------------------------------------------------------------ const updateCellText = useCallback( (cellId: string, newText: string) => { if (!grid) return pushUndo(grid.zones) setGrid((prev) => { if (!prev) return prev return { ...prev, zones: prev.zones.map((zone) => ({ ...zone, cells: zone.cells.map((cell) => cell.cell_id === cellId ? { ...cell, text: newText } : cell, ), })), } }) setDirty(true) }, [grid, pushUndo], ) // ------------------------------------------------------------------ // Column formatting // ------------------------------------------------------------------ const toggleColumnBold = useCallback( (zoneIndex: number, colIndex: number) => { if (!grid) return pushUndo(grid.zones) setGrid((prev) => { if (!prev) return prev return { ...prev, zones: prev.zones.map((zone) => { if (zone.zone_index !== zoneIndex) return zone const col = zone.columns.find((c) => c.index === colIndex) const newBold = col ? !col.bold : true return { ...zone, columns: zone.columns.map((c) => c.index === colIndex ? { ...c, bold: newBold } : c, ), cells: zone.cells.map((cell) => cell.col_index === colIndex ? { ...cell, is_bold: newBold } : cell, ), } }), } }) setDirty(true) }, [grid, pushUndo], ) // ------------------------------------------------------------------ // Row formatting // ------------------------------------------------------------------ const toggleRowHeader = useCallback( (zoneIndex: number, rowIndex: number) => { if (!grid) return pushUndo(grid.zones) setGrid((prev) => { if (!prev) return prev return { ...prev, zones: prev.zones.map((zone) => { if (zone.zone_index !== zoneIndex) return zone return { ...zone, rows: zone.rows.map((r) => r.index === rowIndex ? { ...r, is_header: !r.is_header } : r, ), } }), } }) setDirty(true) }, [grid, pushUndo], ) // ------------------------------------------------------------------ // Column management // ------------------------------------------------------------------ const deleteColumn = useCallback( (zoneIndex: number, colIndex: number) => { if (!grid) return const zone = grid.zones.find((z) => z.zone_index === zoneIndex) if (!zone || zone.columns.length <= 1) return // keep at least 1 column pushUndo(grid.zones) setGrid((prev) => { if (!prev) return prev return { ...prev, zones: prev.zones.map((z) => { if (z.zone_index !== zoneIndex) return z const newColumns = z.columns .filter((c) => c.index !== colIndex) .map((c, i) => ({ ...c, index: i, label: `column_${i + 1}` })) const newCells = z.cells .filter((c) => c.col_index !== colIndex) .map((c) => { const newCI = c.col_index > colIndex ? c.col_index - 1 : c.col_index return { ...c, col_index: newCI, cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`, } }) return { ...z, columns: newColumns, cells: newCells } }), summary: { ...prev.summary, total_columns: prev.summary.total_columns - 1, total_cells: prev.zones.reduce( (sum, z) => sum + (z.zone_index === zoneIndex ? z.cells.filter((c) => c.col_index !== colIndex).length : z.cells.length), 0, ), }, } }) setDirty(true) }, [grid, pushUndo], ) const addColumn = useCallback( (zoneIndex: number, afterColIndex: number) => { if (!grid) return const zone = grid.zones.find((z) => z.zone_index === zoneIndex) if (!zone) return pushUndo(grid.zones) const newColIndex = afterColIndex + 1 setGrid((prev) => { if (!prev) return prev return { ...prev, zones: prev.zones.map((z) => { if (z.zone_index !== zoneIndex) return z // Shift existing columns const shiftedCols = z.columns.map((c) => c.index > afterColIndex ? { ...c, index: c.index + 1, label: `column_${c.index + 2}` } : c, ) // Insert new column const refCol = z.columns.find((c) => c.index === afterColIndex) || z.columns[z.columns.length - 1] const newCol = { index: newColIndex, label: `column_${newColIndex + 1}`, x_min_px: refCol.x_max_px, x_max_px: refCol.x_max_px + (refCol.x_max_px - refCol.x_min_px), x_min_pct: refCol.x_max_pct, x_max_pct: Math.min(100, refCol.x_max_pct + (refCol.x_max_pct - refCol.x_min_pct)), bold: false, } const allCols = [...shiftedCols, newCol].sort((a, b) => a.index - b.index) // Shift existing cells and create empty cells for new column const shiftedCells = z.cells.map((c) => { if (c.col_index > afterColIndex) { const newCI = c.col_index + 1 return { ...c, col_index: newCI, cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`, } } return c }) // Create empty cells for each row const newCells = z.rows.map((row) => ({ cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`, zone_index: zoneIndex, row_index: row.index, col_index: newColIndex, col_type: `column_${newColIndex + 1}`, text: '', confidence: 0, bbox_px: { x: 0, y: 0, w: 0, h: 0 }, bbox_pct: { x: 0, y: 0, w: 0, h: 0 }, word_boxes: [], ocr_engine: 'manual', is_bold: false, })) return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] } }), summary: { ...prev.summary, total_columns: prev.summary.total_columns + 1, total_cells: prev.summary.total_cells + (zone?.rows.length ?? 0), }, } }) setDirty(true) }, [grid, pushUndo], ) // ------------------------------------------------------------------ // Undo / Redo // ------------------------------------------------------------------ const undo = useCallback(() => { if (!grid || undoStack.current.length === 0) return redoStack.current.push(JSON.stringify(grid.zones)) const prev = undoStack.current.pop()! setGrid((g) => (g ? { ...g, zones: JSON.parse(prev) } : g)) setDirty(true) }, [grid]) const redo = useCallback(() => { if (!grid || redoStack.current.length === 0) return undoStack.current.push(JSON.stringify(grid.zones)) const next = redoStack.current.pop()! setGrid((g) => (g ? { ...g, zones: JSON.parse(next) } : g)) setDirty(true) }, [grid]) const canUndo = undoStack.current.length > 0 const canRedo = redoStack.current.length > 0 // ------------------------------------------------------------------ // Navigation helpers // ------------------------------------------------------------------ const getAdjacentCell = useCallback( (cellId: string, direction: 'up' | 'down' | 'left' | 'right'): string | null => { if (!grid) return null for (const zone of grid.zones) { const cell = zone.cells.find((c) => c.cell_id === cellId) if (!cell) continue let targetRow = cell.row_index let targetCol = cell.col_index if (direction === 'up') targetRow-- if (direction === 'down') targetRow++ if (direction === 'left') targetCol-- if (direction === 'right') targetCol++ const target = zone.cells.find( (c) => c.row_index === targetRow && c.col_index === targetCol, ) return target?.cell_id ?? null } return null }, [grid], ) return { grid, loading, saving, error, dirty, selectedCell, selectedZone, setSelectedCell, setSelectedZone, buildGrid, loadGrid, saveGrid, updateCellText, toggleColumnBold, toggleRowHeader, undo, redo, canUndo, canRedo, getAdjacentCell, deleteColumn, addColumn, } }