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:
@@ -40,6 +40,8 @@
|
||||
let isAudio = false;
|
||||
let isImage = false;
|
||||
let isExcel = false;
|
||||
let isDocx = false;
|
||||
let isPptx = false;
|
||||
|
||||
let selectedTab = '';
|
||||
let excelWorkbook: WorkBook | null = null;
|
||||
@@ -49,6 +51,15 @@
|
||||
let excelError = '';
|
||||
let rowCount = 0;
|
||||
|
||||
// DOCX state
|
||||
let docxHtml = '';
|
||||
let docxError = '';
|
||||
|
||||
// PPTX state
|
||||
let pptxSlides: string[] = [];
|
||||
let pptxCurrentSlide = 0;
|
||||
let pptxError = '';
|
||||
|
||||
let pzInstance: PanZoom | null = null;
|
||||
|
||||
const initImagePanzoom = (node: HTMLElement) => {
|
||||
@@ -128,6 +139,16 @@
|
||||
item.name.toLowerCase().endsWith('.xlsx') ||
|
||||
item.name.toLowerCase().endsWith('.csv')));
|
||||
|
||||
$: isDocx =
|
||||
item?.meta?.content_type ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
(item?.name && item.name.toLowerCase().endsWith('.docx'));
|
||||
|
||||
$: isPptx =
|
||||
item?.meta?.content_type ===
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
(item?.name && item.name.toLowerCase().endsWith('.pptx'));
|
||||
|
||||
const loadExcelContent = async () => {
|
||||
try {
|
||||
excelError = '';
|
||||
@@ -170,6 +191,37 @@
|
||||
renderExcelSheet();
|
||||
}
|
||||
|
||||
const loadDocxContent = async () => {
|
||||
try {
|
||||
docxError = '';
|
||||
const [arrayBuffer, mammoth] = await Promise.all([
|
||||
getFileContentById(item.id),
|
||||
import('mammoth')
|
||||
]);
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
docxHtml = DOMPurify.sanitize(result.value);
|
||||
} catch (error) {
|
||||
console.error('Error loading DOCX file:', error);
|
||||
docxError = $i18n.t('Failed to load DOCX file. Please try downloading it instead.');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPptxContent = async () => {
|
||||
try {
|
||||
pptxError = '';
|
||||
const [arrayBuffer, { pptxToImages }] = await Promise.all([
|
||||
getFileContentById(item.id),
|
||||
import('$lib/utils/pptxToHtml')
|
||||
]);
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
pptxSlides = result.images;
|
||||
pptxCurrentSlide = 0;
|
||||
} catch (error) {
|
||||
console.error('Error loading PPTX file:', error);
|
||||
pptxError = $i18n.t('Failed to load PPTX file. Please try downloading it instead.');
|
||||
}
|
||||
};
|
||||
|
||||
const loadContent = async () => {
|
||||
selectedTab = '';
|
||||
expandedContent = false;
|
||||
@@ -201,6 +253,12 @@
|
||||
if (isExcel) {
|
||||
await loadExcelContent();
|
||||
}
|
||||
if (isDocx) {
|
||||
await loadDocxContent();
|
||||
}
|
||||
if (isPptx) {
|
||||
await loadPptxContent();
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
@@ -358,7 +416,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAudio || isPDF || isExcel || isCode || isMarkdown}
|
||||
{#if isAudio || isPDF || isExcel || isCode || isMarkdown || isDocx || isPptx}
|
||||
<div
|
||||
class="flex mb-2.5 scrollbar-none overflow-x-auto w-full border-b border-gray-50 dark:border-gray-850/30 text-center text-sm font-medium bg-transparent dark:text-gray-200"
|
||||
>
|
||||
@@ -534,6 +592,56 @@
|
||||
>
|
||||
<Markdown content={item.file.data.content} id="markdown-viewer" />
|
||||
</div>
|
||||
{:else if isDocx}
|
||||
{#if docxError}
|
||||
<div class="text-red-500 text-sm p-4">{docxError}</div>
|
||||
{:else if docxHtml}
|
||||
<div class="office-preview max-h-[60vh] overflow-auto p-4 prose dark:prose-invert max-w-full text-sm">
|
||||
{@html docxHtml}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-500 text-sm p-4">No content available</div>
|
||||
{/if}
|
||||
{:else if isPptx}
|
||||
{#if pptxError}
|
||||
<div class="text-red-500 text-sm p-4">{pptxError}</div>
|
||||
{:else if pptxSlides.length > 0}
|
||||
<div class="max-h-[60vh] overflow-auto">
|
||||
<div class="flex justify-center p-4">
|
||||
<img
|
||||
src={pptxSlides[pptxCurrentSlide]}
|
||||
alt="Slide {pptxCurrentSlide + 1}"
|
||||
class="max-w-full max-h-[50vh] object-contain rounded-md shadow-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{#if pptxSlides.length > 1}
|
||||
<div class="flex items-center justify-center gap-3 pb-3 text-sm text-gray-500">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={pptxCurrentSlide === 0}
|
||||
on:click={() => pptxCurrentSlide = Math.max(0, pptxCurrentSlide - 1)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<span>{pptxCurrentSlide + 1} / {pptxSlides.length}</span>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={pptxCurrentSlide === pptxSlides.length - 1}
|
||||
on:click={() => pptxCurrentSlide = Math.min(pptxSlides.length - 1, pptxCurrentSlide + 1)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-500 text-sm p-4">No content available</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
|
||||
{(item?.file?.data?.content ?? '').trim() || 'No content'}
|
||||
|
||||
Reference in New Issue
Block a user