feat: add Woerterbuch category + column add/delete in grid editor
- New document category "Woerterbuch" (frontend type + backend validation) - Column delete: hover column header → red "x" button (with confirmation) - Column add: hover column header → "+" button inserts after that column - Both operations support undo/redo, update cell IDs and summary - Available in both GridEditor and StepGridReview (Kombi last step) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,12 @@ export interface PipelineStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentCategory =
|
export type DocumentCategory =
|
||||||
| 'vokabelseite' | 'buchseite' | 'arbeitsblatt' | 'klausurseite'
|
| 'vokabelseite' | 'woerterbuch' | 'buchseite' | 'arbeitsblatt' | 'klausurseite'
|
||||||
| 'mathearbeit' | 'statistik' | 'zeitung' | 'formular' | 'handschrift' | 'sonstiges'
|
| 'mathearbeit' | 'statistik' | 'zeitung' | 'formular' | 'handschrift' | 'sonstiges'
|
||||||
|
|
||||||
export const DOCUMENT_CATEGORIES: { value: DocumentCategory; label: string; icon: string }[] = [
|
export const DOCUMENT_CATEGORIES: { value: DocumentCategory; label: string; icon: string }[] = [
|
||||||
{ value: 'vokabelseite', label: 'Vokabelseite', icon: '📖' },
|
{ value: 'vokabelseite', label: 'Vokabelseite', icon: '📖' },
|
||||||
|
{ value: 'woerterbuch', label: 'Woerterbuch', icon: '📕' },
|
||||||
{ value: 'buchseite', label: 'Buchseite', icon: '📚' },
|
{ value: 'buchseite', label: 'Buchseite', icon: '📚' },
|
||||||
{ value: 'arbeitsblatt', label: 'Arbeitsblatt', icon: '📝' },
|
{ value: 'arbeitsblatt', label: 'Arbeitsblatt', icon: '📝' },
|
||||||
{ value: 'klausurseite', label: 'Klausurseite', icon: '📄' },
|
{ value: 'klausurseite', label: 'Klausurseite', icon: '📄' },
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) {
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
getAdjacentCell,
|
getAdjacentCell,
|
||||||
|
deleteColumn,
|
||||||
|
addColumn,
|
||||||
} = useGridEditor(sessionId)
|
} = useGridEditor(sessionId)
|
||||||
|
|
||||||
const [showOverlay, setShowOverlay] = useState(false)
|
const [showOverlay, setShowOverlay] = useState(false)
|
||||||
@@ -219,6 +221,8 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) {
|
|||||||
onToggleColumnBold={toggleColumnBold}
|
onToggleColumnBold={toggleColumnBold}
|
||||||
onToggleRowHeader={toggleRowHeader}
|
onToggleRowHeader={toggleRowHeader}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
onDeleteColumn={deleteColumn}
|
||||||
|
onAddColumn={addColumn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface GridTableProps {
|
|||||||
onToggleColumnBold: (zoneIndex: number, colIndex: number) => void
|
onToggleColumnBold: (zoneIndex: number, colIndex: number) => void
|
||||||
onToggleRowHeader: (zoneIndex: number, rowIndex: number) => void
|
onToggleRowHeader: (zoneIndex: number, rowIndex: number) => void
|
||||||
onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void
|
onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void
|
||||||
|
onDeleteColumn?: (zoneIndex: number, colIndex: number) => void
|
||||||
|
onAddColumn?: (zoneIndex: number, afterColIndex: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gutter width for row numbers (px). */
|
/** Gutter width for row numbers (px). */
|
||||||
@@ -32,6 +34,8 @@ export function GridTable({
|
|||||||
onToggleColumnBold,
|
onToggleColumnBold,
|
||||||
onToggleRowHeader,
|
onToggleRowHeader,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onDeleteColumn,
|
||||||
|
onAddColumn,
|
||||||
}: GridTableProps) {
|
}: GridTableProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [containerWidth, setContainerWidth] = useState(0)
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
@@ -245,11 +249,11 @@ export function GridTable({
|
|||||||
{/* Header: row-number corner */}
|
{/* Header: row-number corner */}
|
||||||
<div className="sticky left-0 z-10 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" />
|
<div className="sticky left-0 z-10 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" />
|
||||||
|
|
||||||
{/* Header: column labels with resize handles */}
|
{/* Header: column labels with resize handles + delete/add */}
|
||||||
{zone.columns.map((col, ci) => (
|
{zone.columns.map((col, ci) => (
|
||||||
<div
|
<div
|
||||||
key={col.index}
|
key={col.index}
|
||||||
className={`relative px-2 py-1.5 text-xs font-medium border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
className={`group/colhdr relative px-2 py-1.5 text-xs font-medium border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||||
col.bold ? 'text-teal-700 dark:text-teal-300' : 'text-gray-600 dark:text-gray-400'
|
col.bold ? 'text-teal-700 dark:text-teal-300' : 'text-gray-600 dark:text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onToggleColumnBold(zone.zone_index, col.index)}
|
onClick={() => onToggleColumnBold(zone.zone_index, col.index)}
|
||||||
@@ -263,6 +267,36 @@ export function GridTable({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Delete column button (visible on hover) */}
|
||||||
|
{onDeleteColumn && numCols > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute top-0 left-0 w-4 h-4 flex items-center justify-center bg-red-500 text-white rounded-br text-[9px] leading-none opacity-0 group-hover/colhdr:opacity-100 transition-opacity z-30"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (confirm(`Spalte "${col.label}" loeschen?`)) {
|
||||||
|
onDeleteColumn(zone.zone_index, col.index)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={`Spalte "${col.label}" loeschen`}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Add column button (visible on hover, after this column) */}
|
||||||
|
{onAddColumn && (
|
||||||
|
<button
|
||||||
|
className="absolute -right-[7px] top-0 w-[14px] h-full flex items-center justify-center text-teal-500 opacity-0 group-hover/colhdr:opacity-100 transition-opacity z-30 hover:bg-teal-100 dark:hover:bg-teal-900/40 rounded"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onAddColumn(zone.zone_index, col.index)
|
||||||
|
}}
|
||||||
|
title={`Spalte nach "${col.label}" einfuegen`}
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Right-edge resize handle */}
|
{/* Right-edge resize handle */}
|
||||||
{ci < numCols - 1 && (
|
{ci < numCols - 1 && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -212,6 +212,131 @@ export function useGridEditor(sessionId: string | null) {
|
|||||||
[grid, pushUndo],
|
[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 newColumns = z.columns
|
||||||
|
.filter((c) => c.index !== colIndex)
|
||||||
|
.map((c, i) => ({ ...c, index: i, label: `column_${i + 1}` }))
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Undo / Redo
|
// Undo / Redo
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -284,5 +409,7 @@ export function useGridEditor(sessionId: string | null) {
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
getAdjacentCell,
|
getAdjacentCell,
|
||||||
|
deleteColumn,
|
||||||
|
addColumn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) {
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
getAdjacentCell,
|
getAdjacentCell,
|
||||||
|
deleteColumn,
|
||||||
|
addColumn,
|
||||||
} = useGridEditor(sessionId)
|
} = useGridEditor(sessionId)
|
||||||
|
|
||||||
const [showImage, setShowImage] = useState(true)
|
const [showImage, setShowImage] = useState(true)
|
||||||
@@ -373,6 +375,8 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) {
|
|||||||
onToggleColumnBold={toggleColumnBold}
|
onToggleColumnBold={toggleColumnBold}
|
||||||
onToggleRowHeader={toggleRowHeader}
|
onToggleRowHeader={toggleRowHeader}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
onDeleteColumn={deleteColumn}
|
||||||
|
onAddColumn={addColumn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class DewarpGroundTruthRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
VALID_DOCUMENT_CATEGORIES = {
|
VALID_DOCUMENT_CATEGORIES = {
|
||||||
'vokabelseite', 'buchseite', 'arbeitsblatt', 'klausurseite',
|
'vokabelseite', 'woerterbuch', 'buchseite', 'arbeitsblatt', 'klausurseite',
|
||||||
'mathearbeit', 'statistik', 'zeitung', 'formular', 'handschrift', 'sonstiges',
|
'mathearbeit', 'statistik', 'zeitung', 'formular', 'handschrift', 'sonstiges',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user