SpreadsheetPreviewModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { FileSpreadsheet, Loader2 } from 'lucide-react';
  4. import { api, getAuthToken } from '../api/client';
  5. import { formatFileSize } from '../utils/file';
  6. import { PreviewModalShell } from './PreviewModalShell';
  7. import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
  8. // Parsing an arbitrarily large workbook would freeze the tab — anything over
  9. // this size (or beyond the row/column caps) falls back to a truncation notice.
  10. export const SPREADSHEET_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
  11. const MAX_ROWS = 500;
  12. const MAX_COLS = 40;
  13. interface SheetGrid {
  14. name: string;
  15. rows: string[][];
  16. totalRows: number;
  17. totalCols: number;
  18. }
  19. interface SpreadsheetPreviewModalProps {
  20. libraryFileId: number;
  21. filename: string;
  22. /** csv | xlsx | ods */
  23. fileType: string;
  24. fileSize: number;
  25. onClose: () => void;
  26. /** Called once with a 256px PNG of the first sheet, for the grid thumbnail (#2976). */
  27. onSnapshot?: (blob: Blob) => void;
  28. }
  29. // Spreadsheet-style column letters: 0 -> A, 25 -> Z, 26 -> AA, ...
  30. function columnLabel(index: number): string {
  31. let label = '';
  32. let i = index;
  33. while (i >= 0) {
  34. label = String.fromCharCode(65 + (i % 26)) + label;
  35. i = Math.floor(i / 26) - 1;
  36. }
  37. return label;
  38. }
  39. // Mini table rendered onto a canvas as the grid thumbnail. Dark background to
  40. // match the STL thumbnails the grid already shows.
  41. function drawSheetSnapshot(rows: string[][]): Promise<Blob | null> {
  42. const size = 256;
  43. const cols = 5;
  44. const rowCount = 9;
  45. const canvas = document.createElement('canvas');
  46. canvas.width = size;
  47. canvas.height = size;
  48. const ctx = canvas.getContext('2d');
  49. if (!ctx) return Promise.resolve(null);
  50. const cellW = size / cols;
  51. const cellH = size / rowCount;
  52. ctx.fillStyle = '#1a1a1a';
  53. ctx.fillRect(0, 0, size, size);
  54. ctx.fillStyle = 'rgba(0, 174, 66, 0.25)';
  55. ctx.fillRect(0, 0, size, cellH);
  56. ctx.strokeStyle = '#333333';
  57. ctx.lineWidth = 1;
  58. for (let c = 1; c < cols; c++) {
  59. ctx.beginPath();
  60. ctx.moveTo(c * cellW + 0.5, 0);
  61. ctx.lineTo(c * cellW + 0.5, size);
  62. ctx.stroke();
  63. }
  64. for (let r = 1; r < rowCount; r++) {
  65. ctx.beginPath();
  66. ctx.moveTo(0, r * cellH + 0.5);
  67. ctx.lineTo(size, r * cellH + 0.5);
  68. ctx.stroke();
  69. }
  70. ctx.fillStyle = '#d4d4d4';
  71. ctx.font = '11px sans-serif';
  72. ctx.textBaseline = 'middle';
  73. for (let r = 0; r < rowCount; r++) {
  74. for (let c = 0; c < cols; c++) {
  75. const text = rows[r]?.[c];
  76. if (!text) continue;
  77. ctx.fillText(String(text), c * cellW + 4, r * cellH + cellH / 2, cellW - 8);
  78. }
  79. }
  80. return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
  81. }
  82. export function SpreadsheetPreviewModal({
  83. libraryFileId,
  84. filename,
  85. fileType,
  86. fileSize,
  87. onClose,
  88. onSnapshot,
  89. }: SpreadsheetPreviewModalProps) {
  90. const { t } = useTranslation();
  91. const [sheets, setSheets] = useState<SheetGrid[] | null>(null);
  92. const [activeSheet, setActiveSheet] = useState(0);
  93. const [error, setError] = useState<string | null>(null);
  94. const snapshotSentRef = useRef(false);
  95. const onSnapshotRef = useRef(onSnapshot);
  96. useEffect(() => {
  97. onSnapshotRef.current = onSnapshot;
  98. });
  99. const fullscreen = usePreviewFullscreen();
  100. const { isFullscreen, toggleFullscreen } = fullscreen;
  101. useEffect(() => {
  102. let cancelled = false;
  103. setSheets(null);
  104. setError(null);
  105. setActiveSheet(0);
  106. if (fileSize > SPREADSHEET_PREVIEW_MAX_BYTES) {
  107. setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
  108. return;
  109. }
  110. const headers: HeadersInit = {};
  111. const token = getAuthToken();
  112. if (token) headers['Authorization'] = `Bearer ${token}`;
  113. (async () => {
  114. const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
  115. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  116. const buffer = await res.arrayBuffer();
  117. let parsed: SheetGrid[];
  118. if (fileType === 'csv') {
  119. // papaparse is loaded on demand so it stays out of the main bundle.
  120. const Papa = (await import('papaparse')).default;
  121. const text = new TextDecoder().decode(buffer);
  122. const rows: string[][] = [];
  123. let totalRows = 0;
  124. let totalCols = 0;
  125. // A trailing newline parses as one empty row, and only a *trailing*
  126. // run of blanks is dropped — so blanks are held back until a later
  127. // row proves they were interior.
  128. let pendingBlanks: string[][] = [];
  129. // Counted and capped row by row, so the whole file is never
  130. // materialised as rows the preview then throws away.
  131. const take = (row: string[]) => {
  132. totalRows += 1;
  133. totalCols = Math.max(totalCols, row.length);
  134. if (rows.length < MAX_ROWS) rows.push(row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
  135. };
  136. Papa.parse<string[]>(text, {
  137. skipEmptyLines: false,
  138. step: ({ data }) => {
  139. if (!Array.isArray(data)) return;
  140. if (data.every((cell) => !cell)) {
  141. pendingBlanks.push(data);
  142. return;
  143. }
  144. pendingBlanks.forEach(take);
  145. pendingBlanks = [];
  146. take(data);
  147. },
  148. });
  149. parsed = [{ name: filename, rows, totalRows, totalCols }];
  150. } else {
  151. // SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
  152. const XLSX = await import('xlsx');
  153. const workbook = XLSX.read(buffer, { dense: true });
  154. parsed = workbook.SheetNames.map((name) => {
  155. const ws = workbook.Sheets[name];
  156. const ref = ws?.['!ref'];
  157. if (!ws || !ref) return { name, rows: [], totalRows: 0, totalCols: 0 };
  158. const range = XLSX.utils.decode_range(ref);
  159. const totalRows = range.e.r - range.s.r + 1;
  160. const totalCols = range.e.c - range.s.c + 1;
  161. // Cap the extracted range instead of slicing afterwards, so a huge
  162. // sheet is never materialised in full.
  163. const capped = {
  164. s: range.s,
  165. e: {
  166. r: Math.min(range.e.r, range.s.r + MAX_ROWS - 1),
  167. c: Math.min(range.e.c, range.s.c + MAX_COLS - 1),
  168. },
  169. };
  170. const rows = XLSX.utils.sheet_to_json(ws, {
  171. header: 1,
  172. raw: false,
  173. defval: '',
  174. range: XLSX.utils.encode_range(capped),
  175. }) as string[][];
  176. return { name, rows, totalRows, totalCols };
  177. });
  178. }
  179. if (cancelled) return;
  180. setSheets(parsed);
  181. if (onSnapshotRef.current && !snapshotSentRef.current) {
  182. const first = parsed.find((sheet) => sheet.rows.length > 0);
  183. if (first) {
  184. snapshotSentRef.current = true;
  185. const blob = await drawSheetSnapshot(first.rows);
  186. if (blob && !cancelled) onSnapshotRef.current?.(blob);
  187. }
  188. }
  189. })().catch((err: unknown) => {
  190. console.error('[sheet-preview] load failed', err);
  191. if (!cancelled) setError(t('fileManager.preview.error'));
  192. });
  193. return () => {
  194. cancelled = true;
  195. };
  196. }, [libraryFileId, fileType, fileSize, filename, t]);
  197. const sheet = sheets?.[activeSheet] ?? null;
  198. const rowsTruncated = sheet != null && sheet.totalRows > sheet.rows.length;
  199. const colsTruncated = sheet != null && sheet.totalCols > MAX_COLS;
  200. const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
  201. return (
  202. <PreviewModalShell
  203. title={filename}
  204. fullscreen={fullscreen}
  205. onClose={onClose}
  206. icon={<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />}
  207. >
  208. {/* Sheet tabs */}
  209. {sheets && sheets.length > 1 && (
  210. <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
  211. {sheets.map((s, index) => (
  212. <button
  213. key={`${s.name}-${index}`}
  214. onClick={() => setActiveSheet(index)}
  215. className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
  216. index === activeSheet
  217. ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
  218. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
  219. }`}
  220. >
  221. {s.name}
  222. </button>
  223. ))}
  224. </div>
  225. )}
  226. {/* Content */}
  227. <div
  228. data-testid="spreadsheet-preview-content"
  229. className={`flex-1 min-h-0 overflow-auto bg-bambu-dark ${isFullscreen ? '' : 'rounded-b-lg'}`}
  230. onDoubleClick={toggleFullscreen}
  231. >
  232. {error ? (
  233. <div className="h-full flex items-center justify-center p-6">
  234. <p className="text-bambu-gray text-center">{error}</p>
  235. </div>
  236. ) : !sheets ? (
  237. <div className="h-full flex items-center justify-center">
  238. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  239. </div>
  240. ) : !sheet || sheet.rows.length === 0 ? (
  241. <div className="h-full flex items-center justify-center p-6">
  242. <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
  243. </div>
  244. ) : (
  245. // min-w-full so a narrow sheet spreads across the wider panel
  246. // instead of huddling in its top-left corner (#2976).
  247. <table className="border-collapse text-xs min-w-full">
  248. <thead>
  249. <tr>
  250. <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
  251. {Array.from({ length: shownCols }, (_, c) => (
  252. <th
  253. key={c}
  254. className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
  255. >
  256. {columnLabel(c)}
  257. </th>
  258. ))}
  259. </tr>
  260. </thead>
  261. <tbody>
  262. {sheet.rows.map((row, r) => (
  263. <tr key={r}>
  264. <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
  265. {r + 1}
  266. </td>
  267. {Array.from({ length: shownCols }, (_, c) => (
  268. <td
  269. key={c}
  270. className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
  271. title={row[c] || undefined}
  272. >
  273. {row[c] ?? ''}
  274. </td>
  275. ))}
  276. </tr>
  277. ))}
  278. </tbody>
  279. </table>
  280. )}
  281. </div>
  282. {/* Truncation notices */}
  283. {(rowsTruncated || colsTruncated) && sheet && (
  284. <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
  285. {rowsTruncated && (
  286. <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
  287. )}
  288. {colsTruncated && (
  289. <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
  290. )}
  291. </div>
  292. )}
  293. </PreviewModalShell>
  294. );
  295. }