/** * viewer.ts — 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 */ // @ts-ignore - Importing JavaScript modules import { ViewportTracker } from "./viewport-tracker.js"; // @ts-ignore - Importing JavaScript modules import { EnhancedPageManager } from "./page-manager-enhanced.js"; // @ts-ignore - Importing JavaScript modules import { RenderSystem } from "./render-system.js"; // @ts-ignore - Importing JavaScript modules import { MessageBridge } from "./message-bridge.js"; // @ts-ignore - Importing JavaScript modules import { AnnotationLayer } from "./annotation-layer.js"; // ── DOM refs ───────────────────────────────────────────────────────────────── const container = document.getElementById("canvas-container"); const pagesWrapper = document.getElementById("pages-wrapper"); const statusEl = document.getElementById("status"); const zoomLabel = document.getElementById("zoom-label"); const pageIndicator = document.getElementById("page-indicator"); // ── Global state ───────────────────────────────────────────────────────────── const params = new URLSearchParams(location.search); const refId = params.get("ref_id") || ""; const savedZoom = parseFloat(params.get("zoom") || ""); // NaN if absent const savedScrollTop = parseFloat(params.get("scroll_top") || ""); // NaN if absent const DPR = window.devicePixelRatio || 1; let pdfDoc = null; let pageManager = null; let viewportTracker = null; let zoomController = null; // Replace with proper type when available let bridge = null; let annotationLayer = null; let renderSystem = null; let viewports = []; // scale=1 dims, used by text layer and annotation layer // ── Utilities ──────────────────────────────────────────────────────────────── function setStatus(msg) { if (statusEl) statusEl.textContent = msg; } function showError(msg) { const b = document.getElementById("error-banner"); if (b) { b.textContent = msg; b.style.display = "block"; } setStatus("Error"); } function refreshPageIndicator() { if (!pageManager) return; try { const cur = pageManager.getCurrentPage(getCurrentVisibleSet()); if (pageIndicator) { pageIndicator.textContent = cur + " / " + pageManager.numPages; } } catch (error) { console.warn("Failed to refresh page indicator:", error); if (pageIndicator) { pageIndicator.textContent = "1 / " + (pageManager.numPages || "1"); } } } function getCurrentVisibleSet() { if (viewportTracker?.visibleSet) { return viewportTracker.visibleSet; } return new Set(); } async function fitToPage() { if (!pdfDoc) return 1.0; const page = await pdfDoc.getPage(1); const vp = page.getViewport({ scale: 1.0 }); const scaleW = (container?.clientWidth || 0 - 40) / vp.width; const scaleH = (container?.clientHeight || 0 - 40) / vp.height; return Math.max(0.1, Math.min(5.0, Math.min(scaleW, scaleH))); } function scrollToPage(pageNum) { if (!pageManager) return; const wrap = pageManager.pageWrappers[pageNum - 1]; if (!wrap) return; if (container) { container.scrollTop += wrap.getBoundingClientRect().top - container.getBoundingClientRect().top; } } // ── Render request handler ──────────────────────────────────────────────────── async function handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality) { if (!renderSystem) return; try { await renderSystem.handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality); } catch (error) { console.warn(`Failed to render page ${pageNum}:`, error); } } // ── 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 (_) { } } } // ── 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 instanceof HTMLElement && node.classList?.contains("page-wrapper"))) { node = node.parentElement; } if (!node) return; const pageNum = parseInt(node.dataset.page || '1', 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 ──────────────────────────────────────────────────────────────── async function load() { if (!refId) { showError("No ref_id in URL."); return; } setStatus("Loading…"); const pdfjsLib = window.pdfjsLib; if (!pdfjsLib) { showError("PDF.js failed to load."); return; } pdfjsLib.GlobalWorkerOptions.workerSrc = "brittle://app/pdfjs/build/pdf.worker.min.js"; const pdfUrl = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId); try { // 1. Load PDF const loadingTask = pdfjsLib.getDocument({ url: pdfUrl }); pdfDoc = await loadingTask.promise; // 2. Fetch all page viewports at scale=1 setStatus("Reading…"); viewports = []; if (pdfDoc) { for (let i = 1; i <= pdfDoc.numPages; i++) { const page = await pdfDoc.getPage(i); const vp = page.getViewport({ scale: 1.0 }); viewports.push({ width: vp.width, height: vp.height }); } } // 3. Compute initial scale: use saved zoom if available, else fit full page const fittedScale = Math.max(0.1, Math.min(5.0, Math.min(((container?.clientWidth || 0) - 40) / viewports[0].width, ((container?.clientHeight || 0) - 40) / viewports[0].height))); const initialScale = (savedZoom > 0) ? Math.max(0.1, Math.min(5.0, savedZoom)) : fittedScale; // 4. EnhancedPageManager — creates placeholder divs with quality support if (!pagesWrapper) throw new Error("Pages wrapper not found"); pageManager = new EnhancedPageManager(pagesWrapper, viewports, initialScale, DPR, (pageNum, scale, vpWidth, vpHeight, gen, quality) => { // Handle render request directly handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality); }); // 4.5. RenderSystem — handles actual page rendering if (!container) throw new Error("Container not found"); if (!pdfDoc) throw new Error("PDF document not loaded"); renderSystem = new RenderSystem(pdfDoc, container, pageManager); // 5. ViewportTracker — handles visibility detection if (!pageManager) throw new Error("Page manager not initialized"); viewportTracker = new ViewportTracker(container, pageManager.pageWrappers, (bufferSet, visibleSet) => { pageManager?.reconcile(bufferSet, visibleSet); if (renderSystem) renderSystem.setVisibility(visibleSet, bufferSet); refreshPageIndicator(); }); // 6. EnhancedZoomController — smooth zoom with quality management // @ts-ignore - Importing JavaScript module const EnhancedZoomController = (await import("./zoom-controller-enhanced.js")).EnhancedZoomController; zoomController = new EnhancedZoomController(container, pageManager, (newScale, bufferSet, visibleSet) => { if (pageManager) pageManager.onScaleChange(newScale, bufferSet, visibleSet); if (renderSystem) renderSystem.setScale(newScale); annotationLayer?.onScaleChange(newScale); sendViewerState(); }, () => ({ 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(viewportTracker?.visibleSet || new Set()) || 1), pdfDoc?.numPages || 1)), () => scrollToPage(Math.max((pageManager?.getCurrentPage(viewportTracker?.visibleSet || new Set()) || 1) - 1, 1)), { onAnnotationsSet: (annotations) => { annotationLayer?.setAnnotations(annotations, zoomController?.scale || 1); }, }); // 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?.zoomOut()); document.getElementById("btn-zoom-in")?.addEventListener("click", () => zoomController?.zoomIn()); document.getElementById("btn-zoom-fit")?.addEventListener("click", 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?.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", () => { refreshPageIndicator(); if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer); _scrollSaveTimer = window.setTimeout(sendViewerState, 500); }, { passive: true }); // Restore saved scroll position (rAF ensures layout is ready) if (savedScrollTop > 0 && container) { requestAnimationFrame(() => { container.scrollTop = savedScrollTop; }); } // Lifecycle: tab hidden → free caches; tab visible → re-reconcile document.addEventListener("visibilitychange", () => { if (document.hidden) { pdfDoc?.destroy(); } else { // Trigger a visibility update to refresh pages const bufferSet = new Set(); const visibleSet = new Set(); pageManager?.reconcile(bufferSet, visibleSet); } }); // Cleanup on unload 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) if (window.matchMedia) { window.matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => { if (pageManager && zoomController) { const bufferSet = new Set(); const visibleSet = new Set(); pageManager?.onScaleChange(zoomController.getCurrentScale(), bufferSet, visibleSet); } }); } if (pageIndicator && pdfDoc) { pageIndicator.textContent = "1 / " + pdfDoc.numPages; } setStatus("Ready"); } catch (e) { showError("Could not load PDF: " + (e instanceof Error ? e.message : String(e))); } } function sendViewerState() { if (!bridge || !zoomController || !container) return; bridge.postViewerState(refId, zoomController.getCurrentScale(), container.scrollTop); } // Start the viewer load(); //# sourceMappingURL=viewer.js.map