Add zooming in/out to PDF
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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!("<Enter>" => 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!("<C-w>" => 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -193,7 +193,7 @@ pub fn LibTab() -> impl IntoView {
|
||||
focused=focused
|
||||
/>
|
||||
</div>
|
||||
<div class="pane pane-right">
|
||||
<div class="pane pane-right" class:pane-focused=move || focused.get() == Pane::Detail>
|
||||
<PubDetail reference=detail_ref />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,18 @@ pub struct OpenPdfContext(pub RwSignal<Option<PdfOpenRequest>>);
|
||||
|
||||
// ── 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<RefCell<KeymapState>> =
|
||||
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<dyn Fn(web_sys::MessageEvent)> =
|
||||
Closure::new(move |ev: web_sys::MessageEvent| {
|
||||
let Ok(msg) =
|
||||
serde_wasm_bindgen::from_value::<IframeKeyMsg>(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<Key> {
|
||||
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<Key> {
|
||||
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<Key> {
|
||||
// For special keys (Tab, arrows, …) shift must be preserved (<S-Tab>).
|
||||
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::<KeymapAction>().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! {
|
||||
<div style=move || if is_visible() { "height:100%" } else { "display:none" }>
|
||||
<PdfViewer ref_id=ref_id />
|
||||
<div style=move || if is_visible.get() { "height:100%" } else { "display:none" }>
|
||||
<PdfViewer
|
||||
ref_id=ref_id
|
||||
is_active=Signal::derive(move || is_visible.get())
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool>) -> impl IntoView {
|
||||
let url = format!("brittle://app/viewer?ref_id={ref_id}");
|
||||
|
||||
let iframe_ref = NodeRef::<leptos::html::Iframe>::new();
|
||||
|
||||
let keymap_action = use_context::<crate::KeymapAction>()
|
||||
.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! {
|
||||
<iframe
|
||||
node_ref=iframe_ref
|
||||
class="pdf-frame"
|
||||
src=url
|
||||
// Intentionally no `sandbox` attribute — the brittle:// protocol
|
||||
|
||||
@@ -17,6 +17,8 @@ pub fn PubList(
|
||||
) -> impl IntoView {
|
||||
use leptos::either::Either;
|
||||
|
||||
let open_pdf_ctx = use_context::<crate::OpenPdfContext>();
|
||||
|
||||
view! {
|
||||
<div class="pub-list-pane" class:pane-focused=move || focused.get() == Pane::List>
|
||||
{move || {
|
||||
@@ -36,6 +38,8 @@ pub fn PubList(
|
||||
let year = item.year.clone().unwrap_or_default();
|
||||
let kind = item.entry_type.to_string();
|
||||
let ref_id = item.id.0.clone();
|
||||
let cite_key = item.cite_key.clone();
|
||||
let ref_id_for_dblclick = ref_id.clone();
|
||||
view! {
|
||||
<li
|
||||
class="pub-item"
|
||||
@@ -51,6 +55,15 @@ pub fn PubList(
|
||||
}
|
||||
}
|
||||
on:click=move |_| cursor.set(i)
|
||||
on:dblclick=move |_| {
|
||||
cursor.set(i);
|
||||
if let Some(ctx) = open_pdf_ctx {
|
||||
ctx.0.set(Some(crate::PdfOpenRequest {
|
||||
ref_id: ref_id_for_dblclick.clone(),
|
||||
title: cite_key.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
>
|
||||
<span class="pub-type">{kind}</span>
|
||||
<span class="pub-title">{title}</span>
|
||||
|
||||
@@ -58,6 +58,12 @@ async fn invoke<T: for<'de> serde::Deserialize<'de>, A: Serialize>(
|
||||
|
||||
// ── Config commands ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the last-opened project path from the global config, or `None` if
|
||||
/// none was recorded or the directory no longer exists on disk.
|
||||
pub async fn get_last_project() -> Result<Option<String>, String> {
|
||||
invoke("get_last_project", &NoArgs {}).await
|
||||
}
|
||||
|
||||
/// Return the user's keybinding overrides from `~/.config/brittle/config.toml`.
|
||||
///
|
||||
/// Keys are snake_case action names (e.g. `"tab_next"`); values are
|
||||
|
||||
Reference in New Issue
Block a user