Files
breakpilot-lehrer/admin-lehrer/components/grid-editor/useGridEditor.ts
Benjamin Admin 83c058e400
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 25s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m50s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 15s
Add language-specific IPA and syllable modes (de/en)
Extend ipa_mode and syllable_mode toggles with language options:
- auto: smart detection (default)
- en: only English headword column
- de: only German definition columns
- all: all content columns
- none: skip entirely

Also improve English column auto-detection: use garbled IPA patterns
(apostrophes, colons) in addition to bracket patterns. This correctly
identifies English dictionary pages where OCR produces garbled ASCII
instead of bracket IPA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 08:16:29 +01:00

932 lines
30 KiB
TypeScript

import { useCallback, 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
await buildGrid()
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)
}
}, [sessionId, buildGrid])
// ------------------------------------------------------------------
// 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,
}
}