Add PDF state persistence

This commit is contained in:
2026-03-30 09:29:19 +02:00
parent d1bb79570d
commit 4613b8e5dd
15 changed files with 380 additions and 55 deletions

View File

@@ -155,7 +155,8 @@ pub fn LibTab() -> impl IntoView {
// ── Initial data load (and reload on repository change) ───────────────────
let reload_trigger = use_context::<crate::ReloadTrigger>().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,

View File

@@ -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 {

View File

@@ -11,6 +11,7 @@ mod tauri;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
@@ -128,6 +129,24 @@ pub struct OpenPdfContext(pub RwSignal<Option<PdfOpenRequest>>);
// ── Keymap provider ────────────────────────────────────────────────────────────
/// Per-tab PDF viewer state (zoom + scroll) shared across the app.
#[derive(Clone, Copy)]
pub struct PdfStatesContext(
pub RwSignal<HashMap<String, crate::tauri::PdfTabState>>,
);
/// 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<AppTab>,
active_tab: usize,
pdf_states: HashMap<String, crate::tauri::PdfTabState>,
) {
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<RefCell<KeymapState>> =
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<Vec<AppTab>>, RwSignal<usize>) {
// 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::<String, crate::tauri::PdfTabState>::new());
provide_context(PdfStatesContext(pdf_states));
// Provide the open-PDF context so LibTab can post open requests.
let open_pdf_req = RwSignal::new(Option::<PdfOpenRequest>::None);
provide_context(OpenPdfContext(open_pdf_req));
@@ -315,6 +358,50 @@ fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
}
});
// 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::<KeymapAction>().unwrap().0;
Effect::new(move |_| {
@@ -361,7 +448,7 @@ fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
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::<PdfStatesContext>().unwrap().0;
let reload_trigger = use_context::<ReloadTrigger>().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<dyn Fn(web_sys::MessageEvent)> =
Closure::new(move |ev: web_sys::MessageEvent| {
let Ok(msg) =
serde_wasm_bindgen::from_value::<IframeViewerStateMsg>(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::<KeymapAction>().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 {
<PdfViewer
ref_id=ref_id
is_active=Signal::derive(move || is_visible.get())
initial_zoom=initial_zoom
initial_scroll_top=initial_scroll_top
/>
</div>
}

View File

@@ -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<bool>) -> impl IntoView {
let url = format!("brittle://app/viewer?ref_id={ref_id}");
pub fn PdfViewer(
ref_id: String,
is_active: Signal<bool>,
initial_zoom: Option<f64>,
initial_scroll_top: Option<f64>,
) -> 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::<leptos::html::Iframe>::new();

View File

@@ -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 {

View File

@@ -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<PdfTabEntry>,
pub active_tab: usize,
pub pdf_states: std::collections::HashMap<String, PdfTabState>,
}
// ── Config commands ────────────────────────────────────────────────────────────
/// Return the session state for the currently open project.
pub async fn get_session() -> Result<SessionConfig, String> {
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<Option<String>, String> {