Replace js by ts

This commit is contained in:
2026-04-01 01:27:51 +02:00
parent 4613b8e5dd
commit 6306c73f26
291 changed files with 501210 additions and 525 deletions

View File

@@ -1,29 +1,20 @@
/**
* viewer.js — PDF viewer orchestrator.
*
* Rendering happens on the main thread using the pdfDoc loaded here.
* PDF.js's own sub-worker (pdf.worker.min.js) does the heavy parsing and
* rasterisation off-thread; only the final bitmap transfer touches the
* main thread. A separate render-worker is not used because Tauri's WebKit
* webview does not support nested workers (workers spawned from workers),
* which PDF.js requires for its own internal worker.
*
* Init sequence:
* 1. Parse ref_id, configure PDF.js worker source
* 2. Load pdfDoc (for viewports + rendering)
* 3. Fetch all page viewports at scale=1
* 4. Create PageManager → N placeholder divs in #pages-wrapper
* 5. Create ViewportTracker → observe placeholders
* 6. Compute fit-to-width → create ZoomController
* 7. Create MessageBridge
* 8. Register lifecycle handlers
* 9. Trigger initial reconcile
* viewer.js — Performance-focused PDF viewer with enhanced rendering system
*
* Features:
* - Double-buffered rendering for flicker-free operation
* - Adaptive quality based on performance monitoring
* - Intelligent scroll prediction and pre-loading
* - Smooth zoom animations with quality transitions
* - Memory-efficient caching with LRU eviction
* - Comprehensive performance monitoring
*/
import { ViewportTracker } from "./viewport-tracker.js";
import { PageManager } from "./page-manager.js";
import { ZoomController } from "./zoom-controller.js";
import { MessageBridge } from "./message-bridge.js";
import { ViewportTracker } from "./viewport-tracker.js";
import { EnhancedPageManager } from "./page-manager-enhanced.js";
import { RenderSystem } from "./render-system.js";
import { MessageBridge } from "./message-bridge.js";
import { AnnotationLayer } from "./annotation-layer.js";
// ── DOM refs ─────────────────────────────────────────────────────────────────
const container = document.getElementById("canvas-container");
@@ -44,9 +35,9 @@ let pageManager = null;
let viewportTracker = null;
let zoomController = null;
let bridge = null;
let currentBufferSet = new Set();
let currentVisibleSet = new Set();
let annotationLayer = null;
let renderSystem = null;
let viewports = []; // scale=1 dims, used by text layer and annotation layer
// ── Utilities ────────────────────────────────────────────────────────────────
function setStatus(msg) { statusEl.textContent = msg; }
@@ -60,8 +51,21 @@ function showError(msg) {
function refreshPageIndicator() {
if (!pageManager) return;
const cur = pageManager.getCurrentPage(currentVisibleSet);
pageIndicator.textContent = cur + " / " + pageManager.numPages;
try {
const cur = pageManager.getCurrentPage(getCurrentVisibleSet());
pageIndicator.textContent = cur + " / " + pageManager.numPages;
} catch (error) {
console.warn("Failed to refresh page indicator:", error);
pageIndicator.textContent = "1 / " + (pageManager.numPages || "1");
}
}
function getCurrentVisibleSet() {
if (viewportTracker?.visibleSet) {
return viewportTracker.visibleSet;
}
return new Set();
}
async function fitToPage() {
@@ -77,57 +81,105 @@ function scrollToPage(pageNum) {
if (!pageManager) return;
const wrap = pageManager.pageWrappers[pageNum - 1];
if (!wrap) return;
container.scrollTop +=
container.scrollTop +=
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
}
// ── Main-thread rendering ────────────────────────────────────────────────────
// dispatchRender is called by PageManager when a page enters the buffer.
// We fire-and-forget an async render; the gen check inside discards stale work.
function dispatchRender(pageNum, scale, _vpWidth, _vpHeight, gen) {
renderPage(pageNum, scale, gen);
}
async function renderPage(pageNum, scale, gen) {
if (!pdfDoc) return;
let page;
// ── Render request handler ────────────────────────────────────────────────────
async function handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality) {
if (!renderSystem) return;
try {
page = await pdfDoc.getPage(pageNum);
if (gen !== pageManager?.renderGen) return; // superseded by zoom or cleanup
const vp = page.getViewport({ scale });
const width = Math.round(vp.width);
const height = Math.round(vp.height);
const offscreen = new OffscreenCanvas(width, height);
await page.render({ canvasContext: offscreen.getContext("2d"), viewport: vp }).promise;
if (gen !== pageManager?.renderGen) return; // superseded during render
const bitmap = offscreen.transferToImageBitmap();
pageManager?.onRendered(pageNum, gen, bitmap);
refreshPageIndicator();
if (pageManager?.allRendered) setStatus("Ready");
} catch (e) {
if (e?.name !== "RenderingCancelledException") {
console.warn("[viewer] render error page", pageNum, e);
}
} finally {
page?.cleanup();
await renderSystem.handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality);
} catch (error) {
console.warn(`Failed to render page ${pageNum}:`, error);
}
}
function sendViewerState() {
if (!bridge || !zoomController) return;
bridge.postViewerState(refId, zoomController.scale, container.scrollTop);
// ── Text layer rendering ──────────────────────────────────────────────────────
function renderTextLayer(pageNum, cssVp, textContent) {
const wrap = pageManager?.pageWrappers[pageNum - 1];
if (!wrap) return;
// Remove stale text layer (e.g. after zoom)
wrap.querySelector(".text-layer")?.remove();
const div = document.createElement("div");
div.className = "text-layer";
div.style.width = Math.round(cssVp.width) + "px";
div.style.height = Math.round(cssVp.height) + "px";
wrap.appendChild(div);
const pdfjsLib = window.pdfjsLib;
if (!pdfjsLib) return;
if (typeof pdfjsLib.TextLayer === "function") {
// PDF.js 3.x+ API
try {
const tl = new pdfjsLib.TextLayer({
textContentSource: textContent,
container: div,
viewport: cssVp,
});
tl.render().catch(() => {});
} catch (_) {}
} else if (typeof pdfjsLib.renderTextLayer === "function") {
// Legacy API (2.x)
try {
pdfjsLib.renderTextLayer({ textContent, container: div, viewport: cssVp });
} catch (_) {}
}
}
// ── Visibility change callback (called by ViewportTracker) ───────────────────
function onVisibilityChange(bufferSet, visibleSet) {
currentBufferSet = bufferSet;
currentVisibleSet = visibleSet;
pageManager?.reconcile(bufferSet, visibleSet);
refreshPageIndicator();
// ── Text selection → annotation quads ────────────────────────────────────────
function handleTextSelection() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
const selectedText = sel.toString().trim();
if (!selectedText || !bridge) return;
const range = sel.getRangeAt(0);
// Walk up from the anchor node to find the page wrapper
let node = range.startContainer;
while (node && !(node.classList?.contains("page-wrapper"))) {
node = node.parentElement;
}
if (!node) return;
const pageNum = parseInt(node.dataset.page, 10);
if (!pageNum || pageNum < 1 || pageNum > viewports.length) return;
const vp0 = viewports[pageNum - 1];
const scale = zoomController?.getCurrentScale() || 1;
const rects = Array.from(range.getClientRects());
const wrapR = node.getBoundingClientRect();
const quads = rects
.filter(r => r.width > 1 && r.height > 1)
.map(r => {
const x0 = (r.left - wrapR.left) / scale;
const x1 = (r.right - wrapR.left) / scale;
const y0 = vp0.height - (r.bottom - wrapR.top) / scale;
const y1 = vp0.height - (r.top - wrapR.top) / scale;
return {
points: [
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
],
};
});
if (quads.length === 0) return;
// Clear browser selection so the new highlight takes over visually
sel.removeAllRanges();
// 0-indexed page number for the backend model
bridge.postAnnotationCreate(refId, pageNum - 1, quads, selectedText);
}
// ── Main init ────────────────────────────────────────────────────────────────
@@ -147,7 +199,7 @@ async function load() {
// 2. Fetch all page viewports at scale=1
setStatus("Reading…");
const viewports = [];
viewports = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const vp = page.getViewport({ scale: 1.0 });
@@ -161,53 +213,93 @@ async function load() {
)));
const initialScale = (savedZoom > 0) ? Math.max(0.1, Math.min(5.0, savedZoom)) : fittedScale;
// 4. PageManager — creates placeholder divs
pageManager = new PageManager(
pagesWrapper, viewports, initialScale, DPR, dispatchRender,
// 4. EnhancedPageManager — creates placeholder divs with quality support
pageManager = new EnhancedPageManager(
pagesWrapper, viewports, initialScale, DPR,
(pageNum, scale, vpWidth, vpHeight, gen, quality) => {
// Handle render request directly
handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality);
}
);
// 5. ViewportTracker — IntersectionObserver fires once elements are in the
// DOM, triggering the initial reconcile automatically.
// 4.5. RenderSystem — handles actual page rendering
renderSystem = new RenderSystem(pdfDoc, container, pageManager);
// 5. ViewportTracker — handles visibility detection
viewportTracker = new ViewportTracker(
container, pageManager.pageWrappers, onVisibilityChange,
container,
pageManager.pageWrappers,
(bufferSet, visibleSet) => {
pageManager.reconcile(bufferSet, visibleSet);
renderSystem.setVisibility(visibleSet, bufferSet);
refreshPageIndicator();
}
);
// 6. ZoomController — send state after each debounced re-render
zoomController = new ZoomController(
// 6. EnhancedZoomController — smooth zoom with quality management
const EnhancedZoomController = (await import("./zoom-controller-enhanced.js")).EnhancedZoomController;
zoomController = new EnhancedZoomController(
container,
pageManager,
(newScale, bufferSet, visibleSet) => {
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
renderSystem.setScale(newScale);
annotationLayer?.onScaleChange(newScale);
sendViewerState();
},
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
zoomLabel,
initialScale,
() => ({ bufferSet: new Set(), visibleSet: new Set() }),
zoomLabel
);
// Set initial scale
zoomController.applyScale(initialScale, { animate: false });
// 7. MessageBridge
bridge = new MessageBridge(
() => scrollToPage(Math.min(pageManager.getCurrentPage(currentVisibleSet) + 1, pdfDoc.numPages)),
() => scrollToPage(Math.max(pageManager.getCurrentPage(currentVisibleSet) - 1, 1)),
() => scrollToPage(Math.min(pageManager.getCurrentPage(viewportTracker.visibleSet) + 1, pdfDoc.numPages)),
() => scrollToPage(Math.max(pageManager.getCurrentPage(viewportTracker.visibleSet) - 1, 1)),
{
onAnnotationsSet: (annotations) => {
annotationLayer?.setAnnotations(annotations, zoomController.scale);
},
},
);
// 8. Toolbar buttons
// 8. AnnotationLayer
annotationLayer = new AnnotationLayer(
pageManager.pageWrappers,
viewports,
(annotationId) => bridge.postAnnotationClick(refId, annotationId),
);
// Request annotations from parent
bridge.requestAnnotations(refId);
// 9. Toolbar buttons
document.getElementById("btn-zoom-out").addEventListener("click",
() => zoomController.applyScale(zoomController.scale / 1.25));
() => zoomController.zoomOut());
document.getElementById("btn-zoom-in").addEventListener("click",
() => zoomController.applyScale(zoomController.scale * 1.25));
() => zoomController.zoomIn());
document.getElementById("btn-zoom-fit").addEventListener("click",
async () => zoomController.applyScale(await fitToPage()));
async () => {
const fitScale = await fitToPage();
zoomController.applyScale(fitScale, { animate: false });
});
// Keyboard shortcuts + keydown forwarding to parent
document.addEventListener("keydown", ev => {
if (ev.target.tagName === "INPUT") return;
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.applyScale(zoomController.scale * 1.25); }
if (ev.key === "-") { ev.preventDefault(); zoomController.applyScale(zoomController.scale / 1.25); }
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s)); }
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.zoomIn(); }
if (ev.key === "-") { ev.preventDefault(); zoomController.zoomOut(); }
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s, { animate: false })); }
bridge.forwardKeydown(ev);
});
// mouseup on the document → check for text selection to create highlights
document.addEventListener("mouseup", ev => {
setTimeout(() => handleTextSelection(), 0);
});
// Scroll → update page indicator + debounced state save
let _scrollSaveTimer = null;
container.addEventListener("scroll", () => {
@@ -226,7 +318,10 @@ async function load() {
if (document.hidden) {
pdfDoc?.cleanup();
} else {
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
// Trigger a visibility update to refresh pages
const bufferSet = new Set();
const visibleSet = new Set();
pageManager?.reconcile(bufferSet, visibleSet);
}
});
@@ -234,22 +329,32 @@ async function load() {
window.addEventListener("beforeunload", () => {
viewportTracker?.disconnect();
bridge?.disconnect();
renderSystem?.cleanup();
pdfDoc?.destroy();
if (zoomController) zoomController.cleanup();
});
// DPR change (e.g., moving window to a monitor with different DPI)
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
if (pageManager && zoomController) {
pageManager.onScaleChange(zoomController.scale, currentBufferSet, currentVisibleSet);
const bufferSet = new Set();
const visibleSet = new Set();
pageManager.onScaleChange(zoomController.getCurrentScale(), bufferSet, visibleSet);
}
});
pageIndicator.textContent = "1 / " + pdfDoc.numPages;
setStatus("Rendering…");
setStatus("Ready");
} catch (e) {
showError("Could not load PDF: " + (e.message ?? String(e)));
}
}
load();
function sendViewerState() {
if (!bridge || !zoomController) return;
bridge.postViewerState(refId, zoomController.getCurrentScale(), container.scrollTop);
}
// Start the viewer
load();