Spaces:
Build error
Build error
| <script lang="ts"> | |
| import { getContext, onDestroy, tick } from 'svelte'; | |
| import panzoom, { type PanZoom } from 'panzoom'; | |
| import { marked } from 'marked'; | |
| import DOMPurify from 'dompurify'; | |
| import { settings } from '$lib/stores'; | |
| import { isCodeFile, highlightCode } from '$lib/utils/codeHighlight'; | |
| import Spinner from '../../common/Spinner.svelte'; | |
| import PDFViewer from '../../common/PDFViewer.svelte'; | |
| import JsonTreeView from './JsonTreeView.svelte'; | |
| import NotebookView from './NotebookView.svelte'; | |
| let pdfViewerRef: PDFViewer; | |
| const i18n = getContext('i18n'); | |
| export let selectedFile: string | null = null; | |
| export let fileLoading = false; | |
| export let fileImageUrl: string | null = null; | |
| export let fileVideoUrl: string | null = null; | |
| export let fileAudioUrl: string | null = null; | |
| export let filePdfData: ArrayBuffer | null = null; | |
| export let fileContent: string | null = null; | |
| // Office preview props | |
| export let fileOfficeHtml: string | null = null; | |
| export let fileOfficeSlides: string[] | null = null; | |
| export let currentSlide = 0; | |
| export let excelSheetNames: string[] = []; | |
| export let selectedExcelSheet = ''; | |
| export let onSheetChange: ((sheet: string) => void) | null = null; | |
| export let overlay = false; | |
| export let onSave: ((content: string) => Promise<void>) | null = null; | |
| export let editing = false; | |
| let editContent = ''; | |
| export let saving = false; | |
| let editTextarea: HTMLTextAreaElement; | |
| // Reset edit state when switching files | |
| $: selectedFile, resetEdit(); | |
| const resetEdit = () => { | |
| editing = false; | |
| editContent = ''; | |
| saving = false; | |
| }; | |
| export const startEdit = async () => { | |
| editContent = fileContent ?? ''; | |
| editing = true; | |
| showRaw = true; | |
| await tick(); | |
| editTextarea?.focus(); | |
| }; | |
| export const saveEdit = async () => { | |
| if (!onSave) return; | |
| saving = true; | |
| await onSave(editContent); | |
| saving = false; | |
| editing = false; | |
| }; | |
| export const cancelEdit = () => { | |
| editing = false; | |
| editContent = ''; | |
| }; | |
| $: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null; | |
| const MD_EXTS = new Set(['md', 'markdown', 'mdx']); | |
| const CSV_EXTS = new Set(['csv', 'tsv']); | |
| const HTML_EXTS = new Set(['html', 'htm']); | |
| const JSON_EXTS = new Set(['json', 'jsonc', 'jsonl', 'json5']); | |
| const getExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? ''; | |
| $: isMarkdown = MD_EXTS.has(getExt(selectedFile)); | |
| $: isCsv = CSV_EXTS.has(getExt(selectedFile)); | |
| $: isHtml = HTML_EXTS.has(getExt(selectedFile)); | |
| $: isJson = JSON_EXTS.has(getExt(selectedFile)); | |
| $: isSvg = getExt(selectedFile) === 'svg'; | |
| $: isNotebook = getExt(selectedFile) === 'ipynb'; | |
| $: isCode = isCodeFile(selectedFile); | |
| $: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ','; | |
| $: renderedHtml = | |
| isMarkdown && fileContent | |
| ? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string) | |
| : ''; | |
| // Simple CSV parser that handles quoted fields | |
| const parseCsv = (text: string, delimiter: string): string[][] => { | |
| const rows: string[][] = []; | |
| let row: string[] = []; | |
| let field = ''; | |
| let inQuotes = false; | |
| for (let i = 0; i < text.length; i++) { | |
| const ch = text[i]; | |
| if (inQuotes) { | |
| if (ch === '"') { | |
| if (text[i + 1] === '"') { | |
| field += '"'; | |
| i++; | |
| } else { | |
| inQuotes = false; | |
| } | |
| } else { | |
| field += ch; | |
| } | |
| } else if (ch === '"') { | |
| inQuotes = true; | |
| } else if (ch === delimiter) { | |
| row.push(field); | |
| field = ''; | |
| } else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) { | |
| if (ch === '\r') i++; | |
| row.push(field); | |
| field = ''; | |
| if (row.some((c) => c !== '')) rows.push(row); | |
| row = []; | |
| } else { | |
| field += ch; | |
| } | |
| } | |
| row.push(field); | |
| if (row.some((c) => c !== '')) rows.push(row); | |
| return rows; | |
| }; | |
| $: csvRows = isCsv && fileContent ? parseCsv(fileContent, csvDelimiter) : []; | |
| $: csvHeader = csvRows.length > 0 ? csvRows[0] : []; | |
| $: csvBody = csvRows.length > 1 ? csvRows.slice(1) : []; | |
| // ββ Shiki code highlighting βββββββββββββββββββββββββββββββββββββββββ | |
| let highlightedHtml: string | null = null; | |
| let highlightingFile: string | null = null; // track which file we're highlighting | |
| $: if ((isCode || isSvg) && fileContent !== null && selectedFile) { | |
| const currentFile = selectedFile; | |
| highlightingFile = currentFile; | |
| const lang = isSvg ? 'xml' : undefined; | |
| (lang | |
| ? import('shiki').then(({ codeToHtml }) => | |
| codeToHtml(fileContent!, { | |
| lang: 'xml', | |
| themes: { light: 'github-light', dark: 'github-dark' }, | |
| defaultColor: 'light' | |
| }) | |
| ) | |
| : highlightCode(fileContent!, selectedFile!) | |
| ) | |
| .then((html) => { | |
| if (highlightingFile === currentFile) highlightedHtml = html; | |
| }) | |
| .catch(() => { | |
| if (highlightingFile === currentFile) highlightedHtml = null; | |
| }); | |
| } else { | |
| highlightedHtml = null; | |
| } | |
| // ββ JSON parsing ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let parsedJson: unknown = undefined; | |
| let jsonError: string | null = null; | |
| $: if (isJson && fileContent !== null) { | |
| try { | |
| parsedJson = JSON.parse(fileContent); | |
| jsonError = null; | |
| } catch (e) { | |
| parsedJson = undefined; | |
| jsonError = e instanceof Error ? e.message : 'Invalid JSON'; | |
| } | |
| } else { | |
| parsedJson = undefined; | |
| jsonError = null; | |
| } | |
| // ββ Notebook parsing βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let parsedNotebook: Record<string, unknown> | null = null; | |
| $: if (isNotebook && fileContent !== null) { | |
| try { | |
| parsedNotebook = JSON.parse(fileContent); | |
| } catch { | |
| parsedNotebook = null; | |
| } | |
| } else { | |
| parsedNotebook = null; | |
| } | |
| export let showRaw = false; | |
| $: selectedFile, (showRaw = false); // reset to preview mode when switching files | |
| let pzInstance: PanZoom | null = null; | |
| const initImagePanzoom = (node: HTMLElement) => { | |
| pzInstance = panzoom(node, { | |
| bounds: true, | |
| boundsPadding: 0.1, | |
| zoomSpeed: 0.065, | |
| zoomDoubleClickSpeed: 1 | |
| }); | |
| }; | |
| export const resetImageView = () => { | |
| if (pzInstance) { | |
| pzInstance.moveTo(0, 0); | |
| pzInstance.zoomAbs(0, 0, 1); | |
| } | |
| }; | |
| export const disposePanzoom = () => { | |
| if (pzInstance) { | |
| pzInstance.dispose(); | |
| pzInstance = null; | |
| } | |
| }; | |
| export const resetPdfView = () => { | |
| pdfViewerRef?.resetView(); | |
| }; | |
| onDestroy(() => { | |
| disposePanzoom(); | |
| }); | |
| </script> | |
| <div | |
| class="flex-1 {fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0) | |
| ? 'overflow-hidden' | |
| : 'overflow-y-auto'} min-h-0 relative h-full" | |
| > | |
| <!-- File preview --> | |
| {#if fileLoading} | |
| <div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div> | |
| {:else if fileImageUrl !== null} | |
| <div class="w-full h-full flex items-center justify-center" use:initImagePanzoom> | |
| <img | |
| src={fileImageUrl} | |
| alt={selectedFile?.split('/').pop()} | |
| class="max-w-full max-h-full object-contain p-3" | |
| draggable="false" | |
| /> | |
| </div> | |
| {:else if fileVideoUrl !== null} | |
| <div class="w-full h-full flex items-center justify-center bg-black"> | |
| <!-- svelte-ignore a11y-media-has-caption --> | |
| <video | |
| src={fileVideoUrl} | |
| controls | |
| class="max-w-full max-h-full" | |
| > | |
| {$i18n.t('Your browser does not support the video tag.')} | |
| </video> | |
| </div> | |
| {:else if fileAudioUrl !== null} | |
| <div class="w-full h-full flex items-center justify-center p-6"> | |
| <audio | |
| src={fileAudioUrl} | |
| controls | |
| class="w-full max-w-md" | |
| > | |
| {$i18n.t('Your browser does not support the audio tag.')} | |
| </audio> | |
| </div> | |
| {:else if filePdfData !== null} | |
| <PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" /> | |
| {:else if fileOfficeHtml !== null} | |
| <div class="flex flex-col h-full"> | |
| <div class="office-preview overflow-auto flex-1 min-h-0"> | |
| {@html fileOfficeHtml} | |
| </div> | |
| {#if excelSheetNames.length > 1} | |
| <div class="flex items-center gap-1 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 overflow-x-auto"> | |
| {#each excelSheetNames as sheet} | |
| <button | |
| class="shrink-0 px-3 py-1 text-xs rounded-md transition-colors | |
| {selectedExcelSheet === sheet | |
| ? 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-medium' | |
| : 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}" | |
| on:click={() => onSheetChange?.(sheet)} | |
| > | |
| {sheet} | |
| </button> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| {:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0} | |
| <div class="flex flex-col h-full"> | |
| <div class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden" use:initImagePanzoom> | |
| <img | |
| src={fileOfficeSlides[currentSlide]} | |
| alt="Slide {currentSlide + 1}" | |
| class="max-w-full max-h-full object-contain p-3" | |
| draggable="false" | |
| /> | |
| </div> | |
| {#if fileOfficeSlides.length > 1} | |
| <div class="flex items-center justify-center gap-3 py-2 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500"> | |
| <button | |
| class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30" | |
| disabled={currentSlide === 0} | |
| on:click={() => { resetImageView(); currentSlide = Math.max(0, currentSlide - 1); }} | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4"> | |
| <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>{currentSlide + 1} / {fileOfficeSlides.length}</span> | |
| <button | |
| class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30" | |
| disabled={currentSlide === fileOfficeSlides.length - 1} | |
| on:click={() => { resetImageView(); currentSlide = Math.min(fileOfficeSlides.length - 1, currentSlide + 1); }} | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4"> | |
| <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 if fileContent !== null} | |
| {#if isHtml && !showRaw} | |
| {#if overlay} | |
| <div class="absolute top-0 left-0 right-0 bottom-0 z-10"></div> | |
| {/if} | |
| <iframe | |
| srcdoc={fileContent} | |
| sandbox="allow-scripts allow-downloads{($settings?.iframeSandboxAllowForms ?? false) | |
| ? ' allow-forms' | |
| : ''}{($settings?.iframeSandboxAllowSameOrigin ?? false) ? ' allow-same-origin' : ''}" | |
| class="w-full h-full border-none bg-white" | |
| title="HTML Preview" | |
| /> | |
| {:else if isMarkdown && !showRaw} | |
| <div class="prose dark:prose-invert max-w-full text-sm p-3"> | |
| {@html renderedHtml} | |
| </div> | |
| {:else if isCsv && !showRaw && csvRows.length > 0} | |
| <div class="overflow-auto h-full px-3 pb-3"> | |
| <table class="csv-table w-full text-xs font-mono border-collapse"> | |
| <thead> | |
| <tr> | |
| <th class="csv-row-num">#</th> | |
| {#each csvHeader as cell} | |
| <th>{cell}</th> | |
| {/each} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {#each csvBody as row, i} | |
| <tr> | |
| <td class="csv-row-num">{i + 1}</td> | |
| {#each row as cell} | |
| <td>{cell}</td> | |
| {/each} | |
| <!-- Pad missing columns --> | |
| {#each Array(Math.max(0, csvHeader.length - row.length)) as _} | |
| <td></td> | |
| {/each} | |
| </tr> | |
| {/each} | |
| </tbody> | |
| </table> | |
| </div> | |
| {:else if isNotebook && !showRaw && parsedNotebook} | |
| <div class="overflow-auto h-full"> | |
| <NotebookView notebook={parsedNotebook} /> | |
| </div> | |
| {:else if isJson && !showRaw && parsedJson !== undefined} | |
| <div class="overflow-auto h-full"> | |
| <JsonTreeView data={parsedJson} /> | |
| </div> | |
| {:else if isJson && !showRaw && jsonError} | |
| <div class="p-3 text-xs"> | |
| <div class="text-red-500 mb-2">JSON parse error: {jsonError}</div> | |
| <pre class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed">{fileContent}</pre> | |
| </div> | |
| {:else if isSvg && !showRaw && fileContent} | |
| <div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3"> | |
| {@html DOMPurify.sanitize(fileContent, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['use'] })} | |
| </div> | |
| {:else if (isCode || isSvg) && highlightedHtml && !showRaw} | |
| <div class="shiki-preview overflow-auto h-full text-xs"> | |
| {@html highlightedHtml} | |
| </div> | |
| {:else if editing} | |
| <textarea | |
| bind:this={editTextarea} | |
| bind:value={editContent} | |
| class="w-full h-full text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre break-all leading-relaxed p-3 bg-transparent border-none outline-none resize-none" | |
| spellcheck="false" | |
| /> | |
| {:else} | |
| <pre | |
| class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed p-3">{fileContent}</pre> | |
| {/if} | |
| {:else} | |
| <div class="text-xs text-gray-400 text-center pt-8"> | |
| {$i18n.t('Could not read file.')} | |
| </div> | |
| {/if} | |
| </div> | |
| <style> | |
| .csv-table { | |
| font-size: 0.7rem; | |
| line-height: 1.4; | |
| } | |
| .csv-table th, | |
| .csv-table td { | |
| padding: 4px 8px; | |
| text-align: left; | |
| white-space: nowrap; | |
| border: 1px solid rgba(128, 128, 128, 0.15); | |
| } | |
| .csv-table thead th { | |
| position: sticky; | |
| top: 0; | |
| background: rgba(243, 244, 246, 0.95); | |
| backdrop-filter: blur(4px); | |
| font-weight: 600; | |
| color: #374151; | |
| border-bottom: 2px solid rgba(128, 128, 128, 0.25); | |
| z-index: 1; | |
| } | |
| :global(.dark) .csv-table thead th { | |
| background: rgba(31, 41, 55, 0.95); | |
| color: #d1d5db; | |
| } | |
| .csv-table tbody tr:nth-child(even) { | |
| background: rgba(128, 128, 128, 0.04); | |
| } | |
| .csv-table tbody tr:hover { | |
| background: rgba(59, 130, 246, 0.06); | |
| } | |
| :global(.dark) .csv-table tbody tr:hover { | |
| background: rgba(59, 130, 246, 0.1); | |
| } | |
| .csv-table td { | |
| color: #374151; | |
| } | |
| :global(.dark) .csv-table td { | |
| color: #d1d5db; | |
| } | |
| .csv-row-num { | |
| color: #9ca3af; | |
| font-size: 0.6rem; | |
| text-align: right !important; | |
| user-select: none; | |
| width: 1px; | |
| padding-right: 6px !important; | |
| } | |
| :global(.dark) .csv-row-num { | |
| color: #6b7280; | |
| } | |
| /* ββ Office preview styles ββββββββββββββββββββββββββββββββββββββββ */ | |
| :global(.office-preview) { | |
| font-size: 0.875rem; | |
| line-height: 1.6; | |
| color: #1f2937; | |
| background: #fff; | |
| border-radius: 4px; | |
| } | |
| :global(.dark .office-preview) { | |
| color: #e5e7eb; | |
| background: #1a1a2e; | |
| } | |
| :global(.office-preview table) { | |
| border-collapse: collapse; | |
| font-size: 0.75rem; | |
| font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; | |
| line-height: 1.3; | |
| } | |
| :global(.office-preview table td), | |
| :global(.office-preview table th) { | |
| border: 1px solid rgba(200, 200, 200, 0.5); | |
| padding: 4px 10px; | |
| text-align: left; | |
| white-space: nowrap; | |
| user-select: text; | |
| cursor: cell; | |
| max-width: 300px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| :global(.dark .office-preview table td), | |
| :global(.dark .office-preview table th) { | |
| border-color: rgba(80, 80, 80, 0.5); | |
| } | |
| /* Column letter headers */ | |
| :global(.office-preview table th.excel-col-hdr) { | |
| position: sticky; | |
| top: 0; | |
| z-index: 2; | |
| background: #f0f0f0; | |
| color: #666; | |
| font-weight: 500; | |
| font-size: 0.65rem; | |
| text-align: center; | |
| padding: 3px 10px; | |
| border-bottom: 2px solid rgba(180, 180, 180, 0.6); | |
| } | |
| :global(.dark .office-preview table th.excel-col-hdr) { | |
| background: #2a2a3e; | |
| color: #888; | |
| border-bottom-color: rgba(100, 100, 100, 0.6); | |
| } | |
| /* Row number cells */ | |
| :global(.office-preview .excel-row-num) { | |
| position: sticky; | |
| left: 0; | |
| z-index: 1; | |
| background: #f0f0f0; | |
| color: #999; | |
| font-size: 0.6rem; | |
| text-align: right !important; | |
| padding: 4px 8px 4px 4px !important; | |
| user-select: none; | |
| width: 1px; | |
| white-space: nowrap; | |
| border-right: 2px solid rgba(180, 180, 180, 0.6) !important; | |
| } | |
| :global(.dark .office-preview .excel-row-num) { | |
| background: #2a2a3e; | |
| color: #666; | |
| border-right-color: rgba(100, 100, 100, 0.6) !important; | |
| } | |
| /* Corner cell (intersection of row nums and col headers) */ | |
| :global(.office-preview thead .excel-row-num) { | |
| z-index: 3; | |
| } | |
| /* Number cells right-aligned */ | |
| :global(.office-preview .excel-num) { | |
| text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* Row hover and selection */ | |
| :global(.office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) { | |
| background: rgba(0, 0, 0, 0.015); | |
| } | |
| :global(.dark .office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) { | |
| background: rgba(255, 255, 255, 0.02); | |
| } | |
| :global(.office-preview table tbody tr:hover td:not(.excel-row-num)) { | |
| background: rgba(59, 130, 246, 0.06); | |
| } | |
| :global(.dark .office-preview table tbody tr:hover td:not(.excel-row-num)) { | |
| background: rgba(59, 130, 246, 0.1); | |
| } | |
| :global(.office-preview table td:focus) { | |
| outline: 2px solid rgba(59, 130, 246, 0.5); | |
| outline-offset: -2px; | |
| } | |
| /* DOCX / generic office styles */ | |
| :global(.office-preview img) { | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| :global(.office-preview h1) { font-size: 1.5rem; font-weight: 700; margin: 0.75em 0 0.5em; } | |
| :global(.office-preview h2) { font-size: 1.25rem; font-weight: 600; margin: 0.75em 0 0.5em; } | |
| :global(.office-preview h3) { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; } | |
| :global(.office-preview p) { margin: 0.25em 0; } | |
| :global(.office-preview ul), | |
| :global(.office-preview ol) { padding-left: 1.5em; margin: 0.5em 0; } | |
| /* ββ Shiki code highlighting βββββββββββββββββββββββββββββββββββ */ | |
| .shiki-preview :global(pre.shiki) { | |
| margin: 0; | |
| padding: 0.75rem 1rem; | |
| font-size: 0.75rem; | |
| line-height: 1.6; | |
| border-radius: 0; | |
| overflow-x: auto; | |
| } | |
| .shiki-preview :global(pre.shiki code) { | |
| counter-reset: line; | |
| } | |
| .shiki-preview :global(pre.shiki code > .line) { | |
| counter-increment: line; | |
| display: inline-block; | |
| width: 100%; | |
| white-space: pre; | |
| } | |
| .shiki-preview :global(pre.shiki code > .line::before) { | |
| content: counter(line); | |
| display: inline-block; | |
| width: 2.5em; | |
| text-align: right; | |
| margin-right: 1em; | |
| color: #9ca3af; | |
| user-select: none; | |
| font-size: 0.65rem; | |
| } | |
| :global(.dark) .shiki-preview :global(pre.shiki code > .line::before) { | |
| color: #4b5563; | |
| } | |
| /* Shiki dual-theme: swap CSS variables in dark mode */ | |
| :global(.dark) .shiki-preview :global(.shiki), | |
| :global(.dark) .shiki-preview :global(.shiki span) { | |
| color: var(--shiki-dark) !important; | |
| background-color: var(--shiki-dark-bg) !important; | |
| font-style: var(--shiki-dark-font-style) !important; | |
| font-weight: var(--shiki-dark-font-weight) !important; | |
| text-decoration: var(--shiki-dark-text-decoration) !important; | |
| } | |
| </style> | |