open-webui/src/lib/components/common/PDFViewer.svelte
Classic298 83556188f5
fix: hide file preview zoom buttons on touch devices (#29176)
On a phone the file preview zoom bar shows plus and minus buttons that duplicate pinch-to-zoom and sit on top of an already small preview. They are hidden on coarse pointers now, so touch users get the preview area back and still zoom the way they expect.

The check is the pointer type rather than the viewport width, because what matters is whether the person can pinch, not how narrow their window is. A narrow desktop window keeps the buttons, a tablet does not.

The zoom percentage doubles as the reset control and has no gesture equivalent, so it stays visible, along with all page and slide navigation. Keyboard zoom is unaffected. Word document previews are left alone: they have no pinch support at all, so hiding their buttons would remove zooming entirely.

Fixes #29152
2026-08-28 18:46:53 -04:00

681 lines
20 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;
export let singlePage = false;
export let itemLabel = 'Page';
export let onPageChange: ((page: number) => void) | 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;
let pageCount = 0;
let renderedPage = 0;
let activePage = 1;
let loadToken = 0;
let renderToken = 0;
let scrollFrame: number | null = null;
let mounted = false;
let loadedSource: ArrayBuffer | Uint8Array | string | null = null;
let wheelDelta = 0;
let lastWheelNavigationAt = 0;
const wheelNavigationThreshold = 80;
const wheelNavigationCooldown = 450;
const pageShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
$: selectedPage = singlePage ? (clampDocumentTargetPage(targetPage, pageCount) ?? 1) : activePage;
// Keep a reference to TextLayer instances so we can update/cancel them
let textLayerInstances: PdfTextLayer[] = [];
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
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: (e) => {
// Only allow drag-to-pan when zoomed in (not at default scale)
if ((e?.target as HTMLElement | null)?.closest?.('.textLayer')) {
return true;
}
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);
}
};
export const scrollToPage = async (page: number | null) => {
targetPage = page;
if (singlePage) {
if (targetPage) onPageChange?.(targetPage);
} else {
await scrollToTargetPage();
}
};
const selectPage = async (page: number) => {
if (!pdfDoc) return;
const nextPage = clampDocumentTargetPage(page, pdfDoc.numPages);
if (!nextPage || nextPage === selectedPage) return;
targetPage = nextPage;
activePage = nextPage;
onPageChange?.(nextPage);
if (!singlePage) await scrollToTargetPage();
};
const scrollToTargetPage = async () => {
if (!outerContainer || !sceneElement || !pdfDoc) return;
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
if (!page) return;
if (singlePage) return;
await tick();
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
| HTMLElement
| undefined;
pageWrapper?.scrollIntoView({ block: 'start' });
activePage = page;
onPageChange?.(page);
};
const syncVisiblePage = () => {
scrollFrame = null;
if (singlePage || !outerContainer || !sceneElement || !pdfDoc) return;
const marker = outerContainer.getBoundingClientRect().top + outerContainer.clientHeight * 0.35;
let bestPage = activePage;
let bestDistance = Number.POSITIVE_INFINITY;
for (const wrapper of sceneElement.querySelectorAll('.pdf-page-wrapper')) {
const el = wrapper as HTMLElement;
const page = Number(el.dataset.pageNumber);
if (!page) continue;
const rect = el.getBoundingClientRect();
const distance =
marker < rect.top ? rect.top - marker : marker > rect.bottom ? marker - rect.bottom : 0;
if (distance < bestDistance) {
bestDistance = distance;
bestPage = page;
}
}
if (bestPage !== activePage) {
activePage = bestPage;
onPageChange?.(bestPage);
}
};
const handleScroll = () => {
if (singlePage || scrollFrame !== null) return;
scrollFrame = requestAnimationFrame(syncVisiblePage);
};
// 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 pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
cancelTextLayers();
for (let i = 0; i < pageWrappers.length; i++) {
const page = await pdfDoc.getPage(singlePage ? selectedPage : i + 1);
const viewport = page.getViewport({ scale: 1 });
const cssScale = getCssScale(viewport);
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 getCssScale = (viewport: { width: number; height: number }) => {
if (!singlePage) return (outerContainer?.clientWidth || 800) / viewport.width;
const availableWidth = Math.max(320, (outerContainer?.clientWidth || 800) - 64);
const availableHeight = Math.max(220, (outerContainer?.clientHeight || 600) - 64);
return Math.min(1, availableWidth / viewport.width, availableHeight / viewport.height);
};
const renderAllPages = async () => {
if (!pdfDoc || !sceneElement) return;
const token = ++renderToken;
// Clear previous content
sceneElement.innerHTML = '';
cancelTextLayers();
const pdfjs = await import('pdfjs-dist');
const dpr = window.devicePixelRatio || 1;
const wrappers: HTMLElement[] = [];
const firstPage = singlePage ? selectedPage : 1;
const lastPage = singlePage ? selectedPage : pdfDoc.numPages;
for (let i = firstPage; i <= lastPage; i++) {
const page = await pdfDoc.getPage(i);
if (token !== renderToken) return;
const viewport = page.getViewport({ scale: 1 });
// Scale to fit container width
const cssScale = getCssScale(viewport);
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.dataset.pageNumber = String(i);
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;
if (token !== renderToken) return;
// 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();
if (token !== renderToken) return;
textLayerInstances.push(textLayer);
wrappers.push(wrapper);
}
sceneElement.replaceChildren(...wrappers);
lastRenderedZoom = 1;
renderedPage = singlePage ? selectedPage : 0;
initPanzoom();
await scrollToTargetPage();
syncVisiblePage();
};
const handleWheel = (e: WheelEvent) => {
if (!singlePage) return;
if (e.ctrlKey || e.metaKey) return;
const transform = pzInstance?.getTransform();
if (transform && Math.abs(transform.scale - 1) >= 0.01) {
e.preventDefault();
pzInstance?.moveBy(-e.deltaX, -e.deltaY, false);
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
return;
}
e.preventDefault();
if (pageCount <= 1) return;
const multiplier = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? outerContainer.clientHeight : 1;
const dominantDelta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
wheelDelta += dominantDelta * multiplier;
const now = Date.now();
if (Math.abs(wheelDelta) < wheelNavigationThreshold) return;
if (now - lastWheelNavigationAt < wheelNavigationCooldown) {
wheelDelta = 0;
return;
}
lastWheelNavigationAt = now;
void selectPage(selectedPage + (wheelDelta > 0 ? 1 : -1));
wheelDelta = 0;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (
!singlePage ||
e.defaultPrevented ||
e.altKey ||
e.ctrlKey ||
e.metaKey ||
pageCount <= 1 ||
!pageShortcutKeys.includes(e.key)
) {
return;
}
e.preventDefault();
void selectPage(selectedPage + (e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 1));
};
const loadPdf = async () => {
if (!url && !data) return;
const source = data ?? url;
if (source === loadedSource && pdfDoc) return;
const token = ++loadToken;
loadedSource = source;
loading = true;
error = '';
renderedPage = 0;
pageCount = 0;
pzInstance?.dispose();
cancelTextLayers();
pdfDoc?.destroy();
pdfDoc = null;
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
let pdfData: ArrayBuffer | Uint8Array;
if (data) {
pdfData = copyPdfData(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;
if (token !== loadToken) return;
pageCount = pdfDoc.numPages;
activePage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
targetPage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
await renderAllPages();
} catch (e) {
if (token === loadToken) {
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
}
} finally {
if (token === loadToken) loading = false;
}
};
onMount(() => {
mounted = true;
loadPdf();
});
$: if (mounted && (data || url)) {
void loadPdf();
}
$: if (!loading && pdfDoc && singlePage && targetPage && selectedPage !== renderedPage) {
void renderAllPages();
}
$: if (!loading && pdfDoc && !singlePage && targetPage) {
void scrollToTargetPage();
}
onDestroy(() => {
loadToken++;
renderToken++;
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
if (rerenderTimer) clearTimeout(rerenderTimer);
pzInstance?.dispose();
cancelTextLayers();
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
});
</script>
<svelte:window on:keydown={handleKeyDown} />
<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={singlePage
? 'overflow-hidden h-full flex items-center justify-center overscroll-contain'
: 'overflow-y-auto h-full'}
bind:this={outerContainer}
on:scroll={handleScroll}
on:wheel|nonpassive={handleWheel}
>
<div bind:this={sceneElement} class={singlePage ? '' : 'w-full'}></div>
</div>
{#if !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"
>
{#if singlePage}
<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 disabled:opacity-30"
disabled={selectedPage === 1}
on:click={() => selectPage(selectedPage - 1)}
aria-label={`Previous ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.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
class="shrink-0 min-w-12 text-center text-[0.6875rem] text-gray-500 dark:text-gray-400 tabular-nums"
>{selectedPage} / {pageCount}</span
>
<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 disabled:opacity-30"
disabled={selectedPage === pageCount}
on:click={() => selectPage(selectedPage + 1)}
aria-label={`Next ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.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>
{/if}
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
<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 pointer-coarse:hidden"
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 pointer-coarse:hidden"
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>