From 0b354462e03fc11d1f265059a3367e398e5250a3 Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Wed, 25 Mar 2026 10:54:14 +0100 Subject: [PATCH] Add zooming in/out to PDF --- brittle-keymap/src/actions.rs | 7 + brittle-keymap/src/defaults.rs | 39 ++++- src-tauri/src/commands/config.rs | 12 ++ src-tauri/src/commands/repository.rs | 24 +++- src-tauri/src/lib.rs | 1 + src-tauri/src/pdf_protocol.rs | 50 +++---- src-tauri/src/pdf_viewer.html | 203 +++++++++++++++++++++------ src/Cargo.toml | 2 +- src/src/lib_tab.rs | 2 +- src/src/lib_tree.rs | 3 +- src/src/main.rs | 88 ++++++++++-- src/src/pdf_viewer.rs | 31 +++- src/src/pub_list.rs | 13 ++ src/src/tauri.rs | 6 + 14 files changed, 386 insertions(+), 95 deletions(-) diff --git a/brittle-keymap/src/actions.rs b/brittle-keymap/src/actions.rs index 9b89f5e..a386f4d 100644 --- a/brittle-keymap/src/actions.rs +++ b/brittle-keymap/src/actions.rs @@ -51,6 +51,13 @@ pub const ACTION_DELETE: &str = "action.delete"; /// Create a new item in the current context. pub const ACTION_NEW: &str = "action.new"; +// ── PDF viewer ──────────────────────────────────────────────────────────────── + +/// Scroll the active PDF viewer to the next page. +pub const PDF_PAGE_NEXT: &str = "pdf.page.next"; +/// Scroll the active PDF viewer to the previous page. +pub const PDF_PAGE_PREV: &str = "pdf.page.prev"; + // ── Tabs ────────────────────────────────────────────────────────────────────── /// Cycle to the next tab (wraps around). diff --git a/brittle-keymap/src/defaults.rs b/brittle-keymap/src/defaults.rs index dc5e7e6..2958774 100644 --- a/brittle-keymap/src/defaults.rs +++ b/brittle-keymap/src/defaults.rs @@ -44,9 +44,14 @@ pub fn default_bindings() -> BindingSet { bind!("zo" => a::TREE_EXPAND); bind!("zc" => a::TREE_COLLAPSE); bind!("za" => a::TREE_TOGGLE); - // Arrow-style tree navigation: l expands, h collapses. - bind!("l" => a::TREE_EXPAND); - bind!("h" => a::TREE_COLLAPSE); + + // ── Pane navigation ─────────────────────────────────────────────────────── + bind!("h" => a::FOCUS_PREV); + bind!("l" => a::FOCUS_NEXT); + + // ── PDF viewer ──────────────────────────────────────────────────────────── + bind!("J" => a::PDF_PAGE_NEXT); + bind!("K" => a::PDF_PAGE_PREV); // ── Item actions ────────────────────────────────────────────────────────── bind!("" => a::ACTION_OPEN); @@ -59,6 +64,7 @@ pub fn default_bindings() -> BindingSet { bind!("gt" => a::TAB_NEXT); bind!("gT" => a::TAB_PREV); bind!("q" => a::TAB_CLOSE); + bind!("" => a::TAB_CLOSE); // ── Input modes ─────────────────────────────────────────────────────────── bind!(":" => a::MODE_COMMAND); @@ -132,6 +138,33 @@ mod tests { ); } + #[test] + fn zc_maps_to_tree_collapse() { + let d = defaults(); + assert_eq!( + d.lookup(&[Key::char('z'), Key::char('c')]), + LookupResult::Exact(a::TREE_COLLAPSE.into()) + ); + } + + #[test] + fn h_maps_to_focus_prev() { + let d = defaults(); + assert_eq!( + d.lookup(&[Key::char('h')]), + LookupResult::Exact(a::FOCUS_PREV.into()) + ); + } + + #[test] + fn l_maps_to_focus_next() { + let d = defaults(); + assert_eq!( + d.lookup(&[Key::char('l')]), + LookupResult::Exact(a::FOCUS_NEXT.into()) + ); + } + #[test] fn colon_maps_to_mode_command() { let d = defaults(); diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 7800dd8..2c64f91 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -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, 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 diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index df45637..13de9fe 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -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, 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, path: String) -> Result<(), Strin .brittle .lock() .map_err(|_| "lock poisoned".to_string())? = Some(brittle); + save_last_opened(&path); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c7d2fa..64d9c2a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/pdf_protocol.rs b/src-tauri/src/pdf_protocol.rs index 14cb614..f5e3b33 100644 --- a/src-tauri/src/pdf_protocol.rs +++ b/src-tauri/src/pdf_protocol.rs @@ -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(app: &AppHandle, req: &Request>) -> 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> { response_ok(html.into_bytes(), "text/html; charset=utf-8") } -fn serve_pdfjs_file(pdfjs_root: &std::path::Path, rel_path: &str) -> Response> { - 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> { + 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(app: &AppHandle, ref_id: &str) -> Response> { @@ -95,22 +99,6 @@ fn serve_pdf(app: &AppHandle, ref_id: &str) -> Response> } } -// ── Path resolution ─────────────────────────────────────────────────────────── - -fn pdfjs_root(app: &AppHandle) -> 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, content_type: &str) -> Response> { diff --git a/src-tauri/src/pdf_viewer.html b/src-tauri/src/pdf_viewer.html index b51bbff..a6c1fc8 100644 --- a/src-tauri/src/pdf_viewer.html +++ b/src-tauri/src/pdf_viewer.html @@ -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 (1–2) + // before the DOM swap. Only a small number of pages, so off-screen is + // fast; the swap then reveals already-drawn content with no white flash. + // Skipped for the initial load (pagesWrapper is null) where progressive + // in-DOM rendering is preferable. + const preRendered = new Set(); + if (pagesWrapper) { + const first = getCurrentPage(); + const last = Math.min(first + 1, pdfDoc.numPages); + for (let p = first; p <= last; p++) { + if (renderGen !== gen) return; + await renderPage(wrappers[p - 1], p, targetScale); + preRendered.add(p - 1); + } } + if (renderGen !== gen) return; + + // Swap into DOM — visible pages are already rendered on re-render. + // DOM mutations + scroll restore are synchronous, landing in one paint. + container.innerHTML = ""; + container.appendChild(newWrapper); + pagesWrapper = newWrapper; + renderScale = targetScale; + container.scrollTop = savedScrollTop; + container.scrollLeft = savedScrollLeft; + + // Render remaining pages in-DOM (GPU-accelerated, progressive). + for (let i = 0; i < wrappers.length; i++) { + if (preRendered.has(i)) continue; + if (renderGen !== gen) return; + await renderPage(wrappers[i], i + 1, targetScale); + } + + if (renderGen !== gen) return; setStatus("Ready"); refreshPageIndicator(); } - async function renderPage(wrapper, pageNum) { + 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(); diff --git a/src/Cargo.toml b/src/Cargo.toml index 74d7d23..ab24dbe 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -13,7 +13,7 @@ wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde-wasm-bindgen = "0.6" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["DataTransfer", "DragEvent", "KeyboardEvent"] } +web-sys = { version = "0.3", features = ["DataTransfer", "DragEvent", "HtmlIFrameElement", "KeyboardEvent", "MessageEvent", "Window"] } [dev-dependencies] serde_json = "1" diff --git a/src/src/lib_tab.rs b/src/src/lib_tab.rs index e4b8a10..cbe9cbf 100644 --- a/src/src/lib_tab.rs +++ b/src/src/lib_tab.rs @@ -193,7 +193,7 @@ pub fn LibTab() -> impl IntoView { focused=focused /> -
+
diff --git a/src/src/lib_tree.rs b/src/src/lib_tree.rs index e3c0e14..b332ece 100644 --- a/src/src/lib_tree.rs +++ b/src/src/lib_tree.rs @@ -151,13 +151,14 @@ pub fn LibraryTree( style=format!("padding-left: {indent_px}px") on:click=move |_| { cursor.set(i); + } + on:dblclick=move |_| { if row_may_have_children { let is_open = expanded.with_untracked(|e| e.contains(&row_id_click)); if is_open { expanded.update(|e| { e.remove(&row_id_click); }); } else { expanded.update(|e| { e.insert(row_id_click.clone()); }); - // Child loading is handled by the reactive Effect above. } } } diff --git a/src/src/main.rs b/src/src/main.rs index 8d0459c..cdea23c 100644 --- a/src/src/main.rs +++ b/src/src/main.rs @@ -129,6 +129,18 @@ pub struct OpenPdfContext(pub RwSignal>); // ── Keymap provider ──────────────────────────────────────────────────────────── +/// Keydown message forwarded from a child iframe (e.g. the PDF viewer). +#[derive(serde::Deserialize)] +struct IframeKeyMsg { + #[serde(rename = "type")] + kind: String, + key: String, + #[serde(rename = "ctrlKey", default)] ctrl: bool, + #[serde(rename = "shiftKey", default)] shift: bool, + #[serde(rename = "altKey", default)] alt: bool, + #[serde(rename = "metaKey", default)] meta: bool, +} + fn provide_keymap() { let state: Rc> = Rc::new(RefCell::new(KeymapState::new(default_bindings()))); @@ -150,6 +162,42 @@ fn provide_keymap() { } }); + // When a child iframe (PDF viewer) has focus, its keydown events don't + // bubble to the outer window. The PDF viewer forwards them via postMessage + // so global keybindings continue to work. + let state_for_msg = state.clone(); + { + use wasm_bindgen::{closure::Closure, JsCast as _}; + let cb: Closure = + Closure::new(move |ev: web_sys::MessageEvent| { + let Ok(msg) = + serde_wasm_bindgen::from_value::(ev.data()) + else { + return; + }; + if msg.kind != "brittle:keydown" { + return; + } + if let Some(key) = + key_from_parts(&msg.key, msg.ctrl, msg.shift, msg.alt, msg.meta) + { + let outcome = state_for_msg.borrow_mut().process(key); + if let Outcome::Action { name, count } = outcome { + action_write.set(Some(ActionEvent { + name, + count, + seq: next_seq(), + })); + } + } + }); + if let Some(win) = web_sys::window() { + let _ = win + .add_event_listener_with_callback("message", cb.as_ref().unchecked_ref()); + } + cb.forget(); // listener lives for the lifetime of the app + } + on_cleanup(move || listener.remove()); // Asynchronously load user keybinding overrides and hot-swap the state. @@ -203,8 +251,11 @@ fn is_input_focused() -> bool { } fn key_from_event(ev: &KeyboardEvent) -> Option { - let key_str = ev.key(); - let code = match key_str.as_str() { + key_from_parts(&ev.key(), ev.ctrl_key(), ev.shift_key(), ev.alt_key(), ev.meta_key()) +} + +fn key_from_parts(key_str: &str, ctrl: bool, shift: bool, alt: bool, meta: bool) -> Option { + let code = match key_str { "Enter" => KeyCode::Enter, "Escape" => KeyCode::Escape, "Tab" => KeyCode::Tab, @@ -230,16 +281,10 @@ fn key_from_event(ev: &KeyboardEvent) -> Option { // For special keys (Tab, arrows, …) shift must be preserved (). let shift = match code { KeyCode::Char(_) => false, - _ => ev.shift_key(), + _ => shift, }; - Some(Key { - code, - ctrl: ev.ctrl_key(), - shift, - alt: ev.alt_key(), - meta: ev.meta_key(), - }) + Some(Key { code, ctrl, shift, alt, meta }) } // ── Root component ───────────────────────────────────────────────────────────── @@ -261,7 +306,17 @@ fn App() -> impl IntoView { provide_context(OpenPdfContext(open_pdf_req)); // Reload trigger: increment when a repository is opened. - provide_context(ReloadTrigger(RwSignal::new(0u32))); + let reload_trigger = RwSignal::new(0u32); + provide_context(ReloadTrigger(reload_trigger)); + + // Auto-open the last repository from the previous session. + leptos::task::spawn_local(async move { + if let Ok(Some(path)) = crate::tauri::get_last_project().await { + if crate::tauri::open_repository(&path).await.is_ok() { + reload_trigger.update(|n| *n += 1); + } + } + }); // ── Keymap wiring ───────────────────────────────────────────────────────── let keymap_action = use_context::().unwrap().0; @@ -353,7 +408,7 @@ fn App() -> impl IntoView { // Derive visibility reactively: look up the live tabs // so the style updates correctly after tab close/reorder. let ref_id_vis = ref_id.clone(); - let is_visible = move || { + let is_visible = Memo::new(move |_| { let active = active_tab.get(); tabs.with(|t| { t.get(active) @@ -363,13 +418,16 @@ fn App() -> impl IntoView { )) .unwrap_or(false) }) - }; + }); // `i` at creation time is used as a fallback for the // close button in TabBar; here we only need visibility. let _ = i; view! { -
- +
+
} } diff --git a/src/src/pdf_viewer.rs b/src/src/pdf_viewer.rs index 647c46b..de78ce5 100644 --- a/src/src/pdf_viewer.rs +++ b/src/src/pdf_viewer.rs @@ -8,17 +8,44 @@ //! `display:none`), so scrolling position and zoom are preserved across //! tab switches. +use brittle_keymap::actions; use leptos::prelude::*; +use wasm_bindgen::JsValue; /// Renders the PDF viewer for a single reference. /// /// `ref_id` must be the UUID string of a reference that has an attached PDF. -/// The iframe loads the viewer HTML served by the `brittle://` custom protocol. +/// `is_active` indicates whether this is the currently visible PDF tab; when +/// `true`, keymap actions for PDF page navigation are forwarded into the iframe. #[component] -pub fn PdfViewer(ref_id: String) -> impl IntoView { +pub fn PdfViewer(ref_id: String, is_active: Signal) -> impl IntoView { let url = format!("brittle://app/viewer?ref_id={ref_id}"); + + let iframe_ref = NodeRef::::new(); + + let keymap_action = use_context::() + .expect("KeymapAction context missing") + .0; + + Effect::new(move |_| { + let Some(ev) = keymap_action.get() else { return }; + if !is_active.get_untracked() { return } + + let cmd = match ev.name.as_str() { + actions::PDF_PAGE_NEXT => actions::PDF_PAGE_NEXT, + actions::PDF_PAGE_PREV => actions::PDF_PAGE_PREV, + _ => return, + }; + + let Some(iframe) = iframe_ref.get() else { return }; + if let Some(win) = iframe.content_window() { + let _ = win.post_message(&JsValue::from_str(cmd), "*"); + } + }); + view! {