From d4353d76fbc298a3fe33a007161bf92c90386946 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 15 Apr 2026 00:51:21 +0200 Subject: [PATCH] SpreadsheetView: multi-sheet tabs instead of unified single sheet Each zone becomes its own Excel sheet tab with independent column widths: - Sheet "Vokabeln": main content zone with EN/DE/example columns - Sheet "Pounds and euros": Box 1 with its own 4-column layout - Sheet "German leihen": Box 2 with single column for flowing text This solves the column-width conflict: boxes have different column widths optimized for their content, which is impossible in a single unified sheet (Excel limitation: column width is per-column, not per-cell). Sheet tabs visible at bottom (showSheetTabs: true). Box sheets get colored tab (from box_bg_hex). First sheet active by default. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ocr-kombi/SpreadsheetView.tsx | 156 +++++++++--------- .../components/ocr-kombi/StepAnsicht.tsx | 10 +- 2 files changed, 86 insertions(+), 80 deletions(-) diff --git a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx index ed690d6..b38db0f 100644 --- a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx +++ b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx @@ -1,85 +1,81 @@ 'use client' /** - * SpreadsheetView — Fortune Sheet integration for unified grid display. + * SpreadsheetView — Fortune Sheet with multi-sheet support. * - * Converts unified grid data into Fortune Sheet format and renders - * a full-featured Excel-like spreadsheet editor. + * Each zone (content + boxes) becomes its own Excel sheet tab, + * so each can have independent column widths optimized for its content. */ 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 + /** Multi-zone grid data (grid_editor_result) */ + gridData: any 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 +/** Convert a single zone to a Fortune Sheet sheet object. */ +function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any { + const isBox = zone.zone_type === 'box' + const boxColor = (zone as any).box_bg_hex || '' + const layoutType = (zone as any).box_layout_type || '' - const numRows = zone.rows.length - const numCols = zone.columns.length + // Sheet name + let name: string + if (!isBox) { + name = 'Vokabeln' + } else { + // Use first cell text as name (truncated) + const firstText = zone.cells?.[0]?.text ?? `Box ${sheetIndex}` + const cleaned = firstText.replace(/[^\w\s\u00C0-\u024F]/g, '').trim() + name = cleaned.length > 20 ? cleaned.slice(0, 20) + '...' : cleaned || `Box ${sheetIndex}` + } - // Build celldata array + const numRows = zone.rows?.length || 0 + const numCols = zone.columns?.length || 1 + + // Build celldata 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) { + 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) { + if (cell.is_bold) v.bl = 1 + + // Header row styling + const row = zone.rows?.find((rr) => rr.index === r) + if (row?.is_header) { v.bl = 1 + v.bg = '#f0f4ff' } - // Text color from word_boxes or color_override + // Text color const color = cell.color_override ?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color - if (color) { - v.fc = 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 - } + // Multi-line: text wrap + if (text.includes('\n')) v.tb = '2' celldata.push({ r, c, v }) @@ -91,51 +87,46 @@ function unifiedGridToSheet(grid: any) { } } - // Column widths from zone columns + // Column widths — optimized per zone 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 + for (const col of (zone.columns || [])) { + const pxW = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) + // Scale: wider for small column counts (boxes), narrower for many columns + const scaleFactor = numCols <= 2 ? 0.6 : 0.4 + columnlen[String(col.index)] = Math.max(80, Math.round(pxW * scaleFactor)) } // 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) + for (const row of (zone.rows || [])) { + 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)) + rowlen[String(row.index)] = Math.max(24, 24 * maxLines) } - // Box region borders + // Box border around entire content 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]], - }], - }) - } + if (isBox && boxColor && numRows > 0 && numCols > 0) { + borderInfo.push({ + rangeType: 'range', + borderType: 'border-outside', + color: boxColor, + style: 2, // thick + range: [{ + row: [0, numRows - 1], + column: [0, numCols - 1], + }], + }) } return { - name: 'Seite', - id: 'unified', + name, + id: `zone_${zone.zone_index}`, celldata, - row: numRows, - column: numCols, + row: Math.max(numRows, 3), // minimum 3 rows + column: Math.max(numCols, 1), + status: isFirst ? 1 : 0, // first sheet is active + color: isBox ? boxColor : undefined, config: { merge: Object.keys(merges).length > 0 ? merges : undefined, columnlen, @@ -145,21 +136,34 @@ function unifiedGridToSheet(grid: any) { } } -export function SpreadsheetView({ unifiedGrid, height = 600 }: SpreadsheetViewProps) { - const sheet = useMemo(() => unifiedGridToSheet(unifiedGrid), [unifiedGrid]) +export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps) { + const sheets = useMemo(() => { + if (!gridData?.zones) return [] - if (!sheet) { + // Sort: content zones first, then boxes by y-position + const sorted = [...gridData.zones].sort((a: GridZone, b: GridZone) => { + if (a.zone_type === 'content' && b.zone_type !== 'content') return -1 + if (a.zone_type !== 'content' && b.zone_type === 'content') return 1 + return (a.bbox_px?.y ?? 0) - (b.bbox_px?.y ?? 0) + }) + + return sorted + .filter((z: GridZone) => z.cells && z.cells.length > 0) + .map((z: GridZone, i: number) => zoneToSheet(z, i, i === 0)) + }, [gridData]) + + if (sheets.length === 0) { return
Keine Daten für Spreadsheet.
} return (
{ if (!sessionId) return + // Load multi-zone grid (for spreadsheet mode) + gridEditor.loadGrid() + // Load unified grid (for grid mode) ;(async () => { try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/unified-grid`) if (res.ok) { setUnifiedGrid(await res.json()) } else { - // Not built yet — build it buildUnified() } } catch { @@ -170,8 +172,8 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) { {/* RIGHT: Spreadsheet or Grid view */}
- {viewMode === 'spreadsheet' && unifiedGrid ? ( - + {viewMode === 'spreadsheet' && (unifiedGrid || gridEditor.grid) ? ( + ) : viewMode === 'grid' && unifiedZone ? (