feat: add DOCX/XLSX/PPTX file preview
- DOCX: mammoth converts to semantic HTML (prose preview) - XLSX: xlsx library extended to FileNav with sheet tabs at bottom - PPTX: custom canvas renderer produces PNG images per slide with panzoom zoom/pan and slide navigation Changes: - New: src/lib/utils/pptxToHtml.ts (canvas-based PPTX renderer) - FileNav.svelte: office format detection, blob download, conversion - FilePreview.svelte: office rendering branches, sheet tabs, slide viewer - FileItemModal.svelte: DOCX/PPTX preview tabs - package.json: added mammoth dependency
This commit is contained in:
1 parent
6e43861c0c
commit
890949abe6
6 files changed
+683
-105
No files matched your search
@@ -37,6 +37,7 @@
|
||||
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
|
||||
import FilePreview from './FileNav/FilePreview.svelte';
|
||||
import FileEntryRow from './FileNav/FileEntryRow.svelte';
|
||||
import PortList from './FileNav/PortList.svelte';
|
||||
import XTerminal from './XTerminal.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
@@ -93,6 +94,14 @@
|
||||
let fileLoading = false;
|
||||
let filePreviewRef: FilePreview;
|
||||
|
||||
// ── Office preview state ────────────────────────────────────────────
|
||||
let fileOfficeHtml: string | null = null;
|
||||
let fileOfficeSlides: string[] | null = null;
|
||||
let currentSlide = 0;
|
||||
let excelSheetNames: string[] = [];
|
||||
let selectedExcelSheet = '';
|
||||
let excelWorkbook: import('xlsx').WorkBook | null = null;
|
||||
|
||||
// ── File preview toolbar state (bound from FilePreview) ─────────────
|
||||
let editing = false;
|
||||
let showRaw = false;
|
||||
@@ -101,12 +110,15 @@
|
||||
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
|
||||
const CSV_EXTS = new Set(['csv', 'tsv']);
|
||||
const HTML_EXTS = new Set(['html', 'htm']);
|
||||
const OFFICE_EXTS = new Set(['docx', 'xlsx', 'pptx']);
|
||||
const getFileExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
$: isMarkdown = MD_EXTS.has(getFileExt(selectedFile));
|
||||
$: isCsv = CSV_EXTS.has(getFileExt(selectedFile));
|
||||
$: isHtml = HTML_EXTS.has(getFileExt(selectedFile));
|
||||
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
|
||||
$: isOfficeFile = OFFICE_EXTS.has(getFileExt(selectedFile));
|
||||
$: isTextFile =
|
||||
fileContent !== null && fileImageUrl === null && filePdfData === null && !isOfficeFile;
|
||||
|
||||
// ── Upload / folder creation ─────────────────────────────────────────
|
||||
let isDragOver = false;
|
||||
@@ -169,6 +181,7 @@
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']);
|
||||
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
|
||||
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
|
||||
const buildBreadcrumbs = (path: string) =>
|
||||
path
|
||||
@@ -192,6 +205,12 @@
|
||||
fileImageUrl = null;
|
||||
}
|
||||
filePdfData = null;
|
||||
fileOfficeHtml = null;
|
||||
fileOfficeSlides = null;
|
||||
currentSlide = 0;
|
||||
excelSheetNames = [];
|
||||
selectedExcelSheet = '';
|
||||
excelWorkbook = null;
|
||||
};
|
||||
|
||||
// ── Directory operations ─────────────────────────────────────────────
|
||||
@@ -244,6 +263,41 @@
|
||||
} else if (isPdf(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) filePdfData = await result.blob.arrayBuffer();
|
||||
} else if (isOffice(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) {
|
||||
const ext = getFileExt(filePath);
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
try {
|
||||
if (ext === 'docx') {
|
||||
const mammoth = await import('mammoth');
|
||||
const res = await mammoth.convertToHtml({ arrayBuffer });
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(res.value);
|
||||
} else if (ext === 'xlsx') {
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
|
||||
excelWorkbook = wb;
|
||||
excelSheetNames = wb.SheetNames;
|
||||
if (excelSheetNames.length > 0) {
|
||||
selectedExcelSheet = excelSheetNames[0];
|
||||
const ws = wb.Sheets[selectedExcelSheet];
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(
|
||||
XLSX.utils.sheet_to_html(ws, { id: 'excel-table', editable: false, header: '' })
|
||||
);
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = result.images;
|
||||
currentSlide = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to render Office file:', e);
|
||||
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileContent = await readFile(terminal.url, terminal.key, filePath);
|
||||
}
|
||||
@@ -536,7 +590,7 @@
|
||||
onUploadFiles={handleUploadFiles}
|
||||
onMove={handleMove}
|
||||
>
|
||||
{#if fileImageUrl !== null}
|
||||
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
|
||||
<Tooltip content={$i18n.t('Reset view')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
@@ -664,6 +718,7 @@
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('Download')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
@@ -696,11 +751,26 @@
|
||||
bind:editing
|
||||
bind:showRaw
|
||||
bind:saving
|
||||
bind:currentSlide
|
||||
{selectedFile}
|
||||
{fileLoading}
|
||||
{fileImageUrl}
|
||||
{filePdfData}
|
||||
{fileContent}
|
||||
{fileOfficeHtml}
|
||||
{fileOfficeSlides}
|
||||
{excelSheetNames}
|
||||
{selectedExcelSheet}
|
||||
onSheetChange={async (sheet) => {
|
||||
if (!excelWorkbook) return;
|
||||
selectedExcelSheet = sheet;
|
||||
const XLSX = await import('xlsx');
|
||||
const ws = excelWorkbook.Sheets[sheet];
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(
|
||||
XLSX.utils.sheet_to_html(ws, { id: 'excel-table', editable: false, header: '' })
|
||||
);
|
||||
}}
|
||||
{overlay}
|
||||
onSave={async (content) => {
|
||||
const terminal = selectedTerminal;
|
||||
@@ -797,6 +867,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Port detection -->
|
||||
{#if selectedTerminal && !selectedFile}
|
||||
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800">
|
||||
<PortList baseUrl={selectedTerminal.url} apiKey={selectedTerminal.key} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Terminal bottom panel -->
|
||||
{#if terminalEnabled}
|
||||
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
|
||||
|
||||
Reference in new issue
Block a user