Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 43s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m55s
CI / test-python-agent-core (push) Successful in 35s
CI / test-nodejs-website (push) Has been cancelled
loadGrid depended on buildGrid (for 404 fallback), which depended on ipaMode/syllableMode. Every mode change created a new loadGrid ref, triggering StepGridReview's useEffect to load the OLD saved grid, overwriting the freshly rebuilt one. Now loadGrid only depends on sessionId. The 404 fallback builds inline with current modes. Mode changes are handled exclusively by the separate rebuild useEffect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
986 lines
32 KiB
TypeScript
986 lines
32 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { StructuredGrid, GridZone, LayoutDividers } from './types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
const MAX_UNDO = 50
|
|
|
|
export interface GridEditorState {
|
|
grid: StructuredGrid | null
|
|
loading: boolean
|
|
saving: boolean
|
|
error: string | null
|
|
dirty: boolean
|
|
selectedCell: string | null
|
|
selectedZone: number | null
|
|
}
|
|
|
|
export type IpaMode = 'auto' | 'all' | 'de' | 'en' | 'none'
|
|
export type SyllableMode = 'auto' | 'all' | 'de' | 'en' | 'none'
|
|
|
|
export function useGridEditor(sessionId: string | null) {
|
|
const [grid, setGrid] = useState<StructuredGrid | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [dirty, setDirty] = useState(false)
|
|
const [selectedCell, setSelectedCell] = useState<string | null>(null)
|
|
const [selectedZone, setSelectedZone] = useState<number | null>(null)
|
|
const [ipaMode, setIpaMode] = useState<IpaMode>('auto')
|
|
const [syllableMode, setSyllableMode] = useState<SyllableMode>('auto')
|
|
|
|
// Undo/redo stacks store serialized zone arrays
|
|
const undoStack = useRef<string[]>([])
|
|
const redoStack = useRef<string[]>([])
|
|
|
|
const pushUndo = useCallback((zones: GridZone[]) => {
|
|
undoStack.current.push(JSON.stringify(zones))
|
|
if (undoStack.current.length > MAX_UNDO) {
|
|
undoStack.current.shift()
|
|
}
|
|
redoStack.current = []
|
|
}, [])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Load / Build
|
|
// ------------------------------------------------------------------
|
|
|
|
const buildGrid = useCallback(async () => {
|
|
if (!sessionId) return
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
params.set('ipa_mode', ipaMode)
|
|
params.set('syllable_mode', syllableMode)
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
|
}
|
|
const data: StructuredGrid = await res.json()
|
|
setGrid(data)
|
|
setDirty(false)
|
|
undoStack.current = []
|
|
redoStack.current = []
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [sessionId, ipaMode, syllableMode])
|
|
|
|
const loadGrid = useCallback(async () => {
|
|
if (!sessionId) return
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/grid-editor`,
|
|
)
|
|
if (res.status === 404) {
|
|
// No grid yet — build it with current modes
|
|
const params = new URLSearchParams()
|
|
params.set('ipa_mode', ipaMode)
|
|
params.set('syllable_mode', syllableMode)
|
|
const buildRes = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (buildRes.ok) {
|
|
const data: StructuredGrid = await buildRes.json()
|
|
setGrid(data)
|
|
setDirty(false)
|
|
}
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
|
}
|
|
const data: StructuredGrid = await res.json()
|
|
setGrid(data)
|
|
setDirty(false)
|
|
undoStack.current = []
|
|
redoStack.current = []
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
// Only depends on sessionId — mode changes are handled by the
|
|
// separate useEffect below, not by re-triggering loadGrid.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId])
|
|
|
|
// Auto-rebuild when IPA or syllable mode changes (skip initial mount).
|
|
// We call the API directly with the new values instead of going through
|
|
// the buildGrid callback, which may still close over stale state due to
|
|
// React's asynchronous state batching.
|
|
const mountedRef = useRef(false)
|
|
useEffect(() => {
|
|
if (!mountedRef.current) {
|
|
// Skip the first trigger (component mount) — don't rebuild yet
|
|
mountedRef.current = true
|
|
return
|
|
}
|
|
if (!sessionId) return
|
|
const rebuild = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
params.set('ipa_mode', ipaMode)
|
|
params.set('syllable_mode', syllableMode)
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid?${params}`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
|
}
|
|
const data: StructuredGrid = await res.json()
|
|
setGrid(data)
|
|
setDirty(false)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
rebuild()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ipaMode, syllableMode])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Save
|
|
// ------------------------------------------------------------------
|
|
|
|
const saveGrid = useCallback(async () => {
|
|
if (!sessionId || !grid) return
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/save-grid`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(grid),
|
|
},
|
|
)
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
|
}
|
|
setDirty(false)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [sessionId, grid])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Cell editing
|
|
// ------------------------------------------------------------------
|
|
|
|
const updateCellText = useCallback(
|
|
(cellId: string, newText: string) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
// Check if cell exists
|
|
const existing = zone.cells.find((c) => c.cell_id === cellId)
|
|
if (existing) {
|
|
return {
|
|
...zone,
|
|
cells: zone.cells.map((cell) =>
|
|
cell.cell_id === cellId ? { ...cell, text: newText } : cell,
|
|
),
|
|
}
|
|
}
|
|
// Cell doesn't exist — create it if the cellId belongs to this zone
|
|
// cellId format: Z{zone}_R{row}_C{col}
|
|
const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/)
|
|
if (!match || parseInt(match[1]) !== zone.zone_index) return zone
|
|
const rowIndex = parseInt(match[2])
|
|
const colIndex = parseInt(match[3])
|
|
const col = zone.columns.find((c) => c.index === colIndex)
|
|
const newCell = {
|
|
cell_id: cellId,
|
|
zone_index: zone.zone_index,
|
|
row_index: rowIndex,
|
|
col_index: colIndex,
|
|
col_type: col?.label ?? '',
|
|
text: newText,
|
|
confidence: 0,
|
|
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
|
|
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
|
|
word_boxes: [],
|
|
ocr_engine: 'manual',
|
|
is_bold: false,
|
|
}
|
|
return { ...zone, cells: [...zone.cells, newCell] }
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Column formatting
|
|
// ------------------------------------------------------------------
|
|
|
|
const toggleColumnBold = useCallback(
|
|
(zoneIndex: number, colIndex: number) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
if (zone.zone_index !== zoneIndex) return zone
|
|
const col = zone.columns.find((c) => c.index === colIndex)
|
|
const newBold = col ? !col.bold : true
|
|
return {
|
|
...zone,
|
|
columns: zone.columns.map((c) =>
|
|
c.index === colIndex ? { ...c, bold: newBold } : c,
|
|
),
|
|
cells: zone.cells.map((cell) =>
|
|
cell.col_index === colIndex
|
|
? { ...cell, is_bold: newBold }
|
|
: cell,
|
|
),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Row formatting
|
|
// ------------------------------------------------------------------
|
|
|
|
const toggleRowHeader = useCallback(
|
|
(zoneIndex: number, rowIndex: number) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
|
|
// Cycle: normal → header → footer → normal
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
if (zone.zone_index !== zoneIndex) return zone
|
|
return {
|
|
...zone,
|
|
rows: zone.rows.map((r) => {
|
|
if (r.index !== rowIndex) return r
|
|
if (!r.is_header && !r.is_footer) {
|
|
return { ...r, is_header: true, is_footer: false }
|
|
} else if (r.is_header) {
|
|
return { ...r, is_header: false, is_footer: true }
|
|
} else {
|
|
return { ...r, is_header: false, is_footer: false }
|
|
}
|
|
}),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Column management
|
|
// ------------------------------------------------------------------
|
|
|
|
const deleteColumn = useCallback(
|
|
(zoneIndex: number, colIndex: number) => {
|
|
if (!grid) return
|
|
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
|
|
if (!zone || zone.columns.length <= 1) return // keep at least 1 column
|
|
pushUndo(grid.zones)
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((z) => {
|
|
if (z.zone_index !== zoneIndex) return z
|
|
const deletedCol = z.columns.find((c) => c.index === colIndex)
|
|
const newColumns = z.columns
|
|
.filter((c) => c.index !== colIndex)
|
|
.map((c, i) => {
|
|
const result = { ...c, index: i, label: `column_${i + 1}` }
|
|
// Merge x-boundary: previous column absorbs deleted column's space
|
|
if (deletedCol) {
|
|
if (c.index === colIndex - 1) {
|
|
result.x_max_pct = deletedCol.x_max_pct
|
|
result.x_max_px = deletedCol.x_max_px
|
|
} else if (colIndex === 0 && c.index === 1) {
|
|
result.x_min_pct = deletedCol.x_min_pct
|
|
result.x_min_px = deletedCol.x_min_px
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
const newCells = z.cells
|
|
.filter((c) => c.col_index !== colIndex)
|
|
.map((c) => {
|
|
const newCI = c.col_index > colIndex ? c.col_index - 1 : c.col_index
|
|
return {
|
|
...c,
|
|
col_index: newCI,
|
|
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
|
|
}
|
|
})
|
|
return { ...z, columns: newColumns, cells: newCells }
|
|
}),
|
|
summary: {
|
|
...prev.summary,
|
|
total_columns: prev.summary.total_columns - 1,
|
|
total_cells: prev.zones.reduce(
|
|
(sum, z) =>
|
|
sum +
|
|
(z.zone_index === zoneIndex
|
|
? z.cells.filter((c) => c.col_index !== colIndex).length
|
|
: z.cells.length),
|
|
0,
|
|
),
|
|
},
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
const addColumn = useCallback(
|
|
(zoneIndex: number, afterColIndex: number) => {
|
|
if (!grid) return
|
|
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
|
|
if (!zone) return
|
|
pushUndo(grid.zones)
|
|
|
|
const newColIndex = afterColIndex + 1
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((z) => {
|
|
if (z.zone_index !== zoneIndex) return z
|
|
// Shift existing columns
|
|
const shiftedCols = z.columns.map((c) =>
|
|
c.index > afterColIndex ? { ...c, index: c.index + 1, label: `column_${c.index + 2}` } : c,
|
|
)
|
|
// Insert new column
|
|
const refCol = z.columns.find((c) => c.index === afterColIndex) || z.columns[z.columns.length - 1]
|
|
const newCol = {
|
|
index: newColIndex,
|
|
label: `column_${newColIndex + 1}`,
|
|
x_min_px: refCol.x_max_px,
|
|
x_max_px: refCol.x_max_px + (refCol.x_max_px - refCol.x_min_px),
|
|
x_min_pct: refCol.x_max_pct,
|
|
x_max_pct: Math.min(100, refCol.x_max_pct + (refCol.x_max_pct - refCol.x_min_pct)),
|
|
bold: false,
|
|
}
|
|
const allCols = [...shiftedCols, newCol].sort((a, b) => a.index - b.index)
|
|
|
|
// Shift existing cells and create empty cells for new column
|
|
const shiftedCells = z.cells.map((c) => {
|
|
if (c.col_index > afterColIndex) {
|
|
const newCI = c.col_index + 1
|
|
return {
|
|
...c,
|
|
col_index: newCI,
|
|
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
// Create empty cells for each row
|
|
const newCells = z.rows.map((row) => ({
|
|
cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`,
|
|
zone_index: zoneIndex,
|
|
row_index: row.index,
|
|
col_index: newColIndex,
|
|
col_type: `column_${newColIndex + 1}`,
|
|
text: '',
|
|
confidence: 0,
|
|
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
|
|
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
|
|
word_boxes: [],
|
|
ocr_engine: 'manual',
|
|
is_bold: false,
|
|
}))
|
|
|
|
return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] }
|
|
}),
|
|
summary: {
|
|
...prev.summary,
|
|
total_columns: prev.summary.total_columns + 1,
|
|
total_cells: prev.summary.total_cells + (zone?.rows.length ?? 0),
|
|
},
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Row management
|
|
// ------------------------------------------------------------------
|
|
|
|
const deleteRow = useCallback(
|
|
(zoneIndex: number, rowIndex: number) => {
|
|
if (!grid) return
|
|
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
|
|
if (!zone || zone.rows.length <= 1) return // keep at least 1 row
|
|
pushUndo(grid.zones)
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((z) => {
|
|
if (z.zone_index !== zoneIndex) return z
|
|
const newRows = z.rows
|
|
.filter((r) => r.index !== rowIndex)
|
|
.map((r, i) => ({ ...r, index: i }))
|
|
const newCells = z.cells
|
|
.filter((c) => c.row_index !== rowIndex)
|
|
.map((c) => {
|
|
const newRI = c.row_index > rowIndex ? c.row_index - 1 : c.row_index
|
|
return {
|
|
...c,
|
|
row_index: newRI,
|
|
cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`,
|
|
}
|
|
})
|
|
return { ...z, rows: newRows, cells: newCells }
|
|
}),
|
|
summary: {
|
|
...prev.summary,
|
|
total_rows: prev.summary.total_rows - 1,
|
|
total_cells: prev.zones.reduce(
|
|
(sum, z) =>
|
|
sum +
|
|
(z.zone_index === zoneIndex
|
|
? z.cells.filter((c) => c.row_index !== rowIndex).length
|
|
: z.cells.length),
|
|
0,
|
|
),
|
|
},
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
const addRow = useCallback(
|
|
(zoneIndex: number, afterRowIndex: number) => {
|
|
if (!grid) return
|
|
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
|
|
if (!zone) return
|
|
pushUndo(grid.zones)
|
|
|
|
const newRowIndex = afterRowIndex + 1
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((z) => {
|
|
if (z.zone_index !== zoneIndex) return z
|
|
// Shift existing rows
|
|
const shiftedRows = z.rows.map((r) =>
|
|
r.index > afterRowIndex ? { ...r, index: r.index + 1 } : r,
|
|
)
|
|
// Insert new row
|
|
const refRow = z.rows.find((r) => r.index === afterRowIndex) || z.rows[z.rows.length - 1]
|
|
const newRow = {
|
|
index: newRowIndex,
|
|
y_min_px: refRow.y_max_px,
|
|
y_max_px: refRow.y_max_px + (refRow.y_max_px - refRow.y_min_px),
|
|
y_min_pct: refRow.y_max_pct,
|
|
y_max_pct: Math.min(100, refRow.y_max_pct + (refRow.y_max_pct - refRow.y_min_pct)),
|
|
is_header: false,
|
|
is_footer: false,
|
|
}
|
|
const allRows = [...shiftedRows, newRow].sort((a, b) => a.index - b.index)
|
|
|
|
// Shift existing cells
|
|
const shiftedCells = z.cells.map((c) => {
|
|
if (c.row_index > afterRowIndex) {
|
|
const newRI = c.row_index + 1
|
|
return {
|
|
...c,
|
|
row_index: newRI,
|
|
cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`,
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
// Create empty cells for each column
|
|
const newCells = z.columns.map((col) => ({
|
|
cell_id: `Z${zoneIndex}_R${String(newRowIndex).padStart(2, '0')}_C${col.index}`,
|
|
zone_index: zoneIndex,
|
|
row_index: newRowIndex,
|
|
col_index: col.index,
|
|
col_type: col.label,
|
|
text: '',
|
|
confidence: 0,
|
|
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
|
|
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
|
|
word_boxes: [],
|
|
ocr_engine: 'manual',
|
|
is_bold: false,
|
|
}))
|
|
|
|
return { ...z, rows: allRows, cells: [...shiftedCells, ...newCells] }
|
|
}),
|
|
summary: {
|
|
...prev.summary,
|
|
total_rows: prev.summary.total_rows + 1,
|
|
total_cells: prev.summary.total_cells + (zone?.columns.length ?? 0),
|
|
},
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Layout editing (image overlay)
|
|
// ------------------------------------------------------------------
|
|
|
|
/** Capture current state for undo — call once at drag start. */
|
|
const commitUndoPoint = useCallback(() => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
}, [grid, pushUndo])
|
|
|
|
/** Move a column boundary. boundaryIndex 0 = left edge of col 0, etc. */
|
|
const updateColumnDivider = useCallback(
|
|
(zoneIndex: number, boundaryIndex: number, newXPct: number) => {
|
|
if (!grid) return
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
const imgW = prev.image_width || 1
|
|
const newPx = Math.round((newXPct / 100) * imgW)
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((z) => {
|
|
if (z.zone_index !== zoneIndex) return z
|
|
return {
|
|
...z,
|
|
columns: z.columns.map((col) => {
|
|
// Right edge of the column before this boundary
|
|
if (col.index === boundaryIndex - 1) {
|
|
return { ...col, x_max_pct: newXPct, x_max_px: newPx }
|
|
}
|
|
// Left edge of the column at this boundary
|
|
if (col.index === boundaryIndex) {
|
|
return { ...col, x_min_pct: newXPct, x_min_px: newPx }
|
|
}
|
|
return col
|
|
}),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid],
|
|
)
|
|
|
|
/** Update horizontal layout guidelines (margins, header, footer). */
|
|
const updateLayoutHorizontals = useCallback(
|
|
(horizontals: LayoutDividers['horizontals']) => {
|
|
if (!grid) return
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
layout_dividers: {
|
|
...(prev.layout_dividers || { horizontals: {} }),
|
|
horizontals,
|
|
},
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid],
|
|
)
|
|
|
|
/** Split a column at a given x percentage, creating a new column. */
|
|
const splitColumnAt = useCallback(
|
|
(zoneIndex: number, xPct: number) => {
|
|
if (!grid) return
|
|
const zone = grid.zones.find((z) => z.zone_index === zoneIndex)
|
|
if (!zone) return
|
|
|
|
const sorted = [...zone.columns].sort((a, b) => a.index - b.index)
|
|
const targetCol = sorted.find((c) => c.x_min_pct <= xPct && c.x_max_pct >= xPct)
|
|
if (!targetCol) return
|
|
|
|
pushUndo(grid.zones)
|
|
const newColIndex = targetCol.index + 1
|
|
const imgW = grid.image_width || 1
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((z) => {
|
|
if (z.zone_index !== zoneIndex) return z
|
|
const leftCol = {
|
|
...targetCol,
|
|
x_max_pct: xPct,
|
|
x_max_px: Math.round((xPct / 100) * imgW),
|
|
}
|
|
const rightCol = {
|
|
index: newColIndex,
|
|
label: `column_${newColIndex + 1}`,
|
|
x_min_pct: xPct,
|
|
x_max_pct: targetCol.x_max_pct,
|
|
x_min_px: Math.round((xPct / 100) * imgW),
|
|
x_max_px: targetCol.x_max_px,
|
|
bold: false,
|
|
}
|
|
const updatedCols = z.columns.map((c) => {
|
|
if (c.index === targetCol.index) return leftCol
|
|
if (c.index > targetCol.index) return { ...c, index: c.index + 1, label: `column_${c.index + 2}` }
|
|
return c
|
|
})
|
|
const allCols = [...updatedCols, rightCol].sort((a, b) => a.index - b.index)
|
|
const shiftedCells = z.cells.map((c) => {
|
|
if (c.col_index > targetCol.index) {
|
|
const newCI = c.col_index + 1
|
|
return {
|
|
...c,
|
|
col_index: newCI,
|
|
cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`,
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
const newCells = z.rows.map((row) => ({
|
|
cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`,
|
|
zone_index: zoneIndex,
|
|
row_index: row.index,
|
|
col_index: newColIndex,
|
|
col_type: `column_${newColIndex + 1}`,
|
|
text: '',
|
|
confidence: 0,
|
|
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
|
|
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
|
|
word_boxes: [],
|
|
ocr_engine: 'manual',
|
|
is_bold: false,
|
|
}))
|
|
return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] }
|
|
}),
|
|
summary: {
|
|
...prev.summary,
|
|
total_columns: prev.summary.total_columns + 1,
|
|
total_cells: prev.summary.total_cells + (zone.rows.length),
|
|
},
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Column pattern auto-correction
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Detect dominant prefix+number patterns per column and complete
|
|
* partial matches. E.g. if 3+ cells read "p.70", "p.71", etc.,
|
|
* a cell reading ".65" is corrected to "p.65".
|
|
* Returns the number of corrections made.
|
|
*/
|
|
const autoCorrectColumnPatterns = useCallback(() => {
|
|
if (!grid) return 0
|
|
pushUndo(grid.zones)
|
|
|
|
let totalFixed = 0
|
|
const numberPattern = /^(.+?)(\d+)\s*$/
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
// Group cells by column
|
|
const cellsByCol = new Map<number, { cell: (typeof zone.cells)[0]; idx: number }[]>()
|
|
zone.cells.forEach((cell, idx) => {
|
|
const arr = cellsByCol.get(cell.col_index) || []
|
|
arr.push({ cell, idx })
|
|
cellsByCol.set(cell.col_index, arr)
|
|
})
|
|
|
|
const newCells = [...zone.cells]
|
|
|
|
for (const [, colEntries] of cellsByCol) {
|
|
// Count prefix occurrences
|
|
const prefixCounts = new Map<string, number>()
|
|
for (const { cell } of colEntries) {
|
|
const m = cell.text.trim().match(numberPattern)
|
|
if (m) {
|
|
prefixCounts.set(m[1], (prefixCounts.get(m[1]) || 0) + 1)
|
|
}
|
|
}
|
|
|
|
// Find dominant prefix (>= 3 occurrences)
|
|
let dominantPrefix = ''
|
|
let maxCount = 0
|
|
for (const [prefix, count] of prefixCounts) {
|
|
if (count >= 3 && count > maxCount) {
|
|
dominantPrefix = prefix
|
|
maxCount = count
|
|
}
|
|
}
|
|
if (!dominantPrefix) continue
|
|
|
|
// Fix partial matches — entries that are just [.?\s*]NUMBER
|
|
for (const { cell, idx } of colEntries) {
|
|
const text = cell.text.trim()
|
|
if (!text || text.startsWith(dominantPrefix)) continue
|
|
|
|
const numMatch = text.match(/^[.\s]*(\d+)\s*$/)
|
|
if (numMatch) {
|
|
newCells[idx] = { ...newCells[idx], text: `${dominantPrefix}${numMatch[1]}` }
|
|
totalFixed++
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ...zone, cells: newCells }
|
|
}),
|
|
}
|
|
})
|
|
|
|
if (totalFixed > 0) setDirty(true)
|
|
return totalFixed
|
|
}, [grid, pushUndo])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Multi-select & bulk formatting
|
|
// ------------------------------------------------------------------
|
|
|
|
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set())
|
|
|
|
const toggleCellSelection = useCallback(
|
|
(cellId: string) => {
|
|
setSelectedCells((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(cellId)) next.delete(cellId)
|
|
else next.add(cellId)
|
|
return next
|
|
})
|
|
},
|
|
[],
|
|
)
|
|
|
|
const clearCellSelection = useCallback(() => {
|
|
setSelectedCells(new Set())
|
|
}, [])
|
|
|
|
/**
|
|
* Set a manual color override on a cell.
|
|
* - hex string (e.g. "#dc2626"): force text color
|
|
* - null: force no color (clear bar)
|
|
* - undefined: remove override, restore OCR-detected color
|
|
*/
|
|
const setCellColor = useCallback(
|
|
(cellId: string, color: string | null | undefined) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => ({
|
|
...zone,
|
|
cells: zone.cells.map((cell) => {
|
|
if (cell.cell_id !== cellId) return cell
|
|
if (color === undefined) {
|
|
// Remove override entirely — restore OCR behavior
|
|
const { color_override: _, ...rest } = cell
|
|
return rest
|
|
}
|
|
return { ...cell, color_override: color }
|
|
}),
|
|
})),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo],
|
|
)
|
|
|
|
/** Toggle bold on all selected cells (and their columns). */
|
|
const toggleSelectedBold = useCallback(() => {
|
|
if (!grid || selectedCells.size === 0) return
|
|
pushUndo(grid.zones)
|
|
|
|
// Determine if we're turning bold on or off (majority rule)
|
|
const cells = grid.zones.flatMap((z) => z.cells)
|
|
const selectedArr = cells.filter((c) => selectedCells.has(c.cell_id))
|
|
const boldCount = selectedArr.filter((c) => c.is_bold).length
|
|
const newBold = boldCount < selectedArr.length / 2
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => ({
|
|
...zone,
|
|
cells: zone.cells.map((cell) =>
|
|
selectedCells.has(cell.cell_id) ? { ...cell, is_bold: newBold } : cell,
|
|
),
|
|
})),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
setSelectedCells(new Set())
|
|
}, [grid, selectedCells, pushUndo])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Undo / Redo
|
|
// ------------------------------------------------------------------
|
|
|
|
const undo = useCallback(() => {
|
|
if (!grid || undoStack.current.length === 0) return
|
|
redoStack.current.push(JSON.stringify(grid.zones))
|
|
const prev = undoStack.current.pop()!
|
|
setGrid((g) => (g ? { ...g, zones: JSON.parse(prev) } : g))
|
|
setDirty(true)
|
|
}, [grid])
|
|
|
|
const redo = useCallback(() => {
|
|
if (!grid || redoStack.current.length === 0) return
|
|
undoStack.current.push(JSON.stringify(grid.zones))
|
|
const next = redoStack.current.pop()!
|
|
setGrid((g) => (g ? { ...g, zones: JSON.parse(next) } : g))
|
|
setDirty(true)
|
|
}, [grid])
|
|
|
|
const canUndo = undoStack.current.length > 0
|
|
const canRedo = redoStack.current.length > 0
|
|
|
|
// ------------------------------------------------------------------
|
|
// Navigation helpers
|
|
// ------------------------------------------------------------------
|
|
|
|
const getAdjacentCell = useCallback(
|
|
(cellId: string, direction: 'up' | 'down' | 'left' | 'right'): string | null => {
|
|
if (!grid) return null
|
|
for (const zone of grid.zones) {
|
|
// Find the cell or derive row/col from cellId pattern
|
|
const cell = zone.cells.find((c) => c.cell_id === cellId)
|
|
let currentRow: number, currentCol: number
|
|
if (cell) {
|
|
currentRow = cell.row_index
|
|
currentCol = cell.col_index
|
|
} else {
|
|
// Try to parse from cellId: Z{zone}_R{row}_C{col}
|
|
const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/)
|
|
if (!match || parseInt(match[1]) !== zone.zone_index) continue
|
|
currentRow = parseInt(match[2])
|
|
currentCol = parseInt(match[3])
|
|
}
|
|
|
|
let targetRow = currentRow
|
|
let targetCol = currentCol
|
|
if (direction === 'up') targetRow--
|
|
if (direction === 'down') targetRow++
|
|
if (direction === 'left') targetCol--
|
|
if (direction === 'right') targetCol++
|
|
|
|
// Check bounds
|
|
const hasRow = zone.rows.some((r) => r.index === targetRow)
|
|
const hasCol = zone.columns.some((c) => c.index === targetCol)
|
|
if (!hasRow || !hasCol) return null
|
|
|
|
// Return existing cell ID or construct one
|
|
const target = zone.cells.find(
|
|
(c) => c.row_index === targetRow && c.col_index === targetCol,
|
|
)
|
|
return target?.cell_id ?? `Z${zone.zone_index}_R${String(targetRow).padStart(2, '0')}_C${targetCol}`
|
|
}
|
|
return null
|
|
},
|
|
[grid],
|
|
)
|
|
|
|
return {
|
|
grid,
|
|
loading,
|
|
saving,
|
|
error,
|
|
dirty,
|
|
selectedCell,
|
|
selectedZone,
|
|
selectedCells,
|
|
setSelectedCell,
|
|
setSelectedZone,
|
|
buildGrid,
|
|
loadGrid,
|
|
saveGrid,
|
|
updateCellText,
|
|
toggleColumnBold,
|
|
toggleRowHeader,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
getAdjacentCell,
|
|
deleteColumn,
|
|
addColumn,
|
|
deleteRow,
|
|
addRow,
|
|
commitUndoPoint,
|
|
updateColumnDivider,
|
|
updateLayoutHorizontals,
|
|
splitColumnAt,
|
|
toggleCellSelection,
|
|
clearCellSelection,
|
|
toggleSelectedBold,
|
|
autoCorrectColumnPatterns,
|
|
setCellColor,
|
|
ipaMode,
|
|
setIpaMode,
|
|
syllableMode,
|
|
setSyllableMode,
|
|
}
|
|
}
|