Skip to content

Commit 20ae28c

Browse files
authored
feat: support Excel spreadsheet preview (#267)
* feat: support Excel spreadsheet preview * feat: CSV files support switching between preview and code modes
1 parent 44b6ff6 commit 20ae28c

7 files changed

Lines changed: 263 additions & 4 deletions

File tree

frontend/package-lock.json

Lines changed: 105 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
"tailwindcss-animate": "^1.0.7",
6161
"remark-gfm": "^4.0.0",
6262
"remark-math": "^6.0.0",
63-
"typescript": "^5.0.0"
63+
"typescript": "^5.0.0",
64+
"xlsx": "^0.18.5"
6465
},
6566
"devDependencies": {
6667
"@types/dagre": "^0.7.53",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"use client"
2+
3+
import { useEffect, useRef, useState } from "react"
4+
import { useI18n } from "@/contexts/i18n-context"
5+
import * as XLSX from "xlsx"
6+
7+
interface ExcelPreviewRendererProps {
8+
base64Content: string
9+
}
10+
11+
export function ExcelPreviewRenderer({ base64Content }: ExcelPreviewRendererProps) {
12+
const containerRef = useRef<HTMLDivElement | null>(null)
13+
const [error, setError] = useState<string | null>(null)
14+
const [activeSheet, setActiveSheet] = useState<string | null>(null)
15+
const [sheets, setSheets] = useState<{ [key: string]: string }>({})
16+
const { t } = useI18n()
17+
18+
useEffect(() => {
19+
const render = async () => {
20+
if (!base64Content) {
21+
return
22+
}
23+
24+
try {
25+
let workbook;
26+
27+
// Check if content is likely base64
28+
const isBase64 = /^[A-Za-z0-9+/=]+$/.test(base64Content.replace(/\s/g, ''));
29+
30+
if (isBase64) {
31+
try {
32+
const binary = atob(base64Content)
33+
const bytes = new Uint8Array(binary.length)
34+
for (let i = 0; i < binary.length; i++) {
35+
bytes[i] = binary.charCodeAt(i)
36+
}
37+
workbook = XLSX.read(bytes, { type: "array" })
38+
} catch (e) {
39+
// Fallback for non-base64 text
40+
workbook = XLSX.read(base64Content, { type: "string" })
41+
}
42+
} else {
43+
workbook = XLSX.read(base64Content, { type: "string" })
44+
}
45+
46+
const sheetData: { [key: string]: string } = {}
47+
48+
workbook.SheetNames.forEach((sheetName) => {
49+
const worksheet = workbook.Sheets[sheetName]
50+
const html = XLSX.utils.sheet_to_html(worksheet)
51+
sheetData[sheetName] = html
52+
})
53+
54+
setSheets(sheetData)
55+
if (workbook.SheetNames.length > 0) {
56+
setActiveSheet(workbook.SheetNames[0])
57+
}
58+
setError(null)
59+
} catch (e) {
60+
console.error(e)
61+
setError(t("files.previewDialog.errors.excelRenderFailed") || "Failed to render Excel file")
62+
}
63+
}
64+
65+
render()
66+
}, [base64Content, t])
67+
68+
if (error) {
69+
return <div className="p-4 text-sm text-destructive">{error}</div>
70+
}
71+
72+
if (!activeSheet || !sheets[activeSheet]) {
73+
return null
74+
}
75+
76+
return (
77+
<div className="flex flex-col h-full overflow-hidden bg-background">
78+
{Object.keys(sheets).length > 1 && (
79+
<div className="flex border-b overflow-x-auto bg-muted/30 p-2 gap-2 flex-shrink-0">
80+
{Object.keys(sheets).map((sheetName) => (
81+
<button
82+
key={sheetName}
83+
onClick={() => setActiveSheet(sheetName)}
84+
className={`px-3 py-1.5 text-sm rounded-md transition-colors whitespace-nowrap ${activeSheet === sheetName
85+
? "bg-background shadow-sm border border-border font-medium"
86+
: "text-muted-foreground hover:bg-muted"
87+
}`}
88+
>
89+
{sheetName}
90+
</button>
91+
))}
92+
</div>
93+
)}
94+
95+
<div
96+
className="flex-1 overflow-auto p-4 excel-preview-container"
97+
ref={containerRef}
98+
>
99+
<div
100+
className="bg-background rounded-md shadow-sm border min-w-max"
101+
dangerouslySetInnerHTML={{ __html: sheets[activeSheet] }}
102+
/>
103+
</div>
104+
105+
<style dangerouslySetInnerHTML={{
106+
__html: `
107+
.excel-preview-container table {
108+
border-collapse: collapse;
109+
width: 100%;
110+
}
111+
.excel-preview-container th,
112+
.excel-preview-container td {
113+
border: 1px solid hsl(var(--border));
114+
padding: 8px;
115+
min-width: 80px;
116+
text-align: left;
117+
}
118+
.excel-preview-container td[data-t="n"] {
119+
text-align: right;
120+
}
121+
.excel-preview-container tr:nth-child(even) {
122+
background-color: hsl(var(--muted) / 0.3);
123+
}
124+
`}} />
125+
</div>
126+
)
127+
}

frontend/src/components/file/file-viewer.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Loader2, XIcon } from "lucide-react"
22
import { DocxPreviewRenderer } from "@/components/file/docx-preview-renderer"
3+
import { ExcelPreviewRenderer } from "@/components/file/excel-preview-renderer"
34
import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
45
import { useI18n } from "@/contexts/i18n-context"
5-
import { getApiUrl, isHtmlFile, isMarkdownFile } from "@/lib/utils"
6+
import { getApiUrl, isHtmlFile, isMarkdownFile, isCsvFile } from "@/lib/utils"
67

