Agent_PDF / web /src /components /ResultsView.jsx
MohamedSameh77i's picture
Add selection padding slider + cell ops + non-blocking upload prep
295679f verified
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>
)
}