From b42f39483377b450b42ef2225a34e5169b30d935 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 15 Apr 2026 00:08:03 +0200 Subject: [PATCH] Integrate Fortune Sheet spreadsheet editor in StepAnsicht 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) --- .../components/ocr-kombi/SpreadsheetView.tsx | 175 ++ .../components/ocr-kombi/StepAnsicht.tsx | 82 +- admin-lehrer/package-lock.json | 1697 +++++++++++++++-- admin-lehrer/package.json | 3 +- 4 files changed, 1731 insertions(+), 226 deletions(-) create mode 100644 admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx diff --git a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx new file mode 100644 index 0000000..ed690d6 --- /dev/null +++ b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx @@ -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: () =>
Spreadsheet wird geladen...
}, +) + +// 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 = {} + + // Build cell lookup + const cellMap = new Map() + 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 = {} + 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 = {} + 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
Keine Daten für Spreadsheet.
+ } + + return ( +
+ +
+ ) +} diff --git a/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx b/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx index 6e71e38..984d9aa 100644 --- a/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx +++ b/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx @@ -8,10 +8,17 @@ */ import { useCallback, useEffect, useRef, useState } from 'react' +import dynamic from 'next/dynamic' import { useGridEditor } from '@/components/grid-editor/useGridEditor' import { GridTable } from '@/components/grid-editor/GridTable' 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: () =>
Spreadsheet wird geladen...
}, +) + const KLAUSUR_API = '/klausur-api' interface StepAnsichtProps { @@ -35,7 +42,7 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) { const [buildError, setBuildError] = useState(null) const leftRef = useRef(null) const [leftHeight, setLeftHeight] = useState(600) - const [showGrid, setShowGrid] = useState(false) + const [viewMode, setViewMode] = useState<'spreadsheet' | 'grid'>('spreadsheet') // Build unified grid const buildUnified = useCallback(async () => { @@ -114,6 +121,20 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {

+
+ + +