78
interface FileViewerProps {
89
fileName: string
@@ -99,6 +100,25 @@ export function FileViewer({
99100
</div>
100101
) : mimeType?.includes('wordprocessingml') || fileName.toLowerCase().endsWith('.docx') ? (
101102
<DocxPreviewRenderer base64Content={content || ''} />
103+
) : mimeType?.includes('spreadsheetml') || fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.csv') ? (
104+
viewMode === 'code' && isCsvFile(fileName) ? (
105+
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words">
106+
{(() => {
107+
const c = content || '';
108+
if (!c) return t('files.previewDialog.emptyContent');
109+
if (/^[A-Za-z0-9+/=]+$/.test(c.replace(/\s/g, ''))) {
110+
try {
111+
return decodeURIComponent(escape(atob(c)));
112+
} catch {
113+
return c;
114+
}
115+
}
116+
return c;
117+
})()}
118+
</pre>
119+
) : (
120+
<ExcelPreviewRenderer base64Content={content || ''} />
121+
)
102122
) : isHtmlFile(fileName) ? (
103123
viewMode === 'code' ? (
104124
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words">

frontend/src/i18n/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,7 @@ Build when you need.`
862862
errors: {
863863
loadFailed: "Failed to load file",
864864
docxRenderFailed: "Failed to render DOCX preview",
865+
excelRenderFailed: "Failed to render Excel preview",
865866
cors: "CORS error: Unable to access the file. This might be a browser cache issue, please try refreshing the page.",
866867
networkErrorWithMsg: "Network error: {msg}",
867868
},

frontend/src/i18n/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,7 @@ Build when you need.`
862862
errors: {
863863
loadFailed: "文件加载失败",
864864
docxRenderFailed: "DOCX 预览渲染失败",
865+
excelRenderFailed: "Excel 预览渲染失败",
865866
cors: "CORS 错误:无法访问文件。这可能是浏览器缓存问题,请尝试刷新页面。",
866867
networkErrorWithMsg: "网络错误:{msg}",
867868
},

frontend/src/lib/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ export function isMarkdownFile(fileName: string): boolean {
135135
return fileName.toLowerCase().endsWith('.md')
136136
}
137137

138+
export function isCsvFile(fileName: string): boolean {
139+
if (!fileName) return false
140+
return fileName.toLowerCase().endsWith('.csv')
141+
}
142+
138143
export function isToggleableFile(fileName: string): boolean {
139-
return isHtmlFile(fileName) || isMarkdownFile(fileName)
144+
return isHtmlFile(fileName) || isMarkdownFile(fileName) || isCsvFile(fileName)
140145
}

0 commit comments

Comments
 (0)