New architecture
This commit is contained in:
1
src-tauri/assets/assets/viewer/viewer.bundle.js
Normal file
1
src-tauri/assets/assets/viewer/viewer.bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/assets/viewer-src/.gitignore
vendored
Normal file
1
src-tauri/assets/viewer-src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
115
src-tauri/assets/viewer-src/build.cjs
Normal file
115
src-tauri/assets/viewer-src/build.cjs
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* build.cjs — esbuild script for the PDF viewer TypeScript bundle.
|
||||
*
|
||||
* Produces two output files in ../assets/viewer/:
|
||||
* viewer.bundle.js — main viewer (IIFE, no PDF.js included)
|
||||
* render-worker.bundle.js — render worker (pdf.min.js prepended + IIFE)
|
||||
*
|
||||
* Usage:
|
||||
* node build.cjs — one-shot production build (minified)
|
||||
* node build.cjs --watch — watch mode for development (unminified)
|
||||
*/
|
||||
|
||||
const esbuild = require("esbuild");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const isWatch = process.argv.includes("--watch");
|
||||
const minify = !isWatch;
|
||||
|
||||
const ROOT = __dirname;
|
||||
const PDFJS_WORKER_MIN = path.join(ROOT, "../viewer/pdfjs/pdf.worker.min.js");
|
||||
const PDFJS_MIN = path.join(ROOT, "../viewer/pdfjs/pdf.min.js");
|
||||
const OUT_DIR = path.join(ROOT, "../viewer");
|
||||
|
||||
// Preamble prepended to the render-worker bundle.
|
||||
//
|
||||
// Sets `globalThis.window = globalThis` before pdf.worker.min.js runs so that:
|
||||
// 1. pdf.worker.min.js does NOT auto-call WorkerMessageHandler.initializeFromPort(self)
|
||||
// (which would hijack our render-worker's own onmessage handler).
|
||||
// 2. pdf.worker.min.js DOES set globalThis.pdfjsWorker.WorkerMessageHandler as usual.
|
||||
// 3. pdf.min.js's _mainThreadWorkerMessageHandler getter then finds the handler via
|
||||
// globalThis.pdfjsWorker and uses it inline — no document.createElement needed.
|
||||
const RENDER_WORKER_PREAMBLE = Buffer.from("globalThis.window=globalThis;\n");
|
||||
|
||||
// ── Render-worker plugin: prepend preamble + pdf.worker.min.js + pdf.min.js ───
|
||||
//
|
||||
// Bundle order matters:
|
||||
// 1. Preamble — sets globalThis.window = globalThis
|
||||
// 2. pdf.worker.min.js — sets globalThis.pdfjsWorker.WorkerMessageHandler
|
||||
// (skips auto-setup because window is now defined)
|
||||
// 3. pdf.min.js — sets globalThis.pdfjsLib; fake-worker path reads pdfjsWorker
|
||||
// 4. Compiled TS — our render-worker code
|
||||
function prependPdfjsPlugin() {
|
||||
const pdfjsWorkerMin = fs.readFileSync(PDFJS_WORKER_MIN);
|
||||
const pdfjsMin = fs.readFileSync(PDFJS_MIN);
|
||||
const NL = Buffer.from("\n");
|
||||
return {
|
||||
name: "prepend-pdfjs",
|
||||
setup(build) {
|
||||
build.onEnd(result => {
|
||||
if (result.errors.length > 0 || !result.outputFiles) return;
|
||||
const compiled = Buffer.from(result.outputFiles[0].contents);
|
||||
const combined = Buffer.concat([
|
||||
RENDER_WORKER_PREAMBLE,
|
||||
pdfjsWorkerMin, NL,
|
||||
pdfjsMin, NL,
|
||||
compiled,
|
||||
]);
|
||||
fs.writeFileSync(path.join(OUT_DIR, "render-worker.bundle.js"), combined);
|
||||
if (!isWatch) console.log("[viewer-src] render-worker.bundle.js written");
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sharedOptions = { bundle: true, format: "iife", target: "es2020", minify };
|
||||
|
||||
if (isWatch) {
|
||||
const viewerCtx = await esbuild.context({
|
||||
...sharedOptions,
|
||||
entryPoints: ["src/viewer.ts"],
|
||||
outfile: path.join(OUT_DIR, "viewer.bundle.js"),
|
||||
});
|
||||
|
||||
const workerCtx = await esbuild.context({
|
||||
...sharedOptions,
|
||||
entryPoints: ["src/render-worker.ts"],
|
||||
write: false,
|
||||
plugins: [prependPdfjsPlugin()],
|
||||
});
|
||||
|
||||
await viewerCtx.watch();
|
||||
await workerCtx.watch();
|
||||
console.log("[viewer-src] watching for changes…");
|
||||
// Keep the process alive
|
||||
return;
|
||||
}
|
||||
|
||||
// One-shot build
|
||||
await esbuild.build({
|
||||
...sharedOptions,
|
||||
entryPoints: ["src/viewer.ts"],
|
||||
outfile: path.join(OUT_DIR, "viewer.bundle.js"),
|
||||
});
|
||||
console.log("[viewer-src] viewer.bundle.js written");
|
||||
|
||||
// Render worker: build to memory then prepend preamble + worker + pdf.min.js
|
||||
const workerResult = await esbuild.build({
|
||||
...sharedOptions,
|
||||
entryPoints: ["src/render-worker.ts"],
|
||||
write: false,
|
||||
});
|
||||
const pdfjsWorkerMin = fs.readFileSync(PDFJS_WORKER_MIN);
|
||||
const pdfjsMin = fs.readFileSync(PDFJS_MIN);
|
||||
const NL = Buffer.from("\n");
|
||||
const compiled = Buffer.from(workerResult.outputFiles[0].contents);
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, "render-worker.bundle.js"),
|
||||
Buffer.concat([RENDER_WORKER_PREAMBLE, pdfjsWorkerMin, NL, pdfjsMin, NL, compiled]),
|
||||
);
|
||||
console.log("[viewer-src] render-worker.bundle.js written");
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
803
src-tauri/assets/viewer-src/package-lock.json
generated
Normal file
803
src-tauri/assets/viewer-src/package-lock.json
generated
Normal file
@@ -0,0 +1,803 @@
|
||||
{
|
||||
"name": "brittle-viewer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "brittle-viewer",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.10.38",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
|
||||
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.65"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src-tauri/assets/viewer-src/package.json
Normal file
15
src-tauri/assets/viewer-src/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "brittle-viewer",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && node build.cjs",
|
||||
"build:no-check": "node build.cjs",
|
||||
"dev": "node build.cjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
275
src-tauri/assets/viewer-src/src/page-manager.ts
Normal file
275
src-tauri/assets/viewer-src/src/page-manager.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* PageManager — page lifecycle: PLACEHOLDER → RENDERING → RENDERED → PLACEHOLDER
|
||||
*
|
||||
* Each page is a sized div (placeholder). When a page enters the buffer zone,
|
||||
* a render job is dispatched to the render worker. When the worker returns an
|
||||
* ImageBitmap, it is swapped into the DOM atomically (old canvas removed and
|
||||
* new canvas appended in the same synchronous turn) so there is never a blank
|
||||
* frame between the old blurry canvas and the new sharp one.
|
||||
*
|
||||
* During CSS zoom (Phase 1 of ZoomController), canvas teardown is suppressed
|
||||
* to prevent dark flashes while intersection data is still settling.
|
||||
*/
|
||||
|
||||
import { PageState } from "./types.js";
|
||||
import type { PageDimensions } from "./types.js";
|
||||
|
||||
const MAX_CANVAS_PIXELS = 16_777_216; // 4096 × 4096
|
||||
|
||||
/**
|
||||
* Clamp render scale so the canvas pixel count stays within 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: number,
|
||||
vpHeight: number,
|
||||
desiredScale: number,
|
||||
dpr: number,
|
||||
): number {
|
||||
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);
|
||||
}
|
||||
|
||||
/** Called by viewer.ts to send a render job to the worker. */
|
||||
export type DispatchRender = (pageNum: number, scale: number, gen: number) => void;
|
||||
|
||||
export class PageManager {
|
||||
private readonly _wrapper: HTMLElement;
|
||||
private readonly _dims: PageDimensions[];
|
||||
private readonly _dpr: number;
|
||||
private readonly _dispatchRender: DispatchRender;
|
||||
private readonly _wrappers: HTMLElement[] = [];
|
||||
// _states[i] tracks the render state of page i+1 (0-indexed).
|
||||
private readonly _states: PageState[];
|
||||
// _canvases[i] is the canvas currently in the DOM for page i+1, or null.
|
||||
private readonly _canvases: (HTMLCanvasElement | null)[];
|
||||
|
||||
private _scale: number;
|
||||
private _renderGen: number = 0;
|
||||
private _inFlight: number = 0;
|
||||
private _zooming: boolean = false;
|
||||
|
||||
constructor(
|
||||
wrapper: HTMLElement,
|
||||
dims: PageDimensions[],
|
||||
initialScale: number,
|
||||
dpr: number,
|
||||
dispatchRender: DispatchRender,
|
||||
) {
|
||||
this._wrapper = wrapper;
|
||||
this._dims = dims;
|
||||
this._scale = initialScale;
|
||||
this._dpr = dpr;
|
||||
this._dispatchRender = dispatchRender;
|
||||
this._states = new Array<PageState>(dims.length).fill(PageState.PLACEHOLDER);
|
||||
this._canvases = new Array<HTMLCanvasElement | null>(dims.length).fill(null);
|
||||
|
||||
this._buildPlaceholders();
|
||||
}
|
||||
|
||||
get pageWrappers(): readonly HTMLElement[] { return this._wrappers; }
|
||||
get numPages(): number { return this._dims.length; }
|
||||
get renderGen(): number { return this._renderGen; }
|
||||
/** True when no renders are currently in flight (all visible pages are sharp). */
|
||||
get allRendered(): boolean { return this._inFlight === 0; }
|
||||
|
||||
/** Called by ZoomController to suppress canvas teardown during Phase 1 CSS zoom. */
|
||||
setZooming(z: boolean): void { this._zooming = z; }
|
||||
|
||||
private _buildPlaceholders(): void {
|
||||
for (let i = 0; i < this._dims.length; i++) {
|
||||
const dim = this._dims[i]!;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "page-wrapper";
|
||||
wrap.dataset["page"] = String(i + 1);
|
||||
wrap.style.width = dim.width * this._scale + "px";
|
||||
wrap.style.height = dim.height * this._scale + "px";
|
||||
this._wrapper.appendChild(wrap);
|
||||
this._wrappers.push(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on each IntersectionObserver tick.
|
||||
* Dispatches renders for pages entering the buffer (visible pages first).
|
||||
* Cleans up pages that have left the buffer (unless zooming).
|
||||
*/
|
||||
reconcile(
|
||||
bufferSet: ReadonlySet<number>,
|
||||
visibleSet: ReadonlySet<number>,
|
||||
): void {
|
||||
const gen = this._renderGen;
|
||||
|
||||
// Visible pages first, then off-screen buffered pages.
|
||||
const toRender: number[] = [
|
||||
...[...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._dims.length) continue;
|
||||
if (this._states[i] === PageState.PLACEHOLDER) {
|
||||
this._startRender(i, gen);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this._dims.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
|
||||
// Skip teardown during active CSS zoom — IntersectionObserver may report
|
||||
// stale data while layout is still settling, causing premature canvas removal.
|
||||
// onScaleChange (Phase 2) performs the authoritative cleanup.
|
||||
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === PageState.RENDERED) {
|
||||
this._cleanup(i);
|
||||
}
|
||||
|
||||
// Cancel in-flight renders that are now out of the buffer.
|
||||
// Leave the canvas in place — removing it would cause a dark flash between
|
||||
// Phase 1 (CSS zoom) and Phase 2 (debounced re-render).
|
||||
if (!bufferSet.has(pageNum) && this._states[i] === PageState.RENDERING) {
|
||||
this._inFlight--;
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _startRender(i: number, gen: number): void {
|
||||
const dim = this._dims[i]!;
|
||||
const scale = clampedRenderScale(dim.width, dim.height, this._scale, this._dpr);
|
||||
this._states[i] = PageState.RENDERING;
|
||||
this._inFlight++;
|
||||
this._dispatchRender(i + 1, scale, gen);
|
||||
}
|
||||
|
||||
private _cleanup(i: number): void {
|
||||
if (this._states[i] === PageState.RENDERING) this._inFlight--;
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.remove();
|
||||
this._canvases[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the render worker returns a finished bitmap.
|
||||
*
|
||||
* Double-buffer swap: the old canvas stays in the DOM until the new one is
|
||||
* ready, then both mutations happen in the same synchronous JS turn so the
|
||||
* browser produces exactly one paint frame for the transition.
|
||||
*/
|
||||
onRendered(pageNum: number, gen: number, bitmap: ImageBitmap): void {
|
||||
if (gen !== this._renderGen) { bitmap.close(); return; }
|
||||
|
||||
const i = pageNum - 1;
|
||||
if (
|
||||
i < 0 ||
|
||||
i >= this._dims.length ||
|
||||
this._states[i] !== PageState.RENDERING
|
||||
) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
this._inFlight--;
|
||||
|
||||
const dim = this._dims[i]!;
|
||||
const wrap = this._wrappers[i]!;
|
||||
const cssW = dim.width * this._scale;
|
||||
const cssH = dim.height * this._scale;
|
||||
|
||||
// Build new canvas off-screen (not in DOM yet).
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.style.width = cssW + "px";
|
||||
canvas.style.height = cssH + "px";
|
||||
canvas.style.display = "block";
|
||||
|
||||
// Zero-copy display via bitmaprenderer; fall back to drawImage if unavailable.
|
||||
const bitmapCtx = canvas.getContext("bitmaprenderer");
|
||||
if (bitmapCtx) {
|
||||
bitmapCtx.transferFromImageBitmap(bitmap);
|
||||
} else {
|
||||
(canvas.getContext("2d") as CanvasRenderingContext2D).drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
}
|
||||
|
||||
wrap.style.width = cssW + "px";
|
||||
wrap.style.height = cssH + "px";
|
||||
// Do NOT reset wrap's CSS zoom here — ZoomController may have advanced it
|
||||
// since the last onScaleChange. The explicit canvas CSS dimensions already
|
||||
// counteract the zoom; resetting it would cause a brief visual scale jump.
|
||||
|
||||
// Atomic DOM swap: old removed + new appended in the same synchronous turn
|
||||
// → the browser paints exactly one frame for the change.
|
||||
const old = this._canvases[i];
|
||||
if (old) old.remove();
|
||||
wrap.appendChild(canvas);
|
||||
|
||||
this._canvases[i] = canvas;
|
||||
this._states[i] = PageState.RENDERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the zoom debounce fires (Phase 2).
|
||||
* Increments renderGen to cancel all stale in-flight renders, resizes
|
||||
* placeholders, then re-dispatches renders for the buffer zone.
|
||||
*/
|
||||
onScaleChange(
|
||||
newScale: number,
|
||||
bufferSet: ReadonlySet<number>,
|
||||
visibleSet: ReadonlySet<number>,
|
||||
): void {
|
||||
this._scale = newScale;
|
||||
this._renderGen++;
|
||||
|
||||
for (let i = 0; i < this._dims.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
const dim = this._dims[i]!;
|
||||
const wrap = this._wrappers[i]!;
|
||||
const cssW = dim.width * newScale;
|
||||
const cssH = dim.height * newScale;
|
||||
wrap.style.width = cssW + "px";
|
||||
wrap.style.height = cssH + "px";
|
||||
wrap.style.setProperty("zoom", "1");
|
||||
|
||||
if (bufferSet.has(pageNum)) {
|
||||
// Keep old canvas 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 when done.
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.style.width = cssW + "px";
|
||||
canvas.style.height = cssH + "px";
|
||||
}
|
||||
if (this._states[i] === PageState.RENDERING) this._inFlight--;
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
} else {
|
||||
// Off-screen: safe to discard immediately (no visible flash).
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) { canvas.remove(); this._canvases[i] = null; }
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
this.reconcile(bufferSet, visibleSet);
|
||||
}
|
||||
|
||||
/** Returns the 1-based page number of the topmost visible page. */
|
||||
getCurrentPage(visibleSet: ReadonlySet<number>): number {
|
||||
if (visibleSet.size > 0) return Math.min(...visibleSet);
|
||||
// Fall back to scroll position when no page is intersecting.
|
||||
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;
|
||||
}
|
||||
}
|
||||
204
src-tauri/assets/viewer-src/src/render-worker.ts
Normal file
204
src-tauri/assets/viewer-src/src/render-worker.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* render-worker.ts — off-main-thread PDF rendering via Web Worker.
|
||||
*
|
||||
* NOTE: pdf.min.js is prepended to the compiled output of this file at build
|
||||
* time by build.cjs (see prependPdfjsPlugin). It sets `self.pdfjsLib` as a
|
||||
* global, which this module reads via the declaration below.
|
||||
*
|
||||
* Worker message protocol
|
||||
* ────────────────────────
|
||||
* Main → Worker:
|
||||
* { type: "init", pdfData: ArrayBuffer } (transferred, not copied)
|
||||
* { type: "render", pageNum, scale, gen }
|
||||
* { type: "cleanup" } — pdfDoc.cleanup(): free internal caches
|
||||
* { type: "destroy" } — pdfDoc.destroy() + self.close()
|
||||
*
|
||||
* Worker → Main:
|
||||
* { type: "ready", numPages, dims } — page-1 dim used as stub for all pages
|
||||
* { type: "rendered", pageNum, gen, bitmap } (bitmap as transferable)
|
||||
* { type: "error", message }
|
||||
*
|
||||
* WebKit nested-worker constraint
|
||||
* ────────────────────────────────
|
||||
* WebKit forbids nested workers (a worker spawning a worker). PDF.js normally
|
||||
* spawns pdf.worker.min.js from wherever it is used — which here would be a
|
||||
* nested worker. Instead, pdf.worker.min.js is bundled into this file (by
|
||||
* build.cjs) preceded by a preamble that sets globalThis.window = globalThis.
|
||||
* This causes pdf.worker.min.js to expose its WorkerMessageHandler on
|
||||
* globalThis.pdfjsWorker without auto-calling initializeFromPort(self). PDF.js
|
||||
* detects the pre-loaded handler via _mainThreadWorkerMessageHandler and runs
|
||||
* inline in this thread — no nested worker creation attempted at all.
|
||||
*
|
||||
* Render queue
|
||||
* ────────────
|
||||
* With disableFontFace: true, every text glyph becomes canvas path operations.
|
||||
* A text-heavy page can require 20 000+ synchronous canvas calls. Running
|
||||
* multiple page renders concurrently exhausts the worker thread and causes
|
||||
* system-wide CPU saturation (perceived UI lock). The queue serialises renders
|
||||
* so exactly one page is active at a time. Incoming render messages for the
|
||||
* same page supersede any queued (but not yet started) request.
|
||||
*/
|
||||
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import type { WorkerInbound, WorkerOutbound, PageDimensions } from "./types.js";
|
||||
|
||||
// pdf.min.js is prepended at build time and sets globalThis.pdfjsLib.
|
||||
declare const pdfjsLib: typeof import("pdfjs-dist");
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
|
||||
// Custom canvas factory that uses OffscreenCanvas instead of
|
||||
// document.createElement("canvas"). Required because this file runs in a Web
|
||||
// Worker where `document` is not available. PDF.js uses the factory to create
|
||||
// intermediate canvases (e.g. for scaling inline images during page rendering).
|
||||
const offscreenCanvasFactory = {
|
||||
create(width: number, height: number) {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
return { canvas, context };
|
||||
},
|
||||
reset(
|
||||
item: { canvas: OffscreenCanvas; context: OffscreenCanvasRenderingContext2D | null },
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
item.canvas.width = width;
|
||||
item.canvas.height = height;
|
||||
},
|
||||
destroy(
|
||||
item: { canvas: OffscreenCanvas; context: OffscreenCanvasRenderingContext2D | null },
|
||||
) {
|
||||
// Release memory by shrinking the canvas; nulling context is cosmetic.
|
||||
item.canvas.width = 1;
|
||||
item.canvas.height = 1;
|
||||
item.context = null;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Render queue ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface RenderJob { pageNum: number; scale: number; gen: number; }
|
||||
|
||||
const renderQueue: RenderJob[] = [];
|
||||
let activeRenders = 0;
|
||||
const MAX_CONCURRENT = 1;
|
||||
|
||||
function enqueueRender(pageNum: number, scale: number, gen: number): void {
|
||||
// Supersede any already-queued (not yet started) job for the same page.
|
||||
const dup = renderQueue.findIndex(j => j.pageNum === pageNum);
|
||||
if (dup >= 0) renderQueue.splice(dup, 1);
|
||||
renderQueue.push({ pageNum, scale, gen });
|
||||
drainQueue();
|
||||
}
|
||||
|
||||
function drainQueue(): void {
|
||||
while (activeRenders < MAX_CONCURRENT && renderQueue.length > 0) {
|
||||
const job = renderQueue.shift()!;
|
||||
activeRenders++;
|
||||
handleRender(job.pageNum, job.scale, job.gen).finally(() => {
|
||||
activeRenders--;
|
||||
drainQueue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message dispatch ──────────────────────────────────────────────────────────
|
||||
|
||||
self.onmessage = async (ev: MessageEvent<WorkerInbound>): Promise<void> => {
|
||||
const msg = ev.data;
|
||||
switch (msg.type) {
|
||||
case "init":
|
||||
await handleInit(msg.pdfData);
|
||||
break;
|
||||
case "render":
|
||||
// Enqueue and return immediately; drainQueue() handles concurrency.
|
||||
enqueueRender(msg.pageNum, msg.scale, msg.gen);
|
||||
break;
|
||||
case "cleanup":
|
||||
await pdfDoc?.cleanup();
|
||||
break;
|
||||
case "destroy":
|
||||
renderQueue.length = 0; // cancel pending jobs
|
||||
if (pdfDoc) { await pdfDoc.destroy(); pdfDoc = null; }
|
||||
self.close();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleInit(pdfData: ArrayBuffer): Promise<void> {
|
||||
try {
|
||||
// pdf.worker.min.js is bundled into this file (prepended by build.cjs).
|
||||
// It sets globalThis.pdfjsWorker.WorkerMessageHandler, which PDF.js detects
|
||||
// via its _mainThreadWorkerMessageHandler getter and uses as an inline fake
|
||||
// worker — no nested-worker creation attempted.
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
"brittle://app/pdfjs/build/pdf.worker.min.js";
|
||||
|
||||
// disableFontFace: PDF.js normally registers custom fonts via
|
||||
// document.fonts.add() (the Font Loading API). In a Web Worker, `document`
|
||||
// is undefined, so font registration fails and text is invisible. Setting
|
||||
// disableFontFace: true makes PDF.js render all glyphs as canvas vector
|
||||
// paths instead — no browser font API needed, text renders correctly.
|
||||
//
|
||||
// canvasFactory: the installed pdfjs-dist types omit this parameter even
|
||||
// though the runtime API accepts it. `any` cast bypasses the type check.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pdfDoc = await pdfjsLib.getDocument({
|
||||
data: new Uint8Array(pdfData),
|
||||
canvasFactory: offscreenCanvasFactory,
|
||||
disableFontFace: true,
|
||||
} as any).promise;
|
||||
const numPages = pdfDoc.numPages;
|
||||
|
||||
// Fetch page-1 dimensions so the main thread can lay out placeholders.
|
||||
// Page-1 dims are used as a uniform stub for all pages — accurate for most
|
||||
// academic PDFs (uniform paper size). We deliberately do NOT fetch dims for
|
||||
// every page here: that would tie up the worker with N getPage() calls while
|
||||
// render jobs are already arriving, causing further delays.
|
||||
const firstPage = await pdfDoc.getPage(1);
|
||||
const firstVp = firstPage.getViewport({ scale: 1.0 });
|
||||
firstPage.cleanup();
|
||||
const stubDim: PageDimensions = { width: firstVp.width, height: firstVp.height };
|
||||
|
||||
const stubDims: PageDimensions[] = Array.from({ length: numPages }, () => stubDim);
|
||||
const out: WorkerOutbound = { type: "ready", numPages, dims: stubDims };
|
||||
self.postMessage(out);
|
||||
} catch (e) {
|
||||
const out: WorkerOutbound = { type: "error", message: String(e) };
|
||||
self.postMessage(out);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender(
|
||||
pageNum: number,
|
||||
scale: number,
|
||||
gen: number,
|
||||
): Promise<void> {
|
||||
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 offscreen = new OffscreenCanvas(width, height);
|
||||
// OffscreenCanvasRenderingContext2D is assignable to the canvasContext
|
||||
// parameter of page.render(); the cast satisfies the type checker.
|
||||
const ctx = offscreen.getContext("2d") as unknown as CanvasRenderingContext2D;
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise;
|
||||
|
||||
const bitmap = offscreen.transferToImageBitmap();
|
||||
const out: WorkerOutbound = { type: "rendered", pageNum, gen, bitmap };
|
||||
(self as unknown as Worker).postMessage(out, [bitmap]);
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== "RenderingCancelledException") {
|
||||
console.warn("[render-worker] render error page", pageNum, e);
|
||||
}
|
||||
} finally {
|
||||
page?.cleanup();
|
||||
}
|
||||
}
|
||||
41
src-tauri/assets/viewer-src/src/types.ts
Normal file
41
src-tauri/assets/viewer-src/src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// ── Page lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
export const PageState = {
|
||||
PLACEHOLDER: 0,
|
||||
RENDERING: 1,
|
||||
RENDERED: 2,
|
||||
} as const;
|
||||
export type PageState = typeof PageState[keyof typeof PageState];
|
||||
|
||||
/** Page dimensions at scale=1 (fetched from the render worker at init). */
|
||||
export interface PageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// ── Render worker message protocol ────────────────────────────────────────────
|
||||
|
||||
/** Messages the main thread sends to the render worker. */
|
||||
export type WorkerInbound =
|
||||
| { type: "init"; pdfData: ArrayBuffer }
|
||||
| { type: "render"; pageNum: number; scale: number; gen: number }
|
||||
| { type: "cleanup" }
|
||||
| { type: "destroy" };
|
||||
|
||||
/**
|
||||
* Messages the render worker sends to the main thread.
|
||||
*
|
||||
* "ready" includes all page dims so the main thread can lay out placeholders
|
||||
* without a second round-trip.
|
||||
*/
|
||||
export type WorkerOutbound =
|
||||
| { type: "ready"; numPages: number; dims: PageDimensions[] }
|
||||
| { type: "rendered"; pageNum: number; gen: number; bitmap: ImageBitmap }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
// ── iframe ↔ parent postMessage protocol ─────────────────────────────────────
|
||||
|
||||
/** Messages the PDF viewer iframe sends up to the parent Leptos app. */
|
||||
export type OutboundMessage =
|
||||
| { type: "brittle:viewer-state"; refId: string; zoom: number; scrollTop: number }
|
||||
| { type: "brittle:keydown"; key: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; metaKey: boolean };
|
||||
291
src-tauri/assets/viewer-src/src/viewer.ts
Normal file
291
src-tauri/assets/viewer-src/src/viewer.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* viewer.ts — PDF viewer orchestrator.
|
||||
*
|
||||
* Rendering is entirely off the main thread: a render worker (render-worker.ts,
|
||||
* with pdf.min.js bundled in) handles all PDF.js calls and canvas rasterisation
|
||||
* via OffscreenCanvas. The main thread only performs DOM manipulation and
|
||||
* receives finished ImageBitmaps for zero-copy display.
|
||||
*
|
||||
* Init sequence:
|
||||
* 1. Fetch render-worker.bundle.js → blob URL → new Worker()
|
||||
* 2. Fetch raw PDF bytes → transfer ArrayBuffer to worker
|
||||
* 3. Worker loads PDF.js, fetches page dims → posts "ready" with dims
|
||||
* 4. Create PageManager (placeholder divs) with received dims
|
||||
* 5. Wire render worker onmessage → pageManager.onRendered()
|
||||
* 6. Create ViewportTracker → IntersectionObserver fires → initial reconcile
|
||||
* 7. Create ZoomController
|
||||
* 8. Register toolbar, keyboard, scroll, and lifecycle handlers
|
||||
*/
|
||||
|
||||
import { PageManager } from "./page-manager.js";
|
||||
import { ViewportTracker } from "./viewport-tracker.js";
|
||||
import { ZoomController } from "./zoom-controller.js";
|
||||
import type { WorkerInbound, WorkerOutbound, OutboundMessage, PageDimensions } from "./types.js";
|
||||
|
||||
// requestIdleCallback polyfill for older WebKitGTK builds.
|
||||
if (typeof requestIdleCallback === "undefined") {
|
||||
(self as unknown as Record<string, unknown>)["requestIdleCallback"] =
|
||||
(cb: IdleRequestCallback): ReturnType<typeof setTimeout> =>
|
||||
setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1);
|
||||
}
|
||||
|
||||
// ── 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")!;
|
||||
|
||||
// ── URL params ─────────────────────────────────────────────────────────────────
|
||||
const params = new URLSearchParams(location.search);
|
||||
const refId = params.get("ref_id") ?? "";
|
||||
const savedZoom = parseFloat(params.get("zoom") ?? "");
|
||||
const savedScrollTop = parseFloat(params.get("scroll_top") ?? "");
|
||||
const DPR = window.devicePixelRatio || 1;
|
||||
|
||||
// ── Mutable state ─────────────────────────────────────────────────────────────
|
||||
let pageManager: PageManager | null = null;
|
||||
let viewportTracker: ViewportTracker | null = null;
|
||||
let zoomController: ZoomController | null = null;
|
||||
let renderWorker: Worker | null = null;
|
||||
|
||||
let currentBufferSet: ReadonlySet<number> = new Set();
|
||||
let currentVisibleSet: ReadonlySet<number> = new Set();
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────────
|
||||
function setStatus(msg: string): void { statusEl.textContent = msg; }
|
||||
|
||||
function showError(msg: string): void {
|
||||
const b = document.getElementById("error-banner")!;
|
||||
b.textContent = msg;
|
||||
b.style.display = "block";
|
||||
setStatus("Error");
|
||||
}
|
||||
|
||||
function refreshPageIndicator(): void {
|
||||
if (!pageManager) return;
|
||||
const cur = pageManager.getCurrentPage(currentVisibleSet);
|
||||
pageIndicator.textContent = `${cur} / ${pageManager.numPages}`;
|
||||
}
|
||||
|
||||
function scrollToPage(pageNum: number): void {
|
||||
if (!pageManager) return;
|
||||
const wrap = pageManager.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
container.scrollTop +=
|
||||
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
function sendViewerState(): void {
|
||||
if (!zoomController || window.parent === window) return;
|
||||
const msg: OutboundMessage = {
|
||||
type: "brittle:viewer-state",
|
||||
refId,
|
||||
zoom: zoomController.scale,
|
||||
scrollTop: container.scrollTop,
|
||||
};
|
||||
window.parent.postMessage(msg, "*");
|
||||
}
|
||||
|
||||
/** Send a render job to the worker. Called by PageManager via dispatchRender. */
|
||||
function dispatchRender(pageNum: number, scale: number, gen: number): void {
|
||||
const msg: WorkerInbound = { type: "render", pageNum, scale, gen };
|
||||
renderWorker?.postMessage(msg);
|
||||
}
|
||||
|
||||
// ── Visibility change callback ─────────────────────────────────────────────────
|
||||
function onVisibilityChange(
|
||||
bufferSet: ReadonlySet<number>,
|
||||
visibleSet: ReadonlySet<number>,
|
||||
): void {
|
||||
currentBufferSet = bufferSet;
|
||||
currentVisibleSet = visibleSet;
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
refreshPageIndicator();
|
||||
}
|
||||
|
||||
// ── Fit-to-page scale computation ─────────────────────────────────────────────
|
||||
function fitScale(dims: PageDimensions[]): number {
|
||||
const dim = dims[0] ?? { width: 595, height: 842 }; // A4 fallback
|
||||
return Math.max(0.1, Math.min(5.0, Math.min(
|
||||
(container.clientWidth - 40) / dim.width,
|
||||
(container.clientHeight - 40) / dim.height,
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Main init ──────────────────────────────────────────────────────────────────
|
||||
async function load(): Promise<void> {
|
||||
if (!refId) { showError("No ref_id in URL."); return; }
|
||||
setStatus("Loading…");
|
||||
|
||||
try {
|
||||
// 1. Fetch the worker bundle and the PDF bytes in parallel — they are
|
||||
// independent, so there is no reason to wait for one before starting
|
||||
// the other. The worker is created from a blob URL because Tauri's
|
||||
// WebView requires workers to be same-origin; blob URLs satisfy that.
|
||||
const [workerBlob, pdfBuf] = await Promise.all([
|
||||
fetch("brittle://app/viewer/render-worker.bundle.js").then(r => r.blob()),
|
||||
fetch(`brittle://app/pdf?ref_id=${encodeURIComponent(refId)}`).then(r => r.arrayBuffer()),
|
||||
]);
|
||||
renderWorker = new Worker(URL.createObjectURL(workerBlob));
|
||||
|
||||
// 2. Send init message; wait for "ready" (stub dims based on page 1).
|
||||
// The worker posts "ready" immediately after loading the PDF so the
|
||||
// main thread unblocks and creates placeholders right away. A follow-up
|
||||
// "dims" message with real per-page dimensions arrives shortly after.
|
||||
const { numPages, dims } = await new Promise<{
|
||||
numPages: number;
|
||||
dims: PageDimensions[];
|
||||
}>((resolve, reject) => {
|
||||
renderWorker!.onmessage = (ev: MessageEvent<WorkerOutbound>) => {
|
||||
const msg = ev.data;
|
||||
if (msg.type === "ready") resolve({ numPages: msg.numPages, dims: msg.dims });
|
||||
if (msg.type === "error") reject(new Error(msg.message));
|
||||
};
|
||||
renderWorker!.onerror = e => reject(new Error(e.message));
|
||||
const initMsg: WorkerInbound = { type: "init", pdfData: pdfBuf };
|
||||
renderWorker!.postMessage(initMsg, [pdfBuf]);
|
||||
});
|
||||
|
||||
// 3. Compute initial scale (saved zoom if valid, otherwise fit-to-page).
|
||||
const fitted = fitScale(dims);
|
||||
const initialScale = (savedZoom > 0)
|
||||
? Math.max(0.1, Math.min(5.0, savedZoom))
|
||||
: fitted;
|
||||
|
||||
// 4. PageManager — creates N placeholder divs in #pages-wrapper.
|
||||
pageManager = new PageManager(
|
||||
pagesWrapper, dims, initialScale, DPR, dispatchRender,
|
||||
);
|
||||
|
||||
// 5. Wire worker onmessage for ongoing renders and dim corrections.
|
||||
renderWorker.onmessage = (ev: MessageEvent<WorkerOutbound>) => {
|
||||
const msg = ev.data;
|
||||
if (msg.type === "rendered") {
|
||||
pageManager?.onRendered(msg.pageNum, msg.gen, msg.bitmap);
|
||||
refreshPageIndicator();
|
||||
if (pageManager?.allRendered) setStatus("Ready");
|
||||
} else if (msg.type === "error") {
|
||||
console.warn("[viewer] worker error:", msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 6. ViewportTracker — IntersectionObserver fires once placeholders are in
|
||||
// the DOM, triggering the initial reconcile automatically.
|
||||
viewportTracker = new ViewportTracker(
|
||||
container,
|
||||
[...pageManager.pageWrappers],
|
||||
onVisibilityChange,
|
||||
);
|
||||
|
||||
// 7. ZoomController — on Phase 2 re-render, also save state.
|
||||
zoomController = new ZoomController(
|
||||
container,
|
||||
pageManager,
|
||||
(newScale, bufferSet, visibleSet) => {
|
||||
pageManager!.onScaleChange(newScale, bufferSet, visibleSet);
|
||||
sendViewerState();
|
||||
},
|
||||
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
||||
zoomLabel,
|
||||
initialScale,
|
||||
);
|
||||
|
||||
// 9. 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",
|
||||
() => zoomController!.applyScale(fitScale(dims)));
|
||||
|
||||
// 10. Keyboard shortcuts + forwarding to parent for global keybindings.
|
||||
document.addEventListener("keydown", ev => {
|
||||
if ((ev.target as HTMLElement).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();
|
||||
zoomController!.applyScale(fitScale(dims));
|
||||
}
|
||||
// Forward to parent Leptos app for global keybinding dispatch.
|
||||
if (window.parent !== window) {
|
||||
const msg: OutboundMessage = {
|
||||
type: "brittle:keydown",
|
||||
key: ev.key,
|
||||
ctrlKey: ev.ctrlKey,
|
||||
shiftKey: ev.shiftKey,
|
||||
altKey: ev.altKey,
|
||||
metaKey: ev.metaKey,
|
||||
};
|
||||
window.parent.postMessage(msg, "*");
|
||||
}
|
||||
});
|
||||
|
||||
// 11. Scroll → update page indicator + debounced state save.
|
||||
let scrollSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
container.addEventListener("scroll", () => {
|
||||
refreshPageIndicator();
|
||||
if (scrollSaveTimer !== null) clearTimeout(scrollSaveTimer);
|
||||
scrollSaveTimer = setTimeout(sendViewerState, 500);
|
||||
}, { passive: true });
|
||||
|
||||
// 12. Restore saved scroll position (rAF ensures layout is ready).
|
||||
if (savedScrollTop > 0) {
|
||||
requestAnimationFrame(() => { container.scrollTop = savedScrollTop; });
|
||||
}
|
||||
|
||||
// 13. Inbound postMessage from parent Leptos app (page navigation commands).
|
||||
window.addEventListener("message", ev => {
|
||||
if (ev.data === "pdf.page.next") {
|
||||
scrollToPage(Math.min(
|
||||
pageManager!.getCurrentPage(currentVisibleSet) + 1, numPages,
|
||||
));
|
||||
}
|
||||
if (ev.data === "pdf.page.prev") {
|
||||
scrollToPage(Math.max(
|
||||
pageManager!.getCurrentPage(currentVisibleSet) - 1, 1,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// 14. Tab visibility lifecycle: free caches when hidden, re-reconcile on show.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
renderWorker?.postMessage({ type: "cleanup" } satisfies WorkerInbound);
|
||||
} else {
|
||||
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
|
||||
}
|
||||
});
|
||||
|
||||
// 15. DPR change (e.g., window moved to a different-DPI monitor).
|
||||
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
|
||||
if (pageManager && zoomController) {
|
||||
pageManager.onScaleChange(
|
||||
zoomController.scale, currentBufferSet, currentVisibleSet,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 16. Teardown on unload.
|
||||
window.addEventListener("beforeunload", () => {
|
||||
viewportTracker?.disconnect();
|
||||
renderWorker?.postMessage({ type: "destroy" } satisfies WorkerInbound);
|
||||
renderWorker = null;
|
||||
});
|
||||
|
||||
pageIndicator.textContent = `1 / ${numPages}`;
|
||||
setStatus("Rendering…");
|
||||
|
||||
} catch (e) {
|
||||
showError("Could not load PDF: " + ((e as Error).message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
88
src-tauri/assets/viewer-src/src/viewport-tracker.ts
Normal file
88
src-tauri/assets/viewer-src/src/viewport-tracker.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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")
|
||||
*
|
||||
* Both observers schedule a single rAF-deferred notification to prevent stale
|
||||
* data when the two observers fire in separate microtasks for the same layout
|
||||
* change (e.g. buffer removes a page before visible has also removed it).
|
||||
*
|
||||
* Hard invariant enforced before every callback: every visible page is also
|
||||
* in the buffer set.
|
||||
*/
|
||||
|
||||
type OnVisibilityChange = (
|
||||
bufferSet: ReadonlySet<number>,
|
||||
visibleSet: ReadonlySet<number>,
|
||||
) => void;
|
||||
|
||||
export class ViewportTracker {
|
||||
private readonly _onChange: OnVisibilityChange;
|
||||
private readonly _visibleSet: Set<number> = new Set();
|
||||
private readonly _bufferSet: Set<number> = new Set();
|
||||
private _visibleObserver: IntersectionObserver | null = null;
|
||||
private _bufferObserver: IntersectionObserver | null = null;
|
||||
private _rafPending: number | null = null;
|
||||
|
||||
constructor(
|
||||
root: HTMLElement,
|
||||
pageWrappers: HTMLElement[],
|
||||
onChange: OnVisibilityChange,
|
||||
) {
|
||||
this._onChange = onChange;
|
||||
this._observe(root, pageWrappers);
|
||||
}
|
||||
|
||||
private _observe(root: HTMLElement, pageWrappers: HTMLElement[]): void {
|
||||
const scheduleNotify = (): void => {
|
||||
if (this._rafPending !== null) return;
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
// Hard invariant: every visible page must also be in the buffer.
|
||||
for (const p of this._visibleSet) this._bufferSet.add(p);
|
||||
this._onChange(new Set(this._bufferSet), new Set(this._visibleSet));
|
||||
});
|
||||
};
|
||||
|
||||
this._visibleObserver = new IntersectionObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const page = parseInt((e.target as HTMLElement).dataset["page"]!, 10);
|
||||
if (e.isIntersecting) this._visibleSet.add(page);
|
||||
else this._visibleSet.delete(page);
|
||||
}
|
||||
scheduleNotify();
|
||||
}, { root, rootMargin: "0px", threshold: 0 });
|
||||
|
||||
this._bufferObserver = new IntersectionObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const page = parseInt((e.target as HTMLElement).dataset["page"]!, 10);
|
||||
if (e.isIntersecting) this._bufferSet.add(page);
|
||||
else this._bufferSet.delete(page);
|
||||
}
|
||||
scheduleNotify();
|
||||
}, { 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 (unused currently, kept for extensibility). */
|
||||
observe(wrap: HTMLElement): void {
|
||||
this._visibleObserver?.observe(wrap);
|
||||
this._bufferObserver?.observe(wrap);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._visibleObserver?.disconnect();
|
||||
this._bufferObserver?.disconnect();
|
||||
this._visibleSet.clear();
|
||||
this._bufferSet.clear();
|
||||
if (this._rafPending !== null) {
|
||||
cancelAnimationFrame(this._rafPending);
|
||||
this._rafPending = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
161
src-tauri/assets/viewer-src/src/zoom-controller.ts
Normal file
161
src-tauri/assets/viewer-src/src/zoom-controller.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* ZoomController — two-phase zoom pipeline.
|
||||
*
|
||||
* Phase 1 (instant, every event):
|
||||
* CSS `zoom` property on each .page-wrapper = newScale / renderScale
|
||||
* Scroll position adjusted to keep the anchor point fixed
|
||||
* Zoom label updated immediately
|
||||
*
|
||||
* Phase 2 (debounced, 250ms after last event):
|
||||
* pageManager.onScaleChange(newScale, bufferSet, visibleSet)
|
||||
* Pages re-rendered at native resolution via the render worker
|
||||
*
|
||||
* Ctrl+Scroll events within a single frame are coalesced via rAF; the 250ms
|
||||
* debounce starts from inside the rAF callback so it begins after the last
|
||||
* event in a burst.
|
||||
*/
|
||||
|
||||
import type { PageManager } from "./page-manager.js";
|
||||
|
||||
// Must match the CSS constants in index.html:
|
||||
// #pages-wrapper { padding: 20px 0; gap: 12px; }
|
||||
const CONTENT_PADDING_TOP = 20;
|
||||
const PAGE_GAP = 12;
|
||||
|
||||
export interface BufferSnapshot {
|
||||
bufferSet: ReadonlySet<number>;
|
||||
visibleSet: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
type OnReRender = (
|
||||
newScale: number,
|
||||
bufferSet: ReadonlySet<number>,
|
||||
visibleSet: ReadonlySet<number>,
|
||||
) => void;
|
||||
|
||||
export class ZoomController {
|
||||
static readonly ZOOM_MIN = 0.1;
|
||||
static readonly ZOOM_MAX = 5.0;
|
||||
|
||||
private readonly _container: HTMLElement;
|
||||
private readonly _pm: PageManager;
|
||||
private readonly _onReRender: OnReRender;
|
||||
private readonly _getBuffer: () => BufferSnapshot;
|
||||
private readonly _zoomLabel: HTMLElement;
|
||||
|
||||
private _scale: number;
|
||||
private _renderScale: number;
|
||||
private _debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
private _rafPending: number | null = null;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
pageManager: PageManager,
|
||||
onReRender: OnReRender,
|
||||
getBuffer: () => BufferSnapshot,
|
||||
zoomLabel: HTMLElement,
|
||||
initialScale: number,
|
||||
) {
|
||||
this._container = container;
|
||||
this._pm = pageManager;
|
||||
this._onReRender = onReRender;
|
||||
this._getBuffer = getBuffer;
|
||||
this._zoomLabel = zoomLabel;
|
||||
this._scale = initialScale;
|
||||
this._renderScale = initialScale;
|
||||
|
||||
this._updateLabel();
|
||||
this._bindScrollZoom();
|
||||
}
|
||||
|
||||
get scale(): number { return this._scale; }
|
||||
|
||||
clamp(s: number): number {
|
||||
return Math.max(ZoomController.ZOOM_MIN, Math.min(ZoomController.ZOOM_MAX, s));
|
||||
}
|
||||
|
||||
private _updateLabel(): void {
|
||||
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new zoom level.
|
||||
*
|
||||
* @param newScale - Target zoom scale.
|
||||
* @param anchorY - Pixel offset within container to hold fixed (default: center).
|
||||
* @param anchorX - Pixel offset within container to hold fixed (default: center).
|
||||
*/
|
||||
applyScale(newScale: number, anchorY?: number, anchorX?: number): void {
|
||||
const container = this._container;
|
||||
const oldScale = this._scale;
|
||||
this._scale = this.clamp(newScale);
|
||||
this._updateLabel();
|
||||
this._pm.setZooming(true);
|
||||
|
||||
if (anchorY === undefined) anchorY = container.clientHeight / 2;
|
||||
if (anchorX === undefined) anchorX = container.clientWidth / 2;
|
||||
|
||||
// Compute the non-scaling portion of content above the anchor point.
|
||||
// Gaps and padding are fixed-size (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 visibly drift
|
||||
// the anchor point on long documents.
|
||||
const anchorContentY = container.scrollTop + anchorY;
|
||||
let fixedAbove = CONTENT_PADDING_TOP;
|
||||
let cumY = CONTENT_PADDING_TOP;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
const zoom = parseFloat(wrap.style.getPropertyValue("zoom") || "1") || 1;
|
||||
const h = parseFloat(wrap.style.height) * zoom;
|
||||
if (cumY + h > anchorContentY) break;
|
||||
cumY += h + PAGE_GAP;
|
||||
fixedAbove += PAGE_GAP;
|
||||
}
|
||||
|
||||
// Phase 1: instant CSS zoom feedback (no re-render yet)
|
||||
const cssZoom = this._scale / this._renderScale;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
wrap.style.setProperty("zoom", String(cssZoom));
|
||||
}
|
||||
|
||||
// Exact scroll anchor: scale only the page-content portion; the fixed
|
||||
// portion (gaps + padding) does not change.
|
||||
// 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);
|
||||
|
||||
this._scheduleReRender();
|
||||
}
|
||||
|
||||
private _scheduleReRender(): void {
|
||||
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
if (this._debounce !== null) clearTimeout(this._debounce);
|
||||
this._debounce = setTimeout(() => this._triggerReRender(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
private _triggerReRender(): void {
|
||||
this._pm.setZooming(false); // Phase 2: onScaleChange handles the cleanup
|
||||
this._renderScale = this._scale;
|
||||
const { bufferSet, visibleSet } = this._getBuffer();
|
||||
this._onReRender(this._scale, bufferSet, visibleSet);
|
||||
}
|
||||
|
||||
private _bindScrollZoom(): void {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
12
src-tauri/assets/viewer-src/tsconfig.json
Normal file
12
src-tauri/assets/viewer-src/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -122,7 +122,6 @@
|
||||
<div id="pages-wrapper"></div>
|
||||
</div>
|
||||
|
||||
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/viewer.js"></script>
|
||||
<script src="brittle://app/viewer/viewer.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** Send the current viewer state (zoom + scroll) to the parent window. */
|
||||
postViewerState(refId, zoom, scrollTop) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({
|
||||
type: "brittle:viewer-state",
|
||||
refId,
|
||||
zoom,
|
||||
scrollTop,
|
||||
}, "*");
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* 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._inFlight = 0; // renders dispatched but not yet completed/cancelled
|
||||
this._zooming = false; // true during Phase 1 CSS zoom (before debounced re-render)
|
||||
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; }
|
||||
|
||||
/** Called by ZoomController to suppress canvas teardown during CSS zoom. */
|
||||
setZooming(z) { this._zooming = z; }
|
||||
|
||||
_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.
|
||||
// Skip during active CSS zoom (Phase 1): IO may report stale intersection
|
||||
// data while the layout is still settling, and prematurely removing a
|
||||
// canvas causes a visible dark flash. onScaleChange (Phase 2) handles the
|
||||
// authoritative cleanup once the debounce fires.
|
||||
for (let i = 0; i < this._viewports.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
|
||||
this._cleanup(i);
|
||||
}
|
||||
// Also cancel stale RENDERING pages outside the buffer.
|
||||
// Don't remove the canvas — it's off-screen and harmless, and tearing it
|
||||
// down immediately causes a dark flash when IntersectionObserver fires
|
||||
// between Phase 1 (CSS zoom) and Phase 2 (debounced re-render).
|
||||
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) {
|
||||
this._inFlight--;
|
||||
this._states[i] = State.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get allRendered() { return this._inFlight === 0; }
|
||||
|
||||
_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._inFlight++;
|
||||
this._dispatchRender(i + 1, scale, vp.width, vp.height, gen);
|
||||
}
|
||||
|
||||
_cleanup(i) {
|
||||
if (this._states[i] === State.RENDERING) this._inFlight--;
|
||||
this._states[i] = State.PLACEHOLDER;
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.remove();
|
||||
this._canvases[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
this._inFlight--;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Set explicit wrapper size. Do NOT touch wrap.style.zoom here —
|
||||
// ZoomController may have applied a CSS zoom since the last onScaleChange
|
||||
// (the user kept zooming while this render was in-flight). Resetting zoom
|
||||
// to "1" would briefly show the page at the wrong visual scale until the
|
||||
// next onScaleChange corrects it, causing the "zoomed far in/out" flash.
|
||||
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.RENDERING) {
|
||||
this._inFlight--;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
150
src-tauri/assets/viewer/render-worker.bundle.js
Normal file
150
src-tauri/assets/viewer/render-worker.bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
567
src-tauri/assets/viewer/viewer.bundle.js
Normal file
567
src-tauri/assets/viewer/viewer.bundle.js
Normal file
@@ -0,0 +1,567 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
// src/types.ts
|
||||
var PageState = {
|
||||
PLACEHOLDER: 0,
|
||||
RENDERING: 1,
|
||||
RENDERED: 2
|
||||
};
|
||||
|
||||
// src/page-manager.ts
|
||||
var MAX_CANVAS_PIXELS = 16777216;
|
||||
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);
|
||||
}
|
||||
var PageManager = class {
|
||||
constructor(wrapper, dims, initialScale, dpr, dispatchRender2) {
|
||||
this._wrappers = [];
|
||||
this._renderGen = 0;
|
||||
this._inFlight = 0;
|
||||
this._zooming = false;
|
||||
this._wrapper = wrapper;
|
||||
this._dims = dims;
|
||||
this._scale = initialScale;
|
||||
this._dpr = dpr;
|
||||
this._dispatchRender = dispatchRender2;
|
||||
this._states = new Array(dims.length).fill(PageState.PLACEHOLDER);
|
||||
this._canvases = new Array(dims.length).fill(null);
|
||||
this._buildPlaceholders();
|
||||
}
|
||||
get pageWrappers() {
|
||||
return this._wrappers;
|
||||
}
|
||||
get numPages() {
|
||||
return this._dims.length;
|
||||
}
|
||||
get renderGen() {
|
||||
return this._renderGen;
|
||||
}
|
||||
/** True when no renders are currently in flight (all visible pages are sharp). */
|
||||
get allRendered() {
|
||||
return this._inFlight === 0;
|
||||
}
|
||||
/** Called by ZoomController to suppress canvas teardown during Phase 1 CSS zoom. */
|
||||
setZooming(z) {
|
||||
this._zooming = z;
|
||||
}
|
||||
_buildPlaceholders() {
|
||||
for (let i = 0; i < this._dims.length; i++) {
|
||||
const dim = this._dims[i];
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "page-wrapper";
|
||||
wrap.dataset["page"] = String(i + 1);
|
||||
wrap.style.width = dim.width * this._scale + "px";
|
||||
wrap.style.height = dim.height * this._scale + "px";
|
||||
this._wrapper.appendChild(wrap);
|
||||
this._wrappers.push(wrap);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called on each IntersectionObserver tick.
|
||||
* Dispatches renders for pages entering the buffer (visible pages first).
|
||||
* Cleans up pages that have left the buffer (unless zooming).
|
||||
*/
|
||||
reconcile(bufferSet, visibleSet) {
|
||||
const gen = this._renderGen;
|
||||
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._dims.length) continue;
|
||||
if (this._states[i] === PageState.PLACEHOLDER) {
|
||||
this._startRender(i, gen);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this._dims.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === PageState.RENDERED) {
|
||||
this._cleanup(i);
|
||||
}
|
||||
if (!bufferSet.has(pageNum) && this._states[i] === PageState.RENDERING) {
|
||||
this._inFlight--;
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
}
|
||||
_startRender(i, gen) {
|
||||
const dim = this._dims[i];
|
||||
const scale = clampedRenderScale(dim.width, dim.height, this._scale, this._dpr);
|
||||
this._states[i] = PageState.RENDERING;
|
||||
this._inFlight++;
|
||||
this._dispatchRender(i + 1, scale, gen);
|
||||
}
|
||||
_cleanup(i) {
|
||||
if (this._states[i] === PageState.RENDERING) this._inFlight--;
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.remove();
|
||||
this._canvases[i] = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called when the render worker returns a finished bitmap.
|
||||
*
|
||||
* Double-buffer swap: the old canvas stays in the DOM until the new one is
|
||||
* ready, then both mutations happen in the same synchronous JS turn so the
|
||||
* browser produces exactly one paint frame for the transition.
|
||||
*/
|
||||
onRendered(pageNum, gen, bitmap) {
|
||||
if (gen !== this._renderGen) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
const i = pageNum - 1;
|
||||
if (i < 0 || i >= this._dims.length || this._states[i] !== PageState.RENDERING) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
this._inFlight--;
|
||||
const dim = this._dims[i];
|
||||
const wrap = this._wrappers[i];
|
||||
const cssW = dim.width * this._scale;
|
||||
const cssH = dim.height * this._scale;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.style.width = cssW + "px";
|
||||
canvas.style.height = cssH + "px";
|
||||
canvas.style.display = "block";
|
||||
const bitmapCtx = canvas.getContext("bitmaprenderer");
|
||||
if (bitmapCtx) {
|
||||
bitmapCtx.transferFromImageBitmap(bitmap);
|
||||
} else {
|
||||
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
}
|
||||
wrap.style.width = cssW + "px";
|
||||
wrap.style.height = cssH + "px";
|
||||
const old = this._canvases[i];
|
||||
if (old) old.remove();
|
||||
wrap.appendChild(canvas);
|
||||
this._canvases[i] = canvas;
|
||||
this._states[i] = PageState.RENDERED;
|
||||
}
|
||||
/**
|
||||
* Called after the zoom debounce fires (Phase 2).
|
||||
* Increments renderGen to cancel all stale in-flight renders, resizes
|
||||
* placeholders, then re-dispatches renders for the buffer zone.
|
||||
*/
|
||||
onScaleChange(newScale, bufferSet, visibleSet) {
|
||||
this._scale = newScale;
|
||||
this._renderGen++;
|
||||
for (let i = 0; i < this._dims.length; i++) {
|
||||
const pageNum = i + 1;
|
||||
const dim = this._dims[i];
|
||||
const wrap = this._wrappers[i];
|
||||
const cssW = dim.width * newScale;
|
||||
const cssH = dim.height * newScale;
|
||||
wrap.style.width = cssW + "px";
|
||||
wrap.style.height = cssH + "px";
|
||||
wrap.style.setProperty("zoom", "1");
|
||||
if (bufferSet.has(pageNum)) {
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.style.width = cssW + "px";
|
||||
canvas.style.height = cssH + "px";
|
||||
}
|
||||
if (this._states[i] === PageState.RENDERING) this._inFlight--;
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
} else {
|
||||
const canvas = this._canvases[i];
|
||||
if (canvas) {
|
||||
canvas.remove();
|
||||
this._canvases[i] = null;
|
||||
}
|
||||
this._states[i] = PageState.PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
this.reconcile(bufferSet, visibleSet);
|
||||
}
|
||||
/** Returns the 1-based page number of the topmost visible page. */
|
||||
getCurrentPage(visibleSet) {
|
||||
if (visibleSet.size > 0) return Math.min(...visibleSet);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// src/viewport-tracker.ts
|
||||
var ViewportTracker = class {
|
||||
constructor(root, pageWrappers, onChange) {
|
||||
this._visibleSet = /* @__PURE__ */ new Set();
|
||||
this._bufferSet = /* @__PURE__ */ new Set();
|
||||
this._visibleObserver = null;
|
||||
this._bufferObserver = null;
|
||||
this._rafPending = null;
|
||||
this._onChange = onChange;
|
||||
this._observe(root, pageWrappers);
|
||||
}
|
||||
_observe(root, pageWrappers) {
|
||||
const scheduleNotify = () => {
|
||||
if (this._rafPending !== null) return;
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
for (const p of this._visibleSet) this._bufferSet.add(p);
|
||||
this._onChange(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);
|
||||
}
|
||||
scheduleNotify();
|
||||
}, { 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);
|
||||
}
|
||||
scheduleNotify();
|
||||
}, { 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 (unused currently, kept for extensibility). */
|
||||
observe(wrap) {
|
||||
this._visibleObserver?.observe(wrap);
|
||||
this._bufferObserver?.observe(wrap);
|
||||
}
|
||||
disconnect() {
|
||||
this._visibleObserver?.disconnect();
|
||||
this._bufferObserver?.disconnect();
|
||||
this._visibleSet.clear();
|
||||
this._bufferSet.clear();
|
||||
if (this._rafPending !== null) {
|
||||
cancelAnimationFrame(this._rafPending);
|
||||
this._rafPending = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/zoom-controller.ts
|
||||
var CONTENT_PADDING_TOP = 20;
|
||||
var PAGE_GAP = 12;
|
||||
var _ZoomController = class _ZoomController {
|
||||
constructor(container2, pageManager2, onReRender, getBuffer, zoomLabel2, initialScale) {
|
||||
this._debounce = null;
|
||||
this._rafPending = null;
|
||||
this._container = container2;
|
||||
this._pm = pageManager2;
|
||||
this._onReRender = onReRender;
|
||||
this._getBuffer = getBuffer;
|
||||
this._zoomLabel = zoomLabel2;
|
||||
this._scale = initialScale;
|
||||
this._renderScale = initialScale;
|
||||
this._updateLabel();
|
||||
this._bindScrollZoom();
|
||||
}
|
||||
get scale() {
|
||||
return this._scale;
|
||||
}
|
||||
clamp(s) {
|
||||
return Math.max(_ZoomController.ZOOM_MIN, Math.min(_ZoomController.ZOOM_MAX, s));
|
||||
}
|
||||
_updateLabel() {
|
||||
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
|
||||
}
|
||||
/**
|
||||
* Apply a new zoom level.
|
||||
*
|
||||
* @param newScale - Target zoom scale.
|
||||
* @param anchorY - Pixel offset within container to hold fixed (default: center).
|
||||
* @param anchorX - Pixel offset within container to hold fixed (default: center).
|
||||
*/
|
||||
applyScale(newScale, anchorY, anchorX) {
|
||||
const container2 = this._container;
|
||||
const oldScale = this._scale;
|
||||
this._scale = this.clamp(newScale);
|
||||
this._updateLabel();
|
||||
this._pm.setZooming(true);
|
||||
if (anchorY === void 0) anchorY = container2.clientHeight / 2;
|
||||
if (anchorX === void 0) anchorX = container2.clientWidth / 2;
|
||||
const anchorContentY = container2.scrollTop + anchorY;
|
||||
let fixedAbove = CONTENT_PADDING_TOP;
|
||||
let cumY = CONTENT_PADDING_TOP;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
const zoom = parseFloat(wrap.style.getPropertyValue("zoom") || "1") || 1;
|
||||
const h = parseFloat(wrap.style.height) * zoom;
|
||||
if (cumY + h > anchorContentY) break;
|
||||
cumY += h + PAGE_GAP;
|
||||
fixedAbove += PAGE_GAP;
|
||||
}
|
||||
const cssZoom = this._scale / this._renderScale;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
wrap.style.setProperty("zoom", String(cssZoom));
|
||||
}
|
||||
const ratio = this._scale / oldScale;
|
||||
const scalable = container2.scrollTop + anchorY - fixedAbove;
|
||||
container2.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY);
|
||||
container2.scrollLeft = Math.max(0, (container2.scrollLeft + anchorX) * ratio - anchorX);
|
||||
this._scheduleReRender();
|
||||
}
|
||||
_scheduleReRender() {
|
||||
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
if (this._debounce !== null) clearTimeout(this._debounce);
|
||||
this._debounce = setTimeout(() => this._triggerReRender(), 250);
|
||||
});
|
||||
}
|
||||
_triggerReRender() {
|
||||
this._pm.setZooming(false);
|
||||
this._renderScale = this._scale;
|
||||
const { bufferSet, visibleSet } = this._getBuffer();
|
||||
this._onReRender(this._scale, bufferSet, visibleSet);
|
||||
}
|
||||
_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 });
|
||||
}
|
||||
};
|
||||
_ZoomController.ZOOM_MIN = 0.1;
|
||||
_ZoomController.ZOOM_MAX = 5;
|
||||
var ZoomController = _ZoomController;
|
||||
|
||||
// src/viewer.ts
|
||||
if (typeof requestIdleCallback === "undefined") {
|
||||
self["requestIdleCallback"] = (cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1);
|
||||
}
|
||||
var container = document.getElementById("canvas-container");
|
||||
var pagesWrapper = document.getElementById("pages-wrapper");
|
||||
var statusEl = document.getElementById("status");
|
||||
var zoomLabel = document.getElementById("zoom-label");
|
||||
var pageIndicator = document.getElementById("page-indicator");
|
||||
var params = new URLSearchParams(location.search);
|
||||
var refId = params.get("ref_id") ?? "";
|
||||
var savedZoom = parseFloat(params.get("zoom") ?? "");
|
||||
var savedScrollTop = parseFloat(params.get("scroll_top") ?? "");
|
||||
var DPR = window.devicePixelRatio || 1;
|
||||
var pageManager = null;
|
||||
var viewportTracker = null;
|
||||
var zoomController = null;
|
||||
var renderWorker = null;
|
||||
var currentBufferSet = /* @__PURE__ */ new Set();
|
||||
var currentVisibleSet = /* @__PURE__ */ new Set();
|
||||
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}`;
|
||||
}
|
||||
function scrollToPage(pageNum) {
|
||||
if (!pageManager) return;
|
||||
const wrap = pageManager.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
container.scrollTop += wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
}
|
||||
function sendViewerState() {
|
||||
if (!zoomController || window.parent === window) return;
|
||||
const msg = {
|
||||
type: "brittle:viewer-state",
|
||||
refId,
|
||||
zoom: zoomController.scale,
|
||||
scrollTop: container.scrollTop
|
||||
};
|
||||
window.parent.postMessage(msg, "*");
|
||||
}
|
||||
function dispatchRender(pageNum, scale, gen) {
|
||||
const msg = { type: "render", pageNum, scale, gen };
|
||||
renderWorker?.postMessage(msg);
|
||||
}
|
||||
function onVisibilityChange(bufferSet, visibleSet) {
|
||||
currentBufferSet = bufferSet;
|
||||
currentVisibleSet = visibleSet;
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
refreshPageIndicator();
|
||||
}
|
||||
function fitScale(dims) {
|
||||
const dim = dims[0] ?? { width: 595, height: 842 };
|
||||
return Math.max(0.1, Math.min(5, Math.min(
|
||||
(container.clientWidth - 40) / dim.width,
|
||||
(container.clientHeight - 40) / dim.height
|
||||
)));
|
||||
}
|
||||
async function load() {
|
||||
if (!refId) {
|
||||
showError("No ref_id in URL.");
|
||||
return;
|
||||
}
|
||||
setStatus("Loading\u2026");
|
||||
try {
|
||||
const [workerBlob, pdfBuf] = await Promise.all([
|
||||
fetch("brittle://app/viewer/render-worker.bundle.js").then((r) => r.blob()),
|
||||
fetch(`brittle://app/pdf?ref_id=${encodeURIComponent(refId)}`).then((r) => r.arrayBuffer())
|
||||
]);
|
||||
renderWorker = new Worker(URL.createObjectURL(workerBlob));
|
||||
const { numPages, dims } = await new Promise((resolve, reject) => {
|
||||
renderWorker.onmessage = (ev) => {
|
||||
const msg = ev.data;
|
||||
if (msg.type === "ready") resolve({ numPages: msg.numPages, dims: msg.dims });
|
||||
if (msg.type === "error") reject(new Error(msg.message));
|
||||
};
|
||||
renderWorker.onerror = (e) => reject(new Error(e.message));
|
||||
const initMsg = { type: "init", pdfData: pdfBuf };
|
||||
renderWorker.postMessage(initMsg, [pdfBuf]);
|
||||
});
|
||||
const fitted = fitScale(dims);
|
||||
const initialScale = savedZoom > 0 ? Math.max(0.1, Math.min(5, savedZoom)) : fitted;
|
||||
pageManager = new PageManager(
|
||||
pagesWrapper,
|
||||
dims,
|
||||
initialScale,
|
||||
DPR,
|
||||
dispatchRender
|
||||
);
|
||||
renderWorker.onmessage = (ev) => {
|
||||
const msg = ev.data;
|
||||
if (msg.type === "rendered") {
|
||||
pageManager?.onRendered(msg.pageNum, msg.gen, msg.bitmap);
|
||||
refreshPageIndicator();
|
||||
if (pageManager?.allRendered) setStatus("Ready");
|
||||
} else if (msg.type === "error") {
|
||||
console.warn("[viewer] worker error:", msg.message);
|
||||
}
|
||||
};
|
||||
viewportTracker = new ViewportTracker(
|
||||
container,
|
||||
[...pageManager.pageWrappers],
|
||||
onVisibilityChange
|
||||
);
|
||||
zoomController = new ZoomController(
|
||||
container,
|
||||
pageManager,
|
||||
(newScale, bufferSet, visibleSet) => {
|
||||
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
||||
sendViewerState();
|
||||
},
|
||||
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
||||
zoomLabel,
|
||||
initialScale
|
||||
);
|
||||
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",
|
||||
() => zoomController.applyScale(fitScale(dims))
|
||||
);
|
||||
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();
|
||||
zoomController.applyScale(fitScale(dims));
|
||||
}
|
||||
if (window.parent !== window) {
|
||||
const msg = {
|
||||
type: "brittle:keydown",
|
||||
key: ev.key,
|
||||
ctrlKey: ev.ctrlKey,
|
||||
shiftKey: ev.shiftKey,
|
||||
altKey: ev.altKey,
|
||||
metaKey: ev.metaKey
|
||||
};
|
||||
window.parent.postMessage(msg, "*");
|
||||
}
|
||||
});
|
||||
let scrollSaveTimer = null;
|
||||
container.addEventListener("scroll", () => {
|
||||
refreshPageIndicator();
|
||||
if (scrollSaveTimer !== null) clearTimeout(scrollSaveTimer);
|
||||
scrollSaveTimer = setTimeout(sendViewerState, 500);
|
||||
}, { passive: true });
|
||||
if (savedScrollTop > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = savedScrollTop;
|
||||
});
|
||||
}
|
||||
window.addEventListener("message", (ev) => {
|
||||
if (ev.data === "pdf.page.next") {
|
||||
scrollToPage(Math.min(
|
||||
pageManager.getCurrentPage(currentVisibleSet) + 1,
|
||||
numPages
|
||||
));
|
||||
}
|
||||
if (ev.data === "pdf.page.prev") {
|
||||
scrollToPage(Math.max(
|
||||
pageManager.getCurrentPage(currentVisibleSet) - 1,
|
||||
1
|
||||
));
|
||||
}
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
renderWorker?.postMessage({ type: "cleanup" });
|
||||
} else {
|
||||
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
|
||||
}
|
||||
});
|
||||
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
|
||||
if (pageManager && zoomController) {
|
||||
pageManager.onScaleChange(
|
||||
zoomController.scale,
|
||||
currentBufferSet,
|
||||
currentVisibleSet
|
||||
);
|
||||
}
|
||||
});
|
||||
window.addEventListener("beforeunload", () => {
|
||||
viewportTracker?.disconnect();
|
||||
renderWorker?.postMessage({ type: "destroy" });
|
||||
renderWorker = null;
|
||||
});
|
||||
pageIndicator.textContent = `1 / ${numPages}`;
|
||||
setStatus("Rendering\u2026");
|
||||
} catch (e) {
|
||||
showError("Could not load PDF: " + (e.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
load();
|
||||
})();
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* 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 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;
|
||||
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 fitToPage() {
|
||||
if (!pdfDoc) return 1.0;
|
||||
const page = await pdfDoc.getPage(1);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
const scaleW = (container.clientWidth - 40) / vp.width;
|
||||
const scaleH = (container.clientHeight - 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;
|
||||
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();
|
||||
if (pageManager?.allRendered) setStatus("Ready");
|
||||
} catch (e) {
|
||||
if (e?.name !== "RenderingCancelledException") {
|
||||
console.warn("[viewer] render error page", pageNum, e);
|
||||
}
|
||||
} finally {
|
||||
page?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function sendViewerState() {
|
||||
if (!bridge || !zoomController) return;
|
||||
bridge.postViewerState(refId, zoomController.scale, container.scrollTop);
|
||||
}
|
||||
|
||||
// ── 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 scale: use saved zoom if available, else fit full page
|
||||
const fittedScale = Math.max(0.1, Math.min(5.0, Math.min(
|
||||
(container.clientWidth - 40) / viewports[0].width,
|
||||
(container.clientHeight - 40) / viewports[0].height,
|
||||
)));
|
||||
const initialScale = (savedZoom > 0) ? Math.max(0.1, Math.min(5.0, savedZoom)) : fittedScale;
|
||||
|
||||
// 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 — send state after each debounced re-render
|
||||
zoomController = new ZoomController(
|
||||
container,
|
||||
pageManager,
|
||||
(newScale, bufferSet, visibleSet) => {
|
||||
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
||||
sendViewerState();
|
||||
},
|
||||
() => ({ 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 fitToPage()));
|
||||
|
||||
// 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(); fitToPage().then(s => zoomController.applyScale(s)); }
|
||||
bridge.forwardKeydown(ev);
|
||||
});
|
||||
|
||||
// Scroll → update page indicator + debounced state save
|
||||
let _scrollSaveTimer = null;
|
||||
container.addEventListener("scroll", () => {
|
||||
refreshPageIndicator();
|
||||
if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer);
|
||||
_scrollSaveTimer = setTimeout(sendViewerState, 500);
|
||||
}, { passive: true });
|
||||
|
||||
// Restore saved scroll position (rAF ensures layout is ready)
|
||||
if (savedScrollTop > 0) {
|
||||
requestAnimationFrame(() => { container.scrollTop = savedScrollTop; });
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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) {
|
||||
// Both observers update their respective sets and then schedule a single
|
||||
// notification via rAF. This prevents a stale notify when the two
|
||||
// observers fire in separate microtasks for the same layout change —
|
||||
// e.g. bufferObserver removes a page from bufferSet before visibleObserver
|
||||
// has had a chance to also remove it from visibleSet, which would let
|
||||
// reconcile() incorrectly tear down a still-visible canvas.
|
||||
this._rafPending = null;
|
||||
const scheduleNotify = () => {
|
||||
if (this._rafPending !== null) return;
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
// Hard invariant: every visible page must also be in the buffer.
|
||||
for (const p of this._visibleSet) this._bufferSet.add(p);
|
||||
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);
|
||||
}
|
||||
scheduleNotify();
|
||||
},
|
||||
{ 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);
|
||||
}
|
||||
scheduleNotify();
|
||||
},
|
||||
{ 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();
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// 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();
|
||||
this._pm.setZooming(true);
|
||||
|
||||
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() {
|
||||
this._pm.setZooming(false); // Phase 2: onScaleChange will handle cleanup
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user