Change PDF rendering
This commit is contained in:
@@ -71,7 +71,7 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Scrollable viewport — contains the zoomed pages wrapper */
|
||||
/* Scrollable viewport — contains the pages wrapper */
|
||||
#canvas-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -79,8 +79,7 @@
|
||||
background: #1d2021;
|
||||
}
|
||||
|
||||
/* Inner column that receives the CSS zoom for instant visual feedback.
|
||||
CSS zoom (unlike transform) affects layout, so scrollbars stay correct. */
|
||||
/* Column of page placeholders/canvases */
|
||||
#pages-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -91,17 +90,21 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-wrapper { flex-shrink: 0; }
|
||||
/* Each page: sized div that holds a canvas once rendered.
|
||||
White background makes unrendered placeholders look like blank pages. */
|
||||
.page-wrapper {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.page-wrapper canvas {
|
||||
display: block;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="error-banner"></div>
|
||||
|
||||
<div id="toolbar">
|
||||
@@ -112,309 +115,14 @@
|
||||
<button id="btn-zoom-fit" title="Fit width [ 0 ]">Fit</button>
|
||||
</div>
|
||||
<span id="page-indicator">— / —</span>
|
||||
<span id="status">Loading PDF.js…</span>
|
||||
<span id="status">Loading…</span>
|
||||
</div>
|
||||
|
||||
<div id="canvas-container"></div>
|
||||
<div id="canvas-container">
|
||||
<div id="pages-wrapper"></div>
|
||||
</div>
|
||||
|
||||
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
const refId = new URLSearchParams(location.search).get("ref_id") || "";
|
||||
|
||||
const pdfjsLib = window.pdfjsLib;
|
||||
if (!pdfjsLib) {
|
||||
showError("PDF.js failed to load. Make sure the app is running inside Brittle.");
|
||||
} else {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
"brittle://app/pdfjs/build/pdf.worker.min.js";
|
||||
|
||||
const container = document.getElementById("canvas-container");
|
||||
const statusEl = document.getElementById("status");
|
||||
const zoomLabel = document.getElementById("zoom-label");
|
||||
const pageIndicator = document.getElementById("page-indicator");
|
||||
|
||||
const DPR = window.devicePixelRatio || 1;
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 5.0;
|
||||
|
||||
let pdfDoc = null;
|
||||
let scale = 1.0; // desired display scale
|
||||
let renderScale = 1.0; // scale at which pagesWrapper canvases are rendered
|
||||
let renderGen = 0; // incremented on each render to cancel stale passes
|
||||
let renderTimer = null;
|
||||
let pagesWrapper = null; // the current #pages-wrapper element
|
||||
let pendingRenderTask = null; // the active page.render() RenderTask, if any
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
|
||||
function clampScale(s) {
|
||||
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s));
|
||||
}
|
||||
|
||||
function updateZoomLabel() {
|
||||
zoomLabel.textContent = Math.round(scale * 100) + "%";
|
||||
}
|
||||
|
||||
// ── Zoom ───────────────────────────────────────────────────────────────
|
||||
|
||||
// anchorY/anchorX: offsets within the container to keep fixed on screen.
|
||||
// Default to the centre of the visible area.
|
||||
function applyScale(
|
||||
newScale,
|
||||
anchorY = container.clientHeight / 2,
|
||||
anchorX = container.clientWidth / 2,
|
||||
) {
|
||||
const oldScale = scale;
|
||||
const oldScrollTop = container.scrollTop;
|
||||
const oldScrollLeft = container.scrollLeft;
|
||||
scale = clampScale(newScale);
|
||||
updateZoomLabel();
|
||||
|
||||
const ratio = scale / oldScale;
|
||||
const cssZoom = scale / renderScale;
|
||||
|
||||
// Apply CSS zoom to each page wrapper individually — NOT to the flex
|
||||
// container. This keeps the container's padding and gap constant, so
|
||||
// page positions are identical before and after the background re-render
|
||||
// (no jump when the re-render swaps in naturally-sized canvases).
|
||||
if (pagesWrapper) {
|
||||
for (const wrap of pagesWrapper.children) {
|
||||
wrap.style.zoom = cssZoom;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the document point under the cursor at the same screen position.
|
||||
container.scrollTop = Math.max(0, (oldScrollTop + anchorY) * ratio - anchorY);
|
||||
container.scrollLeft = Math.max(0, (oldScrollLeft + anchorX) * ratio - anchorX);
|
||||
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
async function fitToWidth() {
|
||||
if (!pdfDoc) return scale;
|
||||
const page = await pdfDoc.getPage(1);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
const avail = container.clientWidth - 40;
|
||||
return clampScale(avail / vp.width);
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
function scheduleRender() {
|
||||
clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(renderAll, 300);
|
||||
}
|
||||
|
||||
async function renderAll() {
|
||||
if (!pdfDoc) return;
|
||||
pendingRenderTask?.cancel();
|
||||
pendingRenderTask = null;
|
||||
const gen = ++renderGen;
|
||||
const targetScale = scale;
|
||||
|
||||
const savedScrollTop = container.scrollTop;
|
||||
const savedScrollLeft = container.scrollLeft;
|
||||
|
||||
// Build new wrapper with pre-sized canvases derived from the old ones.
|
||||
// Pre-sizing means the scroll range is correct the instant we swap, so
|
||||
// savedScrollTop/Left can be restored exactly without a jump.
|
||||
const newWrapper = document.createElement("div");
|
||||
newWrapper.id = "pages-wrapper";
|
||||
|
||||
const wrappers = [];
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "page-wrapper";
|
||||
wrap.dataset.page = String(i);
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
if (pagesWrapper) {
|
||||
const oldCanvas = pagesWrapper.children[i - 1]?.querySelector("canvas");
|
||||
if (oldCanvas?.style.width) {
|
||||
const ratio = targetScale / renderScale;
|
||||
canvas.style.width = Math.round(parseFloat(oldCanvas.style.width) * ratio) + "px";
|
||||
canvas.style.height = Math.round(parseFloat(oldCanvas.style.height) * ratio) + "px";
|
||||
}
|
||||
}
|
||||
|
||||
wrap.appendChild(canvas);
|
||||
newWrapper.appendChild(wrap);
|
||||
wrappers.push(wrap);
|
||||
}
|
||||
|
||||
// For zoom re-renders: pre-render the currently visible pages (1–2)
|
||||
// before the DOM swap. Only a small number of pages, so off-screen is
|
||||
// fast; the swap then reveals already-drawn content with no white flash.
|
||||
// Skipped for the initial load (pagesWrapper is null) where progressive
|
||||
// in-DOM rendering is preferable.
|
||||
const preRendered = new Set();
|
||||
if (pagesWrapper) {
|
||||
const first = getCurrentPage();
|
||||
const last = Math.min(first + 1, pdfDoc.numPages);
|
||||
for (let p = first; p <= last; p++) {
|
||||
if (renderGen !== gen) return;
|
||||
await renderPage(wrappers[p - 1], p, targetScale);
|
||||
preRendered.add(p - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (renderGen !== gen) return;
|
||||
|
||||
// Swap into DOM — visible pages are already rendered on re-render.
|
||||
// DOM mutations + scroll restore are synchronous, landing in one paint.
|
||||
container.innerHTML = "";
|
||||
container.appendChild(newWrapper);
|
||||
pagesWrapper = newWrapper;
|
||||
renderScale = targetScale;
|
||||
container.scrollTop = savedScrollTop;
|
||||
container.scrollLeft = savedScrollLeft;
|
||||
|
||||
// Render remaining pages in-DOM (GPU-accelerated, progressive).
|
||||
for (let i = 0; i < wrappers.length; i++) {
|
||||
if (preRendered.has(i)) continue;
|
||||
if (renderGen !== gen) return;
|
||||
await renderPage(wrappers[i], i + 1, targetScale);
|
||||
}
|
||||
|
||||
if (renderGen !== gen) return;
|
||||
setStatus("Ready");
|
||||
refreshPageIndicator();
|
||||
}
|
||||
|
||||
async function renderPage(wrapper, pageNum, targetScale) {
|
||||
let task = null;
|
||||
try {
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const vp = page.getViewport({ scale: targetScale * DPR });
|
||||
const canvas = wrapper.querySelector("canvas");
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.width = vp.width;
|
||||
canvas.height = vp.height;
|
||||
canvas.style.width = Math.round(vp.width / DPR) + "px";
|
||||
canvas.style.height = Math.round(vp.height / DPR) + "px";
|
||||
|
||||
task = page.render({ canvasContext: canvas.getContext("2d"), viewport: vp });
|
||||
pendingRenderTask = task;
|
||||
await task.promise;
|
||||
} catch (e) {
|
||||
if (e?.name !== "RenderingCancelledException") console.warn("render:", e);
|
||||
} finally {
|
||||
if (pendingRenderTask === task) pendingRenderTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Page indicator and page navigation ─────────────────────────────────
|
||||
|
||||
// Returns the 1-based number of the topmost visible page.
|
||||
function getCurrentPage() {
|
||||
const top = container.getBoundingClientRect().top;
|
||||
for (const wrap of container.querySelectorAll(".page-wrapper")) {
|
||||
if (wrap.getBoundingClientRect().bottom > top + 4) {
|
||||
return parseInt(wrap.dataset.page, 10);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function refreshPageIndicator() {
|
||||
if (!pdfDoc) return;
|
||||
pageIndicator.textContent = getCurrentPage() + " / " + pdfDoc.numPages;
|
||||
}
|
||||
|
||||
// Scroll so that the top of page `pageNum` aligns with the container top.
|
||||
// getBoundingClientRect() is used so CSS zoom is accounted for correctly.
|
||||
function scrollToPage(pageNum) {
|
||||
const wrap = container.querySelector(`.page-wrapper[data-page="${pageNum}"]`);
|
||||
if (!wrap) return;
|
||||
container.scrollTop +=
|
||||
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
|
||||
|
||||
// Page-navigation commands posted from the outer Leptos app (fired when
|
||||
// a global keymap action such as pdf.page.next/prev is dispatched while
|
||||
// this tab is active, regardless of which element has keyboard focus).
|
||||
window.addEventListener("message", ev => {
|
||||
if (ev.data === "pdf.page.next" && pdfDoc)
|
||||
scrollToPage(Math.min(getCurrentPage() + 1, pdfDoc.numPages));
|
||||
if (ev.data === "pdf.page.prev" && pdfDoc)
|
||||
scrollToPage(Math.max(getCurrentPage() - 1, 1));
|
||||
});
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
if (!refId) { showError("No ref_id in URL."); return; }
|
||||
setStatus("Loading…");
|
||||
try {
|
||||
const url = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
|
||||
pdfDoc = await pdfjsLib.getDocument({ url }).promise;
|
||||
|
||||
scale = await fitToWidth();
|
||||
renderScale = scale;
|
||||
updateZoomLabel();
|
||||
setStatus("Rendering…");
|
||||
await renderAll();
|
||||
} catch (e) {
|
||||
showError("Could not load PDF: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toolbar buttons ────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById("btn-zoom-out").addEventListener("click",
|
||||
() => applyScale(scale / 1.25));
|
||||
document.getElementById("btn-zoom-in").addEventListener("click",
|
||||
() => applyScale(scale * 1.25));
|
||||
document.getElementById("btn-zoom-fit").addEventListener("click",
|
||||
async () => applyScale(await fitToWidth()));
|
||||
|
||||
// Ctrl+Scroll — anchor at the cursor position.
|
||||
container.addEventListener("wheel", ev => {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const anchorY = ev.clientY - rect.top;
|
||||
const anchorX = ev.clientX - rect.left;
|
||||
applyScale(scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX);
|
||||
}, { passive: false });
|
||||
|
||||
// Keyboard shortcuts (active when the iframe has focus).
|
||||
// Also forwards every keydown to the parent window so global keybindings
|
||||
// (tab switching, etc.) keep working when the PDF view has focus.
|
||||
document.addEventListener("keydown", ev => {
|
||||
if (ev.target.tagName === "INPUT") return;
|
||||
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); applyScale(scale * 1.25); }
|
||||
if (ev.key === "-") { ev.preventDefault(); applyScale(scale / 1.25); }
|
||||
if (ev.key === "0") { ev.preventDefault(); fitToWidth().then(applyScale); }
|
||||
// Forward to the parent Leptos document for the global keymap.
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: "brittle:keydown",
|
||||
key: ev.key,
|
||||
ctrlKey: ev.ctrlKey,
|
||||
shiftKey: ev.shiftKey,
|
||||
altKey: ev.altKey,
|
||||
metaKey: ev.metaKey,
|
||||
}, "*");
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const b = document.getElementById("error-banner");
|
||||
b.textContent = msg;
|
||||
b.style.display = "block";
|
||||
document.getElementById("status").textContent = "Error";
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="brittle://app/viewer/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
37
src-tauri/assets/viewer/message-bridge.js
Normal file
37
src-tauri/assets/viewer/message-bridge.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* MessageBridge — postMessage protocol between the PDF viewer iframe
|
||||
* and the parent Leptos application.
|
||||
*
|
||||
* Inbound (parent → iframe): "pdf.page.next" | "pdf.page.prev"
|
||||
* Outbound (iframe → parent): { type: "brittle:keydown", key, ctrlKey, ... }
|
||||
*/
|
||||
export class MessageBridge {
|
||||
/**
|
||||
* @param {Function} onPageNext - () => void
|
||||
* @param {Function} onPagePrev - () => void
|
||||
*/
|
||||
constructor(onPageNext, onPagePrev) {
|
||||
this._handler = ev => {
|
||||
if (ev.data === "pdf.page.next") onPageNext();
|
||||
if (ev.data === "pdf.page.prev") onPagePrev();
|
||||
};
|
||||
window.addEventListener("message", this._handler);
|
||||
}
|
||||
|
||||
/** Forward a keydown event to the parent window for global keybindings. */
|
||||
forwardKeydown(ev) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({
|
||||
type: "brittle:keydown",
|
||||
key: ev.key,
|
||||
ctrlKey: ev.ctrlKey,
|
||||
shiftKey: ev.shiftKey,
|
||||
altKey: ev.altKey,
|
||||
metaKey: ev.metaKey,
|
||||
}, "*");
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener("message", this._handler);
|
||||
}
|
||||
}
|
||||
235
src-tauri/assets/viewer/page-manager.js
Normal file
235
src-tauri/assets/viewer/page-manager.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* PageManager — page lifecycle: PLACEHOLDER → RENDERING → RENDERED → PLACEHOLDER
|
||||
*
|
||||
* Each page is a sized div (placeholder). When a page enters the buffer,
|
||||
* a canvas is created and a render is dispatched to the worker. When a page
|
||||
* leaves the buffer, its canvas is removed and the page is reset to placeholder.
|
||||
*/
|
||||
|
||||
const MAX_CANVAS_PIXELS = 16_777_216; // 4096 × 4096
|
||||
|
||||
/**
|
||||
* Clamp render scale so the canvas pixel count stays within the budget.
|
||||
* CSS dimensions stay correct — pages appear at the right size, just at
|
||||
* reduced effective DPI when zoomed very high. Same strategy as Chrome's viewer.
|
||||
*/
|
||||
function clampedRenderScale(vpWidth, vpHeight, desiredScale, dpr) {
|
||||
const w = vpWidth * desiredScale * dpr;
|
||||
const h = vpHeight * desiredScale * dpr;
|
||||
const pixels = w * h;
|
||||
if (pixels <= MAX_CANVAS_PIXELS) return desiredScale * dpr;
|
||||
return desiredScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels);
|
||||
}
|
||||
|
||||
const State = Object.freeze({ PLACEHOLDER: 0, RENDERING: 1, RENDERED: 2 });
|
||||
|
||||
export class PageManager {
|
||||
/**
|
||||
* @param {HTMLElement} wrapper - #pages-wrapper container (already in DOM)
|
||||
* @param {Array<{width:number,height:number}>} viewports - scale=1 viewports (0-indexed)
|
||||
* @param {number} initialScale - initial display scale
|
||||
* @param {number} dpr - devicePixelRatio
|
||||
* @param {Function} dispatchRender - (pageNum, scale, vpWidth, vpHeight, gen) => void
|
||||
*/
|
||||
constructor(wrapper, viewports, initialScale, dpr, dispatchRender) {
|
||||
this._wrapper = wrapper;
|
||||
this._viewports = viewports;
|
||||
this._scale = initialScale;
|
||||
this._dpr = dpr;
|
||||
this._dispatchRender = dispatchRender;
|
||||
this._renderGen = 0;
|
||||
this._states = new Array(viewports.length).fill(State.PLACEHOLDER);
|
||||
this._canvases = new Array(viewports.length).fill(null);
|
||||
this._wrappers = [];
|
||||
|
||||
this._buildPlaceholders();
|
||||
}
|
||||
|
||||
get pageWrappers() { return this._wrappers; }
|
||||
get numPages() { return this._viewports.length; }
|
||||
get renderGen() { return this._renderGen; }
|
||||
|
||||
_buildPlaceholders() {
|
||||
for (let i = 0; i < this._viewports.length; i++) {
|
||||
const vp = this._viewports[i];
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "page-wrapper";
|
||||
wrap.dataset.page = String(i + 1);
|
||||
wrap.style.width = vp.width * this._scale + "px";
|
||||
wrap.style.height = vp.height * this._scale + "px";
|
||||
this._wrapper.appendChild(wrap);
|
||||
this._wrappers.push(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on each IntersectionObserver tick.
|
||||
* Renders pages entering the buffer; cleans up pages leaving it.
|
||||
* Visible pages are prioritized in the render queue.
|
||||
*
|
||||
* @param {Set<number>} bufferSet - 1-based page numbers in the buffer zone
|
||||
* @param {Set<number>} visibleSet - 1-based page numbers currently on screen
|
||||
*/
|
||||
reconcile(bufferSet, visibleSet) {
|
||||
const gen = this._renderGen;
|
||||
|
||||
// Prioritize visible pages, then the rest of the buffer
|
||||
const toRender = [
|
||||
...[...visibleSet].sort((a, b) => a - b),
|
||||
...[...bufferSet].filter(p => !visibleSet.has(p)).sort((a, b) => a - b),
|
||||
];
|
||||
|
||||
for (const pageNum of toRender) {
|
||||
const i = pageNum - 1;
|
||||
if (i < 0 || i >= this._viewports.length) continue;
|
||||
if (this._states[i] === State.PLACEHOLDER) {
|
||||
this._startRender(i, gen);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up pages no longer in the buffer
|
||||
for (let i = 0; i < this._viewports.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
|
||||
this._cleanup(i);
|
||||
}
|
||||
// Also cancel stale RENDERING pages outside the buffer
|
||||
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) {
|
||||
this._states[i] = State.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_startRender(i, gen) {
|
||||
const vp = this._viewports[i];
|
||||
const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr);
|
||||
this._states[i] = State.RENDERING;
|
||||
this._dispatchRender(i + 1, scale, vp.width, vp.height, gen);
|
||||
}
|
||||
|
||||
_cleanup(i) {
|
||||
this._states[i] = State.PLACEHOLDER;
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.remove();
|
||||
this._canvases[i] = null;
|
||||
}
|
||||
// Reset wrapper size (without canvas it still holds placeholder dimensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the worker returns a rendered bitmap.
|
||||
*
|
||||
* @param {number} pageNum
|
||||
* @param {number} gen - render generation the bitmap was rendered for
|
||||
* @param {ImageBitmap} bitmap
|
||||
*/
|
||||
onRendered(pageNum, gen, bitmap) {
|
||||
if (gen !== this._renderGen) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
const i = pageNum - 1;
|
||||
if (i < 0 || i >= this._viewports.length || this._states[i] !== State.RENDERING) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const vp = this._viewports[i];
|
||||
const wrap = this._wrappers[i];
|
||||
const cssW = vp.width * this._scale;
|
||||
const cssH = vp.height * this._scale;
|
||||
|
||||
// Remove the old canvas (may be present — kept visible during re-render to
|
||||
// avoid a blank flash). Both mutations land in the same paint frame.
|
||||
const old = wrap.querySelector("canvas");
|
||||
if (old) old.remove();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.style.width = cssW + "px";
|
||||
canvas.style.height = cssH + "px";
|
||||
|
||||
// Zero-copy display — fall back to drawImage if bitmaprenderer unavailable
|
||||
const ctx = canvas.getContext("bitmaprenderer");
|
||||
if (ctx) {
|
||||
ctx.transferFromImageBitmap(bitmap);
|
||||
} else {
|
||||
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
}
|
||||
|
||||
// Remove CSS zoom and set explicit size (canvas is now at the right dimensions)
|
||||
wrap.style.zoom = "1";
|
||||
wrap.style.width = cssW + "px";
|
||||
wrap.style.height = cssH + "px";
|
||||
wrap.appendChild(canvas);
|
||||
|
||||
this._canvases[i] = canvas;
|
||||
this._states[i] = State.RENDERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the zoom debounce fires.
|
||||
* Increments renderGen (cancels stale work), resizes all placeholders,
|
||||
* then re-renders the buffer pages at the new resolution.
|
||||
*
|
||||
* @param {number} newScale
|
||||
* @param {Set<number>} bufferSet
|
||||
* @param {Set<number>} visibleSet
|
||||
*/
|
||||
onScaleChange(newScale, bufferSet, visibleSet) {
|
||||
this._scale = newScale;
|
||||
this._renderGen++;
|
||||
|
||||
for (let i = 0; i < this._viewports.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
const vp = this._viewports[i];
|
||||
const wrap = this._wrappers[i];
|
||||
const cssW = vp.width * newScale;
|
||||
const cssH = vp.height * newScale;
|
||||
wrap.style.width = cssW + "px";
|
||||
wrap.style.height = cssH + "px";
|
||||
wrap.style.zoom = "1";
|
||||
|
||||
if (bufferSet.has(pageNum)) {
|
||||
// Keep the old canvas visible (stretched to the new CSS size) so there
|
||||
// is no blank flash while the new render is in flight. onRendered()
|
||||
// will replace it atomically in the same synchronous JS turn.
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.style.width = cssW + "px";
|
||||
canvas.style.height = cssH + "px";
|
||||
}
|
||||
if (this._states[i] !== State.PLACEHOLDER) {
|
||||
this._states[i] = State.PLACEHOLDER;
|
||||
}
|
||||
} else {
|
||||
// Off-screen: clean up immediately — not visible, so no flash.
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) { canvas.remove(); this._canvases[i] = null; }
|
||||
this._states[i] = State.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
this.reconcile(bufferSet, visibleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based number of the topmost visible page.
|
||||
*
|
||||
* @param {Set<number>} visibleSet
|
||||
*/
|
||||
getCurrentPage(visibleSet) {
|
||||
if (visibleSet.size > 0) return Math.min(...visibleSet);
|
||||
// Fall back to scroll position
|
||||
const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0;
|
||||
for (const wrap of this._wrappers) {
|
||||
if (wrap.getBoundingClientRect().bottom > top + 4) {
|
||||
return parseInt(wrap.dataset.page, 10);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
87
src-tauri/assets/viewer/render-worker.js
Normal file
87
src-tauri/assets/viewer/render-worker.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* render-worker.js — Web Worker for off-main-thread PDF rendering.
|
||||
*
|
||||
* Has its own PDF.js instance. Renders pages via OffscreenCanvas and transfers
|
||||
* ImageBitmap objects back to the main thread with zero-copy transfer.
|
||||
*
|
||||
* Message protocol:
|
||||
*
|
||||
* Main → Worker:
|
||||
* { type: "init", pdfData: ArrayBuffer } (transferred, not copied)
|
||||
* { type: "render", pageNum, scale, gen }
|
||||
* { type: "cancel", gen } — renderGen check handles this implicitly
|
||||
* { type: "cleanup" } — pdfDoc.cleanup() (free caches)
|
||||
* { type: "destroy" } — pdfDoc.destroy(); self.close()
|
||||
*
|
||||
* Worker → Main:
|
||||
* { type: "ready", numPages }
|
||||
* { type: "rendered", pageNum, gen, bitmap } (bitmap as transferable)
|
||||
* { type: "error", message }
|
||||
*/
|
||||
|
||||
importScripts("brittle://app/pdfjs/build/pdf.min.js");
|
||||
|
||||
const pdfjsLib = globalThis.pdfjsLib;
|
||||
// Do NOT set workerSrc to a brittle:// URL here. When PDF.js tries to spawn its
|
||||
// own sub-worker with new Worker("brittle://…") and that fails, it falls back to
|
||||
// a "fake worker" that injects a <script> tag — which throws because `document`
|
||||
// does not exist inside a Web Worker.
|
||||
//
|
||||
// Instead, we fetch pdf.worker.min.js in handleInit() and hand PDF.js a blob URL
|
||||
// it can actually use with new Worker(blobUrl). Blob URLs created inside a worker
|
||||
// are same-origin and can be used for nested workers.
|
||||
|
||||
let pdfDoc = null;
|
||||
|
||||
self.onmessage = async function (ev) {
|
||||
const msg = ev.data;
|
||||
switch (msg.type) {
|
||||
case "init": await handleInit(msg); break;
|
||||
case "render": await handleRender(msg); break;
|
||||
case "cleanup":
|
||||
if (pdfDoc) await pdfDoc.cleanup();
|
||||
break;
|
||||
case "destroy":
|
||||
if (pdfDoc) { await pdfDoc.destroy(); pdfDoc = null; }
|
||||
self.close();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
async function handleInit({ pdfData }) {
|
||||
try {
|
||||
// Fetch pdf.worker.min.js and create a blob URL so PDF.js can spawn its
|
||||
// own sub-worker without relying on brittle:// for new Worker().
|
||||
const resp = await fetch("brittle://app/pdfjs/build/pdf.worker.min.js");
|
||||
const text = await resp.text();
|
||||
const blob = new Blob([text], { type: "application/javascript" });
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob);
|
||||
|
||||
pdfDoc = await pdfjsLib.getDocument({ data: new Uint8Array(pdfData) }).promise;
|
||||
self.postMessage({ type: "ready", numPages: pdfDoc.numPages });
|
||||
} catch (e) {
|
||||
self.postMessage({ type: "error", message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender({ pageNum, scale, gen }) {
|
||||
if (!pdfDoc) return;
|
||||
let page = null;
|
||||
try {
|
||||
page = await pdfDoc.getPage(pageNum);
|
||||
const vp = page.getViewport({ scale });
|
||||
const width = Math.round(vp.width);
|
||||
const height = Math.round(vp.height);
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise;
|
||||
const bitmap = canvas.transferToImageBitmap();
|
||||
self.postMessage({ type: "rendered", pageNum, gen, bitmap }, [bitmap]);
|
||||
} catch (e) {
|
||||
if (e?.name !== "RenderingCancelledException") {
|
||||
console.warn("[render-worker] render error page", pageNum, e);
|
||||
}
|
||||
} finally {
|
||||
if (page) page.cleanup();
|
||||
}
|
||||
}
|
||||
230
src-tauri/assets/viewer/viewer.js
Normal file
230
src-tauri/assets/viewer/viewer.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { ViewportTracker } from "./viewport-tracker.js";
|
||||
import { PageManager } from "./page-manager.js";
|
||||
import { ZoomController } from "./zoom-controller.js";
|
||||
import { MessageBridge } from "./message-bridge.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 refId = new URLSearchParams(location.search).get("ref_id") || "";
|
||||
const DPR = window.devicePixelRatio || 1;
|
||||
|
||||
let pdfDoc = null;
|
||||
let pageManager = null;
|
||||
let viewportTracker = null;
|
||||
let zoomController = null;
|
||||
let bridge = null;
|
||||
|
||||
let currentBufferSet = new Set();
|
||||
let currentVisibleSet = new Set();
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
|
||||
function showError(msg) {
|
||||
const b = document.getElementById("error-banner");
|
||||
b.textContent = msg;
|
||||
b.style.display = "block";
|
||||
setStatus("Error");
|
||||
}
|
||||
|
||||
function refreshPageIndicator() {
|
||||
if (!pageManager) return;
|
||||
const cur = pageManager.getCurrentPage(currentVisibleSet);
|
||||
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
||||
}
|
||||
|
||||
async function fitToWidth() {
|
||||
if (!pdfDoc) return 1.0;
|
||||
const page = await pdfDoc.getPage(1);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
const avail = container.clientWidth - 40;
|
||||
return Math.max(0.1, Math.min(5.0, avail / vp.width));
|
||||
}
|
||||
|
||||
function scrollToPage(pageNum) {
|
||||
if (!pageManager) return;
|
||||
const wrap = pageManager.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
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;
|
||||
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();
|
||||
setStatus("Ready");
|
||||
} catch (e) {
|
||||
if (e?.name !== "RenderingCancelledException") {
|
||||
console.warn("[viewer] render error page", pageNum, e);
|
||||
}
|
||||
} finally {
|
||||
page?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visibility change callback (called by ViewportTracker) ───────────────────
|
||||
function onVisibilityChange(bufferSet, visibleSet) {
|
||||
currentBufferSet = bufferSet;
|
||||
currentVisibleSet = visibleSet;
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
refreshPageIndicator();
|
||||
}
|
||||
|
||||
// ── 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
|
||||
pdfDoc = await pdfjsLib.getDocument({ url: pdfUrl }).promise;
|
||||
|
||||
// 2. Fetch all page viewports at scale=1
|
||||
setStatus("Reading…");
|
||||
const viewports = [];
|
||||
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 fit-to-width scale
|
||||
const avail = container.clientWidth - 40;
|
||||
const initialScale = Math.max(0.1, Math.min(5.0, avail / viewports[0].width));
|
||||
|
||||
// 4. PageManager — creates placeholder divs
|
||||
pageManager = new PageManager(
|
||||
pagesWrapper, viewports, initialScale, DPR, dispatchRender,
|
||||
);
|
||||
|
||||
// 5. ViewportTracker — IntersectionObserver fires once elements are in the
|
||||
// DOM, triggering the initial reconcile automatically.
|
||||
viewportTracker = new ViewportTracker(
|
||||
container, pageManager.pageWrappers, onVisibilityChange,
|
||||
);
|
||||
|
||||
// 6. ZoomController
|
||||
zoomController = new ZoomController(
|
||||
container,
|
||||
pageManager,
|
||||
(newScale, bufferSet, visibleSet) => pageManager.onScaleChange(newScale, bufferSet, visibleSet),
|
||||
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
||||
zoomLabel,
|
||||
initialScale,
|
||||
);
|
||||
|
||||
// 7. MessageBridge
|
||||
bridge = new MessageBridge(
|
||||
() => scrollToPage(Math.min(pageManager.getCurrentPage(currentVisibleSet) + 1, pdfDoc.numPages)),
|
||||
() => scrollToPage(Math.max(pageManager.getCurrentPage(currentVisibleSet) - 1, 1)),
|
||||
);
|
||||
|
||||
// 8. Toolbar buttons
|
||||
document.getElementById("btn-zoom-out").addEventListener("click",
|
||||
() => zoomController.applyScale(zoomController.scale / 1.25));
|
||||
document.getElementById("btn-zoom-in").addEventListener("click",
|
||||
() => zoomController.applyScale(zoomController.scale * 1.25));
|
||||
document.getElementById("btn-zoom-fit").addEventListener("click",
|
||||
async () => zoomController.applyScale(await fitToWidth()));
|
||||
|
||||
// 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(); fitToWidth().then(s => zoomController.applyScale(s)); }
|
||||
bridge.forwardKeydown(ev);
|
||||
});
|
||||
|
||||
// Scroll → update page indicator
|
||||
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
|
||||
|
||||
// Lifecycle: tab hidden → free caches; tab visible → re-reconcile
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
pdfDoc?.cleanup();
|
||||
} else {
|
||||
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
viewportTracker?.disconnect();
|
||||
bridge?.disconnect();
|
||||
pdfDoc?.destroy();
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
pageIndicator.textContent = "1 / " + pdfDoc.numPages;
|
||||
setStatus("Rendering…");
|
||||
|
||||
} catch (e) {
|
||||
showError("Could not load PDF: " + (e.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
72
src-tauri/assets/viewer/viewport-tracker.js
Normal file
72
src-tauri/assets/viewer/viewport-tracker.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* ViewportTracker — dual IntersectionObserver for page visibility detection.
|
||||
*
|
||||
* - visibleSet: pages currently on screen (rootMargin "0px")
|
||||
* - bufferSet: pages within ~2 viewport heights above/below (rootMargin "200% 0px")
|
||||
*
|
||||
* Calls onVisibilityChange(bufferSet, visibleSet) whenever either set changes.
|
||||
*/
|
||||
export class ViewportTracker {
|
||||
/**
|
||||
* @param {HTMLElement} root - scroll container (#canvas-container)
|
||||
* @param {HTMLElement[]} pageWrappers - array of .page-wrapper elements
|
||||
* @param {Function} onVisibilityChange - (bufferSet: Set<number>, visibleSet: Set<number>) => void
|
||||
*/
|
||||
constructor(root, pageWrappers, onVisibilityChange) {
|
||||
this._onVisibilityChange = onVisibilityChange;
|
||||
this._visibleSet = new Set();
|
||||
this._bufferSet = new Set();
|
||||
this._visibleObserver = null;
|
||||
this._bufferObserver = null;
|
||||
|
||||
this._observe(root, pageWrappers);
|
||||
}
|
||||
|
||||
_observe(root, pageWrappers) {
|
||||
const notify = () => {
|
||||
this._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet));
|
||||
};
|
||||
|
||||
this._visibleObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) {
|
||||
const page = parseInt(e.target.dataset.page, 10);
|
||||
if (e.isIntersecting) this._visibleSet.add(page);
|
||||
else this._visibleSet.delete(page);
|
||||
}
|
||||
notify();
|
||||
},
|
||||
{ root, rootMargin: "0px", threshold: 0 }
|
||||
);
|
||||
|
||||
this._bufferObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) {
|
||||
const page = parseInt(e.target.dataset.page, 10);
|
||||
if (e.isIntersecting) this._bufferSet.add(page);
|
||||
else this._bufferSet.delete(page);
|
||||
}
|
||||
notify();
|
||||
},
|
||||
{ root, rootMargin: "200% 0px", threshold: 0 }
|
||||
);
|
||||
|
||||
for (const wrap of pageWrappers) {
|
||||
this._visibleObserver.observe(wrap);
|
||||
this._bufferObserver.observe(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe a newly-added page wrapper. */
|
||||
observe(wrap) {
|
||||
this._visibleObserver?.observe(wrap);
|
||||
this._bufferObserver?.observe(wrap);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._visibleObserver?.disconnect();
|
||||
this._bufferObserver?.disconnect();
|
||||
this._visibleSet.clear();
|
||||
this._bufferSet.clear();
|
||||
}
|
||||
}
|
||||
146
src-tauri/assets/viewer/zoom-controller.js
Normal file
146
src-tauri/assets/viewer/zoom-controller.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// Must match the CSS constants in index.html:
|
||||
// #pages-wrapper { padding: 20px 0; gap: 12px; }
|
||||
const CONTENT_PADDING_TOP = 20;
|
||||
const PAGE_GAP = 12;
|
||||
|
||||
/**
|
||||
* ZoomController — two-phase zoom pipeline.
|
||||
*
|
||||
* Phase 1 (instant, every event):
|
||||
* CSS `zoom` on each .page-wrapper = newScale / renderScale
|
||||
* Scroll position adjusted to keep the anchor point fixed
|
||||
* Zoom label updated
|
||||
*
|
||||
* Phase 2 (debounced, 250ms after last event):
|
||||
* Calls pageManager.onScaleChange(newScale, bufferSet, visibleSet)
|
||||
* Pages re-rendered at the new native resolution
|
||||
*
|
||||
* Ctrl+Scroll coalescing: multiple wheel events within a frame are coalesced
|
||||
* via requestAnimationFrame. The 250ms debounce starts inside the rAF callback,
|
||||
* so it begins after the last event in a burst.
|
||||
*/
|
||||
export class ZoomController {
|
||||
/**
|
||||
* @param {HTMLElement} container - #canvas-container (scrollable)
|
||||
* @param {object} pageManager - PageManager instance
|
||||
* @param {Function} onReRender - (newScale, bufferSet, visibleSet) => void
|
||||
* @param {Function} getBuffer - () => { bufferSet: Set, visibleSet: Set }
|
||||
* @param {HTMLElement} zoomLabel
|
||||
* @param {number} initialScale
|
||||
*/
|
||||
constructor(container, pageManager, onReRender, getBuffer, zoomLabel, initialScale) {
|
||||
this._container = container;
|
||||
this._pm = pageManager;
|
||||
this._onReRender = onReRender;
|
||||
this._getBuffer = getBuffer;
|
||||
this._zoomLabel = zoomLabel;
|
||||
this._scale = initialScale;
|
||||
this._renderScale = initialScale;
|
||||
this._debounce = null;
|
||||
this._rafPending = null;
|
||||
|
||||
this.ZOOM_MIN = 0.1;
|
||||
this.ZOOM_MAX = 5.0;
|
||||
|
||||
this._updateLabel();
|
||||
this._bindScrollZoom();
|
||||
}
|
||||
|
||||
get scale() { return this._scale; }
|
||||
|
||||
clamp(s) {
|
||||
return Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, s));
|
||||
}
|
||||
|
||||
_updateLabel() {
|
||||
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new zoom level.
|
||||
*
|
||||
* @param {number} newScale
|
||||
* @param {number} [anchorY] - pixel offset within container to hold fixed (default: center)
|
||||
* @param {number} [anchorX]
|
||||
*/
|
||||
applyScale(newScale, anchorY, anchorX) {
|
||||
const container = this._container;
|
||||
const oldScale = this._scale;
|
||||
this._scale = this.clamp(newScale);
|
||||
this._updateLabel();
|
||||
|
||||
if (anchorY === undefined) anchorY = container.clientHeight / 2;
|
||||
if (anchorX === undefined) anchorX = container.clientWidth / 2;
|
||||
|
||||
// Compute fixedAbove — the non-scaling portion of content above the anchor:
|
||||
// top padding plus one gap per page that is fully above the anchor.
|
||||
// Gaps and padding are fixed-size and do not scale with zoom, so a naive
|
||||
// `(scrollTop + anchorY) * ratio` formula accumulates one error of
|
||||
// `GAP * (ratio - 1)` per gap above the anchor — enough to shift the
|
||||
// anchor by tens of pixels near the bottom of a long document.
|
||||
const anchorContentY = container.scrollTop + anchorY;
|
||||
let fixedAbove = CONTENT_PADDING_TOP;
|
||||
let cumY = CONTENT_PADDING_TOP;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
// Use the element's current layout height (CSS height × CSS zoom).
|
||||
const h = parseFloat(wrap.style.height) * (parseFloat(wrap.style.zoom) || 1);
|
||||
if (cumY + h > anchorContentY) break;
|
||||
cumY += h + PAGE_GAP;
|
||||
fixedAbove += PAGE_GAP;
|
||||
}
|
||||
|
||||
// Phase 1: instant CSS zoom feedback
|
||||
const cssZoom = this._scale / this._renderScale;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
wrap.style.zoom = cssZoom;
|
||||
}
|
||||
|
||||
// Exact scroll anchor: scale only the page-content portion; keep the
|
||||
// fixed portion (gaps + padding) unchanged.
|
||||
// T_new = fixedAbove + (T_old + anchorY − fixedAbove) × ratio − anchorY
|
||||
const ratio = this._scale / oldScale;
|
||||
const scalable = container.scrollTop + anchorY - fixedAbove;
|
||||
container.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY);
|
||||
container.scrollLeft = Math.max(0, (container.scrollLeft + anchorX) * ratio - anchorX);
|
||||
|
||||
// Phase 2: debounced native re-render
|
||||
this._scheduleReRender();
|
||||
}
|
||||
|
||||
_scheduleReRender() {
|
||||
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
clearTimeout(this._debounce);
|
||||
this._debounce = setTimeout(() => this._triggerReRender(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
_triggerReRender() {
|
||||
const newScale = this._scale;
|
||||
this._renderScale = newScale;
|
||||
const { bufferSet, visibleSet } = this._getBuffer();
|
||||
this._onReRender(newScale, bufferSet, visibleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the initial scale without triggering a re-render or CSS zoom.
|
||||
* Used when the initial fit-to-width is computed before any pages are rendered.
|
||||
*/
|
||||
setInitialScale(s) {
|
||||
this._scale = this.clamp(s);
|
||||
this._renderScale = this._scale;
|
||||
this._updateLabel();
|
||||
}
|
||||
|
||||
_bindScrollZoom() {
|
||||
this._container.addEventListener("wheel", ev => {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const rect = this._container.getBoundingClientRect();
|
||||
const anchorY = ev.clientY - rect.top;
|
||||
const anchorX = ev.clientX - rect.left;
|
||||
this.applyScale(this._scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX);
|
||||
}, { passive: false });
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,7 @@ mod tests {
|
||||
let original = LayoutConfig {
|
||||
left_pane_fraction: 0.25,
|
||||
right_pane_fraction: 0.40,
|
||||
..LayoutConfig::default()
|
||||
};
|
||||
let s = toml::to_string_pretty(&original).unwrap();
|
||||
let parsed: LayoutConfig = toml::from_str(&s).unwrap();
|
||||
@@ -286,6 +287,7 @@ mod tests {
|
||||
layout: LayoutConfig {
|
||||
left_pane_fraction: 0.30,
|
||||
right_pane_fraction: 0.40,
|
||||
..LayoutConfig::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -26,6 +26,14 @@ static PDFJS_WORKER_JS: &[u8] = include_bytes!(
|
||||
"../assets/viewer/pdfjs/pdf.worker.min.js"
|
||||
);
|
||||
|
||||
// Viewer JS modules (served at brittle://app/viewer/<file>)
|
||||
static VIEWER_JS: &[u8] = include_bytes!("../assets/viewer/viewer.js");
|
||||
static VIEWPORT_TRACKER_JS: &[u8] = include_bytes!("../assets/viewer/viewport-tracker.js");
|
||||
static PAGE_MANAGER_JS: &[u8] = include_bytes!("../assets/viewer/page-manager.js");
|
||||
static ZOOM_CONTROLLER_JS: &[u8] = include_bytes!("../assets/viewer/zoom-controller.js");
|
||||
static RENDER_WORKER_JS: &[u8] = include_bytes!("../assets/viewer/render-worker.js");
|
||||
static MESSAGE_BRIDGE_JS: &[u8] = include_bytes!("../assets/viewer/message-bridge.js");
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point registered with `tauri::Builder::register_uri_scheme_protocol`.
|
||||
@@ -37,6 +45,7 @@ pub fn handle<R: Runtime>(app: &AppHandle<R>, req: &Request<Vec<u8>>) -> Respons
|
||||
|
||||
match routing::classify(uri.path(), uri.query()) {
|
||||
routing::Route::Viewer { ref_id } => serve_viewer(&ref_id),
|
||||
routing::Route::ViewerAsset { file_name } => serve_viewer_asset(&file_name),
|
||||
routing::Route::PdfjsAsset { rel_path } => {
|
||||
if rel_path.contains("..") {
|
||||
return response_403();
|
||||
@@ -57,6 +66,19 @@ fn serve_viewer(_ref_id: &str) -> Response<Vec<u8>> {
|
||||
response_ok(VIEWER_HTML.to_vec(), "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
fn serve_viewer_asset(file_name: &str) -> Response<Vec<u8>> {
|
||||
let bytes: &[u8] = match file_name {
|
||||
"viewer.js" => VIEWER_JS,
|
||||
"viewport-tracker.js" => VIEWPORT_TRACKER_JS,
|
||||
"page-manager.js" => PAGE_MANAGER_JS,
|
||||
"zoom-controller.js" => ZOOM_CONTROLLER_JS,
|
||||
"render-worker.js" => RENDER_WORKER_JS,
|
||||
"message-bridge.js" => MESSAGE_BRIDGE_JS,
|
||||
_ => return response_404(),
|
||||
};
|
||||
response_ok(bytes.to_vec(), "application/javascript; charset=utf-8")
|
||||
}
|
||||
|
||||
fn serve_pdfjs_file(rel_path: &str) -> Response<Vec<u8>> {
|
||||
let bytes: &[u8] = match rel_path {
|
||||
"build/pdf.min.js" => PDFJS_MIN_JS,
|
||||
@@ -152,17 +174,31 @@ pub mod routing {
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Route {
|
||||
Viewer { ref_id: String },
|
||||
ViewerAsset { file_name: String },
|
||||
PdfjsAsset { rel_path: String },
|
||||
Pdf { ref_id: String },
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Classify a `brittle://app/{path}?{query}` request into a `Route`.
|
||||
///
|
||||
/// - `/viewer` (no further path segments) → HTML shell
|
||||
/// - `/viewer/<file>` (one path segment) → viewer JS module
|
||||
/// - `/pdfjs/<rel>` → PDF.js asset
|
||||
/// - `/pdf` → raw PDF bytes
|
||||
pub fn classify(path: &str, query: Option<&str>) -> Route {
|
||||
let ref_id = extract_ref_id(query);
|
||||
|
||||
if path == "/viewer" {
|
||||
Route::Viewer { ref_id }
|
||||
} else if let Some(rest) = path.strip_prefix("/viewer/") {
|
||||
// Reject any path that contains directory separators or dots
|
||||
// at the start, to prevent traversal.
|
||||
if rest.contains('/') || rest.starts_with('.') {
|
||||
Route::NotFound
|
||||
} else {
|
||||
Route::ViewerAsset { file_name: rest.to_owned() }
|
||||
}
|
||||
} else if let Some(rel) = path.strip_prefix("/pdfjs/") {
|
||||
Route::PdfjsAsset {
|
||||
rel_path: rel.to_owned(),
|
||||
@@ -239,6 +275,37 @@ pub mod routing {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_asset_route() {
|
||||
let r = classify("/viewer/viewer.js", None);
|
||||
assert_eq!(r, Route::ViewerAsset { file_name: "viewer.js".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_asset_route_all_modules() {
|
||||
for name in &[
|
||||
"viewer.js",
|
||||
"viewport-tracker.js",
|
||||
"page-manager.js",
|
||||
"zoom-controller.js",
|
||||
"render-worker.js",
|
||||
"message-bridge.js",
|
||||
] {
|
||||
let path = format!("/viewer/{}", name);
|
||||
assert_eq!(
|
||||
classify(&path, None),
|
||||
Route::ViewerAsset { file_name: name.to_string() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_asset_traversal_blocked() {
|
||||
// Directory traversal via nested path → NotFound
|
||||
assert_eq!(classify("/viewer/../secret.js", None), Route::NotFound);
|
||||
assert_eq!(classify("/viewer/sub/file.js", None), Route::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_paths_are_not_found() {
|
||||
assert_eq!(classify("/unknown", None), Route::NotFound);
|
||||
|
||||
Reference in New Issue
Block a user