Files
breakpilot-lehrer/admin-lehrer/components/grid-editor/useGridEditor.ts
Benjamin Admin 5244e10728
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
Fix IPA/syllable race condition: loadGrid no longer depends on buildGrid
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>
2026-04-12 09:59:49 +02:00

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,
}
}