From cdc288e78d56d796a5dcbd9a4ac28800474aae82 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:39:11 +0100 Subject: [PATCH] nonpdf-viewer (#6024) Co-authored-by: a --- .../public/locales/en-GB/translation.toml | 17 +- .../core/components/fileEditor/FileEditor.tsx | 13 +- .../core/components/viewer/NonPdfViewer.tsx | 113 +++++++++++ .../src/core/components/viewer/Viewer.tsx | 29 ++- .../components/viewer/nonpdf/CsvViewer.tsx | 191 +++++++++++++++++ .../components/viewer/nonpdf/HtmlViewer.tsx | 36 ++++ .../components/viewer/nonpdf/ImageViewer.tsx | 44 ++++ .../components/viewer/nonpdf/JsonViewer.tsx | 57 ++++++ .../components/viewer/nonpdf/NonPdfBanner.tsx | 53 +++++ .../components/viewer/nonpdf/TextViewer.tsx | 192 ++++++++++++++++++ .../core/components/viewer/nonpdf/types.ts | 51 +++++ frontend/src/core/utils/fileUtils.ts | 42 ++++ 12 files changed, 824 insertions(+), 14 deletions(-) create mode 100644 frontend/src/core/components/viewer/NonPdfViewer.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/ImageViewer.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/JsonViewer.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/TextViewer.tsx create mode 100644 frontend/src/core/components/viewer/nonpdf/types.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 6ba6ca42be..bde7c6d1d2 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4856,7 +4856,7 @@ enableSepiaFilter = "Enable Sepia Filter" firstPage = "First Page" lastPage = "Last Page" nextPage = "Next Page" -onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." +onlyPdfSupported = "This file format is not supported for preview." previousPage = "Previous Page" singlePageView = "Single Page View" unknownFile = "Unknown file" @@ -4864,6 +4864,21 @@ zoomIn = "Zoom In" zoomOut = "Zoom Out" resetZoom = "Reset zoom" +[viewer.nonPdf] +fileTypeBadge = "{{type}} File" +convertToPdf = "Convert to PDF" +loading = "Loading..." +emptyFile = "Empty file" +csvStats = "{{rows}} rows · {{columns}} columns · {{size}}" +sortedBy = "Sorted by: {{column}}" +columnDefault = "Column {{index}}" +htmlPreviewWarning = "HTML preview — external resources may not load · {{size}}" +htmlPreview = "HTML preview" +invalidJson = "Invalid JSON — showing raw content" +textStats = "{{lines}} lines · {{size}}" +lineNumbers = "Line numbers" +renderMarkdown = "Render markdown" + [rightRail] closeSelected = "Close Selected Files" selectAll = "Select All" diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index 8636d82c06..eac0cca7fc 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -5,6 +5,7 @@ import { import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext'; import { useNavigationActions } from '@app/contexts/NavigationContext'; +import { useViewer } from '@app/contexts/ViewerContext'; import { zipFileService } from '@app/services/zipFileService'; import { detectFileExtension } from '@app/utils/fileUtils'; import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail'; @@ -51,6 +52,9 @@ const FileEditor = ({ // Get navigation actions const { actions: navActions } = useNavigationActions(); + // Get viewer context for setting active file index + const { setActiveFileIndex } = useViewer(); + // Get file selection context const { setSelectedFiles } = useFileSelection(); @@ -345,13 +349,14 @@ const FileEditor = ({ }, [activeStirlingFileStubs, selectors, fileActions, removeFiles]); const handleViewFile = useCallback((fileId: FileId) => { - const record = activeStirlingFileStubs.find(r => r.id === fileId); - if (record) { - // Set the file as selected in context and switch to viewer for preview + const index = activeStirlingFileStubs.findIndex(r => r.id === fileId); + if (index !== -1) { + // Set the file as selected in context, sync the viewer index, and switch to viewer setSelectedFiles([fileId]); + setActiveFileIndex(index); navActions.setWorkbench('viewer'); } - }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]); + }, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions.setWorkbench]); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; diff --git a/frontend/src/core/components/viewer/NonPdfViewer.tsx b/frontend/src/core/components/viewer/NonPdfViewer.tsx new file mode 100644 index 0000000000..dd13a4a889 --- /dev/null +++ b/frontend/src/core/components/viewer/NonPdfViewer.tsx @@ -0,0 +1,113 @@ +import { useCallback, useMemo } from 'react'; +import { Box, Button, Center, Stack, Text } from '@mantine/core'; +import ArticleIcon from '@mui/icons-material/Article'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; + +import { useFileState } from '@app/contexts/FileContext'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { detectFileExtension, detectNonPdfFileType } from '@app/utils/fileUtils'; +import { CONVERSION_MATRIX } from '@app/constants/convertConstants'; + +import { NonPdfBanner } from '@app/components/viewer/nonpdf/NonPdfBanner'; +import { getFileTypeMeta } from '@app/components/viewer/nonpdf/types'; +import { ImageViewer } from '@app/components/viewer/nonpdf/ImageViewer'; +import { CsvViewer } from '@app/components/viewer/nonpdf/CsvViewer'; +import { JsonViewer } from '@app/components/viewer/nonpdf/JsonViewer'; +import { TextViewer } from '@app/components/viewer/nonpdf/TextViewer'; +import { HtmlViewer } from '@app/components/viewer/nonpdf/HtmlViewer'; + +export interface ViewerProps { + sidebarsVisible: boolean; + setSidebarsVisible: (v: boolean) => void; + onClose?: () => void; + previewFile?: File | null; + activeFileIndex?: number; + setActiveFileIndex?: (index: number) => void; +} + +export interface NonPdfViewerProps extends ViewerProps { + file: File; +} + +export function NonPdfViewer({ file }: NonPdfViewerProps) { + const fileType = useMemo(() => detectNonPdfFileType(file), [file]); + const meta = useMemo(() => getFileTypeMeta(fileType, file.name), [fileType, file.name]); + + const { handleToolSelect, toolAvailability } = useToolWorkflow(); + + const fileExtension = detectFileExtension(file.name); + // Only show convert when the extension has an explicit entry in the conversion matrix + // (skip the 'any'/'image' wildcard fallbacks that would match everything) + const isConvertAvailable = toolAvailability['convert']?.available === true + && fileExtension !== '' + && fileExtension in CONVERSION_MATRIX; + + const handleConvertToPdf = useCallback(() => { + handleToolSelect('convert'); + }, [handleToolSelect]); + + const renderContent = () => { + switch (fileType) { + case 'image': + return ; + case 'csv': + return ; + case 'json': + return ; + case 'markdown': + return ; + case 'text': + return ; + case 'html': + return ; + default: + return ( +
+ + + Preview not available for this file type + {isConvertAvailable && ( + + )} + +
+ ); + } + }; + + return ( + + + + {renderContent()} + + + ); +} + +// ─── Wrapper that resolves the active file from FileContext ─────────────────── + +export function NonPdfViewerWrapper(props: ViewerProps) { + const { selectors } = useFileState(); + const activeFiles = selectors.getFiles(); + const activeFileIndex = props.activeFileIndex ?? 0; + + const file = props.previewFile ?? activeFiles[activeFileIndex] ?? activeFiles[0] ?? null; + + if (!file) { + return ( +
+ No file selected +
+ ); + } + + return ; +} + +export default NonPdfViewerWrapper; diff --git a/frontend/src/core/components/viewer/Viewer.tsx b/frontend/src/core/components/viewer/Viewer.tsx index e296c9d6d8..adea7d8145 100644 --- a/frontend/src/core/components/viewer/Viewer.tsx +++ b/frontend/src/core/components/viewer/Viewer.tsx @@ -1,16 +1,27 @@ +import { useMemo } from 'react'; import EmbedPdfViewer from '@app/components/viewer/EmbedPdfViewer'; +import { NonPdfViewerWrapper, type ViewerProps } from '@app/components/viewer/NonPdfViewer'; +import { useFileState } from '@app/contexts/FileContext'; +import { isPdfFile } from '@app/utils/fileUtils'; -export interface ViewerProps { - sidebarsVisible: boolean; - setSidebarsVisible: (v: boolean) => void; - onClose?: () => void; - previewFile?: File | null; - activeFileIndex?: number; - setActiveFileIndex?: (index: number) => void; -} +export type { ViewerProps }; const Viewer = (props: ViewerProps) => { - // Default to EmbedPDF viewer + const { selectors } = useFileState(); + const activeFiles = selectors.getFiles(); + const activeFileIndex = props.activeFileIndex ?? 0; + + // Determine the active file — previewFile takes priority + const activeFile = useMemo(() => { + if (props.previewFile) return props.previewFile; + return activeFiles[activeFileIndex] ?? activeFiles[0] ?? null; + }, [props.previewFile, activeFiles, activeFileIndex]); + + // Route to the appropriate viewer based on file type + if (activeFile && !isPdfFile(activeFile)) { + return ; + } + return ; }; diff --git a/frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx b/frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx new file mode 100644 index 0000000000..298109a9e7 --- /dev/null +++ b/frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Badge, Center, Group, Paper, ScrollArea, Stack, Table, Text } from '@mantine/core'; +import SortIcon from '@mui/icons-material/Sort'; +import { useTranslation } from 'react-i18next'; + +import { formatFileSize } from '@app/utils/fileUtils'; + +// ─── CSV parser ─────────────────────────────────────────────────────────────── + +function parseCsv(text: string, delimiter: string): string[][] { + const rows: string[][] = []; + let field = ''; + let inQuotes = false; + let row: string[] = []; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + + if (inQuotes) { + if (ch === '"' && next === '"') { + field += '"'; + i++; + } else if (ch === '"') { + inQuotes = false; + } else { + field += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === delimiter) { + row.push(field); + field = ''; + } else if (ch === '\r' && next === '\n') { + row.push(field); + field = ''; + rows.push(row); + row = []; + i++; + } else if (ch === '\n' || ch === '\r') { + row.push(field); + field = ''; + rows.push(row); + row = []; + } else { + field += ch; + } + } + if (field || row.length > 0) { + row.push(field); + rows.push(row); + } + // Remove trailing empty row + if (rows.length > 0 && rows[rows.length - 1].every(f => f === '')) { + rows.pop(); + } + return rows; +} + +// ─── CSV viewer ─────────────────────────────────────────────────────────────── + +interface CsvViewerProps { + file: File; + isTsv: boolean; +} + +export function CsvViewer({ file, isTsv }: CsvViewerProps) { + const { t } = useTranslation(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [sortCol, setSortCol] = useState(null); + const [sortAsc, setSortAsc] = useState(true); + + useEffect(() => { + setLoading(true); + file.text().then(text => { + const delimiter = isTsv ? '\t' : ','; + setRows(parseCsv(text, delimiter)); + setLoading(false); + }); + }, [file, isTsv]); + + const headers = rows[0] ?? []; + const dataRows = rows.slice(1); + + const sortedDataRows = useMemo(() => { + if (sortCol === null) return dataRows; + return [...dataRows].sort((a, b) => { + const av = a[sortCol] ?? ''; + const bv = b[sortCol] ?? ''; + const numA = Number(av); + const numB = Number(bv); + const cmp = (!isNaN(numA) && !isNaN(numB)) ? numA - numB : av.localeCompare(bv); + return sortAsc ? cmp : -cmp; + }); + }, [dataRows, sortCol, sortAsc]); + + const handleSort = (colIdx: number) => { + if (sortCol === colIdx) { + setSortAsc(a => !a); + } else { + setSortCol(colIdx); + setSortAsc(true); + } + }; + + if (loading) { + return
{t('viewer.nonPdf.loading')}
; + } + + if (rows.length === 0) { + return
{t('viewer.nonPdf.emptyFile')}
; + } + + return ( + + {/* Stats bar */} + + + + {t('viewer.nonPdf.csvStats', { rows: dataRows.length.toLocaleString(), columns: headers.length, size: formatFileSize(file.size) })} + + {sortCol !== null && ( + { setSortCol(null); setSortAsc(true); }} + > + {t('viewer.nonPdf.sortedBy', { column: headers[sortCol] || t('viewer.nonPdf.columnDefault', { index: sortCol + 1 }) })} {sortAsc ? '\u2191' : '\u2193'} \u2715 + + )} + + + + {/* Table */} + + + + + + # + + {headers.map((h, i) => ( + handleSort(i)} + > + + {h || t('viewer.nonPdf.columnDefault', { index: i + 1 })} + + + + ))} + + + + {sortedDataRows.map((row, ri) => ( + + + {ri + 1} + + {headers.map((_, ci) => ( + + {row[ci] ?? ''} + + ))} + + ))} + +
+
+
+ ); +} diff --git a/frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx b/frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx new file mode 100644 index 0000000000..d970c353b8 --- /dev/null +++ b/frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import { Box, Paper, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +import { formatFileSize } from '@app/utils/fileUtils'; + +interface HtmlViewerProps { + file: File; +} + +export function HtmlViewer({ file }: HtmlViewerProps) { + const { t } = useTranslation(); + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + const url = URL.createObjectURL(file); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + return ( + + + {t('viewer.nonPdf.htmlPreviewWarning', { size: formatFileSize(file.size) })} + + {objectUrl && ( +