This commit is contained in:
Timothy Jaeryang Baek 2026-08-27 16:41:43 -04:00
parent 9d95a0148b
commit ae549d3e4a
3 changed files with 432 additions and 54 deletions

View file

@ -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}

View file

@ -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"

View file

@ -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>