Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 24s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 15s
Backend: Remove en_col_type fallback heuristic (longest avg text) that incorrectly identified German columns as English. IPA now only applied when OCR bracket patterns are actually found. Add ipa_mode (auto/all/none) and syllable_mode (auto/all/none) query params to build-grid API. Frontend: Add IPA and Silben dropdown selects to GridToolbar. Modes are passed as query params on rebuild. Auto = current smart detection, All = force for all words, Aus = skip entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
300 lines
9.4 KiB
TypeScript
300 lines
9.4 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { useGridEditor } from './useGridEditor'
|
|
import type { GridZone } from './types'
|
|
import { GridToolbar } from './GridToolbar'
|
|
import { GridTable } from './GridTable'
|
|
import { GridImageOverlay } from './GridImageOverlay'
|
|
|
|
interface GridEditorProps {
|
|
sessionId: string | null
|
|
onNext?: () => void
|
|
}
|
|
|
|
export function GridEditor({ sessionId, onNext }: GridEditorProps) {
|
|
const {
|
|
grid,
|
|
loading,
|
|
saving,
|
|
error,
|
|
dirty,
|
|
selectedCell,
|
|
setSelectedCell,
|
|
buildGrid,
|
|
loadGrid,
|
|
saveGrid,
|
|
updateCellText,
|
|
toggleColumnBold,
|
|
toggleRowHeader,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
getAdjacentCell,
|
|
deleteColumn,
|
|
addColumn,
|
|
deleteRow,
|
|
addRow,
|
|
ipaMode,
|
|
setIpaMode,
|
|
syllableMode,
|
|
setSyllableMode,
|
|
} = useGridEditor(sessionId)
|
|
|
|
const [showOverlay, setShowOverlay] = useState(false)
|
|
|
|
// Load grid on mount
|
|
useEffect(() => {
|
|
if (sessionId) {
|
|
loadGrid()
|
|
}
|
|
}, [sessionId, loadGrid])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
undo()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
|
|
e.preventDefault()
|
|
redo()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
e.preventDefault()
|
|
saveGrid()
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [undo, redo, saveGrid])
|
|
|
|
const handleNavigate = useCallback(
|
|
(cellId: string, direction: 'up' | 'down' | 'left' | 'right') => {
|
|
const target = getAdjacentCell(cellId, direction)
|
|
if (target) {
|
|
setSelectedCell(target)
|
|
// Focus the input
|
|
setTimeout(() => {
|
|
const el = document.getElementById(`cell-${target}`)
|
|
if (el) {
|
|
el.focus()
|
|
if (el instanceof HTMLInputElement) el.select()
|
|
}
|
|
}, 0)
|
|
}
|
|
},
|
|
[getAdjacentCell, setSelectedCell],
|
|
)
|
|
|
|
if (!sessionId) {
|
|
return (
|
|
<div className="text-center py-12 text-gray-400">
|
|
Keine Session ausgewaehlt.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Grid wird aufgebaut...
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-sm text-red-700 dark:text-red-300">
|
|
Fehler: {error}
|
|
</p>
|
|
<button
|
|
onClick={buildGrid}
|
|
className="mt-2 text-xs px-3 py-1.5 bg-red-600 text-white rounded hover:bg-red-700"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!grid || !grid.zones.length) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-400 mb-4">Kein Grid vorhanden.</p>
|
|
<button
|
|
onClick={buildGrid}
|
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm"
|
|
>
|
|
Grid aus OCR-Ergebnissen erstellen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary bar */}
|
|
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
|
<span>{grid.summary.total_zones} Zone(n)</span>
|
|
<span>{grid.summary.total_columns} Spalten</span>
|
|
<span>{grid.summary.total_rows} Zeilen</span>
|
|
<span>{grid.summary.total_cells} Zellen</span>
|
|
{grid.boxes_detected > 0 && (
|
|
<span className="text-amber-600 dark:text-amber-400">
|
|
{grid.boxes_detected} Box(en) erkannt
|
|
</span>
|
|
)}
|
|
{grid.summary.color_stats && Object.entries(grid.summary.color_stats)
|
|
.filter(([name]) => name !== 'black')
|
|
.map(([name, count]) => (
|
|
<span key={name} className="inline-flex items-center gap-1">
|
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: {
|
|
red: '#dc2626', blue: '#2563eb', green: '#16a34a',
|
|
orange: '#ea580c', purple: '#9333ea', yellow: '#ca8a04',
|
|
}[name] || '#6b7280' }} />
|
|
<span>{count} {name}</span>
|
|
</span>
|
|
))
|
|
}
|
|
{(grid.summary.recovered_colored ?? 0) > 0 && (
|
|
<span className="text-purple-600 dark:text-purple-400">
|
|
+{grid.summary.recovered_colored} recovered
|
|
</span>
|
|
)}
|
|
{grid.dictionary_detection?.is_dictionary && (
|
|
<span className="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
|
Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%)
|
|
</span>
|
|
)}
|
|
<span className="text-gray-400">
|
|
{grid.duration_seconds.toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
|
|
<GridToolbar
|
|
dirty={dirty}
|
|
saving={saving}
|
|
canUndo={canUndo}
|
|
canRedo={canRedo}
|
|
showOverlay={showOverlay}
|
|
ipaMode={ipaMode}
|
|
syllableMode={syllableMode}
|
|
onSave={saveGrid}
|
|
onUndo={undo}
|
|
onRedo={redo}
|
|
onRebuild={buildGrid}
|
|
onToggleOverlay={() => setShowOverlay(!showOverlay)}
|
|
onIpaModeChange={setIpaMode}
|
|
onSyllableModeChange={setSyllableMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* Image overlay */}
|
|
{showOverlay && (
|
|
<GridImageOverlay sessionId={sessionId} grid={grid} />
|
|
)}
|
|
|
|
{/* Zone tables — group vsplit zones side by side */}
|
|
<div className="space-y-4">
|
|
{(() => {
|
|
// Group consecutive zones with same vsplit_group
|
|
const groups: GridZone[][] = []
|
|
for (const zone of grid.zones) {
|
|
const prev = groups[groups.length - 1]
|
|
if (
|
|
prev &&
|
|
zone.vsplit_group != null &&
|
|
prev[0].vsplit_group === zone.vsplit_group
|
|
) {
|
|
prev.push(zone)
|
|
} else {
|
|
groups.push([zone])
|
|
}
|
|
}
|
|
return groups.map((group) =>
|
|
group.length === 1 ? (
|
|
<div
|
|
key={group[0].zone_index}
|
|
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
|
|
>
|
|
<GridTable
|
|
zone={group[0]}
|
|
layoutMetrics={grid.layout_metrics}
|
|
selectedCell={selectedCell}
|
|
onSelectCell={setSelectedCell}
|
|
onCellTextChange={updateCellText}
|
|
onToggleColumnBold={toggleColumnBold}
|
|
onToggleRowHeader={toggleRowHeader}
|
|
onNavigate={handleNavigate}
|
|
onDeleteColumn={deleteColumn}
|
|
onAddColumn={addColumn}
|
|
onDeleteRow={deleteRow}
|
|
onAddRow={addRow}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={`vsplit-${group[0].vsplit_group}`}
|
|
className="flex gap-2"
|
|
>
|
|
{group.map((zone) => (
|
|
<div
|
|
key={zone.zone_index}
|
|
className="flex-1 min-w-0 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
|
|
>
|
|
<GridTable
|
|
zone={zone}
|
|
layoutMetrics={grid.layout_metrics}
|
|
selectedCell={selectedCell}
|
|
onSelectCell={setSelectedCell}
|
|
onCellTextChange={updateCellText}
|
|
onToggleColumnBold={toggleColumnBold}
|
|
onToggleRowHeader={toggleRowHeader}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
),
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
{/* Tip */}
|
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-4">
|
|
<span>Tab: naechste Zelle</span>
|
|
<span>Enter: Zeile runter</span>
|
|
<span>Spalte fett: Klick auf Spaltenkopf</span>
|
|
<span>Header: Klick auf Zeilennummer</span>
|
|
<span>Ctrl+Z/Y: Undo/Redo</span>
|
|
<span>Ctrl+S: Speichern</span>
|
|
</div>
|
|
|
|
{/* Next step button */}
|
|
{onNext && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={async () => {
|
|
if (dirty) await saveGrid()
|
|
onNext()
|
|
}}
|
|
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 transition-colors"
|
|
>
|
|
Fertig
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|