This commit is contained in:
2026-07-23 17:48:44 -05:00
commit 5830cd7ab5
33 changed files with 6866 additions and 0 deletions

362
viewer-phase1/app.js Normal file
View File

@@ -0,0 +1,362 @@
let state;
let groups = [];
let selectedIndex = -1;
let loadedIndex = -1; // which document the PDF viewer currently shows/loads
let pdfViewer = 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 pdfMessage = document.querySelector("#pdfMessage");
const status = document.querySelector("#status");
const search = document.querySelector("#search");
const errorBanner = document.querySelector("#errorBanner");
const fieldDefs = [
["Name / Company", "name_company"],
["Prospective Buyer", "prospective_buyer"],
["Company", "company"],
null,
["Phone", "phone"],
["Cell", "cell"],
["Email", "email"],
null,
["Address", "address"],
["State", "state"],
null,
["Businesses from Notes", "notes_business_raw"],
["Types of Businesses", "types_of_business_raw"],
["Background Experience", "background_experience"],
null,
["How Did You Hear", "how_did_you_hear"],
["Interested in Updates", "interested_in_updates"],
["Down Payment", { key: "down_payment_raw", fallback: "down_payment" }],
["Total Purchase Price", "total_purchase_price"],
["Date of Introduction", "date_of_introduction"],
null,
["Notes Page", "_notes_page"],
["Buyer Info Page", "_info_page"],
["CA Page", "_ca_page"],
];
const esc = (value) =>
String(value ?? "").replace(/[&<>"']/g, (char) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
// Normalized person key from the vision-extracted buyer name: lowercase,
// punctuation stripped, name tokens sorted so "Zahoor Bilal" and
// "Bilal Zahoor" compare equal.
function personKey(group) {
const name = group.docs.map((d) => d.prospective_buyer).find((v) =>
typeof v === "string" && v.trim()
);
if (!name) return null;
const tokens = name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")
.trim().split(/\s+/).filter(Boolean).sort();
return tokens.length ? tokens.join(" ") : null;
}
function buildGroups(docs) {
const map = new Map();
docs.forEach((doc, index) => {
const key = doc.name_from_filename;
const group = map.get(key) || {
key,
displayName: doc.prospective_buyer || key,
docs: [],
text: "",
};
group.docs.push({ ...doc, index });
group.text += " " + [
key,
doc.prospective_buyer,
doc.types_of_business_raw,
doc.notes_business_raw,
doc.address,
].filter(Boolean).join(" ");
map.set(key, group);
});
// Second pass: merge filename-based groups that refer to the same person
// according to the extracted prospective_buyer. This catches typos in the
// scan filenames (e.g. "Zaboor, Bilal" vs "Zahoor, Bilal") which would
// otherwise show the same buyer twice. Groups without a prospective_buyer
// are never merged.
const byPerson = new Map();
const merged = [];
for (const group of map.values()) {
const pKey = personKey(group);
const target = pKey ? byPerson.get(pKey) : undefined;
if (target) {
target.docs.push(...group.docs);
target.text += " " + group.text;
continue;
}
if (pKey) byPerson.set(pKey, group);
merged.push(group);
}
for (const group of merged) {
group.docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
const preferred = group.docs.find((d) =>
typeof d.prospective_buyer === "string" && d.prospective_buyer.trim()
);
if (preferred) group.displayName = preferred.prospective_buyer;
}
return merged.sort((a, b) => a.key.localeCompare(b.key));
}
function visibleGroups() {
const terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean);
return groups.filter((group) =>
terms.every((term) => group.text.toLowerCase().includes(term))
);
}
function updateStatus(shownCount = visibleGroups().length) {
const source = state.dataSource === "sample" ? "sample data" : "JSON file";
status.textContent =
`${shownCount} people / ${state.documents.length} documents \u00b7 ${source}`;
status.className = "";
}
function showError(message) {
errorBanner.textContent = message;
errorBanner.hidden = !message;
}
function renderList() {
const shown = visibleGroups();
people.innerHTML = shown.map((group) => `
<div class="person">
<div class="person-title">${
esc(group.displayName)
} <span class="muted">(${group.docs.length})</span></div>
${
group.docs.map((doc) => `
<button class="doc ${
doc.index === selectedIndex ? "active" : ""
}" data-index="${doc.index}">
${esc(doc.file_name)}
</button>`).join("")
}
</div>
`).join("");
updateStatus(shown.length);
}
async function loadPdf(index) {
const doc = state.documents[index];
if (!doc) return;
// Deduplicate: don't reload the document that is already shown.
// NOTE: must compare against loadedIndex, NOT selectedIndex --
// select() updates selectedIndex before calling loadPdf(), so a
// selectedIndex comparison is always true and blocks every reload.
if (index === loadedIndex && pdfViewer) {
return;
}
loadedIndex = index;
const previousZoom = pdfViewer ? pdfViewer.zoom : 1;
if (pdfViewer) {
pdfViewer.destroy();
}
pdfViewer = new PdfViewer(pdfContainer, { initialZoom: previousZoom });
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;
loadedIndex = -1; // allow retry: clicking the same document again reloads it
const message = error instanceof Error ? error.message : String(error);
pdfMessage.textContent = `Cannot open PDF: ${message}`;
pdfMessage.hidden = false;
}
}
function select(index) {
selectedIndex = index;
const doc = state.documents[index];
if (!doc) return;
let rowIdx = 0;
fields.innerHTML = `
<h2>${esc(doc.name_from_filename)}</h2>
<p class="muted">${esc(doc.file_name)} \u00b7 ${
esc(doc._doc_type || "unknown")
} \u00b7 ${esc(doc._pages_total ?? "?")} pages</p>
${
doc._vision_error
? `<p class="error">Vision error: ${esc(doc._vision_error)}</p>`
: ""
}
${
fieldDefs.map((def) => {
if (!def) return '<hr class="field-sep">';
const [label, keyOrObj] = def;
let value;
if (typeof keyOrObj === "object") {
value = esc(doc[keyOrObj.key] || doc[keyOrObj.fallback] || "\u2014");
} else {
value = esc(doc[keyOrObj] || "\u2014");
}
const bgClass = rowIdx % 2 === 0 ? "row-even" : "row-odd";
rowIdx++;
return `<div class="field ${bgClass}"><b>${label}</b><div>${value}</div></div>`;
}).join("")
}
`;
void loadPdf(index);
renderList();
}
people.addEventListener("click", (event) => {
const button = event.target.closest("[data-index]");
if (button) select(Number(button.dataset.index));
});
search.addEventListener("input", renderList);
document.addEventListener("keydown", (event) => {
if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
select(
Math.max(
0,
Math.min(
state.documents.length - 1,
selectedIndex < 0 ? 0 : selectedIndex + delta,
),
),
);
}
});
const dialog = document.querySelector("#settingsDialog");
const jsonPath = document.querySelector("#jsonPath");
const pdfBase = document.querySelector("#pdfBase");
const useAnonData = document.querySelector("#useAnonData");
const settingsError = document.querySelector("#settingsError");
const saveSettings = document.querySelector("#saveSettings");
document.querySelector("#settings").onclick = () => {
jsonPath.value = state.config.jsonPath || "";
pdfBase.value = state.config.pdfBaseDirectory || "";
useAnonData.checked = !!state.config.useAnonymousData;
settingsError.hidden = true;
settingsError.textContent = "";
dialog.showModal();
};
saveSettings.onclick = async (event) => {
event.preventDefault();
saveSettings.disabled = true;
settingsError.hidden = true;
try {
const response = await fetch("/api/config", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonPath: jsonPath.value,
pdfBaseDirectory: pdfBase.value,
useAnonymousData: useAnonData.checked,
}),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || "Could not save settings.");
dialog.close();
await load();
} catch (error) {
settingsError.textContent = error instanceof Error
? error.message
: String(error);
settingsError.hidden = false;
} finally {
saveSettings.disabled = false;
}
};
async function load() {
try {
const response = await fetch("/api/state", { cache: "no-store" });
if (!response.ok) {
throw new Error(`State request failed with HTTP ${response.status}.`);
}
state = await response.json();
loadedIndex = -1; // document set changed; index-based dedup is invalid now
groups = buildGroups(state.documents);
showError(state.loadError || "");
renderList();
if (state.documents.length) select(0);
} catch (error) {
showError(error instanceof Error ? error.message : String(error));
}
}
void load();
// ---- Window size reporting ----
// The backend's native size APIs are unreliable (see main.ts), so the
// webview reports its own viewport size. The first report, sent shortly
// after startup, calibrates the decoration offset on the backend; later
// reports track user resizes.
let metricsTimer = null;
function reportWindowMetrics() {
fetch("/api/window-metrics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
innerWidth: globalThis.innerWidth,
innerHeight: globalThis.innerHeight,
}),
}).catch(() => {});
}
globalThis.addEventListener("resize", () => {
clearTimeout(metricsTimer);
metricsTimer = setTimeout(reportWindowMetrics, 250);
});
setTimeout(reportWindowMetrics, 800); // calibration report

