From ae549d3e4af672a63a809e8fe2a6c91e0bfc1067 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Aug 2026 16:41:43 -0400 Subject: [PATCH] refac --- .../chat/FileNav/FilePreview.svelte | 3 + src/lib/components/common/PDFViewer.svelte | 261 ++++++++++++++++-- .../components/common/PdfPagesPreview.svelte | 222 ++++++++++++--- 3 files changed, 432 insertions(+), 54 deletions(-) diff --git a/src/lib/components/chat/FileNav/FilePreview.svelte b/src/lib/components/chat/FileNav/FilePreview.svelte index fded8feb78..aa457e56b2 100644 --- a/src/lib/components/chat/FileNav/FilePreview.svelte +++ b/src/lib/components/chat/FileNav/FilePreview.svelte @@ -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} diff --git a/src/lib/components/common/PDFViewer.svelte b/src/lib/components/common/PDFViewer.svelte index e409b24a7d..c05a603da5 100644 --- a/src/lib/components/common/PDFViewer.svelte +++ b/src/lib/components/common/PDFViewer.svelte @@ -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; @@ -22,10 +25,28 @@ let zoomLevel = 1; let rerenderTimer: ReturnType | 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 @@ }); + +
{#if loading}
@@ -299,14 +475,67 @@
{/if} -
-
+
+
- {#if !loading && !error && pdfDoc} + {#if !error && pdfDoc}
+ {#if singlePage} + + {selectedPage} / {pageCount} + + {/if} + {/each} + {/if} + + +
+ {#if viewerData} + + {:else} +
+ +
+ {/if} +
+
+ +