@tangle-network/ui 11.7.0 → 11.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,53 +1,72 @@
1
1
  /**
2
2
  * FilePreview — universal file renderer.
3
3
  *
4
- * Renders any file type beautifully:
5
- * - PDF: embedded viewer
6
- * - CSV/XLSX: tabular preview
4
+ * Renders any file type:
5
+ * - PDF: embedded viewer at full pane height
6
+ * - Images: fit to the pane on a checker ground; click toggles natural size
7
+ * - Video / audio: native players
8
+ * - CSV: sticky-header table, capped at CSV_PREVIEW_ROW_LIMIT rows
9
+ * - XLSX / XLS: download card
7
10
  * - Code (py/json/yaml/ts/js): syntax-highlighted, line-numbered viewer
8
11
  * - Markdown: rendered prose
9
- * - Images: inline display
10
12
  * - Text: monospace preview
13
+ * - Anything else: text when content is a string, otherwise a download card
11
14
  */
12
15
 
16
+ import { useState } from "react";
13
17
  import {
14
18
  Download,
15
- X,
19
+ FileSpreadsheet,
16
20
  FileText,
21
+ Music,
22
+ X,
23
+ type LucideIcon,
17
24
  } from "lucide-react";
18
25
  import { cn } from "../lib/utils";
26
+ import { Button } from "../primitives/button";
19
27
  import { Markdown } from "../markdown/markdown";
20
28
  import { CodeBlock, CopyButton } from "../markdown/code-block";
29
+ import { formatBytes } from "../utils/format";
21
30
  import {
22
- detectFileFormat,
23
31
  fileExtension,
24
32
  getCodeLanguage,
25
33
  getFormatLabel,
26
- type FileFormat,
34
+ resolveFilePreviewKind,
35
+ type FilePreviewKind,
27
36
  } from "./file-format";
28
37
 
29
38
  export interface FilePreviewProps {
30
39
  filename: string;
31
40
  content?: string;
41
+ /** Object URL or remote URL for binary content: images, PDF, video, audio. */
32
42
  blobUrl?: string;
33
43
  mimeType?: string;
44
+ /** Size in bytes; download cards show it when known. */
45
+ size?: number;
34
46
  onClose?: () => void;
35
47
  onDownload?: () => void;
36
48
  hideHeader?: boolean;
37
49
  className?: string;
38
50
  }
39
51
 
