mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
refac
This commit is contained in:
parent
9d95a0148b
commit
ae549d3e4a
3 changed files with 432 additions and 54 deletions
|
|
@ -328,6 +328,9 @@
|
|||
data={filePdfData}
|
||||
bind:currentSlide
|
||||
{targetPage}
|
||||
singlePage={isPptx}
|
||||
itemLabel={isPptx ? 'Slide' : 'Page'}
|
||||
listLabel={isPptx ? 'Slides' : 'Pages'}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{:else if fileSqliteData !== null}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
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>;
|
||||
|
|
@ -22,10 +25,28 @@
|
|||
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 {
|
||||
|
|
@ -53,8 +74,11 @@
|
|||
}
|
||||
return false;
|
||||
},
|
||||
beforeMouseDown: () => {
|
||||
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
|
||||
|
|
@ -100,16 +124,73 @@
|
|||
}
|
||||
};
|
||||
|
||||
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)
|
||||
|
|
@ -117,16 +198,15 @@
|
|||
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 page = await pdfDoc.getPage(singlePage ? selectedPage : i + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const cssScale = containerWidth / viewport.width;
|
||||
const cssScale = getCssScale(viewport);
|
||||
const renderScale = cssScale * forZoom * dpr;
|
||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||
const cssViewport = page.getViewport({ scale: cssScale });
|
||||
|
|
@ -162,8 +242,17 @@
|
|||
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 = '';
|
||||
|
|
@ -172,14 +261,17 @@
|
|||
|
||||
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 = 1; i <= pdfDoc.numPages; i++) {
|
||||
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 containerWidth = outerContainer?.clientWidth || 800;
|
||||
const cssScale = containerWidth / viewport.width;
|
||||
const cssScale = getCssScale(viewport);
|
||||
const renderScale = cssScale * dpr;
|
||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||
const cssViewport = page.getViewport({ scale: cssScale });
|
||||
|
|
@ -187,6 +279,7 @@
|
|||
// 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`;
|
||||
|
|
@ -217,6 +310,7 @@
|
|||
canvasContext: ctx,
|
||||
viewport: scaledViewport
|
||||
}).promise;
|
||||
if (token !== renderToken) return;
|
||||
|
||||
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
|
||||
const textLayerDiv = document.createElement('div');
|
||||
|
|
@ -230,21 +324,83 @@
|
|||
viewport: cssViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
if (token !== renderToken) return;
|
||||
textLayerInstances.push(textLayer);
|
||||
|
||||
sceneElement.appendChild(wrapper);
|
||||
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');
|
||||
|
|
@ -252,7 +408,7 @@
|
|||
|
||||
let pdfData: ArrayBuffer | Uint8Array;
|
||||
if (data) {
|
||||
pdfData = data;
|
||||
pdfData = copyPdfData(data);
|
||||
} else {
|
||||
// Fetch with credentials so auth cookies are sent
|
||||
const res = await fetch(url!, { credentials: 'include' });
|
||||
|
|
@ -260,24 +416,42 @@
|
|||
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) {
|
||||
console.error('PDF render error:', e);
|
||||
error = 'Failed to load PDF.';
|
||||
if (token === loadToken) {
|
||||
console.error('PDF render error:', e);
|
||||
error = 'Failed to load PDF.';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
if (token === loadToken) loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
loadPdf();
|
||||
});
|
||||
|
||||
$: if (!loading && pdfDoc && targetPage) {
|
||||
$: 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();
|
||||
|
|
@ -288,6 +462,8 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
|
||||
<div class="relative {className}">
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
|
|
@ -299,14 +475,67 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
|
||||
<div bind:this={sceneElement} class="w-full"></div>
|
||||
<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 !loading && !error && pdfDoc}
|
||||
{#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}
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -1,28 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import PptxPreview from './PptxPreview.svelte';
|
||||
import PDFViewer from './PDFViewer.svelte';
|
||||
|
||||
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||
export let currentSlide = 0;
|
||||
export let className = '';
|
||||
export let targetPage: number | null = null;
|
||||
export let singlePage = false;
|
||||
export let itemLabel = 'Page';
|
||||
export let listLabel = 'Pages';
|
||||
|
||||
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let slides: string[] = [];
|
||||
let pagesPreviewRef: PptxPreview;
|
||||
let rootEl: HTMLDivElement;
|
||||
let pdfViewerRef: PDFViewer;
|
||||
let viewerData: ArrayBuffer | Uint8Array | null = null;
|
||||
let pageTarget: number | null = null;
|
||||
let thumbsLoading = false;
|
||||
let thumbnails: string[] = [];
|
||||
let thumbnailButtons: Array<HTMLButtonElement | undefined> = [];
|
||||
let hideThumbs = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let pdfDoc: PdfDocument | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
const loadPdf = async (pdfData: ArrayBuffer | Uint8Array) => {
|
||||
$: safePage = Math.min(Math.max(0, currentSlide), Math.max(0, thumbnails.length - 1));
|
||||
|
||||
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
|
||||
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
|
||||
|
||||
const updateLayout = () => {
|
||||
hideThumbs = (rootEl?.clientWidth ?? window.innerWidth) < 720;
|
||||
};
|
||||
|
||||
const trackThumbnail = (node: HTMLButtonElement, index: number) => {
|
||||
thumbnailButtons[index] = node;
|
||||
return {
|
||||
destroy: () => {
|
||||
if (thumbnailButtons[index] === node) thumbnailButtons[index] = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const scrollSelectedThumbnailIntoView = () => {
|
||||
if (hideThumbs) return;
|
||||
thumbnailButtons[safePage]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
};
|
||||
|
||||
const selectPage = async (index: number) => {
|
||||
currentSlide = Math.min(Math.max(0, index), Math.max(0, thumbnails.length - 1));
|
||||
pageTarget = currentSlide + 1;
|
||||
await tick();
|
||||
await pdfViewerRef?.scrollToPage?.(pageTarget);
|
||||
scrollSelectedThumbnailIntoView();
|
||||
};
|
||||
|
||||
const loadThumbnails = async (pdfData: ArrayBuffer | Uint8Array) => {
|
||||
const token = ++loadToken;
|
||||
loading = true;
|
||||
error = '';
|
||||
slides = [];
|
||||
thumbsLoading = true;
|
||||
thumbnails = [];
|
||||
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
|
|
@ -31,12 +69,12 @@
|
|||
pdfDoc?.destroy();
|
||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||
|
||||
const renderedSlides: string[] = [];
|
||||
const rendered: string[] = [];
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
if (token !== loadToken) return;
|
||||
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2 });
|
||||
const viewport = page.getViewport({ scale: 0.28 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
|
@ -45,48 +83,156 @@
|
|||
if (!ctx) continue;
|
||||
|
||||
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
|
||||
renderedSlides.push(canvas.toDataURL('image/png'));
|
||||
rendered.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
if (token === loadToken) slides = renderedSlides;
|
||||
if (token === loadToken) thumbnails = rendered;
|
||||
} catch (e) {
|
||||
console.error('PDF deck render error:', e);
|
||||
if (token === loadToken) error = 'Failed to load preview.';
|
||||
console.error('PDF thumbnail render error:', e);
|
||||
if (token === loadToken) thumbnails = [];
|
||||
} finally {
|
||||
if (token === loadToken) loading = false;
|
||||
if (token === loadToken) thumbsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (data) {
|
||||
void loadPdf(data);
|
||||
viewerData = copyPdfData(data);
|
||||
void loadThumbnails(copyPdfData(data));
|
||||
} else {
|
||||
viewerData = null;
|
||||
thumbnails = [];
|
||||
}
|
||||
|
||||
$: if (targetPage) {
|
||||
currentSlide = Math.max(0, targetPage - 1);
|
||||
pageTarget = targetPage;
|
||||
}
|
||||
|
||||
$: if (thumbnails.length > 0) {
|
||||
void tick().then(scrollSelectedThumbnailIntoView);
|
||||
}
|
||||
|
||||
export const resetView = () => {
|
||||
pagesPreviewRef?.resetView();
|
||||
pdfViewerRef?.resetView();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentSlide = page - 1;
|
||||
if (singlePage) pageTarget = page;
|
||||
void tick().then(scrollSelectedThumbnailIntoView);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
resizeObserver = new ResizeObserver(updateLayout);
|
||||
if (rootEl) resizeObserver.observe(rootEl);
|
||||
updateLayout();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
loadToken++;
|
||||
resizeObserver?.disconnect();
|
||||
pdfDoc?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-full items-center justify-center {className}">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex h-full items-center justify-center text-sm text-red-500 {className}">
|
||||
{error}
|
||||
</div>
|
||||
{:else}
|
||||
<PptxPreview
|
||||
bind:this={pagesPreviewRef}
|
||||
{slides}
|
||||
bind:currentSlide
|
||||
{targetPage}
|
||||
{className}
|
||||
itemLabel="Page"
|
||||
listLabel="Pages"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
class="relative grid {hideThumbs
|
||||
? 'grid-cols-[minmax(0,1fr)]'
|
||||
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
|
||||
>
|
||||
<aside
|
||||
class={hideThumbs
|
||||
? 'hidden'
|
||||
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label={listLabel}
|
||||
>
|
||||
{#if thumbsLoading && thumbnails.length === 0}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else}
|
||||
{#each thumbnails as thumbnail, index}
|
||||
<button
|
||||
use:trackThumbnail={index}
|
||||
type="button"
|
||||
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
|
||||
on:click={() => selectPage(index)}
|
||||
aria-label="{itemLabel} {index + 1}"
|
||||
aria-current={safePage === index ? 'true' : undefined}
|
||||
>
|
||||
<span
|
||||
class="pt-1.5 text-[0.6875rem] font-medium text-right {safePage === index
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-300/70 dark:text-gray-700'}">{index + 1}</span
|
||||
>
|
||||
<span
|
||||
class="block overflow-hidden rounded-md bg-transparent {safePage === index
|
||||
? 'opacity-100'
|
||||
: 'opacity-55 hover:opacity-80'}"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt="{itemLabel} {index + 1} thumbnail"
|
||||
class="block w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section class="min-w-0 min-h-0 overflow-hidden">
|
||||
{#if viewerData}
|
||||
<PDFViewer
|
||||
bind:this={pdfViewerRef}
|
||||
data={viewerData}
|
||||
targetPage={pageTarget}
|
||||
{singlePage}
|
||||
{itemLabel}
|
||||
onPageChange={handlePageChange}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumbnail-sidebar {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover,
|
||||
.thumbnail-sidebar:focus,
|
||||
.thumbnail-sidebar:focus-within,
|
||||
.thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .thumbnail-sidebar:hover,
|
||||
:global(.dark) .thumbnail-sidebar:focus,
|
||||
:global(.dark) .thumbnail-sidebar:focus-within,
|
||||
:global(.dark) .thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue