mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
451 lines
13 KiB
Svelte
451 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, tick } from 'svelte';
|
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
|
import panzoom, { type PanZoom } from 'panzoom';
|
|
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
|
|
import Spinner from './Spinner.svelte';
|
|
|
|
export let url: string | null = null;
|
|
export let data: ArrayBuffer | Uint8Array | null = null;
|
|
export let className = 'w-full h-[70vh]';
|
|
export let targetPage: number | null = null;
|
|
|
|
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
|
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
|
|
|
|
let outerContainer: HTMLDivElement;
|
|
let sceneElement: HTMLDivElement;
|
|
let loading = true;
|
|
let error = '';
|
|
let pdfDoc: PdfDocument | null = null;
|
|
let pzInstance: PanZoom | null = null;
|
|
let zoomLevel = 1;
|
|
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let lastRenderedZoom = 1;
|
|
|
|
// Keep a reference to TextLayer instances so we can update/cancel them
|
|
let textLayerInstances: PdfTextLayer[] = [];
|
|
|
|
const cancelTextLayers = () => {
|
|
for (const tl of textLayerInstances) {
|
|
try {
|
|
tl.cancel();
|
|
} catch {
|
|
// Text layers can already be resolved or canceled during rerenders.
|
|
}
|
|
}
|
|
textLayerInstances = [];
|
|
};
|
|
|
|
const initPanzoom = () => {
|
|
if (pzInstance) {
|
|
pzInstance.dispose();
|
|
}
|
|
if (sceneElement) {
|
|
pzInstance = panzoom(sceneElement, {
|
|
bounds: true,
|
|
boundsPadding: 0.1,
|
|
zoomSpeed: 0.065,
|
|
beforeWheel: (e) => {
|
|
// Only zoom on pinch (ctrlKey / metaKey); let normal scroll pass through
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
return true; // returning true cancels the panzoom wheel handling
|
|
}
|
|
return false;
|
|
},
|
|
beforeMouseDown: () => {
|
|
// Only allow drag-to-pan when zoomed in (not at default scale)
|
|
const transform = pzInstance?.getTransform();
|
|
if (transform && Math.abs(transform.scale - 1) < 0.01) {
|
|
return true; // cancel panzoom mouse handling at 1x — allow text selection / normal interaction
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
pzInstance.on('zoom', () => {
|
|
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
|
|
// Debounced re-render at new resolution so text stays crisp
|
|
if (rerenderTimer) clearTimeout(rerenderTimer);
|
|
rerenderTimer = setTimeout(() => {
|
|
if (Math.abs(zoomLevel - lastRenderedZoom) > 0.05) {
|
|
rerenderPages(zoomLevel);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
};
|
|
|
|
const zoomIn = () => {
|
|
if (!pzInstance || !outerContainer) return;
|
|
const cx = outerContainer.clientWidth / 2;
|
|
const cy = outerContainer.clientHeight / 2;
|
|
pzInstance.zoomTo(cx, cy, 1.25); // +25%
|
|
zoomLevel = pzInstance.getTransform().scale;
|
|
};
|
|
|
|
const zoomOut = () => {
|
|
if (!pzInstance || !outerContainer) return;
|
|
const cx = outerContainer.clientWidth / 2;
|
|
const cy = outerContainer.clientHeight / 2;
|
|
pzInstance.zoomTo(cx, cy, 0.8); // -20% (inverse of 1.25)
|
|
zoomLevel = pzInstance.getTransform().scale;
|
|
};
|
|
|
|
export const resetView = () => {
|
|
if (pzInstance) {
|
|
pzInstance.moveTo(0, 0);
|
|
pzInstance.zoomAbs(0, 0, 1);
|
|
zoomLevel = 1;
|
|
rerenderPages(1);
|
|
}
|
|
};
|
|
|
|
const scrollToTargetPage = async () => {
|
|
if (!outerContainer || !sceneElement || !pdfDoc) return;
|
|
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
|
|
if (!page) return;
|
|
|
|
await tick();
|
|
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
|
|
| HTMLElement
|
|
| undefined;
|
|
pageWrapper?.scrollIntoView({ block: 'start' });
|
|
};
|
|
|
|
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
|
|
const rerenderPages = async (forZoom: number) => {
|
|
if (!pdfDoc || !sceneElement) return;
|
|
const pdfjs = await import('pdfjs-dist');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const containerWidth = outerContainer?.clientWidth || 800;
|
|
|
|
const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
|
|
|
|
cancelTextLayers();
|
|
|
|
for (let i = 0; i < pageWrappers.length; i++) {
|
|
const page = await pdfDoc.getPage(i + 1);
|
|
const viewport = page.getViewport({ scale: 1 });
|
|
const cssScale = containerWidth / viewport.width;
|
|
const renderScale = cssScale * forZoom * dpr;
|
|
const scaledViewport = page.getViewport({ scale: renderScale });
|
|
const cssViewport = page.getViewport({ scale: cssScale });
|
|
|
|
const wrapper = pageWrappers[i] as HTMLElement;
|
|
// Update the CSS custom property so textLayer dimensions resolve correctly
|
|
wrapper.style.setProperty('--scale-factor', String(cssViewport.scale));
|
|
|
|
const canvas = wrapper.querySelector('canvas')!;
|
|
canvas.width = scaledViewport.width;
|
|
canvas.height = scaledViewport.height;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx) {
|
|
await page.render({ canvas, canvasContext: ctx, viewport: scaledViewport }).promise;
|
|
}
|
|
|
|
// Rebuild text layer
|
|
const textLayerDiv = wrapper.querySelector('.textLayer') as HTMLElement;
|
|
if (textLayerDiv) {
|
|
textLayerDiv.innerHTML = '';
|
|
|
|
const textContent = await page.getTextContent();
|
|
const textLayer = new pdfjs.TextLayer({
|
|
textContentSource: textContent,
|
|
container: textLayerDiv,
|
|
viewport: cssViewport
|
|
});
|
|
await textLayer.render();
|
|
textLayerInstances.push(textLayer);
|
|
}
|
|
}
|
|
lastRenderedZoom = forZoom;
|
|
};
|
|
|
|
const renderAllPages = async () => {
|
|
if (!pdfDoc || !sceneElement) return;
|
|
|
|
// Clear previous content
|
|
sceneElement.innerHTML = '';
|
|
|
|
cancelTextLayers();
|
|
|
|
const pdfjs = await import('pdfjs-dist');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
const page = await pdfDoc.getPage(i);
|
|
const viewport = page.getViewport({ scale: 1 });
|
|
|
|
// Scale to fit container width
|
|
const containerWidth = outerContainer?.clientWidth || 800;
|
|
const cssScale = containerWidth / viewport.width;
|
|
const renderScale = cssScale * dpr;
|
|
const scaledViewport = page.getViewport({ scale: renderScale });
|
|
const cssViewport = page.getViewport({ scale: cssScale });
|
|
|
|
// Create page wrapper (positioned container for canvas + text layer)
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'pdf-page-wrapper';
|
|
wrapper.style.position = 'relative';
|
|
wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`;
|
|
wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`;
|
|
wrapper.style.display = 'block';
|
|
// pdfjs TextLayer uses --total-scale-factor (= --scale-factor * --user-unit)
|
|
// to position/size text spans. We must set --scale-factor so the calc resolves.
|
|
wrapper.style.setProperty('--scale-factor', String(cssViewport.scale));
|
|
|
|
if (i > 1) {
|
|
wrapper.style.marginTop = '4px';
|
|
}
|
|
|
|
// Create canvas
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = scaledViewport.width;
|
|
canvas.height = scaledViewport.height;
|
|
// CSS size stays at the CSS-pixel dimensions for layout
|
|
canvas.style.width = `${Math.round(cssScale * viewport.width)}px`;
|
|
canvas.style.height = `${Math.round(cssScale * viewport.height)}px`;
|
|
canvas.style.display = 'block';
|
|
wrapper.appendChild(canvas);
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) continue;
|
|
|
|
await page.render({
|
|
canvas,
|
|
canvasContext: ctx,
|
|
viewport: scaledViewport
|
|
}).promise;
|
|
|
|
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
|
|
const textLayerDiv = document.createElement('div');
|
|
textLayerDiv.className = 'textLayer';
|
|
wrapper.appendChild(textLayerDiv);
|
|
|
|
const textContent = await page.getTextContent();
|
|
const textLayer = new pdfjs.TextLayer({
|
|
textContentSource: textContent,
|
|
container: textLayerDiv,
|
|
viewport: cssViewport
|
|
});
|
|
await textLayer.render();
|
|
textLayerInstances.push(textLayer);
|
|
|
|
sceneElement.appendChild(wrapper);
|
|
}
|
|
|
|
lastRenderedZoom = 1;
|
|
initPanzoom();
|
|
await scrollToTargetPage();
|
|
};
|
|
|
|
const loadPdf = async () => {
|
|
if (!url && !data) return;
|
|
|
|
loading = true;
|
|
error = '';
|
|
|
|
try {
|
|
const pdfjs = await import('pdfjs-dist');
|
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
|
|
let pdfData: ArrayBuffer | Uint8Array;
|
|
if (data) {
|
|
pdfData = data;
|
|
} else {
|
|
// Fetch with credentials so auth cookies are sent
|
|
const res = await fetch(url!, { credentials: 'include' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
pdfData = await res.arrayBuffer();
|
|
}
|
|
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
|
await renderAllPages();
|
|
} catch (e) {
|
|
console.error('PDF render error:', e);
|
|
error = 'Failed to load PDF.';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
};
|
|
|
|
onMount(() => {
|
|
loadPdf();
|
|
});
|
|
|
|
$: if (!loading && pdfDoc && targetPage) {
|
|
void scrollToTargetPage();
|
|
}
|
|
|
|
onDestroy(() => {
|
|
if (rerenderTimer) clearTimeout(rerenderTimer);
|
|
pzInstance?.dispose();
|
|
cancelTextLayers();
|
|
if (pdfDoc) {
|
|
pdfDoc.destroy();
|
|
pdfDoc = null;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="relative {className}">
|
|
{#if loading}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<Spinner className="size-5" />
|
|
</div>
|
|
{:else if error}
|
|
<div class="absolute inset-0 flex items-center justify-center text-sm text-red-500">
|
|
{error}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
|
|
<div bind:this={sceneElement} class="w-full"></div>
|
|
</div>
|
|
|
|
{#if !loading && !error && pdfDoc}
|
|
<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
|
|
type="button"
|
|
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center 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
|
|
type="button"
|
|
class="shrink-0 min-w-12 h-7 px-1.5 py-1 text-center text-[0.6875rem] 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
|
|
type="button"
|
|
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center 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>
|
|
/*
|
|
* Minimal textLayer styles extracted from pdfjs-dist/web/pdf_viewer.css.
|
|
* These ensure the invisible text spans are positioned exactly over the
|
|
* rendered canvas so that browser-native Ctrl+F search and text selection
|
|
* work correctly.
|
|
*/
|
|
:global(.textLayer) {
|
|
position: absolute;
|
|
text-align: initial;
|
|
inset: 0;
|
|
overflow: clip;
|
|
opacity: 1;
|
|
line-height: 1;
|
|
-webkit-text-size-adjust: none;
|
|
-moz-text-size-adjust: none;
|
|
text-size-adjust: none;
|
|
forced-color-adjust: none;
|
|
transform-origin: 0 0;
|
|
caret-color: CanvasText;
|
|
z-index: 0;
|
|
}
|
|
|
|
:global(.textLayer :is(span, br)) {
|
|
color: transparent;
|
|
position: absolute;
|
|
white-space: pre;
|
|
cursor: text;
|
|
transform-origin: 0% 0%;
|
|
}
|
|
|
|
:global(.textLayer) {
|
|
/* --total-scale-factor is derived from --scale-factor (set on the wrapper)
|
|
and --user-unit (defaults to 1). This mirrors the official pdf_viewer.css. */
|
|
--user-unit: 1;
|
|
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
|
|
--min-font-size: 1;
|
|
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
|
--min-font-size-inv: calc(1 / var(--min-font-size));
|
|
}
|
|
|
|
:global(.textLayer > :not(.markedContent)),
|
|
:global(.textLayer .markedContent span:not(.markedContent)) {
|
|
z-index: 1;
|
|
--font-height: 0;
|
|
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
|
--scale-x: 1;
|
|
--rotate: 0deg;
|
|
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
|
}
|
|
|
|
:global(.textLayer .markedContent) {
|
|
display: contents;
|
|
}
|
|
|
|
:global(.textLayer span[role='img']) {
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
user-select: none;
|
|
cursor: default;
|
|
}
|
|
|
|
/* Selection highlight color */
|
|
:global(.textLayer ::-moz-selection) {
|
|
background: rgba(0, 0, 255, 0.25);
|
|
}
|
|
|
|
:global(.textLayer ::selection) {
|
|
background: rgba(0, 0, 255, 0.25);
|
|
}
|
|
|
|
:global(.textLayer br::-moz-selection) {
|
|
background: transparent;
|
|
}
|
|
|
|
:global(.textLayer br::selection) {
|
|
background: transparent;
|
|
}
|
|
|
|
:global(.textLayer .endOfContent) {
|
|
display: block;
|
|
position: absolute;
|
|
inset: 100% 0 0;
|
|
z-index: 0;
|
|
cursor: default;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
:global(.textLayer.selecting .endOfContent) {
|
|
top: 0;
|
|
}
|
|
</style>
|