52
+ /** Rows rendered by the CSV preview; the count line reports the total. */
53
+ export const CSV_PREVIEW_ROW_LIMIT = 500;
54
+
55
+ const NEEDS_DOWNLOAD_LINK = "This file needs a download link to preview.";
56
+ const NO_INLINE_PREVIEW = "This file type has no inline preview.";
57
+ const NO_INLINE_CONTENT = "This file has no inline content to preview yet.";
58
+
40
59
  function CodePreview({
41
60
  content,
42
61
  filename,
43
- format,
62
+ kind,
44
63
  }: {
45
64
  content: string;
46
65
  filename: string;
47
- format: FileFormat;
66
+ kind: FilePreviewKind;
48
67
  }) {
49
68
  const lineCount = content.split("\n").length;
50
- const language = getCodeLanguage(filename, format);
69
+ const language = getCodeLanguage(filename, kind);
51
70
  // Prefer the extension; for an extensionless file (e.g. one detected from its
52
71
  // MIME type) fall back to the highlight language so the label stays meaningful.
53
72
  const labelToken = fileExtension(filename) || language || "txt";
@@ -67,135 +86,219 @@ function CodePreview({
67
86
  );
68
87
  }
69
88
 
70
- function parseCsvRow(line: string) {
71
- const cells: string[] = [];
72
- let current = "";
89
+ /**
90
+ * Split CSV text into rows of cells (RFC 4180): a quoted cell may hold commas,
91
+ * line breaks, and doubled quotes. Unquoted cells are trimmed; blank lines and
92
+ * a trailing line break add no row.
93
+ */
94
+ export function parseCsv(content: string): string[][] {
95
+ const rows: string[][] = [];
96
+ let row: string[] = [];
97
+ let cell = "";
98
+ let quoted = false;
73
99
  let inQuotes = false;
74
100
 
75
- for (let index = 0; index < line.length; index += 1) {
76
- const char = line[index];
77
- const next = line[index + 1];
101
+ const endCell = () => {
102
+ row.push(quoted ? cell : cell.trim());
103
+ cell = "";
104
+ quoted = false;
105
+ };
106
+ const endRow = () => {
107
+ endCell();
108
+ if (row.length > 1 || row[0] !== "") rows.push(row);
109
+ row = [];
110
+ };
78
111
 
79
- if (char === "\"") {
80
- if (inQuotes && next === "\"") {
81
- current += "\"";
82
- index += 1;
83
- continue;
84
- }
112
+ for (let index = 0; index < content.length; index += 1) {
113
+ const char = content[index];
85
114
 
86
- inQuotes = !inQuotes;
115
+ if (inQuotes) {
116
+ if (char === '"') {
117
+ if (content[index + 1] === '"') {
118
+ cell += '"';
119
+ index += 1;
120
+ } else {
121
+ inQuotes = false;
122
+ }
123
+ } else {
124
+ cell += char;
125
+ }
87
126
  continue;
88
127
  }
89
128
 
90
- if (char === "," && !inQuotes) {
91
- cells.push(current.trim());
92
- current = "";
129
+ if (char === '"') {
130
+ inQuotes = true;
131
+ quoted = true;
93
132
  continue;
94
133
  }
95
-
96
- current += char;
134
+ if (char === ",") {
135
+ endCell();
136
+ continue;
137
+ }
138
+ if (char === "\r" || char === "\n") {
139
+ if (char === "\r" && content[index + 1] === "\n") index += 1;
140
+ endRow();
141
+ continue;
142
+ }
143
+ cell += char;
97
144
  }
98
145
 
99
- cells.push(current.trim());
100
- return cells;
146
+ if (cell.length > 0 || quoted || row.length > 0) endRow();
147
+ return rows;
101
148
  }
102
149
 
103
150
  function CsvPreview({ content }: { content: string }) {
104
- const lines = content
105
- .trim()
106
- .split(/\r?\n/)
107
- .filter(Boolean);
151
+ const [headers = [], ...body] = parseCsv(content);
152
+ if (headers.length === 0) return null;
108
153
 
109
- if (lines.length === 0) return null;
110
-
111
- const headers = parseCsvRow(lines[0]).map((header) => header.replace(/^"|"$/g, ""));
112
- const rows = lines.slice(1).map((line) =>
113
- parseCsvRow(line).map((cell) => cell.replace(/^"|"$/g, "")),
114
- );
154
+ const visible = body.slice(0, CSV_PREVIEW_ROW_LIMIT);
155
+ const columnCount = Math.max(headers.length, ...visible.map((cells) => cells.length));
156
+ const capped = body.length > visible.length;
115
157
 
116
158
  return (
117
- <div className="overflow-auto max-h-[70vh] rounded-[var(--radius-md)] border border-border">
118
- <table className="w-full text-sm">
119
- <thead>
120
- <tr className="bg-muted/50 sticky top-0">
121
- {headers.map((h, i) => (
122
- <th
123
- key={i}
124
- className="px-3 py-2 text-left text-xs font-semibold text-foreground border-b border-border whitespace-nowrap"
125
- >
126
- {h}
127
- </th>
128
- ))}
129
- </tr>
130
- </thead>
131
- <tbody>
132
- {rows.map((row, i) => (
133
- <tr key={i} className="border-b border-border hover:bg-accent">
134
- {row.map((cell, j) => (
135
- <td
136
- key={j}
137
- className="px-3 py-1.5 text-foreground font-mono text-xs whitespace-nowrap"
159
+ <div className="flex min-h-0 flex-1 flex-col gap-2">
160
+ {/* Content height when short; shrinks and scrolls (sticky header) when tall. */}
161
+ <div className="min-h-0 overflow-auto rounded-[var(--radius-md)] border border-border">
162
+ <table className="w-full text-sm">
163
+ <thead>
164
+ <tr>
165
+ {Array.from({ length: columnCount }, (_, column) => (
166
+ <th
167
+ key={column}
168
+ className="sticky top-0 whitespace-nowrap border-b border-border bg-card px-3 py-2 text-left text-xs font-semibold text-foreground"
138
169
  >
139
- {cell}
140
- </td>
170
+ {headers[column] ?? ""}
171
+ </th>
141
172
  ))}
142
173
  </tr>
143
- ))}
144
- </tbody>
145
- </table>
174
+ </thead>
175
+ <tbody>
176
+ {visible.map((cells, rowIndex) => (
177
+ <tr key={rowIndex} className="border-b border-border hover:bg-accent">
178
+ {Array.from({ length: columnCount }, (_, column) => (
179
+ <td
180
+ key={column}
181
+ className="whitespace-nowrap px-3 py-1.5 font-mono text-xs text-foreground"
182
+ >
183
+ {cells[column] ?? ""}
184
+ </td>
185
+ ))}
186
+ </tr>
187
+ ))}
188
+ </tbody>
189
+ </table>
190
+ </div>
191
+ <p className="shrink-0 text-xs text-muted-foreground">
192
+ {capped
193
+ ? `Showing ${visible.length} of ${body.length} rows`
194
+ : `${body.length} ${body.length === 1 ? "row" : "rows"}`}
195
+ {` · ${columnCount} ${columnCount === 1 ? "column" : "columns"}`}
196
+ </p>
146
197
  </div>
147
198
  );
148
199
  }
149
200
 
201
+ // Checker ground behind images so transparency and edges stay visible in
202
+ // every theme; both colors come from the active theme's tokens.
203
+ const CHECKER_STYLE = {
204
+ backgroundColor: "var(--color-background)",
205
+ backgroundImage: "repeating-conic-gradient(var(--color-muted) 0% 25%, transparent 0% 50%)",
206
+ backgroundSize: "16px 16px",
207
+ } as const;
208
+
150
209
  function ImagePreview({ src, filename }: { src: string; filename: string }) {
210
+ const [naturalSize, setNaturalSize] = useState(false);
151
211
  return (
152
- <div className="flex items-center justify-center p-4 bg-background rounded-[var(--radius-md)] border border-border">
153
- <img src={src} alt={filename} className="max-w-full max-h-[70vh] object-contain rounded" />
212
+ <div
213
+ className="flex min-h-[12rem] flex-1 overflow-auto rounded-[var(--radius-md)] border border-border"
214
+ style={CHECKER_STYLE}
215
+ >
216
+ <button
217
+ type="button"
218
+ onClick={() => setNaturalSize((value) => !value)}
219
+ aria-pressed={naturalSize}
220
+ aria-label={naturalSize ? "Fit image to pane" : "Show image at natural size"}
221
+ className={cn(
222
+ "flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/60",
223
+ naturalSize ? "m-auto cursor-zoom-out" : "h-full w-full cursor-zoom-in p-4",
224
+ )}
225
+ >
226
+ <img
227
+ src={src}
228
+ alt={filename}
229
+ draggable={false}
230
+ className={naturalSize ? "max-w-none" : "max-h-full max-w-full object-contain"}
231
+ />
232
+ </button>
154
233
  </div>
155
234
  );
156
235
  }
157
236
 
158
- function PdfPreview({ blobUrl, filename }: { blobUrl: string; filename: string }) {
159
- // Simple iframe-based PDF viewer. For richer rendering, consumers can
160
- // swap in react-pdf at the app level.
237
+ function PdfPreview({
238
+ blobUrl,
239
+ filename,
240
+ size,
241
+ onDownload,
242
+ }: {
243
+ blobUrl: string;
244
+ filename: string;
245
+ size?: number;
246
+ onDownload?: () => void;
247
+ }) {
248
+ // <object> lets a browser without an inline PDF viewer render the fallback
249
+ // card instead of a blank frame. The viewer draws its own chrome, so the
250
+ // object has no border; the card brings its own and fills the same height.
161
251
  return (
162
- <div className="rounded-[var(--radius-md)] border border-border overflow-hidden bg-background">
163
- <iframe
164
- src={blobUrl}
165
- title={filename}
166
- className="w-full h-[70vh] border-0"
252
+ <object
253
+ data={blobUrl}
254
+ type="application/pdf"
255
+ title={filename}
256
+ className="min-h-[24rem] w-full flex-1 rounded-[var(--radius-md)]"
257
+ >
258
+ <DownloadCard
259
+ filename={filename}
260
+ size={size}
261
+ sentence="This browser does not show PDFs inline."
262
+ onDownload={onDownload}
263
+ className="h-full"
167
264
  />
168
- </div>
265
+ </object>
169
266
  );
170
267
  }
171
268
 
172
- function TextPreview({ content }: { content: string }) {
269
+ function VideoPreview({ src, filename }: { src: string; filename: string }) {
173
270
  return (
174
- <pre className="bg-card rounded-[var(--radius-md)] border border-border p-4 overflow-auto max-h-[70vh] text-sm text-foreground font-mono leading-[1.55]">
175
- {content}
176
- </pre>
271
+ <div className="flex min-h-[12rem] flex-1 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border bg-background">
272
+ {/* Caption tracks are unknown for an arbitrary artifact, so there is no <track>. */}
273
+ <video
274
+ controls
275
+ preload="metadata"
276
+ src={src}
277
+ aria-label={filename}
278
+ className="max-h-full max-w-full"
279
+ />
280
+ </div>
177
281
  );
178
282
  }
179
283
 
180
- function UnsupportedPreview({
181
- filename,
182
- title,
183
- description,
184
- }: {
185
- filename: string;
186
- title: string;
187
- description: string;
188
- }) {
284
+ function AudioPreview({ src, filename }: { src: string; filename: string }) {
189
285
  return (
190
- <div className="flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center text-muted-foreground">
191
- <FileText className="mb-3 h-12 w-12 opacity-30" />
192
- <p className="text-sm text-foreground">{title}</p>
193
- <p className="mt-1 max-w-md text-xs">{description}</p>
194
- <p className="mt-4 text-[11px] uppercase tracking-[0.12em]">{filename}</p>
286
+ <div className="flex flex-col items-center gap-4 rounded-[var(--radius-md)] border border-border bg-background px-6 py-10">
287
+ <Music className="h-10 w-10 text-muted-foreground opacity-60" />
288
+ <p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
289
+ <audio controls preload="metadata" src={src} aria-label={filename} className="w-full max-w-md" />
195
290
  </div>
196
291
  );
197
292
  }
198
293
 
294
+ function TextPreview({ content }: { content: string }) {
295
+ return (
296
+ <pre className="bg-card rounded-[var(--radius-md)] border border-border p-4 overflow-auto max-h-[70vh] text-sm text-foreground font-mono leading-[1.55]">
297
+ {content}
298
+ </pre>
299
+ );
300
+ }
301
+
199
302
  function MarkdownPreview({ content }: { content: string }) {
200
303
  return (
201
304
  <div className="rounded-[var(--radius-md)] border border-border bg-background p-5">
@@ -204,33 +307,127 @@ function MarkdownPreview({ content }: { content: string }) {
204
307
  );
205
308
  }
206
309
 
207
- function EmptyPreview({ filename }: { filename: string }) {
310
+ /**
311
+ * One muted sentence plus the filename, its size when known, and a Download
312
+ * button when the host wires `onDownload`. Serves spreadsheets, binaries, and
313
+ * every kind whose source (blob or text) is missing.
314
+ */
315
+ function DownloadCard({
316
+ filename,
317
+ size,
318
+ sentence,
319
+ icon: Icon = FileText,
320
+ onDownload,
321
+ className,
322
+ }: {
323
+ filename: string;
324
+ size?: number;
325
+ sentence: string;
326
+ icon?: LucideIcon;
327
+ onDownload?: () => void;
328
+ className?: string;
329
+ }) {
208
330
  return (
209
- <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
210
- <FileText className="h-12 w-12 mb-3 opacity-30" />
211
- <p className="text-sm">Cannot preview {filename}</p>
212
- <p className="text-xs mt-1">Download to view this file</p>
331
+ <div
332
+ className={cn(
333
+ "flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center",
334
+ className,
335
+ )}
336
+ >
337
+ <Icon className="mb-3 h-12 w-12 text-muted-foreground opacity-40" />
338
+ <p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
339
+ {size !== undefined && (
340
+ <p className="mt-1 text-xs text-muted-foreground">{formatBytes(size)}</p>
341
+ )}
342
+ <p className="mt-3 max-w-md text-sm text-muted-foreground">{sentence}</p>
343
+ {onDownload && (
344
+ <Button type="button" variant="outline" size="sm" className="mt-5" onClick={onDownload}>
345
+ <Download />
346
+ Download
347
+ </Button>
348
+ )}
213
349
  </div>
214
350
  );
215
351
  }
216
352
 
353
+ function PreviewBody({
354
+ kind,
355
+ filename,
356
+ content,
357
+ blobUrl,
358
+ size,
359
+ onDownload,
360
+ }: {
361
+ kind: FilePreviewKind;
362
+ filename: string;
363
+ content?: string;
364
+ blobUrl?: string;
365
+ size?: number;
366
+ onDownload?: () => void;
367
+ }) {
368
+ const hasText = typeof content === "string";
369
+ const card = (sentence: string, icon?: LucideIcon) => (
370
+ <DownloadCard
371
+ filename={filename}
372
+ size={size}
373
+ sentence={sentence}
374
+ icon={icon}
375
+ onDownload={onDownload}
376
+ />
377
+ );
378
+
379
+ switch (kind) {
380
+ case "pdf":
381
+ if (blobUrl) {
382
+ return <PdfPreview blobUrl={blobUrl} filename={filename} size={size} onDownload={onDownload} />;
383
+ }
384
+ return card(NEEDS_DOWNLOAD_LINK);
385
+ case "image":
386
+ if (blobUrl) return <ImagePreview src={blobUrl} filename={filename} />;
387
+ // An SVG arrives as text when the host read it as source.
388
+ if (hasText) return <CodePreview content={content} filename={filename} kind="code" />;
389
+ return card(NEEDS_DOWNLOAD_LINK);
390
+ case "video":
391
+ if (blobUrl) return <VideoPreview src={blobUrl} filename={filename} />;
392
+ return card(NEEDS_DOWNLOAD_LINK);
393
+ case "audio":
394
+ if (blobUrl) return <AudioPreview src={blobUrl} filename={filename} />;
395
+ return card(NEEDS_DOWNLOAD_LINK, Music);
396
+ case "csv":
397
+ if (hasText) return <CsvPreview content={content} />;
398
+ return card(NO_INLINE_CONTENT, FileSpreadsheet);
399
+ case "code":
400
+ case "json":
401
+ case "yaml":
402
+ if (hasText) return <CodePreview content={content} filename={filename} kind={kind} />;
403
+ return card(NO_INLINE_CONTENT);
404
+ case "markdown":
405
+ if (hasText) return <MarkdownPreview content={content} />;
406
+ return card(NO_INLINE_CONTENT);
407
+ case "text":
408
+ if (hasText) return <TextPreview content={content} />;
409
+ return card(NO_INLINE_CONTENT);
410
+ case "spreadsheet":
411
+ return card("Download to open this workbook in a spreadsheet app.", FileSpreadsheet);
412
+ case "binary":
413
+ if (hasText) return <TextPreview content={content} />;
414
+ return card(blobUrl ? NO_INLINE_PREVIEW : NEEDS_DOWNLOAD_LINK);
415
+ }
416
+ }
417
+
217
418
  export function FilePreview({
218
419
  filename,
219
420
  content,
220
421
  blobUrl,
221
422
  mimeType,
423
+ size,
222
424
  onClose,
223
425
  onDownload,
224
426
  hideHeader = false,
225
427
  className,
226
428
  }: FilePreviewProps) {
227
- const format: FileFormat = detectFileFormat(filename, mimeType);
228
- const previewLabel = getFormatLabel(format);
229
- const hasRenderableSource =
230
- Boolean(content) ||
231
- Boolean(blobUrl) ||
232
- format === "unknown" ||
233
- format === "spreadsheet";
429
+ const kind = resolveFilePreviewKind(filename, mimeType);
430
+ const previewLabel = getFormatLabel(kind);
234
431
 
235
432
  return (
236
433
  <div className={cn("flex flex-col h-full", className)}>
@@ -265,31 +462,17 @@ export function FilePreview({
265
462
  </div>
266
463
  )}
267
464
 
268
- <div className="flex-1 overflow-auto p-3">
269
- {format === "pdf" && blobUrl && <PdfPreview blobUrl={blobUrl} filename={filename} />}
270
- {format === "image" && blobUrl && <ImagePreview src={blobUrl} filename={filename} />}
271
- {format === "csv" && typeof content === "string" && <CsvPreview content={content} />}
272
- {(format === "code" || format === "json" || format === "yaml") && typeof content === "string" && (
273
- <CodePreview content={content} filename={filename} format={format} />
274
- )}
275
- {format === "text" && typeof content === "string" && <TextPreview content={content} />}
276
- {format === "markdown" && typeof content === "string" && <MarkdownPreview content={content} />}
277
- {format === "spreadsheet" && (
278
- <UnsupportedPreview
279
- filename={filename}
280
- title="Spreadsheet preview is not available in this surface"
281
- description="Download the workbook or convert it to CSV when you need an inline preview."
282
- />
283
- )}
284
- {format === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
285
- {format === "unknown" && typeof content === "string" && <TextPreview content={content} />}
286
- {!hasRenderableSource && typeof content !== "string" && (
287
- <UnsupportedPreview
288
- filename={filename}
289
- title="Preview data is not available yet"
290
- description="This artifact can be shown once the app provides inline content or a downloadable blob."
291
- />
292
- )}
465
+ {/* A flex column so full-height previews (PDF, image, video, CSV) fill
466
+ the pane with `flex-1`, while prose and code keep their own height. */}
467
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto p-3">
468
+ <PreviewBody
469
+ kind={kind}
470
+ filename={filename}
471
+ content={content}
472
+ blobUrl={blobUrl}
473
+ size={size}
474
+ onDownload={onDownload}
475
+ />
293
476
  </div>
294
477
  </div>
295
478
  );
@@ -12,7 +12,12 @@ export {
12
12
  type RichFileTreeGitStatus,
13
13
  type RichFileTreeThemeVars,
14
14
  } from "./rich-file-tree";
15
- export { FilePreview, type FilePreviewProps } from "./file-preview";
15
+ export {
16
+ CSV_PREVIEW_ROW_LIMIT,
17
+ FilePreview,
18
+ parseCsv,
19
+ type FilePreviewProps,
20
+ } from "./file-preview";
16
21
  export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
17
22
  export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
18
23
  export {
@@ -21,5 +26,7 @@ export {
21
26
  getCodeLanguage,
22
27
  getFormatLabel,
23
28
  getSyntaxLanguage,
29
+ resolveFilePreviewKind,
24
30
  type FileFormat,
31
+ type FilePreviewKind,
25
32
  } from "./file-format";