|
|
|
|
@ -354,9 +354,14 @@ function _clearPdfPreview(body) {
|
|
|
|
|
const state = _pdfPreviewStates.get(body);
|
|
|
|
|
if (!state) return;
|
|
|
|
|
state.disposed = true;
|
|
|
|
|
if (state.renderTask) state.renderTask.cancel();
|
|
|
|
|
if (state.renderTasks) {
|
|
|
|
|
for (const task of state.renderTasks.values()) task.cancel();
|
|
|
|
|
state.renderTasks.clear();
|
|
|
|
|
}
|
|
|
|
|
if (state.loadingTask) state.loadingTask.destroy();
|
|
|
|
|
if (state.resizeObserver) state.resizeObserver.disconnect();
|
|
|
|
|
if (state.pageObserver) state.pageObserver.disconnect();
|
|
|
|
|
if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame);
|
|
|
|
|
if (state.onResize) window.removeEventListener("resize", state.onResize);
|
|
|
|
|
if (state.pdf) state.pdf.destroy();
|
|
|
|
|
_pdfPreviewStates.delete(body);
|
|
|
|
|
@ -370,18 +375,19 @@ async function _showPdfIn(body, blob, fallbackFn) {
|
|
|
|
|
loadingTask: null,
|
|
|
|
|
pageNumber: 1,
|
|
|
|
|
zoom: 1,
|
|
|
|
|
renderTask: null,
|
|
|
|
|
renderTasks: new Map(),
|
|
|
|
|
renderSeq: 0,
|
|
|
|
|
pageShells: [],
|
|
|
|
|
pageObserver: null,
|
|
|
|
|
resizeObserver: null,
|
|
|
|
|
onResize: null,
|
|
|
|
|
scrollFrame: null,
|
|
|
|
|
};
|
|
|
|
|
_pdfPreviewStates.set(body, state);
|
|
|
|
|
body.className = "body pdf-preview";
|
|
|
|
|
body.innerHTML = `
|
|
|
|
|
<div class="pdf-toolbar">
|
|
|
|
|
<button class="small pdf-prev" type="button" aria-label="上一页" disabled>上一页</button>
|
|
|
|
|
<label>第 <input class="pdf-page" type="number" min="1" value="1" inputmode="numeric" disabled> / <span class="pdf-pages">…</span> 页</label>
|
|
|
|
|
<button class="small pdf-next" type="button" aria-label="下一页" disabled>下一页</button>
|
|
|
|
|
<span class="pdf-toolbar-spacer"></span>
|
|
|
|
|
<button class="small pdf-zoom-out" type="button" aria-label="缩小" disabled>−</button>
|
|
|
|
|
<span class="pdf-zoom">适宽</span>
|
|
|
|
|
@ -393,20 +399,22 @@ async function _showPdfIn(body, blob, fallbackFn) {
|
|
|
|
|
const pageInput = body.querySelector(".pdf-page");
|
|
|
|
|
const pagesLabel = body.querySelector(".pdf-pages");
|
|
|
|
|
const zoomLabel = body.querySelector(".pdf-zoom");
|
|
|
|
|
const prev = body.querySelector(".pdf-prev");
|
|
|
|
|
const next = body.querySelector(".pdf-next");
|
|
|
|
|
const zoomOut = body.querySelector(".pdf-zoom-out");
|
|
|
|
|
const zoomIn = body.querySelector(".pdf-zoom-in");
|
|
|
|
|
|
|
|
|
|
const renderPage = async () => {
|
|
|
|
|
const renderPage = async (pageNumber) => {
|
|
|
|
|
if (state.disposed || !state.pdf) return;
|
|
|
|
|
const seq = ++state.renderSeq;
|
|
|
|
|
if (state.renderTask) state.renderTask.cancel();
|
|
|
|
|
const page = await state.pdf.getPage(state.pageNumber);
|
|
|
|
|
if (state.disposed || seq !== state.renderSeq) return;
|
|
|
|
|
const shell = state.pageShells[pageNumber - 1];
|
|
|
|
|
if (!shell) return;
|
|
|
|
|
const renderKey = `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`;
|
|
|
|
|
if (shell.dataset.renderKey === renderKey || state.renderTasks.has(pageNumber)) return;
|
|
|
|
|
const page = await state.pdf.getPage(pageNumber);
|
|
|
|
|
if (state.disposed || !shell.isConnected) return;
|
|
|
|
|
const baseViewport = page.getViewport({ scale: 1 });
|
|
|
|
|
const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width);
|
|
|
|
|
const viewport = page.getViewport({ scale: fitScale * state.zoom });
|
|
|
|
|
shell.dataset.baseWidth = String(baseViewport.width);
|
|
|
|
|
shell.dataset.baseHeight = String(baseViewport.height);
|
|
|
|
|
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
const context = canvas.getContext("2d");
|
|
|
|
|
@ -415,56 +423,72 @@ async function _showPdfIn(body, blob, fallbackFn) {
|
|
|
|
|
canvas.height = Math.floor(viewport.height * outputScale);
|
|
|
|
|
canvas.style.width = Math.floor(viewport.width) + "px";
|
|
|
|
|
canvas.style.height = Math.floor(viewport.height) + "px";
|
|
|
|
|
viewportHost.innerHTML = "";
|
|
|
|
|
viewportHost.appendChild(canvas);
|
|
|
|
|
state.renderTask = page.render({
|
|
|
|
|
shell.style.width = Math.floor(viewport.width) + "px";
|
|
|
|
|
shell.style.height = Math.floor(viewport.height) + "px";
|
|
|
|
|
const renderTask = page.render({
|
|
|
|
|
canvasContext: context,
|
|
|
|
|
viewport,
|
|
|
|
|
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
|
|
|
|
|
});
|
|
|
|
|
state.renderTasks.set(pageNumber, renderTask);
|
|
|
|
|
try {
|
|
|
|
|
await state.renderTask.promise;
|
|
|
|
|
await renderTask.promise;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (e && e.name === "RenderingCancelledException") return;
|
|
|
|
|
throw e;
|
|
|
|
|
} finally {
|
|
|
|
|
if (seq === state.renderSeq) state.renderTask = null;
|
|
|
|
|
if (state.renderTasks.get(pageNumber) === renderTask) state.renderTasks.delete(pageNumber);
|
|
|
|
|
}
|
|
|
|
|
if (state.disposed || seq !== state.renderSeq) return;
|
|
|
|
|
pageInput.value = String(state.pageNumber);
|
|
|
|
|
prev.disabled = state.pageNumber <= 1;
|
|
|
|
|
next.disabled = state.pageNumber >= state.pdf.numPages;
|
|
|
|
|
zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
|
|
|
|
|
viewportHost.scrollTo({ top: 0, left: 0 });
|
|
|
|
|
if (state.disposed || renderKey !== `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`) return;
|
|
|
|
|
shell.innerHTML = "";
|
|
|
|
|
shell.appendChild(canvas);
|
|
|
|
|
shell.dataset.renderKey = renderKey;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const goToPage = (pageNumber) => {
|
|
|
|
|
if (!state.pdf) return;
|
|
|
|
|
state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber));
|
|
|
|
|
renderPage().catch((e) => {
|
|
|
|
|
if (state.disposed) return;
|
|
|
|
|
pageInput.value = String(state.pageNumber);
|
|
|
|
|
const shell = state.pageShells[state.pageNumber - 1];
|
|
|
|
|
if (shell) viewportHost.scrollTo({ top: Math.max(0, shell.offsetTop - 12), behavior: "smooth" });
|
|
|
|
|
};
|
|
|
|
|
pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);
|
|
|
|
|
|
|
|
|
|
const rerenderVisiblePages = () => {
|
|
|
|
|
state.renderSeq += 1;
|
|
|
|
|
for (const task of state.renderTasks.values()) task.cancel();
|
|
|
|
|
state.renderTasks.clear();
|
|
|
|
|
const availableWidth = Math.max(1, viewportHost.clientWidth - 24);
|
|
|
|
|
for (const shell of state.pageShells) {
|
|
|
|
|
shell.dataset.renderKey = "";
|
|
|
|
|
const baseWidth = Number(shell.dataset.baseWidth);
|
|
|
|
|
const baseHeight = Number(shell.dataset.baseHeight);
|
|
|
|
|
const scale = Math.max(0.1, availableWidth / baseWidth) * state.zoom;
|
|
|
|
|
shell.style.width = Math.floor(baseWidth * scale) + "px";
|
|
|
|
|
shell.style.height = Math.floor(baseHeight * scale) + "px";
|
|
|
|
|
const rect = shell.getBoundingClientRect();
|
|
|
|
|
const hostRect = viewportHost.getBoundingClientRect();
|
|
|
|
|
if (rect.bottom >= hostRect.top - hostRect.height && rect.top <= hostRect.bottom + hostRect.height) {
|
|
|
|
|
renderPage(Number(shell.dataset.page)).catch(handleRenderError);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRenderError = (e) => {
|
|
|
|
|
if (state.disposed || (e && e.name === "RenderingCancelledException")) return;
|
|
|
|
|
_clearPdfPreview(body);
|
|
|
|
|
fallbackFn("PDF 渲染失败:" + e.message);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
prev.onclick = () => goToPage(state.pageNumber - 1);
|
|
|
|
|
next.onclick = () => goToPage(state.pageNumber + 1);
|
|
|
|
|
pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);
|
|
|
|
|
|
|
|
|
|
zoomOut.onclick = () => {
|
|
|
|
|
state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2));
|
|
|
|
|
renderPage().catch((e) => {
|
|
|
|
|
if (state.disposed) return;
|
|
|
|
|
_clearPdfPreview(body);
|
|
|
|
|
fallbackFn("PDF 渲染失败:" + e.message);
|
|
|
|
|
});
|
|
|
|
|
zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
|
|
|
|
|
rerenderVisiblePages();
|
|
|
|
|
};
|
|
|
|
|
zoomIn.onclick = () => {
|
|
|
|
|
state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2));
|
|
|
|
|
renderPage().catch((e) => {
|
|
|
|
|
if (state.disposed) return;
|
|
|
|
|
_clearPdfPreview(body);
|
|
|
|
|
fallbackFn("PDF 渲染失败:" + e.message);
|
|
|
|
|
});
|
|
|
|
|
zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
|
|
|
|
|
rerenderVisiblePages();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
@ -483,10 +507,58 @@ async function _showPdfIn(body, blob, fallbackFn) {
|
|
|
|
|
pageInput.disabled = false;
|
|
|
|
|
zoomOut.disabled = false;
|
|
|
|
|
zoomIn.disabled = false;
|
|
|
|
|
await renderPage();
|
|
|
|
|
const firstPage = await pdf.getPage(1);
|
|
|
|
|
const firstViewport = firstPage.getViewport({ scale: 1 });
|
|
|
|
|
const initialScale = Math.max(0.1, (viewportHost.clientWidth - 24) / firstViewport.width);
|
|
|
|
|
viewportHost.innerHTML = '<div class="pdf-pages-list"></div>';
|
|
|
|
|
const pagesList = viewportHost.firstElementChild;
|
|
|
|
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
|
|
|
|
const shell = document.createElement("div");
|
|
|
|
|
shell.className = "pdf-page-shell";
|
|
|
|
|
shell.dataset.page = String(pageNumber);
|
|
|
|
|
shell.dataset.baseWidth = String(firstViewport.width);
|
|
|
|
|
shell.dataset.baseHeight = String(firstViewport.height);
|
|
|
|
|
shell.style.width = Math.floor(firstViewport.width * initialScale) + "px";
|
|
|
|
|
shell.style.height = Math.floor(firstViewport.height * initialScale) + "px";
|
|
|
|
|
shell.innerHTML = `<span>第 ${pageNumber} 页</span>`;
|
|
|
|
|
pagesList.appendChild(shell);
|
|
|
|
|
state.pageShells.push(shell);
|
|
|
|
|
}
|
|
|
|
|
state.pageObserver = new IntersectionObserver((entries) => {
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (entry.isIntersecting) renderPage(Number(entry.target.dataset.page)).catch(handleRenderError);
|
|
|
|
|
}
|
|
|
|
|
}, { root: viewportHost, rootMargin: "100% 0px" });
|
|
|
|
|
for (const shell of state.pageShells) state.pageObserver.observe(shell);
|
|
|
|
|
|
|
|
|
|
viewportHost.addEventListener("scroll", () => {
|
|
|
|
|
if (state.scrollFrame) return;
|
|
|
|
|
state.scrollFrame = requestAnimationFrame(() => {
|
|
|
|
|
state.scrollFrame = null;
|
|
|
|
|
const center = viewportHost.scrollTop + viewportHost.clientHeight / 2;
|
|
|
|
|
let current = 1;
|
|
|
|
|
let distance = Infinity;
|
|
|
|
|
for (const shell of state.pageShells) {
|
|
|
|
|
const delta = Math.abs(shell.offsetTop + shell.offsetHeight / 2 - center);
|
|
|
|
|
if (delta < distance) {
|
|
|
|
|
distance = delta;
|
|
|
|
|
current = Number(shell.dataset.page);
|
|
|
|
|
}
|
|
|
|
|
const farAway = shell.offsetTop + shell.offsetHeight < viewportHost.scrollTop - viewportHost.clientHeight * 2
|
|
|
|
|
|| shell.offsetTop > viewportHost.scrollTop + viewportHost.clientHeight * 3;
|
|
|
|
|
if (farAway && shell.dataset.renderKey) {
|
|
|
|
|
shell.innerHTML = `<span>第 ${shell.dataset.page} 页</span>`;
|
|
|
|
|
shell.dataset.renderKey = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.pageNumber = current;
|
|
|
|
|
pageInput.value = String(current);
|
|
|
|
|
});
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
|
|
|
|
|
const rerender = () => {
|
|
|
|
|
if (state.disposed) return;
|
|
|
|
|
renderPage().catch(() => {});
|
|
|
|
|
rerenderVisiblePages();
|
|
|
|
|
};
|
|
|
|
|
if (window.ResizeObserver) {
|
|
|
|
|
state.resizeObserver = new ResizeObserver(rerender);
|
|
|
|
|
|