Add zooming in/out to PDF

This commit is contained in:
2026-03-25 10:54:14 +01:00
parent 75dcacf011
commit 0b354462e0
14 changed files with 386 additions and 95 deletions

View File

@@ -60,6 +60,18 @@ pub fn set_theme(theme: String) -> Result<(), String> {
config.save().map_err(|e| e.to_string())
}
/// Return the last-opened project path, or `None` if none was recorded or
/// the directory no longer exists on disk.
#[tauri::command]
pub fn get_last_project() -> Result<Option<String>, String> {
let cfg = GlobalConfig::load().map_err(|e| e.to_string())?;
Ok(cfg
.projects
.last_opened
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().into_owned()))
}
/// Return the user's keybinding overrides from the global config.
///
/// Map keys are action names in snake_case (e.g. `"tab_next"`); values are

View File

@@ -2,9 +2,29 @@
use crate::state::AppState;
use brittle_core::Brittle;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tauri::State;
/// Persist `path` as the last-opened project in the global config.
///
/// Best-effort: errors are logged to stderr rather than propagated, so they
/// never cause the primary open/create operation to fail.
fn save_last_opened(path: &Path) {
match crate::config::GlobalConfig::load() {
Ok(mut cfg) => {
let path = path.to_owned();
cfg.projects.recent.retain(|p| p != &path);
cfg.projects.recent.insert(0, path.clone());
cfg.projects.recent.truncate(10);
cfg.projects.last_opened = Some(path);
if let Err(e) = cfg.save() {
eprintln!("[brittle] could not save config after open: {e}");
}
}
Err(e) => eprintln!("[brittle] could not load config: {e}"),
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
@@ -28,6 +48,7 @@ pub fn create_repository(state: State<AppState>, path: String) -> Result<(), Str
.brittle
.lock()
.map_err(|_| "lock poisoned".to_string())? = Some(brittle);
save_last_opened(&path);
Ok(())
}
@@ -40,6 +61,7 @@ pub fn open_repository(state: State<AppState>, path: String) -> Result<(), Strin
.brittle
.lock()
.map_err(|_| "lock poisoned".to_string())? = Some(brittle);
save_last_opened(&path);
Ok(())
}

View File