63
viewer-phase1/index.html Normal file
View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<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>
<strong>BizMatch QC</strong>
<input id="search" placeholder="Search name, business, or address">
<button id="settings">Settings</button>
<span id="status"></span>
</header>
<div id="errorBanner" class="error-banner" hidden></div>
<main>
<aside>
<div id="people"></div>
</aside>
<section class="details">
<div id="fields"></div>
</section>
<section class="viewer">
<div id="pdfViewer"></div>
<div id="pdfMessage">Select a document</div>
</section>
</main>
<dialog id="settingsDialog">
<form method="dialog">
<h2>Settings</h2>
<label class="checkbox-label">
<input type="checkbox" id="useAnonData">
Use anonymized sample data
</label>
<label>buyers_vision.json
<input id="jsonPath" autocomplete="off">
</label>
<label>PDF base directory
<input id="pdfBase" autocomplete="off">
</label>
<div id="settingsError" class="dialog-error" hidden></div>
<div class="actions">
<button value="cancel">Cancel</button>
<button id="saveSettings" value="default">Save</button>
</div>
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>

385
viewer-phase1/pdf_viewer.js Normal file
View 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="Verkleinern (Strg+Mausrad)">\u2212</button>
<button class="pdf-v-zoom-label" title="Auf Seitenbreite einpassen">100%</button>
<button class="pdf-v-zoom-in" title="Vergr\u00f6\u00dfern (Strg+Mausrad)">+</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 QC] 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 QC] 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 QC] 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 QC] PDF viewer load completed: generation ${generation}`,
);
console.log(
`[BizMatch QC] 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 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");
}
}
// 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 QC] 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 QC] 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 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";
}
}
}
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;

