'use client' /** * SpreadsheetView — Fortune Sheet with multi-sheet support. * * 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' const Workbook = dynamic( () => import('@fortune-sheet/react').then((m) => m.Workbook), { ssr: false, loading: () =>
Spreadsheet wird geladen...
}, ) import '@fortune-sheet/react/dist/index.css' import type { GridZone } from '@/components/grid-editor/types' interface SpreadsheetViewProps { gridData: any height?: number } /** No expansion — keep multi-line cells as single cells with \n and text-wrap. */ /** 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 || '' // Sheet name let name: string if (!isBox) { name = 'Vokabeln' } else { const firstText = zone.cells?.[0]?.text ?? `Box ${sheetIndex}` const cleaned = firstText.replace(/[^\w\s\u00C0-\u024F„"]/g, '').trim() name = cleaned.length > 25 ? cleaned.slice(0, 25) + '…' : cleaned || `Box ${sheetIndex}` } const numCols = zone.columns?.length || 1 const numRows = zone.rows?.length || 0 const expandedCells = zone.cells || [] // Compute zone-wide median word height for font-size detection const allWordHeights = zone.cells .flatMap((c: any) => (c.word_boxes || []).map((wb: any) => wb.height || 0)) .filter((h: number) => h > 0) const medianWordH = allWordHeights.length ? [...allWordHeights].sort((a, b) => a - b)[Math.floor(allWordHeights.length / 2)] : 0 // Build celldata const celldata: any[] = [] const merges: Record = {} for (const cell of expandedCells) { const r = cell.row_index const c = cell.col_index const text = cell.text ?? '' // Row metadata const row = zone.rows?.find((rr) => rr.index === r) const isHeader = row?.is_header ?? false // Font size detection from word_boxes const avgWbH = cell.word_boxes?.length ? cell.word_boxes.reduce((s: number, wb: any) => s + (wb.height || 0), 0) / cell.word_boxes.length : 0 const isLargerFont = avgWbH > 0 && medianWordH > 0 && avgWbH > medianWordH * 1.3 const v: any = { v: text, m: text } // Bold: headers, is_bold, larger font if (cell.is_bold || isHeader || isLargerFont) { v.bl = 1 } // Larger font for box titles if (isLargerFont && isBox) { v.fs = 12 } // Multi-line text (bullets with \n): enable text wrap + vertical top align // Add bullet marker (•) if multi-line and no bullet present if (text.includes('\n') && !isHeader) { if (!text.startsWith('•') && !text.startsWith('-') && !text.startsWith('–') && r > 0) { text = '• ' + text v.v = text v.m = text } v.tb = '2' // text wrap v.vt = 0 // vertical align: top } // Header row background if (isHeader) { v.bg = isBox ? `${boxColor || '#2563eb'}18` : '#f0f4ff' } // Box cells: light tinted background if (isBox && !isHeader && boxColor) { v.bg = `${boxColor}08` } // Text color from OCR const color = cell.color_override ?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color if (color) v.fc = color 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 — auto-fit based on longest text const columnlen: Record = {} for (const col of (zone.columns || [])) { const colCells = expandedCells.filter( (c: any) => c.col_index === col.index && c.col_type !== 'spanning_header' ) let maxTextLen = 0 for (const c of colCells) { const len = (c.text ?? '').length if (len > maxTextLen) maxTextLen = len } const autoWidth = Math.max(60, maxTextLen * 7.5 + 16) const pxW = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) const scaledPxW = Math.max(60, Math.round(pxW * (numCols <= 2 ? 0.6 : 0.4))) columnlen[String(col.index)] = Math.round(Math.max(autoWidth, scaledPxW)) } // Row heights — taller for multi-line cells const rowlen: Record = {} for (const row of (zone.rows || [])) { const rowCells = expandedCells.filter((c: any) => c.row_index === row.index) const maxLines = Math.max(1, ...rowCells.map((c: any) => (c.text ?? '').split('\n').length)) const baseH = 24 rowlen[String(row.index)] = Math.max(baseH, baseH * maxLines) } // Border info const borderInfo: any[] = [] // Box: colored outside border if (isBox && boxColor && numRows > 0 && numCols > 0) { borderInfo.push({ rangeType: 'range', borderType: 'border-outside', color: boxColor, style: 5, range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }], }) borderInfo.push({ rangeType: 'range', borderType: 'border-inside', color: `${boxColor}40`, style: 1, range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }], }) } // Content zone: light grid lines if (!isBox && numRows > 0 && numCols > 0) { borderInfo.push({ rangeType: 'range', borderType: 'border-all', color: '#e5e7eb', style: 1, range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }], }) } return { name, id: `zone_${zone.zone_index}`, celldata, row: numRows, column: Math.max(numCols, 1), status: isFirst ? 1 : 0, color: isBox ? boxColor : undefined, config: { merge: Object.keys(merges).length > 0 ? merges : undefined, columnlen, rowlen, borderInfo: borderInfo.length > 0 ? borderInfo : undefined, }, } } export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps) { const sheets = useMemo(() => { if (!gridData?.zones) return [] 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]) const maxRows = Math.max(0, ...sheets.map((s: any) => s.row || 0)) const estimatedHeight = Math.max(height, maxRows * 26 + 80) if (sheets.length === 0) { return
Keine Daten für Spreadsheet.
} return (
) }