import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useStudio } from './StudioContext' import { getTablesForPage } from './studioUtils' import TableEditor from './TableEditorFull' function toNumber(value, fallback = 0) { const num = Number(value) return Number.isFinite(num) ? num : fallback } function makeCellId(tableId, row, col) { return `t${tableId}-r${row}-c${col}` } function buildGridFromTable(table) { const cells = table?.cells || [] // Safely compute dimensions — guard against undefined/null span fields. const rowCount = cells.length ? Math.max(...cells.map((cell) => toNumber(cell.row, 0) + Math.max(1, toNumber(cell.row_span, 1)))) : 1 const colCount = cells.length ? Math.max(...cells.map((cell) => toNumber(cell.col, 0) + Math.max(1, toNumber(cell.col_span, 1)))) : 1 const safRows = Math.max(1, rowCount) const safCols = Math.max(1, colCount) const grid = Array.from({ length: safRows }, (_, row) => Array.from({ length: safCols }, (_, col) => ({ id: makeCellId(table?.table_id ?? 0, row, col), value: '', rowSpan: 1, colSpan: 1, hidden: false, })) ) for (const cell of cells) { const row = toNumber(cell.row, 0) const col = toNumber(cell.col, 0) const rowSpan = Math.max(1, toNumber(cell.row_span, 1)) const colSpan = Math.max(1, toNumber(cell.col_span, 1)) // Skip cells that are out-of-bounds (shouldn't happen but guards against bad data). if (row >= safRows || col >= safCols || !grid[row]?.[col]) { continue } grid[row][col] = { id: makeCellId(table.table_id, row, col), value: String(cell.text || ''), rowSpan, colSpan, hidden: false, fontFamily: String(cell.font_family || ''), fontWeight: String(cell.font_weight || ''), backgroundColor: String(cell.background_class || ''), } // Mark spanned-over slots as hidden for (let r = row; r < Math.min(row + rowSpan, safRows); r += 1) { for (let c = col; c < Math.min(col + colSpan, safCols); c += 1) { if ((r !== row || c !== col) && grid[r]?.[c]) { grid[r][c].hidden = true grid[r][c].value = '' grid[r][c].rowSpan = 1 grid[r][c].colSpan = 1 } } } } return grid } function normalizeCellsFromGrid(grid, table) { if (!Array.isArray(grid) || !grid.length || !Array.isArray(grid[0]) || !grid[0].length) { return [] } const previousByAnchor = new Map( (table?.cells || []).map((cell) => [`${cell.row},${cell.col}`, cell]), ) const rowCount = grid.length const colCount = grid[0].length const [tx1, ty1, tx2, ty2] = Array.isArray(table?.bbox) && table.bbox.length === 4 ? table.bbox.map((value) => toNumber(value, 0)) : [0, 0, colCount, rowCount] const cellWidth = (tx2 - tx1) / Math.max(colCount, 1) const cellHeight = (ty2 - ty1) / Math.max(rowCount, 1) const nextCells = [] for (let row = 0; row < rowCount; row += 1) { for (let col = 0; col < colCount; col += 1) { const current = grid[row][col] if (!current || current.hidden) { continue } const rowSpan = Math.max(1, toNumber(current.rowSpan, 1)) const colSpan = Math.max(1, toNumber(current.colSpan, 1)) const previous = previousByAnchor.get(`${row},${col}`) const fallbackBbox = [ Number((tx1 + (col * cellWidth)).toFixed(1)), Number((ty1 + (row * cellHeight)).toFixed(1)), Number((tx1 + ((col + colSpan) * cellWidth)).toFixed(1)), Number((ty1 + ((row + rowSpan) * cellHeight)).toFixed(1)), ] nextCells.push({ row, col, row_span: rowSpan, col_span: colSpan, text: String(current.value || ''), bbox: Array.isArray(previous?.bbox) && previous.bbox.length === 4 ? previous.bbox : fallbackBbox, ocr_score: previous?.ocr_score ?? null, font_family: String(current.fontFamily || previous?.font_family || ''), font_weight: String(current.fontWeight || previous?.font_weight || ''), background_class: String(current.backgroundColor || previous?.background_class || ''), }) } } return nextCells } function hashGrid(grid) { return JSON.stringify( (grid || []).map((row) => row.map((cell) => ({ v: cell.value, rs: cell.rowSpan, cs: cell.colSpan, h: cell.hidden, bg: cell.backgroundColor || '', fw: cell.fontWeight || '', ff: cell.fontFamily || '', }))), ) } export default function StudioGridPane({ annotation, onReplaceTable, onEditingChange, hoveredCell, onHoverCellChange, }) { const { state, dispatch } = useStudio() const containerRef = useRef(null) const saveTimerRef = useRef(null) const activeHashRef = useRef('') const latestGridRef = useRef([]) const contextTableRef = useRef(null) const isSavingRef = useRef(false) const [isSaving, setIsSaving] = useState(false) const activePageTables = useMemo( () => getTablesForPage(annotation, state.activePage), [annotation, state.activePage], ) const contextTable = activePageTables.find((table) => table.table_id === state.activeTable) || activePageTables[0] || null const hoveredCellInContext = hoveredCell && contextTable && hoveredCell.tableId === contextTable.table_id ? { r: hoveredCell.row, c: hoveredCell.col } : null const editorSeed = useMemo( () => (contextTable ? buildGridFromTable(contextTable) : []), [contextTable], ) useEffect(() => { if (contextTable && state.activeTable !== contextTable.table_id) { dispatch({ type: 'setActiveTable', tableId: contextTable.table_id }) } }, [contextTable, dispatch, state.activeTable]) // Keep a ref to contextTable so persistGrid (debounced) always uses the latest value. useEffect(() => { contextTableRef.current = contextTable }, [contextTable]) useEffect(() => { const nextHash = hashGrid(editorSeed) activeHashRef.current = nextHash latestGridRef.current = editorSeed }, [editorSeed]) useEffect(() => { const node = containerRef.current if (!node) { return undefined } const handleFocusIn = () => onEditingChange?.(true) const handleFocusOut = () => { window.requestAnimationFrame(() => { const active = document.activeElement onEditingChange?.(Boolean(active && node.contains(active))) }) } node.addEventListener('focusin', handleFocusIn) node.addEventListener('focusout', handleFocusOut) return () => { node.removeEventListener('focusin', handleFocusIn) node.removeEventListener('focusout', handleFocusOut) onEditingChange?.(false) } }, [onEditingChange]) useEffect(() => () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current) } }, []) const persistGrid = useCallback(async () => { const table = contextTableRef.current if (!table || !onReplaceTable || isSavingRef.current) { return } const latest = latestGridRef.current const nextHash = hashGrid(latest) if (nextHash === activeHashRef.current) { return } isSavingRef.current = true setIsSaving(true) try { const nextCells = normalizeCellsFromGrid(latest, table) const ok = await onReplaceTable({ tableId: table.table_id, cells: nextCells, }) if (ok) { activeHashRef.current = nextHash } } finally { isSavingRef.current = false setIsSaving(false) } }, [onReplaceTable]) const handleGridChange = useCallback((nextGrid) => { latestGridRef.current = nextGrid if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current) } saveTimerRef.current = window.setTimeout(() => { void persistGrid() }, 350) }, [persistGrid]) if (!activePageTables.length) { return (
Viewing: Page {state.activePage + 1} No table on this page
No tables were detected on page {state.activePage + 1}.
) } return (
Viewing: Page {state.activePage + 1}
{isSaving && Saving…}
{editorSeed.length ? ( { if (!onHoverCellChange || !contextTable) { return } onHoverCellChange(cell ? { tableId: contextTable.table_id, row: cell.r, col: cell.c, pageIndex: contextTable.page ?? state.activePage, } : null) }} /> ) : (
Selected table has no cells.
)}
) }