This commit is contained in:
Timothy Jaeryang Baek
2026-08-10 01:14:53 -06:00
parent 8dd23f74c9
commit ff7467b4c5
7 changed files with 377 additions and 20 deletions
+10
View File
@@ -52,6 +52,7 @@
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"docx-preview": "^0.4.0",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
@@ -7640,6 +7641,15 @@
"node": ">=6.0.0"
}
},
"node_modules/docx-preview": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.0.tgz",
"integrity": "sha512-OdKtE/uj3M4RfGarLkGjahUzRg8/kBp0Sraj1r1NAY1tp/sTpHOBqDrzVf9onMBt9vxP6SdQ6bpLCUCsFwjgcA==",
"license": "Apache-2.0",
"dependencies": {
"jszip": ">=3.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+1
View File
@@ -96,6 +96,7 @@
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"docx-preview": "^0.4.0",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
+5 -6
View File
@@ -184,6 +184,7 @@
let fileAudioUrl: string | null = null;
let filePdfData: ArrayBuffer | null = null;
let fileSqliteData: ArrayBuffer | null = null;
let fileDocxData: ArrayBuffer | null = null;
let fileLoading = false;
let filePreviewRef: FilePreview;
@@ -247,8 +248,7 @@
const chatScoped = !!systemTerminal && chatConfig?.context_id === 'chat_id';
terminalChatContextHidden =
!!systemTerminal && (chatConfig === false || (chatScoped && isTemporaryChatId(chatId)));
terminalChatContextPending =
chatScoped && !terminalChatContextHidden && !isSavedChatId(chatId);
terminalChatContextPending = chatScoped && !terminalChatContextHidden && !isSavedChatId(chatId);
if (terminalChatContextHidden || terminalChatContextPending) return null;
const userTerminal = ($settings?.terminalServers ?? []).find(
@@ -395,6 +395,7 @@
}
filePdfData = null;
fileSqliteData = null;
fileDocxData = null;
fileOfficeHtml = null;
fileOfficeSlides = null;
currentSlide = 0;
@@ -505,10 +506,7 @@
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);
fileDocxData = arrayBuffer;
} else if (ext === 'xlsx') {
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
@@ -1396,6 +1394,7 @@
{fileAudioUrl}
{filePdfData}
{fileSqliteData}
{fileDocxData}
{fileContent}
{fileOfficeHtml}
{fileOfficeSlides}
@@ -9,6 +9,7 @@
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
import DocxPreview from '../../common/DocxPreview.svelte';
import JsonTreeView from './JsonTreeView.svelte';
import NotebookView from './NotebookView.svelte';
import SqliteView from './SqliteView.svelte';
@@ -26,6 +27,7 @@
export let fileAudioUrl: string | null = null;
export let filePdfData: ArrayBuffer | null = null;
export let fileSqliteData: ArrayBuffer | null = null;
export let fileDocxData: ArrayBuffer | null = null;
export let fileContent: string | null = null;
// Terminal connection for notebook execution
@@ -271,7 +273,9 @@
</script>
<div
class="flex-1 {fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)
class="flex-1 {fileImageUrl !== null ||
fileDocxData !== null ||
(fileOfficeSlides !== null && fileOfficeSlides.length > 0)
? 'overflow-hidden'
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
>
@@ -308,6 +312,8 @@
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileDocxData !== null}
<DocxPreview data={fileDocxData} 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">
@@ -0,0 +1,318 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import type { Readable } from 'svelte/store';
import Spinner from './Spinner.svelte';
type I18n = {
t: (key: string, values?: Record<string, unknown>) => string;
};
const i18n = getContext<Readable<I18n>>('i18n');
export let data: ArrayBuffer | null = null;
export let className = '';
let outerContainer: HTMLDivElement;
let containerEl: HTMLDivElement;
let styleEl: HTMLDivElement;
let fallbackHtml = '';
let error = '';
let loading = false;
let renderId = 0;
let mounted = false;
let fitScale = 1;
let zoomLevel = 1;
let resizeObserver: ResizeObserver | null = null;
$: docxScale = Math.max(0.25, fitScale * zoomLevel);
const clearPreview = () => {
if (containerEl) containerEl.innerHTML = '';
if (styleEl) styleEl.innerHTML = '';
fallbackHtml = '';
error = '';
};
const updateFitScale = () => {
const page = containerEl?.querySelector('section.docx') as HTMLElement | null;
if (!outerContainer || !page) {
fitScale = 1;
return;
}
const computedWidth = parseFloat(getComputedStyle(page).width);
const pageWidth = Number.isFinite(computedWidth)
? computedWidth
: page.getBoundingClientRect().width / docxScale;
const availableWidth = Math.max(320, outerContainer.clientWidth - 32);
fitScale = Math.min(1, availableWidth / pageWidth);
};
const zoomIn = () => {
zoomLevel = Math.min(4, zoomLevel * 1.25);
};
const zoomOut = () => {
zoomLevel = Math.max(0.25, zoomLevel * 0.8);
};
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
if (!outerContainer) return;
e.preventDefault();
const oldScale = docxScale;
const rect = outerContainer.getBoundingClientRect();
const pointerX = e.clientX - rect.left + outerContainer.scrollLeft;
const pointerY = e.clientY - rect.top + outerContainer.scrollTop;
const factor = Math.exp(-e.deltaY * 0.002);
zoomLevel = Math.max(0.25, Math.min(4, zoomLevel * factor));
void tick().then(() => {
const ratio = docxScale / oldScale;
outerContainer.scrollLeft = pointerX * ratio - (e.clientX - rect.left);
outerContainer.scrollTop = pointerY * ratio - (e.clientY - rect.top);
});
};
export const resetView = () => {
zoomLevel = 1;
updateFitScale();
};
const renderDocx = async (arrayBuffer: ArrayBuffer | null) => {
const currentRender = ++renderId;
clearPreview();
zoomLevel = 1;
if (!arrayBuffer || !containerEl || !styleEl) return;
loading = true;
await tick();
try {
const { renderAsync } = await import('docx-preview');
if (currentRender !== renderId) return;
await renderAsync(arrayBuffer.slice(0), containerEl, styleEl, {
breakPages: true,
className: 'docx',
ignoreLastRenderedPageBreak: false,
inWrapper: true,
renderEndnotes: true,
renderFooters: true,
renderFootnotes: true,
renderHeaders: true,
useBase64URL: true
});
await tick();
updateFitScale();
} catch (e) {
console.error('Error rendering DOCX preview:', e);
try {
const { docxToHtml } = await import('$lib/utils/docxToHtml');
if (currentRender !== renderId) return;
fallbackHtml = DOMPurify.sanitize(await docxToHtml(arrayBuffer.slice(0)));
} catch (fallbackError) {
console.error('Error rendering DOCX fallback:', fallbackError);
error = $i18n.t('Failed to load DOCX file. Please try downloading it instead.');
}
} finally {
if (currentRender === renderId) loading = false;
}
};
$: if (mounted) renderDocx(data);
onMount(() => {
mounted = true;
void tick().then(() => {
if (!outerContainer) return;
resizeObserver = new ResizeObserver(updateFitScale);
resizeObserver.observe(outerContainer);
updateFitScale();
});
});
onDestroy(() => {
renderId += 1;
resizeObserver?.disconnect();
clearPreview();
});
</script>
<div class="owui-docx-preview {className}" style="--docx-scale: {docxScale};">
<div bind:this={styleEl}></div>
{#if loading}
<div class="absolute inset-0 flex items-center justify-center bg-white/70 dark:bg-gray-950/60">
<Spinner className="size-4" />
</div>
{/if}
{#if error}
<div class="text-red-500 text-sm p-4">{error}</div>
{:else if fallbackHtml}
<div bind:this={outerContainer} class="owui-docx-scroll">
<div class="owui-docx-fallback">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html fallbackHtml}
</div>
</div>
{:else}
<div bind:this={outerContainer} class="owui-docx-scroll" on:wheel|nonpassive={handleWheel}>
<div bind:this={containerEl}></div>
</div>
{/if}
{#if !loading && !error && data && !fallbackHtml}
<div
class="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5"
>
<button
class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomOut}
aria-label="Zoom out"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="px-1.5 py-1 min-w-[3rem] text-center text-[11px] font-normal text-gray-500 dark:text-gray-400 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition tabular-nums"
on:click={resetView}
aria-label="Reset zoom"
>
{Math.round(zoomLevel * 100)}%
</button>
<button
class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomIn}
aria-label="Zoom in"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</div>
{/if}
</div>
<style>
.owui-docx-preview {
position: relative;
min-height: 100%;
background: #f3f4f6;
}
:global(.dark) .owui-docx-preview {
background: #111827;
}
.owui-docx-scroll {
height: 100%;
overflow: auto;
overscroll-behavior: contain;
}
.owui-docx-preview :global(.docx-wrapper) {
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 56px;
}
.owui-docx-preview :global(.docx-wrapper > section.docx) {
margin: 0 auto 4px !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18) !important;
zoom: var(--docx-scale);
}
.owui-docx-fallback {
max-width: 816px;
min-height: 1056px;
margin: 16px auto;
padding: 56px 64px;
background: #fff;
color: #111827;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16);
border-radius: 2px;
font-family: Calibri, Arial, sans-serif;
font-size: 1rem;
line-height: 1.45;
}
.owui-docx-fallback :global(.docx-title) {
font-size: 2rem;
line-height: 1.2;
margin: 0 0 1rem;
}
.owui-docx-fallback :global(.docx-subtitle) {
font-size: 1.25rem;
color: #6b7280;
margin: -0.5rem 0 1.25rem;
}
.owui-docx-fallback :global(.docx-caption) {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
}
.owui-docx-fallback :global(p) {
margin: 0 0 0.65rem;
}
.owui-docx-fallback :global(img) {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem auto;
}
.owui-docx-fallback :global(table) {
width: 100%;
margin: 0.75rem 0;
border-collapse: collapse;
font-family: Calibri, Arial, sans-serif;
font-size: 0.95rem;
}
.owui-docx-fallback :global(table td),
.owui-docx-fallback :global(table th) {
border: 1px solid rgba(200, 200, 200, 0.6);
padding: 6px 8px;
vertical-align: top;
}
.owui-docx-fallback :global(blockquote) {
margin: 1rem 0;
padding-left: 1rem;
border-left: 3px solid #d1d5db;
color: #374151;
}
</style>
+6 -13
View File
@@ -26,6 +26,7 @@
import Spinner from './Spinner.svelte';
import PDFViewer from './PDFViewer.svelte';
import PanzoomContainer from './PanzoomContainer.svelte';
import DocxPreview from './DocxPreview.svelte';
import Reset from '../icons/Reset.svelte';
export let item;
@@ -51,7 +52,7 @@
let rowCount = 0;
// DOCX state
let docxHtml = '';
let docxData: ArrayBuffer | null = null;
let docxError = '';
// PPTX state
@@ -172,12 +173,7 @@
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);
docxData = await getFileContentById(item.id);
} catch (error) {
console.error('Error loading DOCX file:', error);
docxError = $i18n.t('Failed to load DOCX file. Please try downloading it instead.');
@@ -203,6 +199,7 @@
const loadContent = async () => {
selectedTab = '';
expandedContent = false;
docxData = null;
if (item?.type === 'collection') {
loading = true;
@@ -570,12 +567,8 @@
{: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 if docxData}
<DocxPreview data={docxData} className="max-h-[60vh]" />
{:else}
<div class="text-gray-500 text-sm p-4">No content available</div>
{/if}
+30
View File
@@ -0,0 +1,30 @@
const DOCX_STYLE_MAP = [
"p[style-name='Title'] => h1.docx-title:fresh",
"p[style-name='Subtitle'] => p.docx-subtitle:fresh",
"p[style-name='Caption'] => p.docx-caption:fresh",
"p[style-name='Quote'] => blockquote:fresh",
"p[style-name='Intense Quote'] => blockquote:fresh",
'r[style-name="Strong"] => strong',
'r[style-name="Emphasis"] => em',
'u => u',
'strike => s'
];
export async function docxToHtml(arrayBuffer: ArrayBuffer): Promise<string> {
const mammoth = await import('mammoth');
const convertImage = mammoth.images.imgElement(async (image) => ({
src: `data:${image.contentType};base64,${await image.readAsBase64String()}`
}));
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{
convertImage,
ignoreEmptyParagraphs: false,
includeDefaultStyleMap: true,
styleMap: DOCX_STYLE_MAP
}
);
return result.value;
}