Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import { useCallback } from 'react'
|
|
import type { StructuredGrid, GridZone, LayoutDividers } from './types'
|
|
|
|
interface LayoutDeps {
|
|
grid: StructuredGrid | null
|
|
setGrid: React.Dispatch<React.SetStateAction<StructuredGrid | null>>
|
|
setDirty: React.Dispatch<React.SetStateAction<boolean>>
|
|
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,
|
|
}
|
|
}
|