import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { StudioProvider, useStudio } from './studio/StudioContext' import StudioDocumentPane from './studio/StudioDocumentPane' import StudioGridPane from './studio/StudioGridPane' import { clamp, findCell, getPages, getTablesForPage, updateAnnotationCell } from './studio/studioUtils' import API from '../api' const SPLIT_MIN = 0.2 const SPLIT_MAX = 0.8 async function parseJsonResponse(response) { let data = null try { data = await response.json() } catch { data = null } if (!response.ok) { const detail = data?.detail || `Request failed with status ${response.status}` throw new Error(detail) } return data || {} } function getToastPosition(clientX, clientY) { if (Number.isFinite(clientX) && Number.isFinite(clientY)) { return { x: clientX, y: clientY } } return { x: 160, y: 90 } } function cloneAnnotation(annotation) { return JSON.parse(JSON.stringify(annotation || {})) } function findTable(annotation, tableId) { return (annotation?.tables || []).find((table) => table.table_id === tableId) || null } function buildOccupancy(table) { const occupied = new Set() for (const cell of table?.cells || []) { const row = Math.max(0, Number(cell.row) || 0) const col = Math.max(0, Number(cell.col) || 0) const rowSpan = Math.max(1, Number(cell.row_span) || 1) const colSpan = Math.max(1, Number(cell.col_span) || 1) for (let r = row; r < row + rowSpan; r += 1) { for (let c = col; c < col + colSpan; c += 1) { occupied.add(`${r}:${c}`) } } } return occupied } function findNextEmptySlot(table, preferredRow = 0) { const occupied = buildOccupancy(table) const cells = table?.cells || [] const rowBound = Math.max(1, ...cells.map((cell) => (Number(cell.row) || 0) + Math.max(1, Number(cell.row_span) || 1))) const colBound = Math.max(1, ...cells.map((cell) => (Number(cell.col) || 0) + Math.max(1, Number(cell.col_span) || 1))) for (let row = Math.max(0, preferredRow); row < rowBound; row += 1) { for (let col = 0; col < colBound + 2; col += 1) { if (!occupied.has(`${row}:${col}`)) { return { row, col } } } } return { row: rowBound, col: 0 } } async function copyTextToClipboard(text) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text) return } const textarea = document.createElement('textarea') textarea.value = text textarea.setAttribute('readonly', 'true') textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.select() document.execCommand('copy') document.body.removeChild(textarea) } function StudioWorkspace({ job, onBack, onJobUpdate }) { const { state, dispatch } = useStudio() const [annotation, setAnnotation] = useState(() => cloneAnnotation(job.annotation)) const [isEditingGridCell, setIsEditingGridCell] = useState(false) const [hoveredCell, setHoveredCell] = useState(null) const [splitRatio, setSplitRatio] = useState(0.5) const annotationRef = useRef(annotation) const workspaceRef = useRef(null) const pages = useMemo(() => getPages(annotation), [annotation]) const pageCount = pages.length const activePageTables = useMemo( () => getTablesForPage(annotation, state.activePage), [annotation, state.activePage], ) const showToast = useCallback((message, clientX, clientY) => { const position = getToastPosition(clientX, clientY) dispatch({ type: 'showToast', toast: { message, x: position.x, y: position.y, }, }) }, [dispatch]) useEffect(() => { annotationRef.current = annotation }, [annotation]) useEffect(() => { const tableOnPage = activePageTables.find((table) => table.table_id === state.activeTable) if (!tableOnPage && activePageTables.length) { dispatch({ type: 'setActiveTable', tableId: activePageTables[0].table_id }) } if (!activePageTables.length && state.activeTable !== null) { dispatch({ type: 'setActiveTable', tableId: null }) } }, [activePageTables, dispatch, state.activeTable]) useEffect(() => { if (!state.activeCell) { return } const current = findCell(annotation, state.activeCell) if (!current || (current.table.page || 0) !== state.activePage) { dispatch({ type: 'setActiveCell', cell: null }) } }, [annotation, dispatch, state.activeCell, state.activePage]) useEffect(() => { if (!state.toast) { return undefined } const timeout = window.setTimeout(() => { dispatch({ type: 'clearToast' }) }, 1500) return () => window.clearTimeout(timeout) }, [dispatch, state.toast]) const handleAnnotationUpdate = useCallback((nextAnnotation) => { annotationRef.current = nextAnnotation setAnnotation(nextAnnotation) onJobUpdate?.(job.id, { annotation: nextAnnotation }) }, [job.id, onJobUpdate]) const handleCopyText = useCallback(async (text, clientX, clientY) => { try { await copyTextToClipboard(text) showToast('Copied!', clientX, clientY) } catch (error) { console.error('Copy failed', error) dispatch({ type: 'incrementErrors' }) showToast('Copy failed', clientX, clientY) } }, [dispatch, showToast]) const commitCellEdit = useCallback(async (target, text, { historyMode }) => { const match = findCell(annotationRef.current, target) if (!match) { dispatch({ type: 'incrementErrors' }) return false } const previousText = String(match.cell.text || '') const nextText = String(text || '') if (previousText === nextText) { return true } try { const response = await fetch(`${API}/api/results/${job.id}/cells`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ table_id: target.tableId, row: target.row, col: target.col, text: nextText, }), }) if (!response.ok) { throw new Error(`Save failed with status ${response.status}`) } const nextAnnotation = updateAnnotationCell(annotationRef.current, target, nextText) handleAnnotationUpdate(nextAnnotation) dispatch({ type: 'setActiveTable', tableId: target.tableId }) dispatch({ type: 'setActiveCell', cell: target }) if (historyMode === 'record') { dispatch({ type: 'recordEdit', edit: { ...target, previousText, nextText, }, }) } return true } catch (error) { console.error('Cell edit failed', error) dispatch({ type: 'incrementErrors' }) return false } }, [dispatch, handleAnnotationUpdate, job.id]) const handleUndo = useCallback(async () => { const edit = state.undoStack[state.undoStack.length - 1] if (!edit) { return } const didSave = await commitCellEdit( { tableId: edit.tableId, row: edit.row, col: edit.col }, edit.previousText, { historyMode: 'undo' }, ) if (didSave) { dispatch({ type: 'completeUndo' }) } }, [commitCellEdit, dispatch, state.undoStack]) const handleRedo = useCallback(async () => { const edit = state.redoStack[state.redoStack.length - 1] if (!edit) { return } const didSave = await commitCellEdit( { tableId: edit.tableId, row: edit.row, col: edit.col }, edit.nextText, { historyMode: 'redo' }, ) if (didSave) { dispatch({ type: 'completeRedo' }) } }, [commitCellEdit, dispatch, state.redoStack]) useEffect(() => { const handleKeyDown = (event) => { const modifier = event.metaKey || event.ctrlKey const tag = document.activeElement?.tagName const isEditable = document.activeElement?.isContentEditable || tag === 'TEXTAREA' || tag === 'INPUT' if (!modifier || isEditable || isEditingGridCell) { return } const key = event.key.toLowerCase() if (key !== 'z') { return } event.preventDefault() if (event.shiftKey) { void handleRedo() } else { void handleUndo() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [handleRedo, handleUndo, isEditingGridCell]) const handleMarqueeOCR = useCallback(async ({ bbox, clientX, clientY }) => { try { const response = await fetch(`${API}/api/process/${job.id}/ocr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_index: state.activePage, bbox: bbox, }), }) const data = await parseJsonResponse(response) if (!data.ok) { throw new Error('OCR request failed') } const text = String(data.text || '').trim() if (!text) { showToast('No text detected in selection', clientX, clientY) return } // Auto-copy to clipboard — no blocking confirm dialog. await copyTextToClipboard(text) showToast(`OCR copied: "${text.slice(0, 40)}${text.length > 40 ? '…' : ''}"`, clientX, clientY) } catch (error) { console.error('Marquee OCR failed', error) dispatch({ type: 'incrementErrors' }) showToast('Marquee OCR failed', clientX, clientY) } finally { dispatch({ type: 'setSelection', selection: null }) dispatch({ type: 'setTool', tool: 'pointer' }) } }, [ job.id, state.activePage, dispatch, showToast, ]) const handleTableSelection = useCallback(async ({ bbox, clientX, clientY }) => { try { const response = await fetch(`${API}/api/process/${job.id}/tsr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_index: state.activePage, bbox: bbox, }), }) const data = await parseJsonResponse(response) if (!data.ok) { throw new Error('TSR request failed') } if (data.annotation) { handleAnnotationUpdate(data.annotation) } const table = data.table if (table) { dispatch({ type: 'setActiveTable', tableId: table.table_id }) dispatch({ type: 'setActivePage', page: table.page || state.activePage }) } showToast('Table structure added', clientX, clientY) } catch (error) { console.error('TSR failed', error) dispatch({ type: 'incrementErrors' }) showToast('Table structure extraction failed', clientX, clientY) } finally { dispatch({ type: 'setSelection', selection: null }) dispatch({ type: 'setTool', tool: 'pointer' }) } }, [ job.id, state.activePage, dispatch, handleAnnotationUpdate, showToast, ]) const requestDeleteTable = useCallback(async (tableId) => { const response = await fetch(`${API}/api/results/${job.id}/tables/${tableId}/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) return parseJsonResponse(response) }, [job.id]) const handleReprocessTable = useCallback(async ({ tableId, pageIndex, bbox, clientX, clientY }) => { try { const response = await fetch(`${API}/api/process/${job.id}/tsr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_index: pageIndex, bbox, }), }) const data = await parseJsonResponse(response) if (!data.ok || !data.annotation || !data.table) { throw new Error('TSR request failed') } let nextAnnotation = data.annotation const nextTableId = Number(data.table.table_id) // Keep a single table for that user intent: replace selected table with reprocessed result. if (Number.isFinite(tableId) && tableId !== nextTableId) { try { const deleteData = await requestDeleteTable(tableId) if (deleteData.ok && deleteData.annotation) { nextAnnotation = deleteData.annotation } } catch (deleteError) { console.error('Failed to delete previous table after TSR rerun', deleteError) dispatch({ type: 'incrementErrors' }) showToast('TSR finished, but old table could not be removed', clientX, clientY) } } handleAnnotationUpdate(nextAnnotation) dispatch({ type: 'setActivePage', page: pageIndex }) dispatch({ type: 'setActiveTable', tableId: nextTableId }) showToast('Table reprocessed with TSR + OCR', clientX, clientY) return true } catch (error) { console.error('Table reprocess failed', error) dispatch({ type: 'incrementErrors' }) showToast(error.message || 'Table reprocess failed', clientX, clientY) return false } }, [dispatch, handleAnnotationUpdate, job.id, requestDeleteTable, showToast]) const handleDeleteTable = useCallback(async ({ tableId, clientX, clientY }) => { try { const data = await requestDeleteTable(tableId) if (!data.ok || !data.annotation) { throw new Error('Delete table failed') } handleAnnotationUpdate(data.annotation) setHoveredCell((current) => (current?.tableId === tableId ? null : current)) dispatch({ type: 'setActiveCell', cell: null }) showToast('Table deleted', clientX, clientY) return true } catch (error) { console.error('Delete table failed', error) dispatch({ type: 'incrementErrors' }) showToast(error.message || 'Delete table failed', clientX, clientY) return false } }, [dispatch, handleAnnotationUpdate, requestDeleteTable, showToast]) const handleReplaceTable = useCallback(async ({ tableId, cells }) => { try { const response = await fetch(`${API}/api/results/${job.id}/tables/${tableId}/cells`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ table_id: tableId, cells, }), }) const data = await parseJsonResponse(response) if (!data.ok || !data.annotation) { throw new Error('Table update failed') } handleAnnotationUpdate(data.annotation) dispatch({ type: 'setActiveTable', tableId }) return true } catch (error) { console.error('Table replacement failed', error) dispatch({ type: 'incrementErrors' }) showToast(error.message || 'Table update failed') return false } }, [dispatch, handleAnnotationUpdate, job.id, showToast]) const handleAddCell = useCallback(async ({ tableId }) => { const table = findTable(annotationRef.current, tableId) if (!table) { showToast('Select a table first') return false } const preferredRow = state.activeCell?.tableId === tableId ? state.activeCell.row : 0 const slot = findNextEmptySlot(table, preferredRow) try { const response = await fetch(`${API}/api/results/${job.id}/cells/add`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ table_id: tableId, row: slot.row, col: slot.col, row_span: 1, col_span: 1, text: '', }), }) const data = await parseJsonResponse(response) if (!data.ok || !data.annotation) { throw new Error('Add cell failed') } handleAnnotationUpdate(data.annotation) dispatch({ type: 'setActiveTable', tableId }) dispatch({ type: 'setActiveCell', cell: { tableId, row: slot.row, col: slot.col } }) showToast(`Cell added at r${slot.row + 1} c${slot.col + 1}`) return true } catch (error) { console.error('Add cell failed', error) dispatch({ type: 'incrementErrors' }) showToast(error.message || 'Add cell failed') return false } }, [dispatch, handleAnnotationUpdate, job.id, showToast, state.activeCell]) const handleDeleteCell = useCallback(async ({ tableId, row, col }) => { try { const response = await fetch(`${API}/api/results/${job.id}/cells/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ table_id: tableId, row, col, }), }) const data = await parseJsonResponse(response) if (!data.ok || !data.annotation) { throw new Error('Delete cell failed') } handleAnnotationUpdate(data.annotation) if (state.activeCell && state.activeCell.tableId === tableId && state.activeCell.row === row && state.activeCell.col === col) { dispatch({ type: 'setActiveCell', cell: null }) } showToast(`Cell deleted from r${row + 1} c${col + 1}`) return true } catch (error) { console.error('Delete cell failed', error) dispatch({ type: 'incrementErrors' }) showToast(error.message || 'Delete cell failed') return false } }, [dispatch, handleAnnotationUpdate, job.id, showToast, state.activeCell]) const handleStartResize = useCallback((event) => { event.preventDefault() const workspace = workspaceRef.current if (!workspace) { return } const updateSplit = (clientX) => { const bounds = workspace.getBoundingClientRect() if (!bounds.width) { return } const ratio = (clientX - bounds.left) / bounds.width setSplitRatio(clamp(ratio, SPLIT_MIN, SPLIT_MAX)) } updateSplit(event.clientX) const handleMove = (moveEvent) => { updateSplit(moveEvent.clientX) } const handleUp = () => { window.removeEventListener('pointermove', handleMove) window.removeEventListener('pointerup', handleUp) window.removeEventListener('pointercancel', handleUp) } window.addEventListener('pointermove', handleMove) window.addEventListener('pointerup', handleUp) window.addEventListener('pointercancel', handleUp) }, []) const handleExport = useCallback((format) => { window.open(`${API}/api/export/${job.id}?format=${format}`, '_blank', 'noopener,noreferrer') }, [job.id]) const secondsPerPage = pageCount ? Number(job.duration || 0) / pageCount : 0 return (
{job.filename}
Pages: {Math.min(state.activePage + 1, pageCount)}/{pageCount} Speed: {secondsPerPage.toFixed(1)} sec/page Errors: {state.errorCount}
{ if (state.activeTool === 'marquee') handleMarqueeOCR(selectionPayload) else handleTableSelection(selectionPayload) }} />
setSplitRatio(0.5)} />
{state.toast && (
{state.toast.message}
)}
) } export default function ResultsView({ job, onBack, onJobUpdate }) { return ( ) }