From 4613b8e5ddf1dad975c6fd023f71f3dc1498140d Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Mon, 30 Mar 2026 09:29:19 +0200 Subject: [PATCH] Add PDF state persistence --- brittle-ui/src/lib_tab.rs | 5 +- brittle-ui/src/lib_tree.rs | 1 + brittle-ui/src/main.rs | 143 +++++++++++++++++++- brittle-ui/src/pdf_viewer.rs | 11 +- brittle-ui/src/pub_list.rs | 5 +- brittle-ui/src/tauri.rs | 33 +++++ src-tauri/assets/viewer/message-bridge.js | 11 ++ src-tauri/assets/viewer/page-manager.js | 53 +++++--- src-tauri/assets/viewer/viewer.js | 59 +++++--- src-tauri/assets/viewer/viewport-tracker.js | 21 ++- src-tauri/assets/viewer/zoom-controller.js | 2 + src-tauri/src/commands/config.rs | 30 +++- src-tauri/src/config/mod.rs | 18 ++- src-tauri/src/config/project.rs | 41 +++++- src-tauri/src/lib.rs | 2 + 15 files changed, 380 insertions(+), 55 deletions(-) diff --git a/brittle-ui/src/lib_tab.rs b/brittle-ui/src/lib_tab.rs index b216e35..9c1d437 100644 --- a/brittle-ui/src/lib_tab.rs +++ b/brittle-ui/src/lib_tab.rs @@ -155,7 +155,8 @@ pub fn LibTab() -> impl IntoView { // ── Initial data load (and reload on repository change) ─────────────────── let reload_trigger = use_context::().map(|r| r.0); Effect::new(move |_| { - if let Some(t) = reload_trigger { t.get(); } // track the trigger + let trigger = reload_trigger.map(|t| t.get()).unwrap_or(0); + if trigger == 0 { return; } // no repository open yet spawn_local(async move { match crate::tauri::list_root_libraries().await { Ok(libs) => root_libs.set(libs), @@ -170,6 +171,8 @@ pub fn LibTab() -> impl IntoView { Effect::new(move |_| { let lib_id = selected_library.get(); let query = search_query.get(); + let trigger = reload_trigger.map(|t| t.get_untracked()).unwrap_or(0); + if trigger == 0 { return; } // no repository open yet spawn_local(async move { let result = match (&lib_id, query.is_empty()) { (Some(id), true) => crate::tauri::list_library_references_recursive(id).await, diff --git a/brittle-ui/src/lib_tree.rs b/brittle-ui/src/lib_tree.rs index 4e03368..3c99040 100644 --- a/brittle-ui/src/lib_tree.rs +++ b/brittle-ui/src/lib_tree.rs @@ -154,6 +154,7 @@ pub fn LibraryTree( style=format!("padding-left: {indent_px}px") on:click=move |_| { cursor.set(i); + focused.set(Pane::Tree); } on:dblclick=move |_| { if row_may_have_children { diff --git a/brittle-ui/src/main.rs b/brittle-ui/src/main.rs index c2b99f4..5c9921b 100644 --- a/brittle-ui/src/main.rs +++ b/brittle-ui/src/main.rs @@ -11,6 +11,7 @@ mod tauri; use std::{ cell::{Cell, RefCell}, + collections::HashMap, rc::Rc, }; @@ -128,6 +129,24 @@ pub struct OpenPdfContext(pub RwSignal>); // ── Keymap provider ──────────────────────────────────────────────────────────── +/// Per-tab PDF viewer state (zoom + scroll) shared across the app. +#[derive(Clone, Copy)] +pub struct PdfStatesContext( + pub RwSignal>, +); + +/// Viewer state update posted by a PDF viewer iframe when zoom/scroll settle. +#[derive(serde::Deserialize)] +struct IframeViewerStateMsg { + #[serde(rename = "type")] + kind: String, + #[serde(rename = "refId")] + ref_id: String, + zoom: f64, + #[serde(rename = "scrollTop")] + scroll_top: f64, +} + /// Keydown message forwarded from a child iframe (e.g. the PDF viewer). #[derive(serde::Deserialize)] struct IframeKeyMsg { @@ -140,6 +159,25 @@ struct IframeKeyMsg { #[serde(rename = "metaKey", default)] meta: bool, } +async fn save_session_async( + tabs: Vec, + active_tab: usize, + pdf_states: HashMap, +) { + let open_tabs = tabs + .iter() + .filter_map(|t| match t { + AppTab::Pdf { ref_id, title } => Some(crate::tauri::PdfTabEntry { + ref_id: ref_id.clone(), + title: title.clone(), + }), + AppTab::Library => None, + }) + .collect(); + let session = crate::tauri::SessionConfig { open_tabs, active_tab, pdf_states }; + let _ = crate::tauri::save_session(&session).await; +} + fn provide_keymap() { let state: Rc> = Rc::new(RefCell::new(KeymapState::new(default_bindings()))); @@ -295,9 +333,14 @@ fn key_from_parts(key_str: &str, ctrl: bool, shift: bool, alt: bool, meta: bool) /// Returns: `(tabs, active_tab)` signals for the view layer. fn provide_tabs() -> (RwSignal>, RwSignal) { // The Library tab is always present at index 0 and cannot be closed. - let tabs = RwSignal::new(vec![AppTab::Library]); + let tabs = RwSignal::new(vec![AppTab::Library]); let active_tab = RwSignal::new(0usize); + // Per-tab PDF viewer state (zoom + scroll) keyed by ref_id. + let pdf_states = + RwSignal::new(HashMap::::new()); + provide_context(PdfStatesContext(pdf_states)); + // Provide the open-PDF context so LibTab can post open requests. let open_pdf_req = RwSignal::new(Option::::None); provide_context(OpenPdfContext(open_pdf_req)); @@ -315,6 +358,50 @@ fn provide_tabs() -> (RwSignal>, RwSignal) { } }); + // When a repository opens (or changes), reset tab state and restore the + // saved session. `prev.is_none()` on the first reactive run means no repo + // has been opened yet — skip that case. + Effect::new(move |prev: Option<()>| { + let _ = reload_trigger.get(); // subscribe + let Some(()) = prev else { return; }; + + // Reset to just the Library tab before restoring. + tabs.set(vec![AppTab::Library]); + active_tab.set(0); + pdf_states.set(HashMap::new()); + + leptos::task::spawn_local(async move { + let Ok(session) = crate::tauri::get_session().await else { return; }; + // Set pdf_states first so initial zoom/scroll is available when + // PdfViewer components are created by the For loop below. + pdf_states.set(session.pdf_states); + let mut new_tabs = vec![AppTab::Library]; + for entry in &session.open_tabs { + new_tabs.push(AppTab::Pdf { + ref_id: entry.ref_id.clone(), + title: entry.title.clone(), + }); + } + let count = new_tabs.len(); + tabs.set(new_tabs); + active_tab.set(session.active_tab.min(count.saturating_sub(1))); + }); + }); + + // Reactively save session whenever the tab list or active tab changes. + // Guard on reload_trigger > 0 to skip saves before any repo is open. + Effect::new(move |_| { + let _ = tabs.get(); // subscribe + let _ = active_tab.get(); // subscribe + if reload_trigger.get_untracked() == 0 { return; } + let tabs_val = tabs.get_untracked(); + let active = active_tab.get_untracked(); + let states = pdf_states.get_untracked(); + leptos::task::spawn_local(async move { + let _ = save_session_async(tabs_val, active, states).await; + }); + }); + // Tab keymap effects (cycling and closing). let keymap_action = use_context::().unwrap().0; Effect::new(move |_| { @@ -361,7 +448,7 @@ fn provide_tabs() -> (RwSignal>, RwSignal) { let new_idx = tabs.with_untracked(Vec::len); tabs.update(|t| t.push(AppTab::Pdf { ref_id: req.ref_id.clone(), - title: req.title.clone(), + title: req.title.clone(), })); active_tab.set(new_idx); } @@ -410,6 +497,49 @@ fn App() -> impl IntoView { provide_search_query(); let (tabs, active_tab) = provide_tabs(); + // Access contexts provided by provide_tabs(). + let pdf_states = use_context::().unwrap().0; + let reload_trigger = use_context::().unwrap().0; + + // Listen for viewer state updates (zoom + scroll) posted by PDF viewer + // iframes and persist them immediately to the project session file. + { + 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:viewer-state" { + return; + } + pdf_states.update(|m| { + m.insert( + msg.ref_id.clone(), + crate::tauri::PdfTabState { + zoom: msg.zoom, + scroll_top: msg.scroll_top, + }, + ); + }); + if reload_trigger.get_untracked() > 0 { + let tabs_val = tabs.get_untracked(); + let active = active_tab.get_untracked(); + let states = pdf_states.get_untracked(); + leptos::task::spawn_local(async move { + let _ = save_session_async(tabs_val, active, states).await; + }); + } + }); + if let Some(win) = web_sys::window() { + let _ = + win.add_event_listener_with_callback("message", cb.as_ref().unchecked_ref()); + } + cb.forget(); + } + // Mode-switching keymap effect (tab actions are handled inside provide_tabs). let keymap_action = use_context::().unwrap().0; Effect::new(move |_| { @@ -446,6 +576,13 @@ fn App() -> impl IntoView { } key=|(_, ref_id)| ref_id.clone() children=move |(i, ref_id)| { + // Capture saved state at creation time; the iframe URL + // is static so these values only matter on first mount. + let initial_zoom = + pdf_states.with_untracked(|m| m.get(&ref_id).map(|s| s.zoom)); + let initial_scroll_top = + pdf_states.with_untracked(|m| m.get(&ref_id).map(|s| s.scroll_top)); + // Derive visibility reactively: look up the live tabs // so the style updates correctly after tab close/reorder. let ref_id_vis = ref_id.clone(); @@ -468,6 +605,8 @@ fn App() -> impl IntoView { } diff --git a/brittle-ui/src/pdf_viewer.rs b/brittle-ui/src/pdf_viewer.rs index de78ce5..356236c 100644 --- a/brittle-ui/src/pdf_viewer.rs +++ b/brittle-ui/src/pdf_viewer.rs @@ -18,8 +18,15 @@ use wasm_bindgen::JsValue; /// `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, is_active: Signal) -> impl IntoView { - let url = format!("brittle://app/viewer?ref_id={ref_id}"); +pub fn PdfViewer( + ref_id: String, + is_active: Signal, + initial_zoom: Option, + initial_scroll_top: Option, +) -> impl IntoView { + let mut url = format!("brittle://app/viewer?ref_id={ref_id}"); + if let Some(z) = initial_zoom { url.push_str(&format!("&zoom={z}")); } + if let Some(s) = initial_scroll_top { url.push_str(&format!("&scroll_top={s}")); } let iframe_ref = NodeRef::::new(); diff --git a/brittle-ui/src/pub_list.rs b/brittle-ui/src/pub_list.rs index 0e2b471..1566fa0 100644 --- a/brittle-ui/src/pub_list.rs +++ b/brittle-ui/src/pub_list.rs @@ -56,7 +56,10 @@ pub fn PubList( ); } } - on:click=move |_| cursor.set(i) + on:click=move |_| { + cursor.set(i); + focused.set(Pane::List); + } on:dblclick=move |_| { cursor.set(i); if let Some(ctx) = open_pdf_ctx { diff --git a/brittle-ui/src/tauri.rs b/brittle-ui/src/tauri.rs index b8e126b..bc5d0f2 100644 --- a/brittle-ui/src/tauri.rs +++ b/brittle-ui/src/tauri.rs @@ -68,8 +68,41 @@ pub struct LayoutConfig { pub center_pane_min: i32, } +// ── Session types ───────────────────────────────────────────────────────────── + +#[derive(Clone, Default, serde::Deserialize, serde::Serialize)] +pub struct PdfTabState { + pub zoom: f64, + pub scroll_top: f64, +} + +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub struct PdfTabEntry { + pub ref_id: String, + pub title: String, +} + +#[derive(Clone, Default, serde::Deserialize, serde::Serialize)] +pub struct SessionConfig { + pub open_tabs: Vec, + pub active_tab: usize, + pub pdf_states: std::collections::HashMap, +} + // ── Config commands ──────────────────────────────────────────────────────────── +/// Return the session state for the currently open project. +pub async fn get_session() -> Result { + invoke("get_session", &NoArgs {}).await +} + +/// Persist the session state for the currently open project. +pub async fn save_session(session: &SessionConfig) -> Result<(), String> { + #[derive(Serialize)] + struct Args<'a> { session: &'a SessionConfig } + invoke("save_session", &Args { session }).await +} + /// 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, String> { diff --git a/src-tauri/assets/viewer/message-bridge.js b/src-tauri/assets/viewer/message-bridge.js index 32167bc..1dc67da 100644 --- a/src-tauri/assets/viewer/message-bridge.js +++ b/src-tauri/assets/viewer/message-bridge.js @@ -18,6 +18,17 @@ export class MessageBridge { 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; diff --git a/src-tauri/assets/viewer/page-manager.js b/src-tauri/assets/viewer/page-manager.js index 10a017f..d116b73 100644 --- a/src-tauri/assets/viewer/page-manager.js +++ b/src-tauri/assets/viewer/page-manager.js @@ -32,15 +32,17 @@ export class PageManager { * @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._wrapper = wrapper; + this._viewports = viewports; + this._scale = initialScale; + this._dpr = dpr; this._dispatchRender = dispatchRender; - this._renderGen = 0; - this._states = new Array(viewports.length).fill(State.PLACEHOLDER); - this._canvases = new Array(viewports.length).fill(null); - this._wrappers = []; + this._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(); } @@ -49,6 +51,9 @@ export class PageManager { 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]; @@ -87,34 +92,45 @@ export class PageManager { } } - // Clean up pages no longer in the buffer + // 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 (!bufferSet.has(pageNum) && this._states[i] === State.RENDERED) { + if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === State.RENDERED) { this._cleanup(i); } - // Also cancel stale RENDERING pages outside the buffer + // 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; } - // Reset wrapper size (without canvas it still holds placeholder dimensions) } /** @@ -134,6 +150,7 @@ export class PageManager { bitmap.close(); return; } + this._inFlight--; const vp = this._viewports[i]; const wrap = this._wrappers[i]; @@ -160,8 +177,11 @@ export class PageManager { bitmap.close(); } - // Remove CSS zoom and set explicit size (canvas is now at the right dimensions) - wrap.style.zoom = "1"; + // 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); @@ -202,9 +222,10 @@ export class PageManager { canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; } - if (this._states[i] !== State.PLACEHOLDER) { - this._states[i] = State.PLACEHOLDER; + 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]; diff --git a/src-tauri/assets/viewer/viewer.js b/src-tauri/assets/viewer/viewer.js index d75470d..5cde248 100644 --- a/src-tauri/assets/viewer/viewer.js +++ b/src-tauri/assets/viewer/viewer.js @@ -33,8 +33,11 @@ const zoomLabel = document.getElementById("zoom-label"); const pageIndicator = document.getElementById("page-indicator"); // ── Global state ───────────────────────────────────────────────────────────── -const refId = new URLSearchParams(location.search).get("ref_id") || ""; -const DPR = window.devicePixelRatio || 1; +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; @@ -61,12 +64,13 @@ function refreshPageIndicator() { pageIndicator.textContent = cur + " / " + pageManager.numPages; } -async function fitToWidth() { +async function fitToPage() { if (!pdfDoc) return 1.0; - const page = await pdfDoc.getPage(1); - const vp = page.getViewport({ scale: 1.0 }); - const avail = container.clientWidth - 40; - return Math.max(0.1, Math.min(5.0, avail / vp.width)); + 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) { @@ -103,7 +107,7 @@ async function renderPage(pageNum, scale, gen) { const bitmap = offscreen.transferToImageBitmap(); pageManager?.onRendered(pageNum, gen, bitmap); refreshPageIndicator(); - setStatus("Ready"); + if (pageManager?.allRendered) setStatus("Ready"); } catch (e) { if (e?.name !== "RenderingCancelledException") { console.warn("[viewer] render error page", pageNum, e); @@ -113,6 +117,11 @@ async function renderPage(pageNum, scale, gen) { } } +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; @@ -145,9 +154,12 @@ async function load() { viewports.push({ width: vp.width, height: vp.height }); } - // 3. Compute initial fit-to-width scale - const avail = container.clientWidth - 40; - const initialScale = Math.max(0.1, Math.min(5.0, avail / viewports[0].width)); + // 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( @@ -160,11 +172,14 @@ async function load() { container, pageManager.pageWrappers, onVisibilityChange, ); - // 6. ZoomController + // 6. ZoomController — send state after each debounced re-render zoomController = new ZoomController( container, pageManager, - (newScale, bufferSet, visibleSet) => pageManager.onScaleChange(newScale, bufferSet, visibleSet), + (newScale, bufferSet, visibleSet) => { + pageManager.onScaleChange(newScale, bufferSet, visibleSet); + sendViewerState(); + }, () => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }), zoomLabel, initialScale, @@ -182,19 +197,29 @@ async function load() { document.getElementById("btn-zoom-in").addEventListener("click", () => zoomController.applyScale(zoomController.scale * 1.25)); document.getElementById("btn-zoom-fit").addEventListener("click", - async () => zoomController.applyScale(await fitToWidth())); + 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(); fitToWidth().then(s => zoomController.applyScale(s)); } + if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s)); } bridge.forwardKeydown(ev); }); - // Scroll → update page indicator - container.addEventListener("scroll", refreshPageIndicator, { passive: true }); + // 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", () => { diff --git a/src-tauri/assets/viewer/viewport-tracker.js b/src-tauri/assets/viewer/viewport-tracker.js index 35e3c6f..f783848 100644 --- a/src-tauri/assets/viewer/viewport-tracker.js +++ b/src-tauri/assets/viewer/viewport-tracker.js @@ -23,8 +23,21 @@ export class ViewportTracker { } _observe(root, pageWrappers) { - const notify = () => { - this._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet)); + // 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( @@ -34,7 +47,7 @@ export class ViewportTracker { if (e.isIntersecting) this._visibleSet.add(page); else this._visibleSet.delete(page); } - notify(); + scheduleNotify(); }, { root, rootMargin: "0px", threshold: 0 } ); @@ -46,7 +59,7 @@ export class ViewportTracker { if (e.isIntersecting) this._bufferSet.add(page); else this._bufferSet.delete(page); } - notify(); + scheduleNotify(); }, { root, rootMargin: "200% 0px", threshold: 0 } ); diff --git a/src-tauri/assets/viewer/zoom-controller.js b/src-tauri/assets/viewer/zoom-controller.js index b04e475..f45e09f 100644 --- a/src-tauri/assets/viewer/zoom-controller.js +++ b/src-tauri/assets/viewer/zoom-controller.js @@ -68,6 +68,7 @@ export class ZoomController { 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; @@ -117,6 +118,7 @@ export class ZoomController { } _triggerReRender() { + this._pm.setZooming(false); // Phase 2: onScaleChange will handle cleanup const newScale = this._scale; this._renderScale = newScale; const { bufferSet, visibleSet } = this._getBuffer(); diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index ec54eff..ef227aa 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -2,7 +2,9 @@ use std::path::Path; -use crate::config::{GlobalConfig, ProjectConfig}; +use crate::config::{GlobalConfig, ProjectConfig, SessionConfig}; +use crate::state::AppState; +use tauri::State; /// Load the global config from `~/.config/brittle/config.toml`. /// Returns the default config if the file does not yet exist. @@ -80,6 +82,32 @@ pub fn get_layout_config() -> Result { .map_err(|e| e.to_string()) } +/// Return the session state for the currently open project. +/// +/// Returns `Default` if no session has been saved yet. +/// Errors if no repository is open. +#[tauri::command] +pub fn get_session(state: State) -> Result { + let guard = state.brittle.lock().map_err(|_| "lock poisoned".to_string())?; + let brittle = guard.as_ref().ok_or_else(|| "no repository open".to_string())?; + ProjectConfig::load(brittle.repository_root()) + .map(|c| c.session) + .map_err(|e| e.to_string()) +} + +/// Persist the session state for the currently open project. +/// +/// Errors if no repository is open. +#[tauri::command] +pub fn save_session(state: State, session: SessionConfig) -> Result<(), String> { + let guard = state.brittle.lock().map_err(|_| "lock poisoned".to_string())?; + let brittle = guard.as_ref().ok_or_else(|| "no repository open".to_string())?; + let root = brittle.repository_root(); + let mut config = ProjectConfig::load(root).map_err(|e| e.to_string())?; + config.session = session; + config.save(root).map_err(|e| e.to_string()) +} + /// 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/config/mod.rs b/src-tauri/src/config/mod.rs index 1ad2e6e..0935df3 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -10,7 +10,7 @@ mod global; mod project; pub use global::GlobalConfig; -pub use project::ProjectConfig; +pub use project::{PdfTabEntry, PdfTabState, ProjectConfig, SessionConfig}; use std::collections::HashMap; @@ -104,8 +104,8 @@ pub struct MergedConfig { pub appearance: AppearanceConfig, pub layout: LayoutConfig, pub keybindings: KeybindingsConfig, - /// Reference IDs of tabs that should be restored on launch. - pub open_tabs: Vec, + /// Tabs that should be restored on launch. + pub open_tabs: Vec, } impl MergedConfig { @@ -157,7 +157,7 @@ pub enum ConfigError { #[cfg(test)] mod tests { use super::*; - use crate::config::project::SessionConfig; + use crate::config::project::{PdfTabEntry, SessionConfig}; // ── AppearanceConfig ────────────────────────────────────────────────────── @@ -273,12 +273,18 @@ mod tests { let global = GlobalConfig::default(); let project = ProjectConfig { session: SessionConfig { - open_tabs: vec!["tab-a".to_string(), "tab-b".to_string()], + open_tabs: vec![ + PdfTabEntry { ref_id: "tab-a".to_string(), title: "a2024".to_string() }, + PdfTabEntry { ref_id: "tab-b".to_string(), title: "b2024".to_string() }, + ], + ..Default::default() }, ..Default::default() }; let merged = MergedConfig::merge(&global, Some(&project)); - assert_eq!(merged.open_tabs, ["tab-a", "tab-b"]); + assert_eq!(merged.open_tabs.len(), 2); + assert_eq!(merged.open_tabs[0].ref_id, "tab-a"); + assert_eq!(merged.open_tabs[1].ref_id, "tab-b"); } #[test] diff --git a/src-tauri/src/config/project.rs b/src-tauri/src/config/project.rs index b7fd590..3842cb3 100644 --- a/src-tauri/src/config/project.rs +++ b/src-tauri/src/config/project.rs @@ -1,16 +1,37 @@ //! Per-project configuration. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use super::{AppearanceOverride, ConfigError}; -/// Reference IDs of tabs to restore when the project is next opened. +/// Persisted zoom and scroll state for a single PDF tab. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PdfTabState { + pub zoom: f64, + pub scroll_top: f64, +} + +/// A PDF tab to restore on next launch. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct PdfTabEntry { + pub ref_id: String, + pub title: String, +} + +/// Session state restored when a project is next opened. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct SessionConfig { - pub open_tabs: Vec, + /// PDF tabs to reopen, in order (Library tab is implicit). + pub open_tabs: Vec, + /// Index into [Library, ...open_tabs] of the tab that was active. + pub active_tab: usize, + /// Per-reference zoom and scroll position, keyed by ref_id. + pub pdf_states: HashMap, } /// Per-project configuration, stored at `{repo}/.brittle/config.toml`. @@ -77,6 +98,8 @@ mod tests { let cfg = ProjectConfig::default(); assert!(cfg.appearance.is_none()); assert!(cfg.session.open_tabs.is_empty()); + assert_eq!(cfg.session.active_tab, 0); + assert!(cfg.session.pdf_states.is_empty()); } #[test] @@ -87,7 +110,13 @@ mod tests { font_size: Some(16), }), session: SessionConfig { - open_tabs: vec!["abc-123".to_string()], + open_tabs: vec![PdfTabEntry { + ref_id: "abc-123".to_string(), + title: "smith2024".to_string(), + }], + active_tab: 1, + pdf_states: [("abc-123".to_string(), PdfTabState { zoom: 1.5, scroll_top: 320.0 })] + .into_iter().collect(), }, }; let s = toml::to_string_pretty(&cfg).unwrap(); @@ -130,7 +159,8 @@ mod tests { font_size: None, }), session: SessionConfig { - open_tabs: vec!["ref-1".to_string()], + open_tabs: vec![PdfTabEntry { ref_id: "ref-1".to_string(), title: "ref2024".to_string() }], + ..Default::default() }, }; original.save_to(&path).unwrap(); @@ -146,7 +176,8 @@ mod tests { let original = ProjectConfig { session: SessionConfig { - open_tabs: vec!["x".to_string()], + open_tabs: vec![PdfTabEntry { ref_id: "x".to_string(), title: "x2024".to_string() }], + ..Default::default() }, ..Default::default() }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index add79c0..f7d67f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,8 @@ pub fn run() { commands::config::get_keybindings, commands::config::get_layout_config, commands::config::get_last_project, + commands::config::get_session, + commands::config::save_session, // repository commands::repository::create_repository, commands::repository::open_repository,