import { useCallback, useState } from 'react' import type { StructuredGrid, GridZone } from './types' interface ActionDeps { grid: StructuredGrid | null setGrid: React.Dispatch> setDirty: React.Dispatch> pushUndo: (zones: GridZone[]) => void } export function useGridEditorActions(deps: ActionDeps) { const { grid, setGrid, setDirty, pushUndo } = deps // ------------------------------------------------------------------ // Multi-select state // ------------------------------------------------------------------ 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()) }, []) // ------------------------------------------------------------------ // 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, setGrid, setDirty], ) // ------------------------------------------------------------------ // Cell color // ------------------------------------------------------------------ /** * 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, setGrid, setDirty], ) // ------------------------------------------------------------------ // 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, setGrid, setDirty], ) // ------------------------------------------------------------------ // 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, setGrid, setDirty], ) // ------------------------------------------------------------------ // 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, setGrid, setDirty]) // ------------------------------------------------------------------ // Multi-select bulk formatting // ------------------------------------------------------------------ /** 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, setGrid, setDirty]) return { // Cell editing updateCellText, setCellColor, // Column formatting toggleColumnBold, // Row formatting toggleRowHeader, // Pattern correction autoCorrectColumnPatterns, // Multi-select selectedCells, toggleCellSelection, clearCellSelection, toggleSelectedBold, } }