nonpdf-viewer (#6024)

Co-authored-by: a <a>
This commit is contained in:
Anthony Stirling
2026-03-30 16:39:11 +01:00
committed by GitHub
parent 82a3b8c770
commit cdc288e78d
12 changed files with 824 additions and 14 deletions

View File

@@ -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"

View File

@@ -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;

View File

@@ -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 <ImageViewer file={file} fileName={file.name} />;
case 'csv':
return <CsvViewer file={file} isTsv={file.name.toLowerCase().endsWith('.tsv')} />;
case 'json':
return <JsonViewer file={file} />;
case 'markdown':
return <TextViewer file={file} isMarkdown />;
case 'text':
return <TextViewer file={file} isMarkdown={false} />;
case 'html':
return <HtmlViewer file={file} />;
default:
return (
<Center style={{ flex: 1 }}>
<Stack align="center" gap="sm">
<ArticleIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-4)' }} />
<Text c="dimmed" size="sm">Preview not available for this file type</Text>
{isConvertAvailable && (
<Button variant="light" color="orange" leftSection={<PictureAsPdfIcon />} onClick={handleConvertToPdf}>
Convert to PDF
</Button>
)}
</Stack>
</Center>
);
}
};
return (
<Stack gap={0} style={{ height: '100%', flex: 1, overflow: 'hidden', position: 'relative' }}>
<NonPdfBanner
meta={meta}
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
/>
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{renderContent()}
</Box>
</Stack>
);
}
// ─── 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 (
<Center style={{ flex: 1 }}>
<Text c="dimmed" size="sm">No file selected</Text>
</Center>
);
}
return <NonPdfViewer {...props} file={file} />;
}
export default NonPdfViewerWrapper;

View File

@@ -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 <NonPdfViewerWrapper {...props} />;
}
return <EmbedPdfViewer {...props} />;
};

View File

