Integrate Fortune Sheet spreadsheet editor in StepAnsicht
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 36s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m40s
CI / test-python-agent-core (push) Successful in 32s
CI / test-nodejs-website (push) Successful in 33s
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 36s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m40s
CI / test-python-agent-core (push) Successful in 32s
CI / test-nodejs-website (push) Successful in 33s
Install @fortune-sheet/react (MIT, v1.0.4) as Excel-like spreadsheet component. New SpreadsheetView.tsx converts unified grid data to Fortune Sheet format (celldata, merge config, column/row sizes). StepAnsicht now has Spreadsheet/Grid toggle: - Spreadsheet mode: full Fortune Sheet with toolbar (bold, italic, color, borders, merge cells, text wrap, undo/redo) - Grid mode: existing GridTable for quick editing Box-origin cells get light tinted background in spreadsheet view. Colspan cells converted to Fortune Sheet merge format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
175
admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
Normal file
175
admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpreadsheetView — Fortune Sheet integration for unified grid display.
|
||||||
|
*
|
||||||
|
* Converts unified grid data into Fortune Sheet format and renders
|
||||||
|
* a full-featured Excel-like spreadsheet editor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
// Lazy-load Fortune Sheet (uses canvas, SSR-incompatible)
|
||||||
|
const Workbook = dynamic(
|
||||||
|
() => import('@fortune-sheet/react').then((m) => m.Workbook),
|
||||||
|
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import Fortune Sheet CSS
|
||||||
|
import '@fortune-sheet/react/dist/index.css'
|
||||||
|
|
||||||
|
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
|
||||||
|
|
||||||
|
interface SpreadsheetViewProps {
|
||||||
|
unifiedGrid: any // unified_grid_result from backend
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert unified grid data to Fortune Sheet format.
|
||||||
|
*/
|
||||||
|
function unifiedGridToSheet(grid: any) {
|
||||||
|
const zone: GridZone | undefined = grid?.zones?.[0]
|
||||||
|
if (!zone) return null
|
||||||
|
|
||||||
|
const numRows = zone.rows.length
|
||||||
|
const numCols = zone.columns.length
|
||||||
|
|
||||||
|
// Build celldata array
|
||||||
|
const celldata: any[] = []
|
||||||
|
const merges: Record<string, any> = {}
|
||||||
|
|
||||||
|
// Build cell lookup
|
||||||
|
const cellMap = new Map<string, GridEditorCell>()
|
||||||
|
for (const cell of zone.cells) {
|
||||||
|
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cell of zone.cells) {
|
||||||
|
const r = cell.row_index
|
||||||
|
const c = cell.col_index
|
||||||
|
const text = cell.text ?? ''
|
||||||
|
const isBox = cell.source_zone_type === 'box'
|
||||||
|
const boxHex = cell.box_region?.bg_hex
|
||||||
|
|
||||||
|
// Cell value
|
||||||
|
const v: any = {
|
||||||
|
v: text,
|
||||||
|
m: text,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
if (cell.is_bold) {
|
||||||
|
v.bl = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text color from word_boxes or color_override
|
||||||
|
const color = cell.color_override
|
||||||
|
?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color
|
||||||
|
if (color) {
|
||||||
|
v.fc = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box background tint
|
||||||
|
if (isBox && boxHex) {
|
||||||
|
v.bg = `${boxHex}15` // very light tint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-line: enable text wrap
|
||||||
|
if (text.includes('\n')) {
|
||||||
|
v.tb = '2' // text wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
celldata.push({ r, c, v })
|
||||||
|
|
||||||
|
// Colspan → merge
|
||||||
|
const colspan = cell.colspan || 0
|
||||||
|
if (colspan > 1 || cell.col_type === 'spanning_header') {
|
||||||
|
const cs = colspan || numCols
|
||||||
|
merges[`${r}_${c}`] = { r, c, rs: 1, cs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column widths from zone columns
|
||||||
|
const columnlen: Record<string, number> = {}
|
||||||
|
for (const col of zone.columns) {
|
||||||
|
const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
|
||||||
|
columnlen[String(col.index)] = Math.max(60, Math.round(w * 0.4)) // scale down for screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row heights
|
||||||
|
const dominantH = grid.dominant_row_h || 30
|
||||||
|
const rowlen: Record<string, number> = {}
|
||||||
|
for (const row of zone.rows) {
|
||||||
|
// Count max lines in this row's cells
|
||||||
|
const rowCells = zone.cells.filter((c: any) => c.row_index === row.index)
|
||||||
|
const maxLines = Math.max(1, ...rowCells.map((c: any) => (c.text ?? '').split('\n').length))
|
||||||
|
rowlen[String(row.index)] = Math.max(22, Math.round(dominantH * 0.6 * maxLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box region borders
|
||||||
|
const borderInfo: any[] = []
|
||||||
|
// Collect box cells and draw borders around box regions
|
||||||
|
const boxCells = zone.cells.filter((c: any) => c.source_zone_type === 'box' && c.box_region?.border)
|
||||||
|
if (boxCells.length > 0) {
|
||||||
|
const boxHex = boxCells[0].box_region?.bg_hex || '#2563eb'
|
||||||
|
const boxRows = [...new Set(boxCells.map((c: any) => c.row_index))].sort((a: number, b: number) => a - b)
|
||||||
|
const boxCols = [...new Set(boxCells.map((c: any) => c.col_index))].sort((a: number, b: number) => a - b)
|
||||||
|
if (boxRows.length > 0 && boxCols.length > 0) {
|
||||||
|
borderInfo.push({
|
||||||
|
rangeType: 'range',
|
||||||
|
borderType: 'border-all',
|
||||||
|
color: boxHex,
|
||||||
|
style: 1,
|
||||||
|
range: [{
|
||||||
|
row: [boxRows[0], boxRows[boxRows.length - 1]],
|
||||||
|
column: [boxCols[0], boxCols[boxCols.length - 1]],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Seite',
|
||||||
|
id: 'unified',
|
||||||
|
celldata,
|
||||||
|
row: numRows,
|
||||||
|
column: numCols,
|
||||||
|
config: {
|
||||||
|
merge: Object.keys(merges).length > 0 ? merges : undefined,
|
||||||
|
columnlen,
|
||||||
|
rowlen,
|
||||||
|
borderInfo: borderInfo.length > 0 ? borderInfo : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpreadsheetView({ unifiedGrid, height = 600 }: SpreadsheetViewProps) {
|
||||||
|
const sheet = useMemo(() => unifiedGridToSheet(unifiedGrid), [unifiedGrid])
|
||||||
|
|
||||||
|
if (!sheet) {
|
||||||
|
return <div className="p-4 text-center text-gray-400">Keine Daten für Spreadsheet.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
<Workbook
|
||||||
|
data={[sheet]}
|
||||||
|
lang="en"
|
||||||
|
showToolbar
|
||||||
|
showFormulaBar={false}
|
||||||
|
showSheetTabs={false}
|
||||||
|
toolbarItems={[
|
||||||
|
'undo', 'redo', '|',
|
||||||
|
'font-bold', 'font-italic', 'font-strikethrough', '|',
|
||||||
|
'font-color', 'background', '|',
|
||||||
|
'font-size', '|',
|
||||||
|
'horizontal-align', 'vertical-align', '|',
|
||||||
|
'text-wrap', 'merge-cell', '|',
|
||||||
|
'border',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,10 +8,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
||||||
import { GridTable } from '@/components/grid-editor/GridTable'
|
import { GridTable } from '@/components/grid-editor/GridTable'
|
||||||
import type { GridZone } from '@/components/grid-editor/types'
|
import type { GridZone } from '@/components/grid-editor/types'
|
||||||
|
|
||||||
|
// Lazy-load SpreadsheetView (Fortune Sheet, SSR-incompatible)
|
||||||
|
const SpreadsheetView = dynamic(
|
||||||
|
() => import('./SpreadsheetView').then((m) => m.SpreadsheetView),
|
||||||
|
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
|
||||||
|
)
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
interface StepAnsichtProps {
|
interface StepAnsichtProps {
|
||||||
@@ -35,7 +42,7 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
const [buildError, setBuildError] = useState<string | null>(null)
|
const [buildError, setBuildError] = useState<string | null>(null)
|
||||||
const leftRef = useRef<HTMLDivElement>(null)
|
const leftRef = useRef<HTMLDivElement>(null)
|
||||||
const [leftHeight, setLeftHeight] = useState(600)
|
const [leftHeight, setLeftHeight] = useState(600)
|
||||||
const [showGrid, setShowGrid] = useState(false)
|
const [viewMode, setViewMode] = useState<'spreadsheet' | 'grid'>('spreadsheet')
|
||||||
|
|
||||||
// Build unified grid
|
// Build unified grid
|
||||||
const buildUnified = useCallback(async () => {
|
const buildUnified = useCallback(async () => {
|
||||||
@@ -114,6 +121,20 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('spreadsheet')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium ${viewMode === 'spreadsheet' ? 'bg-teal-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
Spreadsheet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium ${viewMode === 'grid' ? 'bg-teal-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={buildUnified}
|
onClick={buildUnified}
|
||||||
disabled={building}
|
disabled={building}
|
||||||
@@ -147,17 +168,15 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Unified Grid Table */}
|
{/* RIGHT: Spreadsheet or Grid view */}
|
||||||
<div className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto bg-white dark:bg-gray-900" style={{ maxHeight: `${Math.max(600, leftHeight)}px` }}>
|
<div className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900" style={{ maxHeight: `${Math.max(700, leftHeight)}px` }}>
|
||||||
|
{viewMode === 'spreadsheet' && unifiedGrid ? (
|
||||||
|
<SpreadsheetView unifiedGrid={unifiedGrid} height={Math.max(650, leftHeight - 10)} />
|
||||||
|
) : viewMode === 'grid' && unifiedZone ? (
|
||||||
|
<div className="overflow-auto h-full">
|
||||||
<div className="px-2 py-1 bg-teal-600/80 text-white text-[10px] font-medium sticky top-0 z-20">
|
<div className="px-2 py-1 bg-teal-600/80 text-white text-[10px] font-medium sticky top-0 z-20">
|
||||||
Unified Grid
|
Grid View ({unifiedGrid?.summary?.total_rows}×{unifiedGrid?.summary?.total_columns})
|
||||||
{unifiedGrid?.is_unified && (
|
|
||||||
<span className="ml-2 opacity-70">
|
|
||||||
({unifiedGrid.summary?.total_rows}×{unifiedGrid.summary?.total_columns})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{unifiedZone ? (
|
|
||||||
<GridTable
|
<GridTable
|
||||||
zone={unifiedZone}
|
zone={unifiedZone}
|
||||||
selectedCell={selectedCell}
|
selectedCell={selectedCell}
|
||||||
@@ -177,6 +196,7 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
onToggleCellSelection={toggleCellSelection}
|
onToggleCellSelection={toggleCellSelection}
|
||||||
onSetCellColor={setCellColor}
|
onSetCellColor={setCellColor}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-gray-400">
|
<div className="p-8 text-center text-gray-400">
|
||||||
<p>Kein Unified Grid verfügbar.</p>
|
<p>Kein Unified Grid verfügbar.</p>
|
||||||
|
|||||||
1697
admin-lehrer/package-lock.json
generated
1697
admin-lehrer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@
|
|||||||
"test:all": "vitest run && playwright test --project=chromium"
|
"test:all": "vitest run && playwright test --project=chromium"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortune-sheet/react": "^1.0.4",
|
||||||
|
"fabric": "^6.0.0",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"fabric": "^6.0.0",
|
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user