document-viewer-ts
Version:
PDF and MS Doc viewer written in TypeScript for React and vanilla JavaScript
435 lines • 20.8 kB
JavaScript
import { GlobalWorkerOptions, RenderingCancelledException, TextLayer, getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
const chevronLeft = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
</svg>`;
const chevronRight = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>`;
const handleError = (containerDiv) => (err) => {
renderErrorMessage(containerDiv)(`There was an error fetching your document. Please try again later. Error: ${err}`);
};
const noop = () => undefined;
// pdf.js 6 fetches its JBIG2/OpenJPEG/QCMS decoders — and the plain-JS fallbacks
// that live beside them — from `wasmUrl`, which defaults to a page-relative
// "wasm" and so 404s on every nested route. Derive it from the worker instead,
// which the consumer already serves from a known location.
const wasmUrlFor = (workerSrc) => String(workerSrc || '').replace(/[^/]*$/, 'wasm/');
const isCancelled = (err) => err instanceof RenderingCancelledException ||
(typeof err === 'object' && err !== null && err.name === 'RenderingCancelledException');
// pdf.js's own ceilings (AppOptions maxCanvasPixels / maxCanvasDim). Past
// either one the browser hands back a blank canvas with no error, so a zoom
// that would exceed them is rendered at the largest scale that fits.
const maxCanvasPixels = 2 ** 25;
const maxCanvasDim = 32767;
const fittedScale = (pdfPage, scale) => {
const { width, height } = pdfPage.getViewport({ scale });
const byArea = Math.sqrt(maxCanvasPixels / (width * height));
const bySide = Math.min(maxCanvasDim / width, maxCanvasDim / height);
return scale * Math.min(1, byArea, bySide);
};
const zoomValues = [50, 80, 100, 125, 150, 200, 300, 400];
const defaultWidth = 80;
const PDFtoCSSConvert = 96 / 72;
const defaultPageWidth = 700;
const scaleTextLayer = async (textLayerDiv, textContent, pdfPage, canvas, viewport, renderScale, slot, isStillCurrent) => {
const textLayerFragment = document.createElement('div');
textLayerFragment.className = 'textLayer';
// renderScale is the scale the viewport was actually built at, so this
// reduces to cssWidth / rawPageWidth even when fittedScale clamped it.
const scale = renderScale * (canvas.offsetWidth / viewport.width);
const vs = pdfPage.getViewport({ scale });
const layer = new TextLayer({
textContentSource: textContent,
container: textLayerFragment,
viewport: vs,
});
// A superseded run streams every item on the page before the generation
// check can discard it; cancelling stops that work instead of wasting it.
slot.current?.cancel();
slot.current = layer;
await layer.render();
// A superseded run must not touch the DOM: two concurrent runs that both
// cleared and refilled the layer are what garbled it on resize.
if (!isStillCurrent())
return;
textLayerDiv.replaceChildren(textLayerFragment);
};
const createLoadingIndicator = () => {
const container = document.createElement('div');
container.className = 'indicator-container';
const indicator = document.createElement('div');
indicator.className = 'lds-ring';
indicator.innerHTML = '<div></div><div></div><div></div><div></div></div>';
container.appendChild(indicator);
return container;
};
export const renderPDF = (containerDiv, documentUrl) => {
const wrapperDiv = document.createElement('div');
wrapperDiv.className = 'wrapper';
containerDiv.appendChild(wrapperDiv);
const loadingIndicator = createLoadingIndicator();
wrapperDiv.appendChild(loadingIndicator);
const documentId = containerDiv.id;
const canvasContainer = document.createElement('div');
canvasContainer.className = 'canvas-container';
const controlsDiv = document.createElement('div');
controlsDiv.className = 'viewer-controls';
const fullPageNumberDiv = document.createElement('div');
fullPageNumberDiv.className = 'page-number';
const pageNumberInput = document.createElement('input');
pageNumberInput.className = 'page-number-input';
pageNumberInput.type = 'number';
const pageDiv = document.createElement('div');
pageDiv.className = 'page-label';
const outOfDiv = document.createElement('div');
const pageCountDiv = document.createElement('div');
const nextButton = document.createElement('button');
nextButton.className = 'next-button';
const prevButton = document.createElement('button');
prevButton.className = 'prev-button';
const loadingTask = getDocument({
url: documentUrl,
wasmUrl: wasmUrlFor(GlobalWorkerOptions.workerSrc),
});
const zoomSelect = document.createElement('select');
zoomSelect.className = 'zoom-select';
zoomValues.forEach((v) => {
const zoomOption = document.createElement('option');
zoomOption.value = `${v}`;
zoomOption.textContent = `${v}%`;
zoomSelect.appendChild(zoomOption);
});
zoomSelect.value = `${defaultWidth}`;
const getZoomVal = (originalPageWidth) => {
const pageWidth = containerDiv.clientWidth * (parseInt(zoomSelect.value) / 100);
const scaledBy = pageWidth / originalPageWidth;
const scaleVal = scaledBy * 2.5 * PDFtoCSSConvert;
return scaleVal;
};
const canvas = document.createElement('canvas');
const textLayerDiv = document.createElement('div');
// pdf.js tracks live render tasks per canvas in an internal WeakSet and throws
// if a second render starts while the first still holds it. `currentRenderTask`
// is that single slot, `renderChain` serializes callers, and `requestedPage`
// lets superseded requests drop out instead of queueing a redundant render.
const controller = new AbortController();
const { signal } = controller;
// JavaScript has no cancellable await, so resumption has to be made
// conditional by hand. Awaiting through this rather than bare keeps the
// guarded form the shorter one to write and the funnel the only exit.
const upTo = (p) => p.then((a) => { signal.throwIfAborted(); return a; });
// pdf.js rejects in-flight work with a plain Error when the loading task is
// destroyed, so a cancelled render is not identifiable from the error alone.
const ignorable = (err) => signal.aborted || isCancelled(err);
let currentRenderTask = null;
let renderChain = Promise.resolve();
let requestedPage = 1;
let currentPage = null;
let textLayerGeneration = 0;
const layerSlot = { current: null };
const refreshTextLayer = (page) => {
// pdf.js sizes the layer as --total-scale-factor * the raw page width, so
// this must come from the laid-out canvas: deriving it from the container
// overshoots wherever .wrapper reserves a classic scrollbar.
// A container that is not laid out gives a zero-width canvas, and the
// text-layer scale below divides by it.
if (canvas.offsetWidth === 0 || page.viewport.width === 0)
return Promise.resolve();
containerDiv.style
.setProperty('--scale-factor', `${canvas.offsetWidth / page.originalPageWidth}`);
textLayerGeneration += 1;
const generation = textLayerGeneration;
return scaleTextLayer(textLayerDiv, page.textContent, page.pdfPage, canvas, page.viewport, page.renderScale, layerSlot, () => !signal.aborted && generation === textLayerGeneration);
};
// One observer over a mutable current-page slot. Registering per page view
// meant N pages produced N concurrent handlers on a single resize, and a
// window listener misses the container being resized by anything other than
// the viewport — a tab, accordion or modal revealing it, most importantly,
// which is what left a hidden container stuck at a 0x0 canvas.
let lastWidth = 0;
let resizeFrame = 0;
let rerenderQueued = false;
let rerender = null;
const onResize = () => {
const width = containerDiv.clientWidth;
// A width that did not change cannot move the text layer, and rebuilding it
// streams every item on the page.
if (currentPage === null || width === 0 || width === lastWidth)
return;
lastWidth = width;
// An animated reveal or a dragged split pane changes width every frame, so
// debounce to the frame rather than rebuilding once per callback.
if (resizeFrame !== 0)
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
if (currentPage === null)
return;
// A container that was not laid out rendered a 0x0 canvas; scaling the
// text layer over it cannot recover, only rendering the page again can.
if (canvas.width === 0) {
if (!rerenderQueued && rerender !== null) {
rerenderQueued = true;
rerender();
}
return;
}
rerenderQueued = false;
refreshTextLayer(currentPage).catch(noop);
});
};
const observer = new ResizeObserver(onResize);
observer.observe(containerDiv);
signal.addEventListener('abort', () => {
observer.disconnect();
if (resizeFrame !== 0)
cancelAnimationFrame(resizeFrame);
layerSlot.current?.cancel();
});
signal.addEventListener('abort', () => {
if (currentRenderTask !== null) {
currentRenderTask.cancel();
currentRenderTask = null;
}
currentPage = null;
wrapperDiv.remove();
containerDiv.classList.remove('document-viewer-ts');
containerDiv.removeAttribute('tabIndex');
containerDiv.style.removeProperty('--scale-factor');
loadingTask.destroy().catch(noop);
});
const destroy = () => { controller.abort(); };
void (async () => {
try {
const pdfDocument = await upTo(loadingTask.promise);
wrapperDiv.removeChild(loadingIndicator);
const isValidPage = (page) => page <= pdfDocument.numPages && page > 0;
// initial viewer setup
pageNumberInput.value = '1';
pageDiv.textContent = 'Page ';
outOfDiv.textContent = '/';
pageCountDiv.textContent = `${pdfDocument.numPages}`;
fullPageNumberDiv.appendChild(pageDiv);
fullPageNumberDiv.appendChild(pageNumberInput);
fullPageNumberDiv.appendChild(outOfDiv);
fullPageNumberDiv.appendChild(pageCountDiv);
// page container setup
const pageContainer = document.createElement('div');
pageContainer.className = 'page-container';
pageContainer.style.width = `${defaultWidth}%`;
canvasContainer.style.alignItems = 'center';
canvasContainer.appendChild(pageContainer);
canvas.className = 'pdf-viewer-canvas';
canvas.id = `${documentId}-canvas`;
pageContainer.appendChild(canvas);
// text layer setup
textLayerDiv.className = 'text-layer';
pageContainer.appendChild(textLayerDiv);
nextButton.innerHTML = chevronRight;
prevButton.innerHTML = chevronLeft;
wrapperDiv.appendChild(controlsDiv);
controlsDiv.appendChild(prevButton);
controlsDiv.appendChild(fullPageNumberDiv);
controlsDiv.appendChild(nextButton);
controlsDiv.appendChild(zoomSelect);
wrapperDiv.appendChild(canvasContainer);
const renderPage = async (page) => {
const pdfPage = await upTo(pdfDocument.getPage(page));
const originalPageWidth = Number(pdfPage.view[2]) - Number(pdfPage.view[0]) || defaultPageWidth;
const renderScale = fittedScale(pdfPage, getZoomVal(originalPageWidth));
const viewport = pdfPage.getViewport({ scale: renderScale });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = '100%';
const task = pdfPage.render({ canvas, viewport });
currentRenderTask = task;
try {
await upTo(task.promise);
}
finally {
if (currentRenderTask === task)
currentRenderTask = null;
}
const textContent = await upTo(pdfPage.getTextContent());
const previous = currentPage;
currentPage = { textContent, pdfPage, viewport, originalPageWidth, renderScale };
await refreshTextLayer(currentPage);
// pdf.js caches every page it hands out; without this a long official
// statement retains the operator list and decoded images of every page
// the reader visited.
if (previous !== null && previous.pdfPage !== pdfPage)
previous.pdfPage.cleanup();
};
const displayPage = (page) => {
requestedPage = page;
pageNumberInput.value = `${page}`;
// Frees the canvas synchronously so the next chain link can claim it.
if (currentRenderTask !== null)
currentRenderTask.cancel();
renderChain = renderChain
.then(() => (signal.aborted || page !== requestedPage ? undefined : renderPage(page)))
.catch((err) => {
if (ignorable(err))
return;
handleError(containerDiv)(err);
});
return renderChain;
};
rerender = () => { void displayPage(requestedPage); };
zoomSelect.onchange = () => {
const pageNumber = requestedPage;
const zoomVal = parseInt(zoomSelect.value);
pageContainer.style.width = `${zoomSelect.value}%`;
if (zoomVal > 100) {
canvasContainer.style.alignItems = 'flex-start';
}
else {
canvasContainer.style.alignItems = 'center';
}
return displayPage(pageNumber);
};
const skipPage = (direction) => () => {
const nextPageNumber = requestedPage + direction;
if (isValidPage(nextPageNumber)) {
void displayPage(nextPageNumber);
}
};
nextButton.onclick = skipPage(1);
prevButton.onclick = skipPage(-1);
pageNumberInput.onchange = () => {
const parsed = parseInt(pageNumberInput.value);
const pageNumber = Number.isNaN(parsed) ? requestedPage : Math.max(Math.min(parsed, pdfDocument.numPages), 1);
void displayPage(pageNumber);
};
pageNumberInput.addEventListener('click', (e) => { e.stopPropagation(); }, { signal });
zoomSelect.addEventListener('click', (e) => { e.stopPropagation(); }, { signal });
containerDiv.addEventListener('keydown', (e) => {
e.stopPropagation();
switch (e.key) {
case ('ArrowLeft'):
skipPage(-1)();
return;
case ('ArrowRight'):
skipPage(1)();
return;
default:
return;
}
}, { signal });
containerDiv.addEventListener('click', () => {
containerDiv.focus();
}, { signal });
containerDiv.setAttribute('tabIndex', '1');
containerDiv.focus();
await displayPage(1);
}
catch (err) {
if (ignorable(err))
return;
// Releases the worker and the observer too; the wrapper goes with it.
controller.abort();
handleError(containerDiv)(err);
}
})();
return destroy;
};
const renderDocx = (containerDiv, documentUrl) => {
const microsoftViewer = document.createElement('iframe');
microsoftViewer.width = '100%';
microsoftViewer.height = '100%';
microsoftViewer.setAttribute('frameborder', '0');
const params = new URLSearchParams({ src: documentUrl });
microsoftViewer.setAttribute('src', `https://view.officeapps.live.com/op/embed.aspx?${params.toString()}`);
containerDiv.appendChild(microsoftViewer);
};
const renderTxt = (containerDiv, documentUrl) => {
const embed = document.createElement('embed');
embed.className = 'txt-embed';
embed.setAttribute('src', documentUrl);
containerDiv.appendChild(embed);
};
const renderErrorMessage = (containerDiv) => (errorMessage) => {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = errorMessage;
containerDiv.appendChild(errorDiv);
};
// `init` renders on call and again on window load, and React StrictMode mounts
// twice; without this the earlier render's worker and resize listener outlive
// the DOM they were built for.
const teardowns = new WeakMap();
const renderDocumentIn = (workerSrc) => (containerDiv) => {
try {
containerDiv.innerHTML = '';
const documentUrl = containerDiv.getAttribute('data-document-url');
containerDiv.classList.add('document-viewer-ts');
if (!documentUrl)
throw new Error('No document url specified');
const splitOnPeriods = documentUrl.split('.');
const extension = splitOnPeriods[(splitOnPeriods.length - 1)]?.split('?')[0]?.toLowerCase();
switch (extension) {
case 'pdf':
try {
(() => globalThis)();
new File([], 'test.txt');
GlobalWorkerOptions.workerSrc = workerSrc;
return renderPDF(containerDiv, documentUrl);
}
catch {
renderErrorMessage(containerDiv)('Your browser does not support showing PDF previews. Click the download button to view this document.');
return noop;
}
case 'doc':
case 'docx':
case 'ppt':
case 'pptx':
case 'xls':
case 'xlsx':
case 'xlt':
case 'xlsm':
case 'xlw':
case 'pps':
case 'ppxs':
case 'ppsm':
case 'sldx':
case 'sldm':
renderDocx(containerDiv, documentUrl);
return noop;
case 'txt':
renderTxt(containerDiv, documentUrl);
return noop;
default:
throw new Error('This file type is not supported for viewing in a web browser. Please click the “Download” button to view this document.');
}
}
catch (err) {
handleError(containerDiv)(err);
return noop;
}
};
export const renderDocument = (workerSrc) => (containerDiv) => {
teardowns.get(containerDiv)?.();
const destroy = renderDocumentIn(workerSrc)(containerDiv);
const teardown = () => {
// A superseded render was already torn down when its replacement claimed
// the container; running this again would blank the live viewer.
if (teardowns.get(containerDiv) !== teardown)
return;
teardowns.delete(containerDiv);
destroy();
// Only the pdf path owns a destroy; the iframe, embed and error renderers
// append straight to the container and would otherwise outlive teardown.
containerDiv.replaceChildren();
};
teardowns.set(containerDiv, teardown);
return teardown;
};
const loadDocuments = (workerSrc) => () => {
const containerDivs = document.getElementsByClassName('viewer-container');
Array.from(containerDivs).forEach(renderDocument(workerSrc));
};
export const init = (workerSrc) => {
loadDocuments(workerSrc)();
window.addEventListener('load', loadDocuments(workerSrc));
};
//# sourceMappingURL=base.js.map