Spaces:
Sleeping
Sleeping
| 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 ( | |
| <div className="studio-shell"> | |
| <header className="studio-header"> | |
| <div className="studio-header-brand"> | |
| <button type="button" className="studio-brand-button" onClick={onBack}> | |
| Agent P-DF | |
| </button> | |
| <div className="studio-header-file">{job.filename}</div> | |
| </div> | |
| <div className="studio-header-pill"> | |
| <span>Pages: {Math.min(state.activePage + 1, pageCount)}/{pageCount}</span> | |
| <span>Speed: {secondsPerPage.toFixed(1)} sec/page</span> | |
| <span>Errors: {state.errorCount}</span> | |
| </div> | |
| <div className="studio-header-actions"> | |
| <button type="button" className="btn" onClick={onBack}> | |
| Back / New Upload | |
| </button> | |
| <button type="button" className="btn btn-primary btn-export" onClick={() => handleExport('csv')}> | |
| Export CSV | |
| </button> | |
| <button type="button" className="btn btn-primary btn-export" onClick={() => handleExport('xlsx')}> | |
| Export Excel | |
| </button> | |
| <button type="button" className="btn btn-primary btn-export" onClick={() => handleExport('html')}> | |
| Export HTML | |
| </button> | |
| </div> | |
| </header> | |
| <div className="studio-workspace" ref={workspaceRef}> | |
| <div className="studio-pane-wrap" style={{ flexBasis: `${splitRatio * 100}%` }}> | |
| <StudioDocumentPane | |
| annotation={annotation} | |
| jobId={job.id} | |
| onCopyText={handleCopyText} | |
| hoveredCell={hoveredCell} | |
| onHoverCellChange={setHoveredCell} | |
| onReprocessTable={handleReprocessTable} | |
| onDeleteTable={handleDeleteTable} | |
| onAddCell={handleAddCell} | |
| onDeleteCell={handleDeleteCell} | |
| onSelectionEnd={(selectionPayload) => { | |
| if (state.activeTool === 'marquee') handleMarqueeOCR(selectionPayload) | |
| else handleTableSelection(selectionPayload) | |
| }} | |
| /> | |
| </div> | |
| <div | |
| className="studio-splitter" | |
| role="separator" | |
| aria-orientation="vertical" | |
| aria-valuemin={Math.round(SPLIT_MIN * 100)} | |
| aria-valuemax={Math.round(SPLIT_MAX * 100)} | |
| aria-valuenow={Math.round(splitRatio * 100)} | |
| onPointerDown={handleStartResize} | |
| onDoubleClick={() => setSplitRatio(0.5)} | |
| /> | |
| <div className="studio-pane-wrap studio-pane-wrap-right" style={{ flexBasis: `${(1 - splitRatio) * 100}%` }}> | |
| <StudioGridPane | |
| annotation={annotation} | |
| onEditingChange={setIsEditingGridCell} | |
| onReplaceTable={handleReplaceTable} | |
| hoveredCell={hoveredCell} | |
| onHoverCellChange={setHoveredCell} | |
| /> | |
| </div> | |
| </div> | |
| {state.toast && ( | |
| <div | |
| className="studio-toast" | |
| style={{ | |
| left: state.toast.x, | |
| top: state.toast.y, | |
| }} | |
| > | |
| {state.toast.message} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| export default function ResultsView({ job, onBack, onJobUpdate }) { | |
| return ( | |
| <StudioProvider key={job.id} annotation={job.annotation}> | |
| <StudioWorkspace job={job} onBack={onBack} onJobUpdate={onJobUpdate} /> | |
| </StudioProvider> | |
| ) | |
| } | |