Replace js by ts
This commit is contained in:
431
src-tauri/assets/viewer/viewer.ts
Normal file
431
src-tauri/assets/viewer/viewer.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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<number>, visibleSet: Set<number>) => void): {
|
||||
visibleSet: Set<number>;
|
||||
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<number>, visibleSet: Set<number>) => void, getVisibilityState: () => { bufferSet: Set<number>, visibleSet: Set<number> }, 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<number> {
|
||||
if (viewportTracker?.visibleSet) {
|
||||
return viewportTracker.visibleSet;
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
async function fitToPage(): Promise<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number>, visibleSet: Set<number>) => {
|
||||
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<number>, visibleSet: Set<number>) => {
|
||||
if (pageManager) pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
||||
if (renderSystem) renderSystem.setScale(newScale);
|
||||
annotationLayer?.onScaleChange(newScale);
|
||||
sendViewerState();
|
||||
},
|
||||
() => ({ bufferSet: new Set<number>(), visibleSet: new Set<number>() }),
|
||||
zoomLabel
|
||||
);
|
||||
|
||||
// Set initial scale
|
||||
zoomController?.applyScale(initialScale, { animate: false });
|
||||
|
||||
// 7. MessageBridge
|
||||
bridge = new MessageBridge(
|
||||
() => scrollToPage(Math.min((pageManager?.getCurrentPage(viewportTracker?.visibleSet || new Set<number>()) || 1), pdfDoc?.numPages || 1)),
|
||||
() => scrollToPage(Math.max((pageManager?.getCurrentPage(viewportTracker?.visibleSet || new Set<number>()) || 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<number>();
|
||||
const visibleSet = new Set<number>();
|
||||
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<number>();
|
||||
const visibleSet = new Set<number>();
|
||||
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();
|
||||
Reference in New Issue
Block a user