SpreadsheetView: bullet indentation, expanded rows, box borders
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 46s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m43s
CI / test-python-agent-core (push) Successful in 35s
CI / test-nodejs-website (push) Successful in 1m4s
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 46s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m43s
CI / test-python-agent-core (push) Successful in 35s
CI / test-nodejs-website (push) Successful in 1m4s
Multi-line cells (\n): expanded into separate rows so each line gets its own cell. Continuation lines (after •) indented with leading spaces. Bullet marker lines (•) are bold. Font-size detection: cells with word_box height >1.3x median get bold and larger font (fs=12) for box titles. Headers: is_header rows always bold with light background tint. Box borders: thick colored outside border + thin inner grid lines. Content zone: light gray grid borders. Auto-fit column widths from longest text per column. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,11 +24,53 @@ interface SpreadsheetViewProps {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Expand multi-line cells (\n) into separate rows for better display. */
|
||||||
|
function expandMultiLineCells(zone: GridZone): { cells: any[]; numRows: number; rowMeta: any[] } {
|
||||||
|
const expandedCells: any[] = []
|
||||||
|
const rowMeta: any[] = [] // metadata per expanded row
|
||||||
|
let expandedRow = 0
|
||||||
|
|
||||||
|
for (const row of (zone.rows || [])) {
|
||||||
|
const rowCells = (zone.cells || []).filter((c) => c.row_index === row.index)
|
||||||
|
const isHeader = row.is_header ?? false
|
||||||
|
|
||||||
|
// Check if any cell in this row has \n
|
||||||
|
const maxLines = Math.max(1, ...rowCells.map((c) => (c.text ?? '').split('\n').length))
|
||||||
|
|
||||||
|
if (maxLines > 1) {
|
||||||
|
// Expand: each line becomes its own row
|
||||||
|
for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
|
||||||
|
const isFirstLine = lineIdx === 0
|
||||||
|
rowMeta.push({ isHeader, isIndented: !isFirstLine, originalRow: row.index })
|
||||||
|
for (const cell of rowCells) {
|
||||||
|
const lines = (cell.text ?? '').split('\n')
|
||||||
|
const lineText = lineIdx < lines.length ? lines[lineIdx] : ''
|
||||||
|
expandedCells.push({
|
||||||
|
...cell,
|
||||||
|
row_index: expandedRow,
|
||||||
|
text: lineText,
|
||||||
|
_isIndented: !isFirstLine,
|
||||||
|
_isFirstBulletLine: isFirstLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expandedRow++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single line — keep as is
|
||||||
|
rowMeta.push({ isHeader, isIndented: false, originalRow: row.index })
|
||||||
|
for (const cell of rowCells) {
|
||||||
|
expandedCells.push({ ...cell, row_index: expandedRow })
|
||||||
|
}
|
||||||
|
expandedRow++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cells: expandedCells, numRows: expandedRow, rowMeta }
|
||||||
|
}
|
||||||
|
|
||||||
/** Convert a single zone to a Fortune Sheet sheet object. */
|
/** Convert a single zone to a Fortune Sheet sheet object. */
|
||||||
function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any {
|
function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any {
|
||||||
const isBox = zone.zone_type === 'box'
|
const isBox = zone.zone_type === 'box'
|
||||||
const boxColor = (zone as any).box_bg_hex || ''
|
const boxColor = (zone as any).box_bg_hex || ''
|
||||||
const layoutType = (zone as any).box_layout_type || ''
|
|
||||||
|
|
||||||
// Sheet name
|
// Sheet name
|
||||||
let name: string
|
let name: string
|
||||||
@@ -40,137 +82,128 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any
|
|||||||
name = cleaned.length > 25 ? cleaned.slice(0, 25) + '…' : cleaned || `Box ${sheetIndex}`
|
name = cleaned.length > 25 ? cleaned.slice(0, 25) + '…' : cleaned || `Box ${sheetIndex}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const numRows = zone.rows?.length || 0
|
|
||||||
const numCols = zone.columns?.length || 1
|
const numCols = zone.columns?.length || 1
|
||||||
|
|
||||||
|
// Expand multi-line cells into separate rows
|
||||||
|
const { cells: expandedCells, numRows, rowMeta } = expandMultiLineCells(zone)
|
||||||
|
|
||||||
|
// 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
|
// Build celldata
|
||||||
const celldata: any[] = []
|
const celldata: any[] = []
|
||||||
const merges: Record<string, any> = {}
|
const merges: Record<string, any> = {}
|
||||||
|
|
||||||
for (const cell of (zone.cells || [])) {
|
for (const cell of expandedCells) {
|
||||||
const r = cell.row_index
|
const r = cell.row_index
|
||||||
const c = cell.col_index
|
const c = cell.col_index
|
||||||
let text = cell.text ?? ''
|
const text = cell.text ?? ''
|
||||||
|
const meta = rowMeta[r] || {}
|
||||||
|
|
||||||
// Detect if this is a header row
|
// Font size detection from word_boxes
|
||||||
const row = zone.rows?.find((rr) => rr.index === r)
|
const avgWbH = cell.word_boxes?.length
|
||||||
const isHeader = row?.is_header ?? false
|
|
||||||
|
|
||||||
// Detect if word_boxes indicate larger font (header detection)
|
|
||||||
const avgWordHeight = cell.word_boxes?.length
|
|
||||||
? cell.word_boxes.reduce((s: number, wb: any) => s + (wb.height || 0), 0) / cell.word_boxes.length
|
? cell.word_boxes.reduce((s: number, wb: any) => s + (wb.height || 0), 0) / cell.word_boxes.length
|
||||||
: 0
|
: 0
|
||||||
const zoneAvgHeight = zone.cells
|
const isLargerFont = avgWbH > 0 && medianWordH > 0 && avgWbH > medianWordH * 1.3
|
||||||
.flatMap((c: any) => (c.word_boxes || []).map((wb: any) => wb.height || 0))
|
|
||||||
.filter((h: number) => h > 0)
|
|
||||||
const medianHeight = zoneAvgHeight.length
|
|
||||||
? [...zoneAvgHeight].sort((a, b) => a - b)[Math.floor(zoneAvgHeight.length / 2)]
|
|
||||||
: 0
|
|
||||||
const isLargerFont = avgWordHeight > 0 && medianHeight > 0 && avgWordHeight > medianHeight * 1.3
|
|
||||||
|
|
||||||
const v: any = {
|
const v: any = { v: text, m: text }
|
||||||
v: text,
|
|
||||||
m: text,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bold: header rows, is_bold cells, or cells with detectably larger font
|
// Bold: headers, is_bold, larger font, first bullet line with •
|
||||||
if (cell.is_bold || isHeader || isLargerFont) {
|
if (cell.is_bold || meta.isHeader || isLargerFont) {
|
||||||
v.bl = 1
|
v.bl = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Larger font for detected large text (e.g., box titles)
|
// Larger font for box titles
|
||||||
if (isLargerFont && isBox) {
|
if (isLargerFont && isBox) {
|
||||||
v.fs = 12 // slightly larger
|
v.fs = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header row light background
|
// Indentation for bullet continuation lines
|
||||||
if (isHeader) {
|
if (cell._isIndented) {
|
||||||
|
v.ht = 0 // left-align
|
||||||
|
// Add visual indent via leading spaces (Fortune Sheet has no padding-left)
|
||||||
|
if (text && !text.startsWith(' ')) {
|
||||||
|
v.v = ' ' + text
|
||||||
|
v.m = ' ' + text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet line: keep • visible
|
||||||
|
if (cell._isFirstBulletLine && text.startsWith('•')) {
|
||||||
|
v.bl = 1 // bold bullet marker line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header row background
|
||||||
|
if (meta.isHeader) {
|
||||||
v.bg = isBox ? `${boxColor || '#2563eb'}18` : '#f0f4ff'
|
v.bg = isBox ? `${boxColor || '#2563eb'}18` : '#f0f4ff'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Box cells get light tinted background
|
// Box cells: light tinted background
|
||||||
if (isBox && !isHeader && boxColor) {
|
if (isBox && !meta.isHeader && boxColor) {
|
||||||
v.bg = `${boxColor}08`
|
v.bg = `${boxColor}08`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text color from OCR word_boxes
|
// Text color from OCR
|
||||||
const color = cell.color_override
|
const color = cell.color_override
|
||||||
?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color
|
?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color
|
||||||
if (color) v.fc = color
|
if (color) v.fc = color
|
||||||
|
|
||||||
// Multi-line text: enable text wrap and preserve formatting
|
|
||||||
if (text.includes('\n')) {
|
|
||||||
v.tb = '2' // text wrap enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
celldata.push({ r, c, v })
|
celldata.push({ r, c, v })
|
||||||
|
|
||||||
// Colspan → merge
|
// Colspan → merge (only on non-expanded rows)
|
||||||
const colspan = cell.colspan || 0
|
const colspan = cell.colspan || 0
|
||||||
if (colspan > 1 || cell.col_type === 'spanning_header') {
|
if ((colspan > 1 || cell.col_type === 'spanning_header') && !cell._isIndented) {
|
||||||
const cs = colspan || numCols
|
const cs = colspan || numCols
|
||||||
merges[`${r}_${c}`] = { r, c, rs: 1, cs }
|
merges[`${r}_${c}`] = { r, c, rs: 1, cs }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column widths — auto-fit based on longest text in each column
|
// Column widths — auto-fit based on longest text
|
||||||
const columnlen: Record<string, number> = {}
|
const columnlen: Record<string, number> = {}
|
||||||
for (const col of (zone.columns || [])) {
|
for (const col of (zone.columns || [])) {
|
||||||
// Find the longest text in this column (considering only non-spanning cells)
|
const colCells = expandedCells.filter(
|
||||||
const colCells = (zone.cells || []).filter(
|
|
||||||
(c: any) => c.col_index === col.index && c.col_type !== 'spanning_header'
|
(c: any) => c.col_index === col.index && c.col_type !== 'spanning_header'
|
||||||
)
|
)
|
||||||
let maxTextLen = 0
|
let maxTextLen = 0
|
||||||
for (const c of colCells) {
|
for (const c of colCells) {
|
||||||
const text = c.text ?? ''
|
const len = (c.text ?? '').length
|
||||||
// For multi-line cells, use the longest line
|
if (len > maxTextLen) maxTextLen = len
|
||||||
const lines = text.split('\n')
|
|
||||||
const longestLine = Math.max(0, ...lines.map((l: string) => l.length))
|
|
||||||
if (longestLine > maxTextLen) maxTextLen = longestLine
|
|
||||||
}
|
}
|
||||||
// ~7px per character as a rough estimate, minimum 60px
|
|
||||||
const autoWidth = Math.max(60, maxTextLen * 7.5 + 16)
|
const autoWidth = Math.max(60, maxTextLen * 7.5 + 16)
|
||||||
// Also consider original pixel width (scaled down)
|
|
||||||
const pxW = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
|
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)))
|
const scaledPxW = Math.max(60, Math.round(pxW * (numCols <= 2 ? 0.6 : 0.4)))
|
||||||
// Use the larger of auto-fit or scaled pixel width
|
|
||||||
columnlen[String(col.index)] = Math.round(Math.max(autoWidth, scaledPxW))
|
columnlen[String(col.index)] = Math.round(Math.max(autoWidth, scaledPxW))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row heights — taller for multi-line cells
|
// Row heights
|
||||||
const rowlen: Record<string, number> = {}
|
const rowlen: Record<string, number> = {}
|
||||||
for (const row of (zone.rows || [])) {
|
for (let ri = 0; ri < numRows; ri++) {
|
||||||
const rowCells = (zone.cells || []).filter((c: any) => c.row_index === row.index)
|
rowlen[String(ri)] = 24
|
||||||
const maxLines = Math.max(1, ...rowCells.map((c: any) => (c.text ?? '').split('\n').length))
|
|
||||||
const baseH = isBox ? 22 : 24
|
|
||||||
rowlen[String(row.index)] = Math.max(baseH, baseH * maxLines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Border info
|
// Border info
|
||||||
const borderInfo: any[] = []
|
const borderInfo: any[] = []
|
||||||
|
|
||||||
// Box: colored outside border around entire sheet content
|
// Box: colored outside border
|
||||||
if (isBox && boxColor && numRows > 0 && numCols > 0) {
|
if (isBox && boxColor && numRows > 0 && numCols > 0) {
|
||||||
borderInfo.push({
|
borderInfo.push({
|
||||||
rangeType: 'range',
|
rangeType: 'range',
|
||||||
borderType: 'border-outside',
|
borderType: 'border-outside',
|
||||||
color: boxColor,
|
color: boxColor,
|
||||||
style: 5, // thick (style 5 = thick in Fortune Sheet)
|
style: 5,
|
||||||
range: [{
|
range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }],
|
||||||
row: [0, numRows - 1],
|
|
||||||
column: [0, numCols - 1],
|
|
||||||
}],
|
|
||||||
})
|
})
|
||||||
// Also add light inner borders for box cells
|
|
||||||
borderInfo.push({
|
borderInfo.push({
|
||||||
rangeType: 'range',
|
rangeType: 'range',
|
||||||
borderType: 'border-inside',
|
borderType: 'border-inside',
|
||||||
color: `${boxColor}40`,
|
color: `${boxColor}40`,
|
||||||
style: 1, // thin
|
style: 1,
|
||||||
range: [{
|
range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }],
|
||||||
row: [0, numRows - 1],
|
|
||||||
column: [0, numCols - 1],
|
|
||||||
}],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +214,7 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any
|
|||||||
borderType: 'border-all',
|
borderType: 'border-all',
|
||||||
color: '#e5e7eb',
|
color: '#e5e7eb',
|
||||||
style: 1,
|
style: 1,
|
||||||
range: [{
|
range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }],
|
||||||
row: [0, numRows - 1],
|
|
||||||
column: [0, numCols - 1],
|
|
||||||
}],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +222,7 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any
|
|||||||
name,
|
name,
|
||||||
id: `zone_${zone.zone_index}`,
|
id: `zone_${zone.zone_index}`,
|
||||||
celldata,
|
celldata,
|
||||||
row: numRows, // exact row count — no padding
|
row: numRows,
|
||||||
column: Math.max(numCols, 1),
|
column: Math.max(numCols, 1),
|
||||||
status: isFirst ? 1 : 0,
|
status: isFirst ? 1 : 0,
|
||||||
color: isBox ? boxColor : undefined,
|
color: isBox ? boxColor : undefined,
|
||||||
@@ -220,9 +250,8 @@ export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps
|
|||||||
.map((z: GridZone, i: number) => zoneToSheet(z, i, i === 0))
|
.map((z: GridZone, i: number) => zoneToSheet(z, i, i === 0))
|
||||||
}, [gridData])
|
}, [gridData])
|
||||||
|
|
||||||
// Calculate minimum height to show all rows of the largest sheet
|
|
||||||
const maxRows = Math.max(0, ...sheets.map((s: any) => s.row || 0))
|
const maxRows = Math.max(0, ...sheets.map((s: any) => s.row || 0))
|
||||||
const estimatedHeight = Math.max(height, maxRows * 26 + 80) // 26px per row + toolbar
|
const estimatedHeight = Math.max(height, maxRows * 26 + 80)
|
||||||
|
|
||||||
if (sheets.length === 0) {
|
if (sheets.length === 0) {
|
||||||
return <div className="p-4 text-center text-gray-400">Keine Daten für Spreadsheet.</div>
|
return <div className="p-4 text-center text-gray-400">Keine Daten für Spreadsheet.</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user