Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1b4e4372f | |||
| 8e0253821f | |||
| 84b7ee0a47 |
@@ -4,9 +4,7 @@
|
||||
"exports": "./main.ts",
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@^1",
|
||||
"@std/path": "jsr:@std/path@^1",
|
||||
"pdfjs-dist": "npm:pdfjs-dist@^6.1.200",
|
||||
"pdfjs-dist/": "npm:/pdfjs-dist@^6.1.200/"
|
||||
"@std/path": "jsr:@std/path@^1"
|
||||
},
|
||||
"tasks": {
|
||||
"dev": "deno desktop --hmr --backend cef --allow-read --allow-write --allow-env main.ts",
|
||||
|
||||
86
deno.lock
generated
86
deno.lock
generated
@@ -4,9 +4,7 @@
|
||||
"jsr:@std/assert@1": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
||||
"jsr:@std/internal@^1.0.14": "1.0.14",
|
||||
"jsr:@std/path@1": "1.1.6",
|
||||
"npm:pdfjs-dist@*": "6.1.200",
|
||||
"npm:pdfjs-dist@^6.1.200": "6.1.200"
|
||||
"jsr:@std/path@1": "1.1.6"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
@@ -25,90 +23,10 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@napi-rs/canvas-android-arm64@1.0.2": {
|
||||
"integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==",
|
||||
"os": ["android"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@napi-rs/canvas-darwin-arm64@1.0.2": {
|
||||
"integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@napi-rs/canvas-darwin-x64@1.0.2": {
|
||||
"integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf@1.0.2": {
|
||||
"integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm"]
|
||||
},
|
||||
"@napi-rs/canvas-linux-arm64-gnu@1.0.2": {
|
||||
"integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@napi-rs/canvas-linux-arm64-musl@1.0.2": {
|
||||
"integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@napi-rs/canvas-linux-riscv64-gnu@1.0.2": {
|
||||
"integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["riscv64"]
|
||||
},
|
||||
"@napi-rs/canvas-linux-x64-gnu@1.0.2": {
|
||||
"integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@napi-rs/canvas-linux-x64-musl@1.0.2": {
|
||||
"integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@napi-rs/canvas-win32-arm64-msvc@1.0.2": {
|
||||
"integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@napi-rs/canvas-win32-x64-msvc@1.0.2": {
|
||||
"integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@napi-rs/canvas@1.0.2": {
|
||||
"integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==",
|
||||
"optionalDependencies": [
|
||||
"@napi-rs/canvas-android-arm64",
|
||||
"@napi-rs/canvas-darwin-arm64",
|
||||
"@napi-rs/canvas-darwin-x64",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf",
|
||||
"@napi-rs/canvas-linux-arm64-gnu",
|
||||
"@napi-rs/canvas-linux-arm64-musl",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu",
|
||||
"@napi-rs/canvas-linux-x64-gnu",
|
||||
"@napi-rs/canvas-linux-x64-musl",
|
||||
"@napi-rs/canvas-win32-arm64-msvc",
|
||||
"@napi-rs/canvas-win32-x64-msvc"
|
||||
]
|
||||
},
|
||||
"pdfjs-dist@6.1.200": {
|
||||
"integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==",
|
||||
"optionalDependencies": [
|
||||
"@napi-rs/canvas"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/path@1",
|
||||
"npm:pdfjs-dist@^6.1.200"
|
||||
"jsr:@std/path@1"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
70
main.ts
70
main.ts
@@ -19,12 +19,6 @@ import STYLES_CSS from "./web/styles.css" with { type: "text" };
|
||||
import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with {
|
||||
type: "json",
|
||||
};
|
||||
import PDFJS_LIB_TEXT from "pdfjs-dist/legacy/build/pdf.min.mjs" with {
|
||||
type: "text",
|
||||
};
|
||||
import PDFJS_WORKER_TEXT from "pdfjs-dist/legacy/build/pdf.worker.min.mjs" with {
|
||||
type: "text",
|
||||
};
|
||||
|
||||
const PREFIX = "[BizMatch QC]";
|
||||
|
||||
@@ -354,6 +348,30 @@ Deno.serve(async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Client-side log forwarding ----
|
||||
if (url.pathname === "/api/client-log" && request.method === "POST") {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
level?: string;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
const level = body.level || "info";
|
||||
const prefix = `${PREFIX} Client`;
|
||||
if (level === "error") {
|
||||
console.error(
|
||||
`${prefix} [ERROR] ${body.message || ""}`,
|
||||
body.data || "",
|
||||
);
|
||||
} else if (level === "warn") {
|
||||
console.warn(`${prefix} [WARN] ${body.message || ""}`, body.data || "");
|
||||
} else {
|
||||
console.log(`${prefix} ${body.message || ""}`, body.data || "");
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// ---- PDF prepare endpoint ----
|
||||
if (url.pathname === "/api/pdf/prepare" && request.method === "POST") {
|
||||
try {
|
||||
@@ -374,9 +392,29 @@ Deno.serve(async (request) => {
|
||||
|
||||
const startTime = performance.now();
|
||||
const cacheResult = await cacheOrGetPdf(sourcePath);
|
||||
|
||||
// Validate cached file
|
||||
let fileSize: number;
|
||||
try {
|
||||
fileSize = (await Deno.stat(cacheResult.path)).size;
|
||||
const f = await Deno.open(cacheResult.path, { read: true });
|
||||
const head = new Uint8Array(5);
|
||||
let n = 0;
|
||||
while (n < 5) {
|
||||
const r = await f.read(head.subarray(n));
|
||||
if (r === null) break;
|
||||
n += r;
|
||||
}
|
||||
f.close();
|
||||
const header = new TextDecoder().decode(head.slice(0, n));
|
||||
if (header !== "%PDF-") {
|
||||
console.warn(
|
||||
`${PREFIX} Cached PDF invalid header: "${header}", re-caching`,
|
||||
);
|
||||
await Deno.remove(cacheResult.path).catch(() => {});
|
||||
const fresh = await cacheOrGetPdf(sourcePath);
|
||||
fileSize = (await Deno.stat(fresh.path)).size;
|
||||
}
|
||||
} catch {
|
||||
throw new Error("Cached file is not readable.");
|
||||
}
|
||||
@@ -452,25 +490,6 @@ Deno.serve(async (request) => {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- PDF.js library assets ----
|
||||
if (url.pathname === "/pdfjs/legacy/pdf.min.mjs") {
|
||||
return new Response(PDFJS_LIB_TEXT, {
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/pdfjs/legacy/pdf.worker.min.mjs") {
|
||||
return new Response(PDFJS_WORKER_TEXT, {
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Static files ----
|
||||
const staticFiles: Record<string, { body: string; type: string }> = {
|
||||
"/": { body: INDEX_HTML, type: "text/html; charset=utf-8" },
|
||||
@@ -503,7 +522,6 @@ console.log(
|
||||
}`,
|
||||
);
|
||||
console.log(`${PREFIX} Documents loaded: ${documents.length}`);
|
||||
console.log(`${PREFIX} PDF.js version: 6.1.200`);
|
||||
if (loadError) console.error(`${PREFIX} ${loadError}`);
|
||||
|
||||
// ------- Window management -------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
118
web/app.js
118
web/app.js
@@ -1,18 +1,20 @@
|
||||
let state;
|
||||
let groups = [];
|
||||
let selectedIndex = -1;
|
||||
let pdfViewer = null;
|
||||
let currentPdfKey = null;
|
||||
let loadRequestId = 0;
|
||||
|
||||
const people = document.querySelector("#people");
|
||||
const fields = document.querySelector("#fields");
|
||||
const viewer = document.querySelector(".viewer");
|
||||
const pdfContainer = document.querySelector("#pdfViewer");
|
||||
const viewerSection = document.querySelector(".viewer");
|
||||
const pdfFrame = document.querySelector("#pdfFrame");
|
||||
const pdfMessage = document.querySelector("#pdfMessage");
|
||||
const status = document.querySelector("#status");
|
||||
const search = document.querySelector("#search");
|
||||
const errorBanner = document.querySelector("#errorBanner");
|
||||
|
||||
const pdfViewer = new NativePdfViewer(pdfFrame, viewerSection);
|
||||
|
||||
const fieldDefs = [
|
||||
["Name / Company", "name_company"],
|
||||
["Prospective Buyer", "prospective_buyer"],
|
||||
@@ -112,69 +114,23 @@ function renderList() {
|
||||
updateStatus(shown.length);
|
||||
}
|
||||
|
||||
async function loadPdf(index) {
|
||||
async function openDocument(index, { force = false } = {}) {
|
||||
const doc = state.documents[index];
|
||||
if (!doc) return;
|
||||
|
||||
// Deduplicate: don't reload the same document
|
||||
if (index === selectedIndex && pdfViewer) {
|
||||
const key = `${doc._letter}/${doc.file_name}`;
|
||||
|
||||
// Deduplicate: ignore click on already-loaded document unless forced
|
||||
if (!force && key === currentPdfKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pdfViewer) {
|
||||
pdfViewer.destroy();
|
||||
}
|
||||
pdfViewer = new PdfViewer(pdfContainer);
|
||||
viewer.classList.remove("loaded");
|
||||
pdfMessage.hidden = false;
|
||||
pdfMessage.textContent = "Loading PDF\u2026";
|
||||
|
||||
const reqId = "pdf-" + (++loadRequestId);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF ${reqId} selection: ${doc._letter}/${doc.file_name}`,
|
||||
);
|
||||
console.log(`[BizMatch QC] PDF ${reqId} prepare requested`);
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const response = await fetch("/api/pdf/prepare", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ index, requestId: reqId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const prepareMs = Math.round(performance.now() - startTime);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF ${reqId} prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
|
||||
);
|
||||
|
||||
if (index !== selectedIndex) {
|
||||
// A newer selection happened while preparing, don't show stale result
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.classList.add("loaded");
|
||||
pdfMessage.hidden = true;
|
||||
console.log(`[BizMatch QC] PDF ${reqId} viewer load started`);
|
||||
await pdfViewer.load(data.url, data.size);
|
||||
console.log(`[BizMatch QC] PDF ${reqId} rendering complete`);
|
||||
} catch (error) {
|
||||
if (index !== selectedIndex) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
||||
pdfMessage.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function select(index) {
|
||||
// Mark as loading this document
|
||||
currentPdfKey = key;
|
||||
selectedIndex = index;
|
||||
const doc = state.documents[index];
|
||||
if (!doc) return;
|
||||
renderList();
|
||||
|
||||
// Update detail panel
|
||||
let rowIdx = 0;
|
||||
fields.innerHTML = `
|
||||
<h2>${esc(doc.name_from_filename)}</h2>
|
||||
@@ -202,13 +158,49 @@ function select(index) {
|
||||
}).join("")
|
||||
}
|
||||
`;
|
||||
void loadPdf(index);
|
||||
renderList();
|
||||
|
||||
pdfMessage.hidden = false;
|
||||
pdfMessage.textContent = "Loading PDF\u2026";
|
||||
|
||||
const reqId = "pdf-" + (++loadRequestId);
|
||||
console.log(`[BizMatch QC] Native PDF prepare: ${reqId} (${key})`);
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const response = await fetch("/api/pdf/prepare", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ index, requestId: reqId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const prepareMs = Math.round(performance.now() - startTime);
|
||||
console.log(
|
||||
`[BizMatch QC] Native PDF prepare: ${prepareMs}ms, cache ${data.cacheStatus}`,
|
||||
);
|
||||
|
||||
if (key !== currentPdfKey) {
|
||||
// A newer document was selected while preparing
|
||||
return;
|
||||
}
|
||||
|
||||
await pdfViewer.load(data.url, data.size, key);
|
||||
pdfMessage.hidden = true;
|
||||
console.log(`[BizMatch QC] Native PDF loaded: ${reqId}`);
|
||||
} catch (error) {
|
||||
if (key !== currentPdfKey) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
||||
pdfMessage.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
people.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-index]");
|
||||
if (button) select(Number(button.dataset.index));
|
||||
if (button) openDocument(Number(button.dataset.index));
|
||||
});
|
||||
|
||||
search.addEventListener("input", renderList);
|
||||
@@ -218,7 +210,7 @@ document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||
select(
|
||||
openDocument(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -284,7 +276,7 @@ async function load() {
|
||||
groups = buildGroups(state.documents);
|
||||
showError(state.loadError || "");
|
||||
renderList();
|
||||
if (state.documents.length) select(0);
|
||||
if (state.documents.length) openDocument(0);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
@@ -5,17 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>BizMatch QC</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script type="module">
|
||||
console.log(
|
||||
"[BizMatch QC] Map.getOrInsertComputed supported:",
|
||||
typeof Map.prototype.getOrInsertComputed === "function",
|
||||
);
|
||||
console.log("[BizMatch QC] 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="/pdf_viewer.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -33,8 +22,9 @@
|
||||
<div id="fields"></div>
|
||||
</section>
|
||||
<section class="viewer">
|
||||
<div id="pdfViewer"></div>
|
||||
<div id="pdfMessage">Select a document</div>
|
||||
<iframe id="pdfFrame" class="native-pdf-frame"
|
||||
title="PDF preview"></iframe>
|
||||
<div id="pdfMessage" class="native-pdf-overlay">Select a document</div>
|
||||
</section>
|
||||
</main>
|
||||
<dialog id="settingsDialog">
|
||||
@@ -58,6 +48,7 @@
|
||||
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
|
||||
</form>
|
||||
</dialog>
|
||||
<script type="module" src="/pdf_viewer.js"></script>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,262 +1,91 @@
|
||||
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
|
||||
// Depends on global pdfjsLib (loaded in index.html).
|
||||
// Native Chromium PDF viewer using a single persistent iframe.
|
||||
|
||||
class PdfViewer {
|
||||
constructor(container) {
|
||||
class NativePdfViewer {
|
||||
constructor(frame, container) {
|
||||
this.frame = frame;
|
||||
this.container = container;
|
||||
this.generation = 0;
|
||||
this.loadingTask = null;
|
||||
this.doc = null;
|
||||
this.renderTasks = new Set();
|
||||
this.currentKey = null;
|
||||
this.lastUrl = null;
|
||||
this.lastSize = null;
|
||||
this.loadTimer = null;
|
||||
this._onLoad = null;
|
||||
this._createDOM();
|
||||
}
|
||||
|
||||
_createDOM() {
|
||||
// The pages container is always visible (never hidden).
|
||||
// Loading and error states are overlays on top.
|
||||
this.container.innerHTML = `
|
||||
<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.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.loadingDiv = document.createElement("div");
|
||||
this.loadingDiv.className = "native-pdf-loading";
|
||||
this.loadingDiv.textContent = "Loading PDF\u2026";
|
||||
this.container.appendChild(this.loadingDiv);
|
||||
|
||||
this.errorDiv = document.createElement("div");
|
||||
//this.errorDiv.className = "native-pdf-error";
|
||||
|
||||
this.errorMsg = document.createElement("div");
|
||||
this.errorMsg.className = "native-pdf-error-msg";
|
||||
this.errorDiv.appendChild(this.errorMsg);
|
||||
|
||||
this.retryBtn = document.createElement("button");
|
||||
this.retryBtn.className = "native-pdf-retry";
|
||||
this.retryBtn.textContent = "Retry";
|
||||
this.retryBtn.addEventListener("click", () => {
|
||||
if (this.lastUrl != null) {
|
||||
this._setState("loading");
|
||||
this._loadInternal(this.lastUrl, this.lastSize);
|
||||
if (this.lastUrl && this.currentKey) {
|
||||
this._navigate(this.lastUrl, this.currentKey, true);
|
||||
}
|
||||
});
|
||||
this.errorDiv.appendChild(this.retryBtn);
|
||||
this.container.appendChild(this.errorDiv);
|
||||
}
|
||||
|
||||
_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;
|
||||
_showState(state) {
|
||||
this.loadingDiv.classList.toggle("is-visible", state === "loading");
|
||||
this.errorDiv.classList.toggle("is-visible", state === "error");
|
||||
}
|
||||
|
||||
async load(url, byteSize) {
|
||||
this.lastUrl = url;
|
||||
this.lastSize = byteSize;
|
||||
this._setState("loading");
|
||||
await this._loadInternal(url, byteSize);
|
||||
load(url, _byteSize, key) {
|
||||
this._navigate(url, key, false);
|
||||
}
|
||||
|
||||
async _loadInternal(url, _byteSize) {
|
||||
_navigate(url, key, force) {
|
||||
if (!force && key && key === this.currentKey) return;
|
||||
|
||||
const generation = ++this.generation;
|
||||
console.log(
|
||||
`[BizMatch QC] PDF viewer load started: generation ${generation}`,
|
||||
);
|
||||
this.currentKey = key;
|
||||
this.lastUrl = url;
|
||||
|
||||
// Await cleanup of previous document (promises may be involved)
|
||||
await this._disposeCurrentDocument();
|
||||
if (generation !== this.generation) return;
|
||||
// Clear old error text, show loading badge
|
||||
this.errorMsg.textContent = "";
|
||||
this._showState("loading");
|
||||
|
||||
this.pagesDiv.innerHTML = "";
|
||||
|
||||
const loadStart = performance.now();
|
||||
try {
|
||||
const task = pdfjsLib.getDocument({
|
||||
url,
|
||||
rangeChunkSize: 65536,
|
||||
disableAutoFetch: false,
|
||||
});
|
||||
this.loadingTask = task;
|
||||
|
||||
const doc = await task.promise;
|
||||
if (generation !== this.generation) {
|
||||
doc.destroy();
|
||||
return;
|
||||
// Shared hide function, idempotent per navigation
|
||||
const hideLoading = () => {
|
||||
if (generation !== this.generation) return;
|
||||
if (this.loadTimer) {
|
||||
clearTimeout(this.loadTimer);
|
||||
this.loadTimer = null;
|
||||
}
|
||||
this.doc = doc;
|
||||
this._showState("loaded");
|
||||
};
|
||||
|
||||
const numPages = doc.numPages;
|
||||
const loadMs = Math.round(performance.now() - loadStart);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF document loaded: ${numPages} pages in ${loadMs}ms`,
|
||||
);
|
||||
// Optional early signal from iframe load event
|
||||
if (this._onLoad) this.frame.removeEventListener("load", this._onLoad);
|
||||
this._onLoad = hideLoading;
|
||||
this.frame.addEventListener("load", hideLoading, { once: true });
|
||||
|
||||
const viewerWidth = this._getViewportWidth();
|
||||
console.log(`[BizMatch QC] PDF usable render width: ${viewerWidth}px`);
|
||||
// Fallback: hide loading after 800ms regardless of load event
|
||||
if (this.loadTimer) clearTimeout(this.loadTimer);
|
||||
this.loadTimer = setTimeout(hideLoading, 800);
|
||||
|
||||
const pageStart = performance.now();
|
||||
await this._renderPage(doc, 1, viewerWidth, generation);
|
||||
if (generation !== this.generation) return;
|
||||
|
||||
this._setState(null); // Remove loading overlay
|
||||
const firstPageMs = Math.round(performance.now() - pageStart);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF page 1 rendered in ${firstPageMs}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[BizMatch QC] First page visible after ${
|
||||
Math.round(performance.now() - loadStart)
|
||||
}ms`,
|
||||
);
|
||||
|
||||
for (let p = 2; p <= numPages; p++) {
|
||||
if (generation !== this.generation) return;
|
||||
const pStart = performance.now();
|
||||
await this._renderPage(doc, p, viewerWidth, generation);
|
||||
if (generation !== this.generation) return;
|
||||
console.log(
|
||||
`[BizMatch QC] PDF page ${p} rendered in ${
|
||||
Math.round(performance.now() - pStart)
|
||||
}ms`,
|
||||
);
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
|
||||
const totalMs = Math.round(performance.now() - loadStart);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
|
||||
);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF fully rendered: ${numPages} pages in ${totalMs}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (generation !== this.generation) return;
|
||||
// Distinguish cancellation from real errors
|
||||
if (isCancellationError(err)) {
|
||||
console.log(
|
||||
`[BizMatch QC] PDF generation ${generation} cancelled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const msg = errorMsg(err);
|
||||
console.error(`[BizMatch QC] PDF load failed:`, msg);
|
||||
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
|
||||
this._setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
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 QC] 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";
|
||||
}
|
||||
}
|
||||
this.frame.src = url;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.loadTimer) clearTimeout(this.loadTimer);
|
||||
if (this._onLoad) this.frame.removeEventListener("load", this._onLoad);
|
||||
this.generation = 0;
|
||||
this._disposeCurrentDocument().catch(() => {});
|
||||
this.pagesDiv.innerHTML = "";
|
||||
this._setState("loading");
|
||||
this.frame.src = "";
|
||||
this._showState("loading");
|
||||
}
|
||||
}
|
||||
|
||||
function isCancellationError(err) {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message || "";
|
||||
return msg.includes("cancelled") ||
|
||||
msg.includes("cancelled") ||
|
||||
msg.includes("destroyed") ||
|
||||
msg.includes("Worker was destroyed");
|
||||
}
|
||||
|
||||
function errorMsg(err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
globalThis.PdfViewer = PdfViewer;
|
||||
globalThis.NativePdfViewer = NativePdfViewer;
|
||||
|
||||
139
web/styles.css
139
web/styles.css
@@ -79,15 +79,15 @@ aside,
|
||||
background: #555;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewer #pdfViewer {
|
||||
position: relative;
|
||||
.native-pdf-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #525659;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
.viewer #pdfMessage {
|
||||
.native-pdf-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
@@ -95,9 +95,77 @@ aside,
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
white-space: pre-wrap;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.viewer.loaded #pdfMessage {
|
||||
.native-pdf-loading {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
.native-pdf-loading.is-visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.native-pdf-loading.is-visible::before {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: pdf-spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes pdf-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.native-pdf-error {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
.native-pdf-error.is-visible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(50, 50, 50, 0.9);
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.native-pdf-error-msg {
|
||||
color: #f88;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.native-pdf-retry {
|
||||
padding: 8px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #888;
|
||||
background: #444;
|
||||
color: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.native-pdf-retry:hover {
|
||||
background: #555;
|
||||
}
|
||||
dialog {
|
||||
width: min(720px, 90vw);
|
||||
@@ -127,7 +195,6 @@ dialog input {
|
||||
.muted {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 10px 16px;
|
||||
background: #fff1f1;
|
||||
@@ -189,59 +256,3 @@ hr.field-sep {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 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: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user