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' | 'en' | 'none' export type SyllableMode = 'auto' | 'all' | 'de' | 'en' | 'none' export function useGridEditor(sessionId: string | null) { const [grid, setGrid] = useState(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [dirty, setDirty] = useState(false) const [selectedCell, setSelectedCell] = useState(null) const [selectedZone, setSelectedZone] = useState(null) const [ipaMode, setIpaMode] = useState('auto') const [syllableMode, setSyllableMode] = useState('auto') // Undo/redo stacks store serialized zone arrays const undoStack = useRef([]) const redoStack = useRef([]) 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]) // Auto-rebuild when IPA or syllable mode changes (skip initial mount) const initialLoadDone = useRef(false) useEffect(() => { if (!initialLoadDone.current) { // Mark as initialized once the first grid is loaded if (grid) initialLoadDone.current = true return } // Mode changed after initial load — rebuild buildGrid() // 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() 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() 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>(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, } }