283
viewer-phase1/styles.css Normal file
View File

@@ -0,0 +1,283 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px system-ui, sans-serif;
color: #202124;
}
header {
height: 52px;
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
border-bottom: 1px solid #ddd;
}
header strong {
font-size: 18px;
}
header input {
flex: 1;
max-width: 620px;
padding: 8px;
}
header span {
margin-left: auto;
color: #666;
}
main {
height: calc(100vh - 52px);
display: grid;
grid-template-columns: 330px 430px minmax(500px, 1fr);
}
aside,
.details {
overflow: auto;
border-right: 1px solid #ddd;
}
.person {
border-bottom: 1px solid #ddd;
}
.person-title {
font-weight: 650;
padding: 10px 12px;
background: #f6f7f8;
}
.doc {
display: block;
width: 100%;
border: 0;
border-top: 1px solid #eee;
background: white;
text-align: left;
padding: 8px 14px;
cursor: pointer;
}
.doc:hover,
.doc.active {
background: #e9f1ff;
}
.details {
padding: 14px;
}
.field {
margin-bottom: 13px;
}
.field b {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 3px;
text-transform: uppercase;
}
.field div {
white-space: pre-wrap;
}
.viewer {
position: relative;
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;
}
.viewer.loaded #pdfMessage {
display: none;
}
dialog {
width: min(720px, 90vw);
}
dialog label {
display: block;
margin: 12px 0;
font-weight: 600;
}
dialog input {
display: block;
width: 100%;
padding: 8px;
margin-top: 5px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.hint {
color: #666;
}
.error {
color: #a40000;
}
.muted {
color: #777;
}
.error-banner {
padding: 10px 16px;
background: #fff1f1;
border-bottom: 1px solid #c62828;
color: #9b1c1c;
white-space: pre-wrap;
}
.dialog-error {
margin-top: 12px;
padding: 10px;
border: 1px solid #c62828;
background: #fff1f1;
color: #9b1c1c;
white-space: pre-wrap;
}
#pdfMessage {
white-space: pre-wrap;
padding: 20px;
color: #8a1c1c;
}
.person-title {
color: #1a56db;
font-size: 15px;
}
.field {
margin-bottom: 0;
padding: 7px 6px;
border-radius: 3px;
}
.field b {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 2px;
text-transform: uppercase;
}
.field div {
white-space: pre-wrap;
}
.field.row-even {
background: #fff;
}
.field.row-odd {
background: #eef2f6;
}
hr.field-sep {
border: none;
border-top: 2px solid #9ca3af;
margin: 14px 0;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 8px;
font-weight: 400 !important;
}
.checkbox-label input {
display: inline;
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: 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;
}