changes
This commit is contained in:
24
web/public/viewer/index.html
Normal file
24
web/public/viewer/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>PDF</title>
|
||||
<link rel="stylesheet" href="/viewer/viewer.css">
|
||||
<script type="module">
|
||||
// Vite serves public/ verbatim, so this page is unbundled in dev and prod alike.
|
||||
console.log("[BizMatch] PDF.js frontend build: legacy");
|
||||
import * as pdfjsLib from "/pdfjs/legacy/pdf.min.mjs";
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/legacy/pdf.worker.min.mjs";
|
||||
globalThis.pdfjsLib = pdfjsLib;
|
||||
</script>
|
||||
<script type="module" src="/viewer/pdf_viewer.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<section class="viewer">
|
||||
<div id="pdfViewer"></div>
|
||||
<div id="pdfMessage">Select a document</div>
|
||||
</section>
|
||||
<script type="module" src="/viewer/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
385
web/public/viewer/pdf_viewer.js
Normal file
385
web/public/viewer/pdf_viewer.js
Normal file
@@ -0,0 +1,385 @@
|
||||
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
|
||||
// Depends on global pdfjsLib (loaded in index.html).
|
||||
|
||||
const ZOOM_MIN = 0.25;
|
||||
const ZOOM_MAX = 5;
|
||||
const ZOOM_STEP = 1.2; // multiplicative
|
||||
|
||||
class PdfViewer {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.generation = 0;
|
||||
this.loadingTask = null;
|
||||
this.doc = null;
|
||||
this.numPages = 0;
|
||||
this.renderTasks = new Set();
|
||||
this.lastUrl = null;
|
||||
this.lastSize = null;
|
||||
// multiplier on fit-to-width (1 = fit width); can be seeded from the
|
||||
// previous viewer instance so zoom survives switching documents
|
||||
this.zoom = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(ZOOM_MIN, options.initialZoom ?? 1),
|
||||
);
|
||||
this._lastRenderWidth = 0;
|
||||
this._resizeTimer = null;
|
||||
this._zoomTimer = null;
|
||||
this.resizeObserver = null;
|
||||
this._createDOM();
|
||||
this._updateZoomLabel();
|
||||
this._observeResize();
|
||||
}
|
||||
|
||||
_createDOM() {
|
||||
// The pages container is always visible (never hidden).
|
||||
// Loading and error states are overlays on top.
|
||||
this.container.innerHTML = `
|
||||
<div class="pdf-v-toolbar" hidden>
|
||||
<button class="pdf-v-zoom-out" title="Zoom out (Ctrl+mouse wheel)">\u2212</button>
|
||||
<button class="pdf-v-zoom-label" title="Fit to page width">100%</button>
|
||||
<button class="pdf-v-zoom-in" title="Zoom in (Ctrl+mouse wheel)">+</button>
|
||||
</div>
|
||||
<div class="pdf-v-pages"></div>
|
||||
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
|
||||
<div class="pdf-v-overlay pdf-v-error" hidden>
|
||||
<div class="pdf-v-error-msg"></div>
|
||||
<button class="pdf-v-retry">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
this.toolbar = this.container.querySelector(".pdf-v-toolbar");
|
||||
this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label");
|
||||
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
|
||||
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
|
||||
this.errorDiv = this.container.querySelector(".pdf-v-error");
|
||||
this.errorMsg = this.container.querySelector(".pdf-v-error-msg");
|
||||
this.retryBtn = this.container.querySelector(".pdf-v-retry");
|
||||
|
||||
this.retryBtn.addEventListener("click", () => {
|
||||
if (this.lastUrl != null) {
|
||||
this._setState("loading");
|
||||
this._loadInternal(this.lastUrl, this.lastSize);
|
||||
}
|
||||
});
|
||||
|
||||
this.container.querySelector(".pdf-v-zoom-in").addEventListener(
|
||||
"click",
|
||||
() => this.setZoom(this.zoom * ZOOM_STEP),
|
||||
);
|
||||
this.container.querySelector(".pdf-v-zoom-out").addEventListener(
|
||||
"click",
|
||||
() => this.setZoom(this.zoom / ZOOM_STEP),
|
||||
);
|
||||
// Clicking the percentage resets to fit-width.
|
||||
this.zoomLabel.addEventListener("click", () => this.setZoom(1));
|
||||
|
||||
// Ctrl + mouse wheel zoom, like in browsers / PDF readers.
|
||||
this.pagesDiv.addEventListener("wheel", (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// ---- Zoom ----
|
||||
|
||||
setZoom(zoom) {
|
||||
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
|
||||
if (Math.abs(clamped - this.zoom) < 0.001) return;
|
||||
this.zoom = clamped;
|
||||
this._updateZoomLabel();
|
||||
if (!this.doc) return;
|
||||
// Debounce so repeated +/+/+ or wheel ticks trigger one re-render.
|
||||
clearTimeout(this._zoomTimer);
|
||||
this._zoomTimer = setTimeout(() => this._rerender(), 120);
|
||||
}
|
||||
|
||||
_updateZoomLabel() {
|
||||
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
}
|
||||
|
||||
// ---- Resize ----
|
||||
|
||||
_observeResize() {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (!this.doc) return;
|
||||
const width = this._renderWidth();
|
||||
if (Math.abs(width - this._lastRenderWidth) < 2) return;
|
||||
clearTimeout(this._resizeTimer);
|
||||
this._resizeTimer = setTimeout(() => {
|
||||
console.log(
|
||||
`[BizMatch] PDF viewer resized, re-rendering at ${this._renderWidth()}px`,
|
||||
);
|
||||
this._rerender();
|
||||
}, 150);
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
_renderWidth() {
|
||||
return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100);
|
||||
}
|
||||
|
||||
// Re-render all pages of the already-loaded document (zoom / resize),
|
||||
// preserving the relative scroll position.
|
||||
async _rerender() {
|
||||
if (!this.doc) return;
|
||||
const generation = ++this.generation;
|
||||
|
||||
for (const rt of this.renderTasks) {
|
||||
try {
|
||||
rt.cancel();
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
this.renderTasks.clear();
|
||||
|
||||
const scrollRatio = this.pagesDiv.scrollHeight > 0
|
||||
? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight
|
||||
: 0;
|
||||
|
||||
await this._renderAllPages(this.doc, generation, false);
|
||||
if (generation !== this.generation) return;
|
||||
this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight;
|
||||
}
|
||||
|
||||
// ---- Loading ----
|
||||
|
||||
async load(url, byteSize) {
|
||||
this.lastUrl = url;
|
||||
this.lastSize = byteSize;
|
||||
this._setState("loading");
|
||||
await this._loadInternal(url, byteSize);
|
||||
}
|
||||
|
||||
async _loadInternal(url, _byteSize) {
|
||||
const generation = ++this.generation;
|
||||
console.log(
|
||||
`[BizMatch] PDF viewer load started: generation ${generation}`,
|
||||
);
|
||||
|
||||
// Await cleanup of previous document (promises may be involved)
|
||||
await this._disposeCurrentDocument();
|
||||
if (generation !== this.generation) return;
|
||||
|
||||
this.pagesDiv.innerHTML = "";
|
||||
|
||||
const loadStart = performance.now();
|
||||
try {
|
||||
const task = pdfjsLib.getDocument({
|
||||
url,
|
||||
rangeChunkSize: 65536,
|
||||
disableAutoFetch: false,
|
||||
// PDF.js >= 5 decodes CCITT-G4/JBIG2 (B/W scans), JPEG2000 and ICC
|
||||
// color via WASM modules fetched from wasmUrl. Without these URLs
|
||||
// the decoders fail silently (ignoreErrors default) and scanned
|
||||
// pages render as blank white canvases of the correct size.
|
||||
wasmUrl: "/pdfjs/wasm/",
|
||||
standardFontDataUrl: "/pdfjs/standard_fonts/",
|
||||
iccUrl: "/pdfjs/iccs/",
|
||||
});
|
||||
this.loadingTask = task;
|
||||
|
||||
const doc = await task.promise;
|
||||
if (generation !== this.generation) {
|
||||
doc.destroy();
|
||||
return;
|
||||
}
|
||||
this.doc = doc;
|
||||
this.numPages = doc.numPages;
|
||||
|
||||
const loadMs = Math.round(performance.now() - loadStart);
|
||||
console.log(
|
||||
`[BizMatch] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`,
|
||||
);
|
||||
|
||||
this.toolbar.hidden = false;
|
||||
this._updateZoomLabel();
|
||||
|
||||
await this._renderAllPages(doc, generation, true);
|
||||
if (generation !== this.generation) return;
|
||||
|
||||
const totalMs = Math.round(performance.now() - loadStart);
|
||||
console.log(
|
||||
`[BizMatch] PDF viewer load completed: generation ${generation}`,
|
||||
);
|
||||
console.log(
|
||||
`[BizMatch] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (generation !== this.generation) return;
|
||||
// Distinguish cancellation from real errors
|
||||
if (isCancellationError(err)) {
|
||||
console.log(
|
||||
`[BizMatch] PDF generation ${generation} cancelled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const msg = errorMsg(err);
|
||||
console.error(`[BizMatch] PDF load failed:`, msg);
|
||||
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
|
||||
this._setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
// Shared render loop for initial load, zoom and resize.
|
||||
// hideLoadingAfterFirstPage: true on initial load (removes the overlay
|
||||
// as soon as page 1 is visible).
|
||||
async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) {
|
||||
this.pagesDiv.innerHTML = "";
|
||||
|
||||
const viewerWidth = this._renderWidth();
|
||||
this._lastRenderWidth = viewerWidth;
|
||||
console.log(`[BizMatch] PDF render width: ${viewerWidth}px`);
|
||||
|
||||
for (let p = 1; p <= this.numPages; p++) {
|
||||
if (generation !== this.generation) return;
|
||||
const pStart = performance.now();
|
||||
await this._renderPage(doc, p, viewerWidth, generation);
|
||||
if (generation !== this.generation) return;
|
||||
if (p === 1 && hideLoadingAfterFirstPage) {
|
||||
this._setState(null); // Remove loading overlay
|
||||
}
|
||||
console.log(
|
||||
`[BizMatch] PDF page ${p} rendered in ${
|
||||
Math.round(performance.now() - pStart)
|
||||
}ms`,
|
||||
);
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
}
|
||||
|
||||
_setState(state) {
|
||||
this.loadingDiv.hidden = true;
|
||||
this.errorDiv.hidden = true;
|
||||
if (state === "loading") this.loadingDiv.hidden = false;
|
||||
if (state === "error") this.errorDiv.hidden = false;
|
||||
this._viewerState = state;
|
||||
}
|
||||
|
||||
_getViewportWidth() {
|
||||
const style = globalThis.getComputedStyle
|
||||
? getComputedStyle(this.pagesDiv)
|
||||
: null;
|
||||
let padding = 24; // default guess
|
||||
if (style) {
|
||||
padding = (parseFloat(style.paddingLeft) || 0) +
|
||||
(parseFloat(style.paddingRight) || 0);
|
||||
}
|
||||
const raw = Math.round(this.pagesDiv.clientWidth || 600);
|
||||
return Math.max(raw - padding, 200);
|
||||
}
|
||||
|
||||
async _disposeCurrentDocument() {
|
||||
for (const rt of this.renderTasks) {
|
||||
try {
|
||||
rt.cancel();
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
this.renderTasks.clear();
|
||||
|
||||
if (this.loadingTask) {
|
||||
try {
|
||||
await this.loadingTask.destroy();
|
||||
} catch { /* ok */ }
|
||||
this.loadingTask = null;
|
||||
}
|
||||
|
||||
if (this.doc) {
|
||||
try {
|
||||
await this.doc.destroy();
|
||||
} catch { /* ok */ }
|
||||
this.doc = null;
|
||||
this.numPages = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async _renderPage(doc, pageNum, viewerWidth, generation) {
|
||||
const pageDiv = document.createElement("div");
|
||||
pageDiv.className = "pdf-v-page";
|
||||
pageDiv.dataset.page = String(pageNum);
|
||||
pageDiv.dataset.renderState = "idle";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
pageDiv.appendChild(canvas);
|
||||
this.pagesDiv.appendChild(pageDiv);
|
||||
|
||||
pageDiv.dataset.renderState = "rendering";
|
||||
|
||||
try {
|
||||
const page = await doc.getPage(pageNum);
|
||||
if (generation !== this.generation) return;
|
||||
|
||||
const vp1 = page.getViewport({ scale: 1 });
|
||||
const fitScale = viewerWidth / vp1.width;
|
||||
const viewport = page.getViewport({ scale: fitScale });
|
||||
|
||||
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.floor(viewport.width * dpr);
|
||||
canvas.height = Math.floor(viewport.height * dpr);
|
||||
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
|
||||
|
||||
const renderTask = page.render({
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
transform,
|
||||
});
|
||||
this.renderTasks.add(renderTask);
|
||||
|
||||
await renderTask.promise;
|
||||
this.renderTasks.delete(renderTask);
|
||||
|
||||
if (generation !== this.generation) return;
|
||||
pageDiv.dataset.renderState = "rendered";
|
||||
} catch (err) {
|
||||
if (generation !== this.generation) return;
|
||||
if (isCancellationError(err)) return;
|
||||
pageDiv.dataset.renderState = "error";
|
||||
console.error(
|
||||
`[BizMatch] Failed to render PDF page ${pageNum}`,
|
||||
err,
|
||||
);
|
||||
if (pageNum === 1) {
|
||||
this.errorMsg.textContent = `Unable to open PDF: ${errorMsg(err)}`;
|
||||
this._setState("error");
|
||||
} else {
|
||||
pageDiv.textContent = `Error rendering page ${pageNum}`;
|
||||
pageDiv.style.padding = "20px";
|
||||
pageDiv.style.color = "#f88";
|
||||
pageDiv.style.textAlign = "center";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.generation = 0;
|
||||
clearTimeout(this._resizeTimer);
|
||||
clearTimeout(this._zoomTimer);
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
this._disposeCurrentDocument().catch(() => {});
|
||||
this.pagesDiv.innerHTML = "";
|
||||
this.toolbar.hidden = true;
|
||||
this._setState("loading");
|
||||
}
|
||||
}
|
||||
|
||||
function isCancellationError(err) {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message || "";
|
||||
return msg.includes("cancelled") ||
|
||||
msg.includes("canceled") ||
|
||||
msg.includes("destroyed") ||
|
||||
msg.includes("Worker was destroyed");
|
||||
}
|
||||
|
||||
function errorMsg(err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
globalThis.PdfViewer = PdfViewer;
|
||||
136
web/public/viewer/viewer.css
Normal file
136
web/public/viewer/viewer.css
Normal file
@@ -0,0 +1,136 @@
|
||||
/* Taken from viewer-phase1/styles.css: the .viewer / #pdfViewer rules plus the
|
||||
PDF.js viewer + zoom toolbar blocks. The page is nothing but the viewer pane,
|
||||
so .viewer fills the iframe instead of sitting in a grid column. */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px system-ui, sans-serif;
|
||||
color: #202124;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: #555;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewer #pdfViewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #525659;
|
||||
}
|
||||
.viewer #pdfMessage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
white-space: pre-wrap;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.viewer.loaded #pdfMessage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* PDF.js viewer */
|
||||
.pdf-v-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.pdf-v-loading {
|
||||
padding: 40px 20px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
.pdf-v-error {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.pdf-v-error-msg {
|
||||
color: #f88;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.pdf-v-retry {
|
||||
padding: 8px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #888;
|
||||
background: #444;
|
||||
color: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pdf-v-retry:hover {
|
||||
background: #555;
|
||||
}
|
||||
.pdf-v-pages {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: auto;
|
||||
scrollbar-gutter: stable;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.pdf-v-page {
|
||||
background: white;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdf-v-page canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Zoom toolbar */
|
||||
.pdf-v-toolbar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 24px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(35, 35, 38, 0.88);
|
||||
border: 1px solid #666;
|
||||
border-radius: 6px;
|
||||
padding: 3px 4px;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.pdf-v-toolbar button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #e5e5e5;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 5px 9px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pdf-v-toolbar button:hover {
|
||||
background: #555;
|
||||
}
|
||||
.pdf-v-zoom-label {
|
||||
min-width: 52px;
|
||||
text-align: center;
|
||||
font-size: 12px !important;
|
||||
color: #ccc !important;
|
||||
}
|
||||
51
web/public/viewer/viewer.js
Normal file
51
web/public/viewer/viewer.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// Bootstrap for the standalone PDF viewer page. Plays the role of app.js in
|
||||
// viewer-phase1: it owns the #pdfMessage overlay and hands one URL to PdfViewer
|
||||
// (from pdf_viewer.js, unchanged). Everything else — zoom, resize, retry — lives
|
||||
// in PdfViewer already.
|
||||
//
|
||||
// URL contract: /viewer/index.html?file=<urlencoded /api/... path>
|
||||
// Same-origin only: any file value that is not a root-relative /api/ path is
|
||||
// refused before it ever reaches pdf.js.
|
||||
|
||||
const viewer = document.querySelector(".viewer");
|
||||
const pdfContainer = document.querySelector("#pdfViewer");
|
||||
const pdfMessage = document.querySelector("#pdfMessage");
|
||||
|
||||
function showMessage(text) {
|
||||
viewer.classList.remove("loaded");
|
||||
pdfMessage.textContent = text;
|
||||
pdfMessage.hidden = false;
|
||||
}
|
||||
|
||||
// Only root-relative /api/ paths. Rejects absolute URLs ("https://..."),
|
||||
// protocol-relative ones ("//host/api/x") and anything outside /api/.
|
||||
function safeFileUrl(raw) {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/api/") || raw.startsWith("//")) return null;
|
||||
// A backslash can be normalized to "/" by some URL parsers — reject outright.
|
||||
if (raw.includes("\\")) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const file = safeFileUrl(params.get("file"));
|
||||
|
||||
if (!file) {
|
||||
showMessage(
|
||||
params.get("file")
|
||||
? "Refused to load this document: only same-origin /api/ paths are allowed."
|
||||
: "Select a document",
|
||||
);
|
||||
} else {
|
||||
showMessage("Loading PDF…");
|
||||
const pdfViewer = new PdfViewer(pdfContainer);
|
||||
viewer.classList.add("loaded");
|
||||
pdfMessage.hidden = true;
|
||||
pdfViewer.load(file).catch((error) => {
|
||||
showMessage(
|
||||
`Cannot open PDF: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user