Spaces:
Sleeping
Sleeping
| 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 ( | |
| <section className="studio-panel studio-grid-pane"> | |
| <div className="studio-grid-context"> | |
| <span>Viewing: Page {state.activePage + 1}</span> | |
| <span>No table on this page</span> | |
| </div> | |
| <div className="studio-grid-scroll"> | |
| <div className="studio-empty-state"> | |
| No tables were detected on page {state.activePage + 1}. | |
| </div> | |
| </div> | |
| </section> | |
| ) | |
| } | |
| return ( | |
| <section className="studio-panel studio-grid-pane" ref={containerRef}> | |
| <div className="studio-grid-context"> | |
| <span>Viewing: Page {state.activePage + 1}</span> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> | |
| <label htmlFor="studio-table-select">Table</label> | |
| <select | |
| id="studio-table-select" | |
| value={contextTable?.table_id ?? ''} | |
| onChange={(event) => { | |
| dispatch({ type: 'setActiveTable', tableId: Number(event.target.value) }) | |
| }} | |
| > | |
| {activePageTables.map((table) => ( | |
| <option key={table.table_id} value={table.table_id}> | |
| Table {table.table_id + 1} | |
| </option> | |
| ))} | |
| </select> | |
| {isSaving && <span>Saving…</span>} | |
| </div> | |
| </div> | |
| <div className="studio-grid-scroll"> | |
| {editorSeed.length ? ( | |
| <TableEditor | |
| initialGrid={editorSeed} | |
| onGridChange={handleGridChange} | |
| persistKey={`${state.activePage}:${contextTable?.table_id ?? 'none'}`} | |
| hoveredCell={hoveredCellInContext} | |
| onHoverCellChange={(cell) => { | |
| if (!onHoverCellChange || !contextTable) { | |
| return | |
| } | |
| onHoverCellChange(cell ? { | |
| tableId: contextTable.table_id, | |
| row: cell.r, | |
| col: cell.c, | |
| pageIndex: contextTable.page ?? state.activePage, | |
| } : null) | |
| }} | |
| /> | |
| ) : ( | |
| <div className="studio-empty-state">Selected table has no cells.</div> | |
| )} | |
| </div> | |
| </section> | |
| ) | |
| } | |