import { useCallback } from 'react' import type { StructuredGrid, GridZone, LayoutDividers } from './types' interface LayoutDeps { grid: StructuredGrid | null setGrid: React.Dispatch> setDirty: React.Dispatch> pushUndo: (zones: GridZone[]) => void } export function useGridEditorLayout(deps: LayoutDeps) { const { grid, setGrid, setDirty, pushUndo } = deps // ------------------------------------------------------------------ // 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, setGrid, setDirty], ) 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, setGrid, setDirty], ) // ------------------------------------------------------------------ // 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, setGrid, setDirty], ) 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, setGrid, setDirty], ) // ------------------------------------------------------------------ // Layout divider 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, setGrid, setDirty], ) /** 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, setGrid, setDirty], ) /** 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, setGrid, setDirty], ) return { deleteColumn, addColumn, deleteRow, addRow, commitUndoPoint, updateColumnDivider, updateLayoutHorizontals, splitColumnAt, } }