refac
This commit is contained in:
1 parent
9d95a0148b
commit
ae549d3e4a
3 files changed
+432
-54
No files matched your search
@@ -328,6 +328,9 @@
|
|||||||
data={filePdfData}
|
data={filePdfData}
|
||||||
bind:currentSlide
|
bind:currentSlide
|
||||||
{targetPage}
|
{targetPage}
|
||||||
|
singlePage={isPptx}
|
||||||
|
itemLabel={isPptx ? 'Slide' : 'Page'}
|
||||||
|
listLabel={isPptx ? 'Slides' : 'Pages'}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
{:else if fileSqliteData !== null}
|
{:else if fileSqliteData !== null}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
export let data: ArrayBuffer | Uint8Array | null = null;
|
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||||
export let className = 'w-full h-[70vh]';
|
export let className = 'w-full h-[70vh]';
|
||||||
export let targetPage: number | null = null;
|
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 PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||||
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
|
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
|
||||||
@@ -22,10 +25,28 @@
|
|||||||
let zoomLevel = 1;
|
let zoomLevel = 1;
|
||||||
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
|
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastRenderedZoom = 1;
|
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
|
// Keep a reference to TextLayer instances so we can update/cancel them
|
||||||
let textLayerInstances: PdfTextLayer[] = [];
|
let textLayerInstances: PdfTextLayer[] = [];
|
||||||
|
|
||||||
|
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
|
||||||
|
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
|
||||||
|
|
||||||
const cancelTextLayers = () => {
|
const cancelTextLayers = () => {
|
||||||
for (const tl of textLayerInstances) {
|
for (const tl of textLayerInstances) {
|
||||||
try {
|
try {
|
||||||
@@ -53,8 +74,11 @@
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
beforeMouseDown: () => {
|
beforeMouseDown: (e) => {
|
||||||
// Only allow drag-to-pan when zoomed in (not at default scale)
|
// 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();
|
const transform = pzInstance?.getTransform();
|
||||||
if (transform && Math.abs(transform.scale - 1) < 0.01) {
|
if (transform && Math.abs(transform.scale - 1) < 0.01) {
|
||||||
return true; // cancel panzoom mouse handling at 1x — allow text selection / normal interaction
|
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 () => {
|
const scrollToTargetPage = async () => {
|
||||||
if (!outerContainer || !sceneElement || !pdfDoc) return;
|
if (!outerContainer || !sceneElement || !pdfDoc) return;
|
||||||
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
|
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
|
||||||
|
if (singlePage) return;
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
|
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
|
||||||
| HTMLElement
|
| HTMLElement
|
||||||
| undefined;
|
| undefined;
|
||||||
pageWrapper?.scrollIntoView({ block: 'start' });
|
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)
|
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
|
||||||
@@ -117,16 +198,15 @@
|
|||||||
if (!pdfDoc || !sceneElement) return;
|
if (!pdfDoc || !sceneElement) return;
|
||||||
const pdfjs = await import('pdfjs-dist');
|
const pdfjs = await import('pdfjs-dist');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const containerWidth = outerContainer?.clientWidth || 800;
|
|
||||||
|
|
||||||
const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
|
const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
|
||||||
|
|
||||||
cancelTextLayers();
|
cancelTextLayers();
|
||||||
|
|
||||||
for (let i = 0; i < pageWrappers.length; i++) {
|
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 viewport = page.getViewport({ scale: 1 });
|
||||||
const cssScale = containerWidth / viewport.width;
|
const cssScale = getCssScale(viewport);
|
||||||
const renderScale = cssScale * forZoom * dpr;
|
const renderScale = cssScale * forZoom * dpr;
|
||||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||||
const cssViewport = page.getViewport({ scale: cssScale });
|
const cssViewport = page.getViewport({ scale: cssScale });
|
||||||
@@ -162,8 +242,17 @@
|
|||||||
lastRenderedZoom = forZoom;
|
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 () => {
|
const renderAllPages = async () => {
|
||||||
if (!pdfDoc || !sceneElement) return;
|
if (!pdfDoc || !sceneElement) return;
|
||||||
|
const token = ++renderToken;
|
||||||
|
|
||||||
// Clear previous content
|
// Clear previous content
|
||||||
sceneElement.innerHTML = '';
|
sceneElement.innerHTML = '';
|
||||||
@@ -172,14 +261,17 @@
|
|||||||
|
|
||||||
const pdfjs = await import('pdfjs-dist');
|
const pdfjs = await import('pdfjs-dist');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
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);
|
const page = await pdfDoc.getPage(i);
|
||||||
|
if (token !== renderToken) return;
|
||||||
const viewport = page.getViewport({ scale: 1 });
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
|
||||||
// Scale to fit container width
|
// Scale to fit container width
|
||||||
const containerWidth = outerContainer?.clientWidth || 800;
|
const cssScale = getCssScale(viewport);
|
||||||
const cssScale = containerWidth / viewport.width;
|
|
||||||
const renderScale = cssScale * dpr;
|
const renderScale = cssScale * dpr;
|
||||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||||
const cssViewport = page.getViewport({ scale: cssScale });
|
const cssViewport = page.getViewport({ scale: cssScale });
|
||||||
@@ -187,6 +279,7 @@
|
|||||||
// Create page wrapper (positioned container for canvas + text layer)
|
// Create page wrapper (positioned container for canvas + text layer)
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'pdf-page-wrapper';
|
wrapper.className = 'pdf-page-wrapper';
|
||||||
|
wrapper.dataset.pageNumber = String(i);
|
||||||
wrapper.style.position = 'relative';
|
wrapper.style.position = 'relative';
|
||||||
wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`;
|
wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`;
|
||||||
wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`;
|
wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`;
|
||||||
@@ -217,6 +310,7 @@
|
|||||||
canvasContext: ctx,
|
canvasContext: ctx,
|
||||||
viewport: scaledViewport
|
viewport: scaledViewport
|
||||||
}).promise;
|
}).promise;
|
||||||
|
if (token !== renderToken) return;
|
||||||
|
|
||||||
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
|
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
|
||||||
const textLayerDiv = document.createElement('div');
|
const textLayerDiv = document.createElement('div');
|
||||||
@@ -230,21 +324,83 @@
|
|||||||
viewport: cssViewport
|
viewport: cssViewport
|
||||||
});
|
});
|
||||||
await textLayer.render();
|
await textLayer.render();
|
||||||
|
if (token !== renderToken) return;
|
||||||
textLayerInstances.push(textLayer);
|
textLayerInstances.push(textLayer);
|
||||||
|
|
||||||
sceneElement.appendChild(wrapper);
|
wrappers.push(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sceneElement.replaceChildren(...wrappers);
|
||||||
lastRenderedZoom = 1;
|
lastRenderedZoom = 1;
|
||||||
|
renderedPage = singlePage ? selectedPage : 0;
|
||||||
initPanzoom();
|
initPanzoom();
|
||||||
await scrollToTargetPage();
|
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 () => {
|
const loadPdf = async () => {
|
||||||
if (!url && !data) return;
|
if (!url && !data) return;
|
||||||
|
|
||||||
|
const source = data ?? url;
|
||||||
|
if (source === loadedSource && pdfDoc) return;
|
||||||
|
const token = ++loadToken;
|
||||||
|
loadedSource = source;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
renderedPage = 0;
|
||||||
|
pageCount = 0;
|
||||||
|
pzInstance?.dispose();
|
||||||
|
cancelTextLayers();
|
||||||
|
pdfDoc?.destroy();
|
||||||
|
pdfDoc = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfjs = await import('pdfjs-dist');
|
const pdfjs = await import('pdfjs-dist');
|
||||||
@@ -252,7 +408,7 @@
|
|||||||
|
|
||||||
let pdfData: ArrayBuffer | Uint8Array;
|
let pdfData: ArrayBuffer | Uint8Array;
|
||||||
if (data) {
|
if (data) {
|
||||||
pdfData = data;
|
pdfData = copyPdfData(data);
|
||||||
} else {
|
} else {
|
||||||
// Fetch with credentials so auth cookies are sent
|
// Fetch with credentials so auth cookies are sent
|
||||||
const res = await fetch(url!, { credentials: 'include' });
|
const res = await fetch(url!, { credentials: 'include' });
|
||||||
@@ -260,24 +416,42 @@
|
|||||||
pdfData = await res.arrayBuffer();
|
pdfData = await res.arrayBuffer();
|
||||||
}
|
}
|
||||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
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();
|
await renderAllPages();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('PDF render error:', e);
|
if (token === loadToken) {
|
||||||
error = 'Failed to load PDF.';
|
console.error('PDF render error:', e);
|
||||||
|
error = 'Failed to load PDF.';
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
if (token === loadToken) loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
loadPdf();
|
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();
|
void scrollToTargetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
loadToken++;
|
||||||
|
renderToken++;
|
||||||
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||||
if (rerenderTimer) clearTimeout(rerenderTimer);
|
if (rerenderTimer) clearTimeout(rerenderTimer);
|
||||||
pzInstance?.dispose();
|
pzInstance?.dispose();
|
||||||
cancelTextLayers();
|
cancelTextLayers();
|
||||||
@@ -288,6 +462,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeyDown} />
|
||||||
|
|
||||||
<div class="relative {className}">
|
<div class="relative {className}">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -299,14 +475,67 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
|
<div
|
||||||
<div bind:this={sceneElement} class="w-full"></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>
|
</div>
|
||||||
|
|
||||||
{#if !loading && !error && pdfDoc}
|
{#if !error && pdfDoc}
|
||||||
<div
|
<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"
|
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
|
<button
|
||||||
type="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"
|
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">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
||||||
import Spinner from './Spinner.svelte';
|
import Spinner from './Spinner.svelte';
|
||||||
import PptxPreview from './PptxPreview.svelte';
|
import PDFViewer from './PDFViewer.svelte';
|
||||||
|
|
||||||
export let data: ArrayBuffer | Uint8Array | null = null;
|
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||||
export let currentSlide = 0;
|
export let currentSlide = 0;
|
||||||
export let className = '';
|
export let className = '';
|
||||||
export let targetPage: number | null = null;
|
export let targetPage: number | null = null;
|
||||||
|
export let singlePage = false;
|
||||||
|
export let itemLabel = 'Page';
|
||||||
|
export let listLabel = 'Pages';
|
||||||
|
|
||||||
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||||
|
|
||||||
let loading = false;
|
let rootEl: HTMLDivElement;
|
||||||
let error = '';
|
let pdfViewerRef: PDFViewer;
|
||||||
let slides: string[] = [];
|
let viewerData: ArrayBuffer | Uint8Array | null = null;
|
||||||
let pagesPreviewRef: PptxPreview;
|
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 pdfDoc: PdfDocument | null = null;
|
||||||
let loadToken = 0;
|
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;
|
const token = ++loadToken;
|
||||||
loading = true;
|
thumbsLoading = true;
|
||||||
error = '';
|
thumbnails = [];
|
||||||
slides = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfjs = await import('pdfjs-dist');
|
const pdfjs = await import('pdfjs-dist');
|
||||||
@@ -31,12 +69,12 @@
|
|||||||
pdfDoc?.destroy();
|
pdfDoc?.destroy();
|
||||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||||
|
|
||||||
const renderedSlides: string[] = [];
|
const rendered: string[] = [];
|
||||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||||
if (token !== loadToken) return;
|
if (token !== loadToken) return;
|
||||||
|
|
||||||
const page = await pdfDoc.getPage(i);
|
const page = await pdfDoc.getPage(i);
|
||||||
const viewport = page.getViewport({ scale: 2 });
|
const viewport = page.getViewport({ scale: 0.28 });
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
@@ -45,48 +83,156 @@
|
|||||||
if (!ctx) continue;
|
if (!ctx) continue;
|
||||||
|
|
||||||
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
|
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) {
|
} catch (e) {
|
||||||
console.error('PDF deck render error:', e);
|
console.error('PDF thumbnail render error:', e);
|
||||||
if (token === loadToken) error = 'Failed to load preview.';
|
if (token === loadToken) thumbnails = [];
|
||||||
} finally {
|
} finally {
|
||||||
if (token === loadToken) loading = false;
|
if (token === loadToken) thumbsLoading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (data) {
|
$: 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 = () => {
|
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(() => {
|
onDestroy(() => {
|
||||||
loadToken++;
|
loadToken++;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
pdfDoc?.destroy();
|
pdfDoc?.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
<div
|
||||||
<div class="flex h-full items-center justify-center {className}">
|
bind:this={rootEl}
|
||||||
<Spinner className="size-5" />
|
class="relative grid {hideThumbs
|
||||||
</div>
|
? 'grid-cols-[minmax(0,1fr)]'
|
||||||
{:else if error}
|
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
|
||||||
<div class="flex h-full items-center justify-center text-sm text-red-500 {className}">
|
>
|
||||||
{error}
|
<aside
|
||||||
</div>
|
class={hideThumbs
|
||||||
{:else}
|
? 'hidden'
|
||||||
<PptxPreview
|
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||||
bind:this={pagesPreviewRef}
|
aria-label={listLabel}
|
||||||
{slides}
|
>
|
||||||
bind:currentSlide
|
{#if thumbsLoading && thumbnails.length === 0}
|
||||||
{targetPage}
|
<div class="flex h-full items-center justify-center">
|
||||||
{className}
|
<Spinner className="size-4" />
|
||||||
itemLabel="Page"
|
</div>
|
||||||
listLabel="Pages"
|
{:else}
|
||||||
/>
|
{#each thumbnails as thumbnail, index}
|
||||||
{/if}
|
<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>
|
||||||
Reference in new issue
Block a user