Files
brittle/src-tauri/dist/viewer.js
2026-04-01 01:27:51 +02:00

353 lines
15 KiB
JavaScript

/**
* 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