@@ -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<string[][]>([]);
const [loading, setLoading] = useState(true);
const [sortCol, setSortCol] = useState<number | null>(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 <Center style={{ flex: 1 }}><Text c="dimmed" size="sm">{t('viewer.nonPdf.loading')}</Text></Center>;
}
if (rows.length === 0) {
return <Center style={{ flex: 1 }}><Text c="dimmed" size="sm">{t('viewer.nonPdf.emptyFile')}</Text></Center>;
}
return (
<Stack gap={0} style={{ height: '100%', flex: 1 }}>
{/* Stats bar */}
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', flexShrink: 0 }}>
<Group gap="md" align="center">
<Text size="xs" c="dimmed">
{t('viewer.nonPdf.csvStats', { rows: dataRows.length.toLocaleString(), columns: headers.length, size: formatFileSize(file.size) })}
</Text>
{sortCol !== null && (
<Badge
variant="light"
color="teal"
size="xs"
style={{ cursor: 'pointer' }}
onClick={() => { setSortCol(null); setSortAsc(true); }}
>
{t('viewer.nonPdf.sortedBy', { column: headers[sortCol] || t('viewer.nonPdf.columnDefault', { index: sortCol + 1 }) })} {sortAsc ? '\u2191' : '\u2193'} \u2715
</Badge>
)}
</Group>
</Paper>
{/* Table */}
<ScrollArea style={{ flex: 1 }}>
<Table
striped
highlightOnHover
withColumnBorders
withTableBorder={false}
style={{ fontSize: 'var(--mantine-font-size-xs)', whiteSpace: 'nowrap' }}
stickyHeader
>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 48, color: 'var(--mantine-color-dimmed)', textAlign: 'center', paddingInline: 8 }}>
#
</Table.Th>
{headers.map((h, i) => (
<Table.Th
key={i}
style={{ cursor: 'pointer', paddingInline: 8 }}
onClick={() => handleSort(i)}
>
<Group gap={4} align="center" wrap="nowrap">
<Text size="xs" fw={600} truncate style={{ maxWidth: 200 }}>{h || t('viewer.nonPdf.columnDefault', { index: i + 1 })}</Text>
<SortIcon
style={{
fontSize: '0.85rem',
opacity: sortCol === i ? 1 : 0.3,
color: sortCol === i ? 'var(--mantine-color-teal-6)' : undefined,
transform: sortCol === i && !sortAsc ? 'scaleY(-1)' : undefined,
}}
/>
</Group>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedDataRows.map((row, ri) => (
<Table.Tr key={ri}>
<Table.Td style={{ color: 'var(--mantine-color-dimmed)', textAlign: 'center', paddingInline: 8 }}>
{ri + 1}
</Table.Td>
{headers.map((_, ci) => (
<Table.Td key={ci} style={{ paddingInline: 8, maxWidth: 300 }}>
<Text size="xs" truncate title={row[ci]}>{row[ci] ?? ''}</Text>
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
);
}

View File

@@ -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<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setObjectUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', flexShrink: 0 }}>
<Text size="xs" c="dimmed">{t('viewer.nonPdf.htmlPreviewWarning', { size: formatFileSize(file.size) })}</Text>
</Paper>
{objectUrl && (
<iframe
src={objectUrl}
title={t('viewer.nonPdf.htmlPreview')}
sandbox="allow-scripts"
style={{ flex: 1, border: 'none', background: '#fff' }}
/>
)}
</Box>
);
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { Box } from '@mantine/core';
interface ImageViewerProps {
file: File;
fileName: string;
}
export function ImageViewer({ file, fileName }: ImageViewerProps) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setObjectUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<Box
style={{
flex: 1,
overflow: 'hidden',
background: 'var(--mantine-color-gray-1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{objectUrl && (
<img
src={objectUrl}
alt={fileName}
draggable={false}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
)}
</Box>
);
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { Box, Center, Paper, ScrollArea, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface JsonViewerProps {
file: File;
}
export function JsonViewer({ file }: JsonViewerProps) {
const { t } = useTranslation();
const [content, setContent] = useState<string | null>(null);
const [isInvalidJson, setIsInvalidJson] = useState(false);
useEffect(() => {
file.text().then(text => {
try {
const parsed = JSON.parse(text);
setContent(JSON.stringify(parsed, null, 2));
setIsInvalidJson(false);
} catch (_e) {
// Show raw content if JSON is invalid
setContent(text);
setIsInvalidJson(true);
}
});
}, [file]);
if (content === null) {
return <Center style={{ flex: 1 }}><Text c="dimmed" size="sm">{t('viewer.nonPdf.loading')}</Text></Center>;
}
return (
<Stack gap={0} style={{ height: '100%', flex: 1 }}>
{isInvalidJson && (
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-red-2)', background: 'var(--mantine-color-red-0)', flexShrink: 0 }}>
<Text size="xs" c="red">{t('viewer.nonPdf.invalidJson')}</Text>
</Paper>
)}
<ScrollArea style={{ flex: 1 }} type="auto">
<Box
component="pre"
style={{
margin: 0,
padding: 'var(--mantine-spacing-sm)',
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.6,
whiteSpace: 'pre',
overflowX: 'auto',
}}
>
{content}
</Box>
</ScrollArea>
</Stack>
);
}

View File

@@ -0,0 +1,53 @@
import { Badge, Box, Button, Group } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { useTranslation } from 'react-i18next';
import type { FileTypeMeta } from '@app/components/viewer/nonpdf/types';
interface NonPdfBannerProps {
meta: FileTypeMeta;
onConvertToPdf?: () => void;
}
export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) {
const { t } = useTranslation();
return (
<Group
gap="xs"
wrap="nowrap"
align="center"
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 10,
pointerEvents: 'auto',
}}
>
<Badge
variant="default"
size="lg"
leftSection={
<Box style={{ display: 'flex', alignItems: 'center', fontSize: '0.85rem' }}>
{meta.icon}
</Box>
}
style={{ fontWeight: 600 }}
>
{t('viewer.nonPdf.fileTypeBadge', { type: meta.label })}
</Badge>
{onConvertToPdf && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<PictureAsPdfIcon style={{ fontSize: '0.9rem' }} />}
onClick={onConvertToPdf}
>
{t('viewer.nonPdf.convertToPdf')}
</Button>
)}
</Group>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useEffect, useState } from 'react';
import { Box, Center, Checkbox, Group, Paper, ScrollArea, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { formatFileSize } from '@app/utils/fileUtils';
// ─── Markdown renderer ────────────────────────────────────────────────────────
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Patterns: **bold**, *italic*, `code`, [link](url)
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
let last = 0;
let match;
let key = 0;
while ((match = re.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index));
if (match[2]) parts.push(<strong key={key++}>{match[2]}</strong>);
else if (match[3]) parts.push(<em key={key++}>{match[3]}</em>);
else if (match[4]) parts.push(<code key={key++} style={{ background: 'var(--mantine-color-gray-1)', padding: '0 3px', borderRadius: 3, fontFamily: 'monospace', fontSize: '0.85em' }}>{match[4]}</code>);
else if (match[5]) parts.push(<a key={key++} href={match[6]} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--mantine-color-blue-6)' }}>{match[5]}</a>);
last = match.index + match[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function renderMarkdown(text: string): React.ReactNode[] {
const lines = text.split('\n');
const elements: React.ReactNode[] = [];
let inCodeBlock = false;
let codeLines: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre key={i} style={{ background: 'var(--mantine-color-gray-1)', padding: '8px 12px', borderRadius: 4, overflowX: 'auto', fontSize: '0.85em', margin: '4px 0' }}>
<code>{codeLines.join('\n')}</code>
</pre>
);
codeLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
i++;
continue;
}
if (inCodeBlock) {
codeLines.push(line);
i++;
continue;
}
if (line.startsWith('### ')) {
elements.push(<Text key={i} fw={600} size="md" mt="xs" mb={2}>{renderInline(line.slice(4))}</Text>);
} else if (line.startsWith('## ')) {
elements.push(<Text key={i} fw={700} size="lg" mt="sm" mb={4}>{renderInline(line.slice(3))}</Text>);
} else if (line.startsWith('# ')) {
elements.push(<Text key={i} fw={800} size="xl" mt="md" mb={6}>{renderInline(line.slice(2))}</Text>);
} else if (line.startsWith('- ') || line.startsWith('* ')) {
elements.push(
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>{'\u2022'}</Text>
<Text size="sm" style={{ lineHeight: 1.6 }}>{renderInline(line.slice(2))}</Text>
</Group>
);
} else if (/^\d+\.\s/.test(line)) {
const num = line.match(/^(\d+)\.\s/)?.[1];
const rest = line.replace(/^\d+\.\s/, '');
elements.push(
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>{num}.</Text>
<Text size="sm" style={{ lineHeight: 1.6 }}>{renderInline(rest)}</Text>
</Group>
);
} else if (line.trim() === '' || line === '---' || line === '***') {
elements.push(<Box key={i} style={{ height: 8 }} />);
} else {
elements.push(<Text key={i} size="sm" style={{ lineHeight: 1.7 }}>{renderInline(line)}</Text>);
}
i++;
}
return elements;
}
// ─── Text / Markdown viewer ───────────────────────────────────────────────────
interface TextViewerProps {
file: File;
isMarkdown: boolean;
}
export function TextViewer({ file, isMarkdown }: TextViewerProps) {
const { t } = useTranslation();
const [content, setContent] = useState<string | null>(null);
const [showLineNumbers, setShowLineNumbers] = useState(!isMarkdown);
const [renderMd, setRenderMd] = useState(isMarkdown);
useEffect(() => {
file.text().then(setContent);
}, [file]);
const lines = content?.split('\n') ?? [];
return (
<Stack gap={0} style={{ height: '100%', flex: 1 }}>
{/* Toolbar */}
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', flexShrink: 0 }}>
<Group gap="md" align="center">
<Text size="xs" c="dimmed">{t('viewer.nonPdf.textStats', { lines: lines.length.toLocaleString(), size: formatFileSize(file.size) })}</Text>
{!isMarkdown && (
<Checkbox
label={<Text size="xs">{t('viewer.nonPdf.lineNumbers')}</Text>}
checked={showLineNumbers}
onChange={e => setShowLineNumbers(e.currentTarget.checked)}
size="xs"
/>
)}
{isMarkdown && (
<Checkbox
label={<Text size="xs">{t('viewer.nonPdf.renderMarkdown')}</Text>}
checked={renderMd}
onChange={e => setRenderMd(e.currentTarget.checked)}
size="xs"
/>
)}
</Group>
</Paper>
{/* Content */}
<ScrollArea style={{ flex: 1 }} p="md">
{content === null ? (
<Center><Text c="dimmed" size="sm">{t('viewer.nonPdf.loading')}</Text></Center>
) : isMarkdown && renderMd ? (
<Box style={{ maxWidth: 800, margin: '0 auto', padding: '8px 0' }}>
{renderMarkdown(content)}
</Box>
) : (
<Box
component="pre"
style={{
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.6,
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
display: 'table',
width: '100%',
}}
>
{lines.map((line, i) => (
<Box
key={i}
component="div"
style={{ display: 'table-row' }}
>
{showLineNumbers && (
<Box
component="span"
style={{
display: 'table-cell',
paddingRight: 16,
paddingLeft: 4,
textAlign: 'right',
color: 'var(--mantine-color-gray-5)',
userSelect: 'none',
borderRight: '1px solid var(--mantine-color-gray-2)',
minWidth: `${String(lines.length).length + 1}ch`,
}}
>
{i + 1}
</Box>
)}
<Box component="span" style={{ display: 'table-cell', paddingLeft: showLineNumbers ? 12 : 0 }}>
{line || '\u00A0'}
</Box>
</Box>
))}
</Box>
)}
</ScrollArea>
</Stack>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import ImageIcon from '@mui/icons-material/Image';
import TableChartIcon from '@mui/icons-material/TableChart';
import ArticleIcon from '@mui/icons-material/Article';
import CodeIcon from '@mui/icons-material/Code';
import DataObjectIcon from '@mui/icons-material/DataObject';
import HtmlIcon from '@mui/icons-material/Html';
import type { NonPdfFileType } from '@app/utils/fileUtils';
export interface FileTypeMeta {
label: string;
icon: React.ReactNode;
color: string; // Mantine color name (e.g. 'teal', 'violet')
accentColor: string;
borderColor: string;
bgColor: string;
textColor: string;
}
// Shared neutral color scheme for all file type badges — consistent in light & dark mode
const BADGE_COLORS = {
color: 'gray' as const,
accentColor: 'var(--mantine-color-gray-6)',
borderColor: 'var(--mantine-color-gray-3)',
bgColor: 'var(--mantine-color-gray-0)',
textColor: 'var(--mantine-color-gray-9)',
};
export function getFileTypeMeta(type: NonPdfFileType, fileName?: string): FileTypeMeta {
switch (type) {
case 'image':
return { label: 'Image', icon: React.createElement(ImageIcon, { fontSize: 'small' }), ...BADGE_COLORS };
case 'csv':
return { label: 'Spreadsheet', icon: React.createElement(TableChartIcon, { fontSize: 'small' }), ...BADGE_COLORS };
case 'json':
return { label: 'JSON', icon: React.createElement(DataObjectIcon, { fontSize: 'small' }), ...BADGE_COLORS };
case 'markdown':
return { label: 'Markdown', icon: React.createElement(CodeIcon, { fontSize: 'small' }), ...BADGE_COLORS };
case 'html':
return { label: 'HTML', icon: React.createElement(HtmlIcon, { fontSize: 'small' }), ...BADGE_COLORS };
case 'text':
return { label: 'Text', icon: React.createElement(ArticleIcon, { fontSize: 'small' }), ...BADGE_COLORS };
default: {
// For unknown types, derive label from the file extension (e.g. ".docx" → "DOCX")
const ext = fileName?.split('.').pop()?.toUpperCase();
const label = ext || 'File';
return { label, icon: React.createElement(ArticleIcon, { fontSize: 'small' }), ...BADGE_COLORS };
}
}
}

View File

@@ -97,3 +97,45 @@ export function isPdfFile(file: { name?: string; type?: string } | File | Blob |
return false;
}
export type NonPdfFileType = 'image' | 'csv' | 'json' | 'text' | 'markdown' | 'html' | 'unknown';
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'tiff', 'tif', 'webp']);
const CSV_EXTENSIONS = new Set(['csv', 'tsv']);
const JSON_EXTENSIONS = new Set(['json']);
const TEXT_EXTENSIONS = new Set(['txt']);
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']);
const HTML_EXTENSIONS = new Set(['html', 'htm']);
/**
* Detects the non-PDF file type category for viewer routing.
* Returns 'unknown' for PDFs or unrecognized formats.
*/
export function detectNonPdfFileType(file: { name?: string; type?: string } | File | null | undefined): NonPdfFileType {
if (!file) return 'unknown';
const name = 'name' in file ? file.name : undefined;
const mimeType = file.type ?? '';
// Check MIME type first
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'text/csv') return 'csv';
if (mimeType === 'text/tab-separated-values') return 'csv';
if (mimeType === 'application/json') return 'json';
if (mimeType === 'text/html') return 'html';
if (mimeType === 'text/markdown') return 'markdown';
// Fall back to extension
if (name) {
const ext = detectFileExtension(name);
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
if (CSV_EXTENSIONS.has(ext)) return 'csv';
if (JSON_EXTENSIONS.has(ext)) return 'json';
if (MARKDOWN_EXTENSIONS.has(ext)) return 'markdown';
if (TEXT_EXTENSIONS.has(ext)) return 'text';
if (HTML_EXTENSIONS.has(ext)) return 'html';
}
return 'unknown';
}