SpreadsheetView: multi-sheet tabs instead of unified single sheet
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 36s
CI / test-go-edu-search (push) Successful in 36s
CI / test-python-klausur (push) Failing after 2m21s
CI / test-python-agent-core (push) Successful in 31s
CI / test-nodejs-website (push) Successful in 31s
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 36s
CI / test-go-edu-search (push) Successful in 36s
CI / test-python-klausur (push) Failing after 2m21s
CI / test-python-agent-core (push) Successful in 31s
CI / test-nodejs-website (push) Successful in 31s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,85 +1,81 @@
|
|||||||
'use client'
|
'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
|
* Each zone (content + boxes) becomes its own Excel sheet tab,
|
||||||
* a full-featured Excel-like spreadsheet editor.
|
* so each can have independent column widths optimized for its content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
// Lazy-load Fortune Sheet (uses canvas, SSR-incompatible)
|
|
||||||
const Workbook = dynamic(
|
const Workbook = dynamic(
|
||||||
() => import('@fortune-sheet/react').then((m) => m.Workbook),
|
() => 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> },
|
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Import Fortune Sheet CSS
|
|
||||||
import '@fortune-sheet/react/dist/index.css'
|
import '@fortune-sheet/react/dist/index.css'
|
||||||
|
|
||||||
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
|
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
|
||||||
|
|
||||||
interface SpreadsheetViewProps {
|
interface SpreadsheetViewProps {
|
||||||
unifiedGrid: any // unified_grid_result from backend
|
/** Multi-zone grid data (grid_editor_result) */
|
||||||
|
gridData: any
|
||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Convert a single zone to a Fortune Sheet sheet object. */
|
||||||
* Convert unified grid data to Fortune Sheet format.
|
function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any {
|
||||||
*/
|
const isBox = zone.zone_type === 'box'
|
||||||
function unifiedGridToSheet(grid: any) {
|
const boxColor = (zone as any).box_bg_hex || ''
|
||||||
const zone: GridZone | undefined = grid?.zones?.[0]
|
const layoutType = (zone as any).box_layout_type || ''
|
||||||
if (!zone) return null
|
|
||||||
|
|
||||||
const numRows = zone.rows.length
|
// Sheet name
|
||||||
const numCols = zone.columns.length
|
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 celldata: any[] = []
|
||||||
const merges: Record<string, any> = {}
|
const merges: Record<string, any> = {}
|
||||||
|
|
||||||
// Build cell lookup
|
for (const cell of (zone.cells || [])) {
|
||||||
const cellMap = new Map<string, GridEditorCell>()
|
|
||||||
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 r = cell.row_index
|
||||||
const c = cell.col_index
|
const c = cell.col_index
|
||||||
const text = cell.text ?? ''
|
const text = cell.text ?? ''
|
||||||
const isBox = cell.source_zone_type === 'box'
|
|
||||||
const boxHex = cell.box_region?.bg_hex
|
|
||||||
|
|
||||||
// Cell value
|
|
||||||
const v: any = {
|
const v: any = {
|
||||||
v: text,
|
v: text,
|
||||||
m: text,
|
m: text,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bold
|
// 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.bl = 1
|
||||||
|
v.bg = '#f0f4ff'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text color from word_boxes or color_override
|
// Text color
|
||||||
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) {
|
if (color) v.fc = color
|
||||||
v.fc = color
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box background tint
|
// Multi-line: text wrap
|
||||||
if (isBox && boxHex) {
|
if (text.includes('\n')) v.tb = '2'
|
||||||
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 })
|
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<string, number> = {}
|
const columnlen: Record<string, number> = {}
|
||||||
for (const col of zone.columns) {
|
for (const col of (zone.columns || [])) {
|
||||||
const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
|
const pxW = (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
|
// 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
|
// Row heights
|
||||||
const dominantH = grid.dominant_row_h || 30
|
|
||||||
const rowlen: Record<string, number> = {}
|
const rowlen: Record<string, number> = {}
|
||||||
for (const row of zone.rows) {
|
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 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))
|
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[] = []
|
const borderInfo: any[] = []
|
||||||
// Collect box cells and draw borders around box regions
|
if (isBox && boxColor && numRows > 0 && numCols > 0) {
|
||||||
const boxCells = zone.cells.filter((c: any) => c.source_zone_type === 'box' && c.box_region?.border)
|
borderInfo.push({
|
||||||
if (boxCells.length > 0) {
|
rangeType: 'range',
|
||||||
const boxHex = boxCells[0].box_region?.bg_hex || '#2563eb'
|
borderType: 'border-outside',
|
||||||
const boxRows = [...new Set(boxCells.map((c: any) => c.row_index))].sort((a: number, b: number) => a - b)
|
color: boxColor,
|
||||||
const boxCols = [...new Set(boxCells.map((c: any) => c.col_index))].sort((a: number, b: number) => a - b)
|
style: 2, // thick
|
||||||
if (boxRows.length > 0 && boxCols.length > 0) {
|
range: [{
|
||||||
borderInfo.push({
|
row: [0, numRows - 1],
|
||||||
rangeType: 'range',
|
column: [0, numCols - 1],
|
||||||
borderType: 'border-all',
|
}],
|
||||||
color: boxHex,
|
})
|
||||||
style: 1,
|
|
||||||
range: [{
|
|
||||||
row: [boxRows[0], boxRows[boxRows.length - 1]],
|
|
||||||
column: [boxCols[0], boxCols[boxCols.length - 1]],
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'Seite',
|
name,
|
||||||
id: 'unified',
|
id: `zone_${zone.zone_index}`,
|
||||||
celldata,
|
celldata,
|
||||||
row: numRows,
|
row: Math.max(numRows, 3), // minimum 3 rows
|
||||||
column: numCols,
|
column: Math.max(numCols, 1),
|
||||||
|
status: isFirst ? 1 : 0, // first sheet is active
|
||||||
|
color: isBox ? boxColor : undefined,
|
||||||
config: {
|
config: {
|
||||||
merge: Object.keys(merges).length > 0 ? merges : undefined,
|
merge: Object.keys(merges).length > 0 ? merges : undefined,
|
||||||
columnlen,
|
columnlen,
|
||||||
@@ -145,21 +136,34 @@ function unifiedGridToSheet(grid: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpreadsheetView({ unifiedGrid, height = 600 }: SpreadsheetViewProps) {
|
export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps) {
|
||||||
const sheet = useMemo(() => unifiedGridToSheet(unifiedGrid), [unifiedGrid])
|
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 <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>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: `${height}px` }}>
|
<div style={{ width: '100%', height: `${height}px` }}>
|
||||||
<Workbook
|
<Workbook
|
||||||
data={[sheet]}
|
data={sheets}
|
||||||
lang="en"
|
lang="en"
|
||||||
showToolbar
|
showToolbar
|
||||||
showFormulaBar={false}
|
showFormulaBar={false}
|
||||||
showSheetTabs={false}
|
showSheetTabs
|
||||||
toolbarItems={[
|
toolbarItems={[
|
||||||
'undo', 'redo', '|',
|
'undo', 'redo', '|',
|
||||||
'font-bold', 'font-italic', 'font-strikethrough', '|',
|
'font-bold', 'font-italic', 'font-strikethrough', '|',
|
||||||
|
|||||||
@@ -67,16 +67,18 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
// Load unified grid on mount (or build if missing)
|
// Load both grids on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
// Load multi-zone grid (for spreadsheet mode)
|
||||||
|
gridEditor.loadGrid()
|
||||||
|
// Load unified grid (for grid mode)
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/unified-grid`)
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/unified-grid`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setUnifiedGrid(await res.json())
|
setUnifiedGrid(await res.json())
|
||||||
} else {
|
} else {
|
||||||
// Not built yet — build it
|
|
||||||
buildUnified()
|
buildUnified()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -170,8 +172,8 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
|
|
||||||
{/* RIGHT: Spreadsheet or Grid view */}
|
{/* RIGHT: Spreadsheet or Grid view */}
|
||||||
<div className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900" style={{ maxHeight: `${Math.max(700, leftHeight)}px` }}>
|
<div className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900" style={{ maxHeight: `${Math.max(700, leftHeight)}px` }}>
|
||||||
{viewMode === 'spreadsheet' && unifiedGrid ? (
|
{viewMode === 'spreadsheet' && (unifiedGrid || gridEditor.grid) ? (
|
||||||
<SpreadsheetView unifiedGrid={unifiedGrid} height={Math.max(650, leftHeight - 10)} />
|
<SpreadsheetView gridData={gridEditor.grid} height={Math.max(650, leftHeight - 10)} />
|
||||||
) : viewMode === 'grid' && unifiedZone ? (
|
) : viewMode === 'grid' && unifiedZone ? (
|
||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<div className="px-2 py-1 bg-teal-600/80 text-white text-[10px] font-medium sticky top-0 z-20">
|
<div className="px-2 py-1 bg-teal-600/80 text-white text-[10px] font-medium sticky top-0 z-20">
|
||||||
|
|||||||
Reference in New Issue
Block a user