@@ -30,6 +30,7 @@ pub fn run() {
commands::config::get_theme,
commands::config::set_theme,
commands::config::get_keybindings,
commands::config::get_last_project,
// repository
commands::repository::create_repository,
commands::repository::open_repository,

View File

@@ -19,6 +19,12 @@ use crate::state::AppState;
// ── Embedded assets ───────────────────────────────────────────────────────────
static VIEWER_HTML: &[u8] = include_bytes!("pdf_viewer.html");
static PDFJS_MIN_JS: &[u8] = include_bytes!(
"../pdfjs/node_modules/pdfjs-dist/build/pdf.min.js"
);
static PDFJS_WORKER_JS: &[u8] = include_bytes!(
"../pdfjs/node_modules/pdfjs-dist/build/pdf.worker.min.js"
);
// ── Public API ────────────────────────────────────────────────────────────────
@@ -35,7 +41,7 @@ pub fn handle<R: Runtime>(app: &AppHandle<R>, req: &Request<Vec<u8>>) -> Respons
if rel_path.contains("..") {
return response_403();
}
serve_pdfjs_file(&pdfjs_root(app), &rel_path)
serve_pdfjs_file(&rel_path)
}
routing::Route::Pdf { ref_id } => serve_pdf(app, &ref_id),
routing::Route::NotFound => response_404(),
@@ -51,20 +57,18 @@ fn serve_viewer(ref_id: &str) -> Response<Vec<u8>> {
response_ok(html.into_bytes(), "text/html; charset=utf-8")
}
fn serve_pdfjs_file(pdfjs_root: &std::path::Path, rel_path: &str) -> Response<Vec<u8>> {
let full_path = pdfjs_root.join(rel_path);
match std::fs::read(&full_path) {
Ok(bytes) => {
let mime = routing::mime_for_path(&full_path);
let mut resp = response_ok(bytes, mime);
resp.headers_mut().insert(
header::CACHE_CONTROL,
"public, max-age=3600".parse().unwrap(),
);
resp
}
Err(_) => response_404(),
}
fn serve_pdfjs_file(rel_path: &str) -> Response<Vec<u8>> {
let bytes: &[u8] = match rel_path {
"build/pdf.min.js" => PDFJS_MIN_JS,
"build/pdf.worker.min.js" => PDFJS_WORKER_JS,
_ => return response_404(),
};
let mut resp = response_ok(bytes.to_vec(), "application/javascript; charset=utf-8");
resp.headers_mut().insert(
header::CACHE_CONTROL,
"public, max-age=3600".parse().unwrap(),
);
resp
}
fn serve_pdf<R: Runtime>(app: &AppHandle<R>, ref_id: &str) -> Response<Vec<u8>> {
@@ -95,22 +99,6 @@ fn serve_pdf<R: Runtime>(app: &AppHandle<R>, ref_id: &str) -> Response<Vec<u8>>
}
}
// ── Path resolution ───────────────────────────────────────────────────────────
fn pdfjs_root<R: Runtime>(app: &AppHandle<R>) -> PathBuf {
if cfg!(debug_assertions) {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("pdfjs")
.join("node_modules")
.join("pdfjs-dist")
} else {
app.path()
.resource_dir()
.unwrap_or_default()
.join("pdfjs-dist")
}
}
// ── Response builders ─────────────────────────────────────────────────────────
fn response_ok(body: Vec<u8>, content_type: &str) -> Response<Vec<u8>> {

View File

@@ -71,16 +71,23 @@
font-size: 12px;
}
/* Scrollable page stack */
/* Scrollable viewport — contains the zoomed pages wrapper */
#canvas-container {
flex: 1;
overflow-y: auto;
overflow-x: auto;
padding: 20px 0;
}
/* Inner column that receives the CSS zoom for instant visual feedback.
CSS zoom (unlike transform) affects layout, so scrollbars stay correct. */
#pages-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
min-width: fit-content;
margin: 0 auto;
}
.page-wrapper { flex-shrink: 0; }
@@ -119,7 +126,8 @@
if (!pdfjsLib) {
showError("PDF.js failed to load. Make sure the app is running inside Brittle.");
} else {
pdfjsLib.GlobalWorkerOptions.workerSrc = "";
pdfjsLib.GlobalWorkerOptions.workerSrc =
"brittle://app/pdfjs/build/pdf.worker.min.js";
const container = document.getElementById("canvas-container");
const statusEl = document.getElementById("status");
@@ -131,9 +139,11 @@
const ZOOM_MAX = 5.0;
let pdfDoc = null;
let scale = 1.0; // display scale (not multiplied by DPR yet)
let renderGen = 0; // incremented on each render pass to cancel stale ones
let scale = 1.0; // desired display scale
let renderScale = 1.0; // scale at which pagesWrapper canvases are rendered
let renderGen = 0; // incremented on each render to cancel stale passes
let renderTimer = null;
let pagesWrapper = null; // the current #pages-wrapper element
// ── Utilities ──────────────────────────────────────────────────────────
@@ -149,17 +159,44 @@
// ── Zoom ───────────────────────────────────────────────────────────────
async function applyScale(newScale) {
// anchorY/anchorX: offsets within the container to keep fixed on screen.
// Default to the centre of the visible area.
function applyScale(
newScale,
anchorY = container.clientHeight / 2,
anchorX = container.clientWidth / 2,
) {
const oldScale = scale;
const oldScrollTop = container.scrollTop;
const oldScrollLeft = container.scrollLeft;
scale = clampScale(newScale);
updateZoomLabel();
const ratio = scale / oldScale;
const cssZoom = scale / renderScale;
// Apply CSS zoom to each page wrapper individually — NOT to the flex
// container. This keeps the container's padding and gap constant, so
// page positions are identical before and after the background re-render
// (no jump when the re-render swaps in naturally-sized canvases).
if (pagesWrapper) {
for (const wrap of pagesWrapper.children) {
wrap.style.zoom = cssZoom;
}
}
// Keep the document point under the cursor at the same screen position.
container.scrollTop = Math.max(0, (oldScrollTop + anchorY) * ratio - anchorY);
container.scrollLeft = Math.max(0, (oldScrollLeft + anchorX) * ratio - anchorX);
scheduleRender();
}
async function fitToWidth() {
if (!pdfDoc) return;
const page = await pdfDoc.getPage(1);
const vp = page.getViewport({ scale: 1.0 });
const avail = container.clientWidth - 40; // padding
if (!pdfDoc) return scale;
const page = await pdfDoc.getPage(1);
const vp = page.getViewport({ scale: 1.0 });
const avail = container.clientWidth - 40;
return clampScale(avail / vp.width);
}
@@ -167,40 +204,87 @@
function scheduleRender() {
clearTimeout(renderTimer);
renderTimer = setTimeout(renderAll, 60);
renderTimer = setTimeout(renderAll, 300);
}
async function renderAll() {
if (!pdfDoc) return;
const gen = ++renderGen;
const gen = ++renderGen;
const targetScale = scale;
const savedScrollTop = container.scrollTop;
const savedScrollLeft = container.scrollLeft;
// Build new wrapper with pre-sized canvases derived from the old ones.
// Pre-sizing means the scroll range is correct the instant we swap, so
// savedScrollTop/Left can be restored exactly without a jump.
const newWrapper = document.createElement("div");
newWrapper.id = "pages-wrapper";
// Replace the entire container contents with fresh wrappers.
container.innerHTML = "";
const wrappers = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
const wrap = document.createElement("div");
wrap.className = "page-wrapper";
wrap.className = "page-wrapper";
wrap.dataset.page = String(i);
const canvas = document.createElement("canvas");
if (pagesWrapper) {
const oldCanvas = pagesWrapper.children[i - 1]?.querySelector("canvas");
if (oldCanvas?.style.width) {
const ratio = targetScale / renderScale;
canvas.style.width = Math.round(parseFloat(oldCanvas.style.width) * ratio) + "px";
canvas.style.height = Math.round(parseFloat(oldCanvas.style.height) * ratio) + "px";
}
}
wrap.appendChild(canvas);
container.appendChild(wrap);
newWrapper.appendChild(wrap);
wrappers.push(wrap);
}
// Render pages one by one; abort if a newer render was requested.
for (let i = 0; i < wrappers.length; i++) {
if (renderGen !== gen) return;
await renderPage(wrappers[i], i + 1);
// For zoom re-renders: pre-render the currently visible pages (12)
// before the DOM swap. Only a small number of pages, so off-screen is
// fast; the swap then reveals already-drawn content with no white flash.
// Skipped for the initial load (pagesWrapper is null) where progressive
// in-DOM rendering is preferable.
const preRendered = new Set();
if (pagesWrapper) {
const first = getCurrentPage();
const last = Math.min(first + 1, pdfDoc.numPages);
for (let p = first; p <= last; p++) {
if (renderGen !== gen) return;
await renderPage(wrappers[p - 1], p, targetScale);
preRendered.add(p - 1);
}
}
if (renderGen !== gen) return;
// Swap into DOM — visible pages are already rendered on re-render.
// DOM mutations + scroll restore are synchronous, landing in one paint.
container.innerHTML = "";
container.appendChild(newWrapper);
pagesWrapper = newWrapper;
renderScale = targetScale;
container.scrollTop = savedScrollTop;
container.scrollLeft = savedScrollLeft;
// Render remaining pages in-DOM (GPU-accelerated, progressive).
for (let i = 0; i < wrappers.length; i++) {
if (preRendered.has(i)) continue;
if (renderGen !== gen) return;
await renderPage(wrappers[i], i + 1, targetScale);
}
if (renderGen !== gen) return;
setStatus("Ready");
refreshPageIndicator();
}
async function renderPage(wrapper, pageNum) {
async function renderPage(wrapper, pageNum, targetScale) {
try {
const page = await pdfDoc.getPage(pageNum);
const vp = page.getViewport({ scale: scale * DPR });
const page = await pdfDoc.getPage(pageNum);
const vp = page.getViewport({ scale: targetScale * DPR });
const canvas = wrapper.querySelector("canvas");
if (!canvas) return;
@@ -215,23 +299,45 @@
}
}
// ── Page indicator (updates on scroll) ─────────────────────────────────
// ── Page indicator and page navigation ─────────────────────────────────
// Returns the 1-based number of the topmost visible page.
function getCurrentPage() {
const top = container.getBoundingClientRect().top;
for (const wrap of container.querySelectorAll(".page-wrapper")) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
return parseInt(wrap.dataset.page, 10);
}
}
return 1;
}
function refreshPageIndicator() {
if (!pdfDoc) return;
const top = container.getBoundingClientRect().top;
let current = 1;
for (const wrap of container.querySelectorAll(".page-wrapper")) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
current = parseInt(wrap.dataset.page, 10);
break;
}
}
pageIndicator.textContent = current + " / " + pdfDoc.numPages;
pageIndicator.textContent = getCurrentPage() + " / " + pdfDoc.numPages;
}
// Scroll so that the top of page `pageNum` aligns with the container top.
// getBoundingClientRect() is used so CSS zoom is accounted for correctly.
function scrollToPage(pageNum) {
const wrap = container.querySelector(`.page-wrapper[data-page="${pageNum}"]`);
if (!wrap) return;
container.scrollTop +=
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
}
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
// Page-navigation commands posted from the outer Leptos app (fired when
// a global keymap action such as pdf.page.next/prev is dispatched while
// this tab is active, regardless of which element has keyboard focus).
window.addEventListener("message", ev => {
if (ev.data === "pdf.page.next" && pdfDoc)
scrollToPage(Math.min(getCurrentPage() + 1, pdfDoc.numPages));
if (ev.data === "pdf.page.prev" && pdfDoc)
scrollToPage(Math.max(getCurrentPage() - 1, 1));
});
// ── Load ───────────────────────────────────────────────────────────────
async function load() {
@@ -239,9 +345,10 @@
setStatus("Loading…");
try {
const url = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
pdfDoc = await pdfjsLib.getDocument({ url, disableWorker: true }).promise;
pdfDoc = await pdfjsLib.getDocument({ url }).promise;
scale = await fitToWidth();
scale = await fitToWidth();
renderScale = scale;
updateZoomLabel();
setStatus("Rendering…");
await renderAll();
@@ -259,19 +366,35 @@
document.getElementById("btn-zoom-fit").addEventListener("click",
async () => applyScale(await fitToWidth()));
// Ctrl+Scroll zoom
// Ctrl+Scroll — anchor at the cursor position.
container.addEventListener("wheel", ev => {
if (!ev.ctrlKey) return;
ev.preventDefault();
applyScale(scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1));
const rect = container.getBoundingClientRect();
const anchorY = ev.clientY - rect.top;
const anchorX = ev.clientX - rect.left;
applyScale(scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX);
}, { passive: false });
// Keyboard shortcuts (active when the iframe has focus)
// Keyboard shortcuts (active when the iframe has focus).
// Also forwards every keydown to the parent window so global keybindings
// (tab switching, etc.) keep working when the PDF view has focus.
document.addEventListener("keydown", ev => {
if (ev.target.tagName === "INPUT") return;
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); applyScale(scale * 1.25); }
if (ev.key === "-") { ev.preventDefault(); applyScale(scale / 1.25); }
if (ev.key === "0") { ev.preventDefault(); fitToWidth().then(applyScale); }
if (ev.key === "-") { ev.preventDefault(); applyScale(scale / 1.25); }
if (ev.key === "0") { ev.preventDefault(); fitToWidth().then(applyScale); }
// Forward to the parent Leptos document for the global keymap.
if (window.parent !== window) {
window.parent.postMessage({
type: "brittle:keydown",
key: ev.key,
ctrlKey: ev.ctrlKey,
shiftKey: ev.shiftKey,
altKey: ev.altKey,
metaKey: ev.metaKey,
}, "*");
}
});
load();