/** * 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"; import { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; // Type definitions for external modules type ViewportTrackerType = { new (container: HTMLElement, pageWrappers: HTMLElement[], callback: (bufferSet: Set, visibleSet: Set) => void): { visibleSet: Set; disconnect(): void; }; }; type MessageBridgeType = { new (nextPage: () => void, prevPage: () => void, handlers: { onAnnotationsSet?: (annotations: any) => void }): { postAnnotationCreate(refId: string, pageNum: number, quads: any[], text: string): void; postAnnotationClick(refId: string, annotationId: string): void; requestAnnotations(refId: string): void; postViewerState(refId: string, scale: number, scrollTop: number): void; forwardKeydown(ev: KeyboardEvent): void; disconnect(): void; }; }; type AnnotationLayerType = { new (pageWrappers: HTMLElement[], viewports: any[], onClick: (annotationId: string) => void): { setAnnotations(annotations: any, scale: number): void; onScaleChange(scale: number): void; }; }; type EnhancedZoomControllerType = { new (container: HTMLElement, pageManager: any, onScaleChange: (newScale: number, bufferSet: Set, visibleSet: Set) => void, getVisibilityState: () => { bufferSet: Set, visibleSet: Set }, zoomLabel: HTMLElement | null): { applyScale(scale: number, options: { animate: boolean }): void; zoomIn(): void; zoomOut(): void; getCurrentScale(): number; scale: number; cleanup(): void; }; }; declare global { interface Window { pdfjsLib: any; } } // ── DOM refs ───────────────────────────────────────────────────────────────── const container = document.getElementById("canvas-container") as HTMLElement | null; const pagesWrapper = document.getElementById("pages-wrapper") as HTMLElement | null; const statusEl = document.getElementById("status") as HTMLElement | null; const zoomLabel = document.getElementById("zoom-label") as HTMLElement | null; const pageIndicator = document.getElementById("page-indicator") as HTMLElement | null; // ── 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: PDFDocumentProxy | null = null; let pageManager: EnhancedPageManager | null = null; let viewportTracker: ViewportTracker | null = null; let zoomController: any | null = null; // Replace with proper type when available let bridge: MessageBridge | null = null; let annotationLayer: AnnotationLayer | null = null; let renderSystem: RenderSystem | null = null; let viewports: Array<{width: number, height: number}> = []; // scale=1 dims, used by text layer and annotation layer // ── Utilities ──────────────────────────────────────────────────────────────── function setStatus(msg: string): void { if (statusEl) statusEl.textContent = msg; } function showError(msg: string): void { const b = document.getElementById("error-banner") as HTMLElement | null; if (b) { b.textContent = msg; b.style.display = "block"; } setStatus("Error"); } function refreshPageIndicator(): void { 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(): Set { if (viewportTracker?.visibleSet) { return viewportTracker.visibleSet; } return new Set(); } async function fitToPage(): Promise { 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: number): void { 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: number, scale: number, vpWidth: number, vpHeight: number, quality: number): Promise { 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: number, cssVp: any, textContent: any): void { 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(): void { 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: Node | null = range.startContainer; while (node && !(node instanceof HTMLElement && node.classList?.contains("page-wrapper"))) { node = node.parentElement; } if (!node) return; const pageNum = parseInt((node as HTMLElement).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 as HTMLElement).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(): Promise { 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: Set, visibleSet: Set) => { 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: number, bufferSet: Set, visibleSet: Set) => { 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: any) => { annotationLayer?.setAnnotations(annotations, zoomController?.scale || 1); }, }, ); // 8. AnnotationLayer annotationLayer = new AnnotationLayer( pageManager?.pageWrappers || [], viewports, (annotationId: string) => 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 as HTMLElement).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: number | null = null; container?.addEventListener("scroll", () => { refreshPageIndicator(); if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer); _scrollSaveTimer = window.setTimeout(sendViewerState, 500) as unknown as number; }, { 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(): void { if (!bridge || !zoomController || !container) return; bridge.postViewerState(refId, zoomController.getCurrentScale(), container.scrollTop); } // Start the viewer load();