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 44s
CI / test-go-edu-search (push) Successful in 40s
CI / test-python-klausur (push) Failing after 2m34s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 38s
Multi-line cells (containing \n) that don't already start with a bullet character get • prepended in the frontend. This ensures bullet points are visible regardless of whether the backend inserted them (depends on when boxes were last rebuilt). Skips header rows and cells that already have •, -, or – prefix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
7.4 KiB
TypeScript
242 lines
7.4 KiB
TypeScript
'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: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
|
||
)
|
||
|
||
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<string, any> = {}
|
||
|
||
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<string, number> = {}
|
||
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<string, number> = {}
|
||
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 <div className="p-4 text-center text-gray-400">Keine Daten für Spreadsheet.</div>
|
||
}
|
||
|
||
return (
|
||
<div style={{ width: '100%', height: `${estimatedHeight}px` }}>
|
||
<Workbook
|
||
data={sheets}
|
||
lang="en"
|
||
showToolbar
|
||
showFormulaBar={false}
|
||
showSheetTabs
|
||
toolbarItems={[
|
||
'undo', 'redo', '|',
|
||
'font-bold', 'font-italic', 'font-strikethrough', '|',
|
||
'font-color', 'background', '|',
|
||
'font-size', '|',
|
||
'horizontal-align', 'vertical-align', '|',
|
||
'text-wrap', 'merge-cell', '|',
|
||
'border',
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|