Add PDF state persistence
This commit is contained in:
@@ -155,7 +155,8 @@ pub fn LibTab() -> impl IntoView {
|
|||||||
// ── Initial data load (and reload on repository change) ───────────────────
|
// ── Initial data load (and reload on repository change) ───────────────────
|
||||||
let reload_trigger = use_context::<crate::ReloadTrigger>().map(|r| r.0);
|
let reload_trigger = use_context::<crate::ReloadTrigger>().map(|r| r.0);
|
||||||
Effect::new(move |_| {
|
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 {
|
spawn_local(async move {
|
||||||
match crate::tauri::list_root_libraries().await {
|
match crate::tauri::list_root_libraries().await {
|
||||||
Ok(libs) => root_libs.set(libs),
|
Ok(libs) => root_libs.set(libs),
|
||||||
@@ -170,6 +171,8 @@ pub fn LibTab() -> impl IntoView {
|
|||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
let lib_id = selected_library.get();
|
let lib_id = selected_library.get();
|
||||||
let query = search_query.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 {
|
spawn_local(async move {
|
||||||
let result = match (&lib_id, query.is_empty()) {
|
let result = match (&lib_id, query.is_empty()) {
|
||||||
(Some(id), true) => crate::tauri::list_library_references_recursive(id).await,
|
(Some(id), true) => crate::tauri::list_library_references_recursive(id).await,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ pub fn LibraryTree(
|
|||||||
style=format!("padding-left: {indent_px}px")
|
style=format!("padding-left: {indent_px}px")
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
cursor.set(i);
|
cursor.set(i);
|
||||||
|
focused.set(Pane::Tree);
|
||||||
}
|
}
|
||||||
on:dblclick=move |_| {
|
on:dblclick=move |_| {
|
||||||
if row_may_have_children {
|
if row_may_have_children {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod tauri;
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
cell::{Cell, RefCell},
|
cell::{Cell, RefCell},
|
||||||
|
collections::HashMap,
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,6 +129,24 @@ pub struct OpenPdfContext(pub RwSignal<Option<PdfOpenRequest>>);
|
|||||||
|
|
||||||
// ── Keymap provider ────────────────────────────────────────────────────────────
|
// ── 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).
|
/// Keydown message forwarded from a child iframe (e.g. the PDF viewer).
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct IframeKeyMsg {
|
struct IframeKeyMsg {
|
||||||
@@ -140,6 +159,25 @@ struct IframeKeyMsg {
|
|||||||
#[serde(rename = "metaKey", default)] meta: bool,
|
#[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() {
|
fn provide_keymap() {
|
||||||
let state: Rc<RefCell<KeymapState>> =
|
let state: Rc<RefCell<KeymapState>> =
|
||||||
Rc::new(RefCell::new(KeymapState::new(default_bindings())));
|
Rc::new(RefCell::new(KeymapState::new(default_bindings())));
|
||||||
@@ -298,6 +336,11 @@ fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
|
|||||||
let tabs = RwSignal::new(vec![AppTab::Library]);
|
let tabs = RwSignal::new(vec![AppTab::Library]);
|
||||||
let active_tab = RwSignal::new(0usize);
|
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.
|
// Provide the open-PDF context so LibTab can post open requests.
|
||||||
let open_pdf_req = RwSignal::new(Option::<PdfOpenRequest>::None);
|
let open_pdf_req = RwSignal::new(Option::<PdfOpenRequest>::None);
|
||||||
provide_context(OpenPdfContext(open_pdf_req));
|
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).
|
// Tab keymap effects (cycling and closing).
|
||||||
let keymap_action = use_context::<KeymapAction>().unwrap().0;
|
let keymap_action = use_context::<KeymapAction>().unwrap().0;
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
@@ -410,6 +497,49 @@ fn App() -> impl IntoView {
|
|||||||
provide_search_query();
|
provide_search_query();
|
||||||
let (tabs, active_tab) = provide_tabs();
|
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).
|
// Mode-switching keymap effect (tab actions are handled inside provide_tabs).
|
||||||
let keymap_action = use_context::<KeymapAction>().unwrap().0;
|
let keymap_action = use_context::<KeymapAction>().unwrap().0;
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
@@ -446,6 +576,13 @@ fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
key=|(_, ref_id)| ref_id.clone()
|
key=|(_, ref_id)| ref_id.clone()
|
||||||
children=move |(i, ref_id)| {
|
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
|
// Derive visibility reactively: look up the live tabs
|
||||||
// so the style updates correctly after tab close/reorder.
|
// so the style updates correctly after tab close/reorder.
|
||||||
let ref_id_vis = ref_id.clone();
|
let ref_id_vis = ref_id.clone();
|
||||||
@@ -468,6 +605,8 @@ fn App() -> impl IntoView {
|
|||||||
<PdfViewer
|
<PdfViewer
|
||||||
ref_id=ref_id
|
ref_id=ref_id
|
||||||
is_active=Signal::derive(move || is_visible.get())
|
is_active=Signal::derive(move || is_visible.get())
|
||||||
|
initial_zoom=initial_zoom
|
||||||
|
initial_scroll_top=initial_scroll_top
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,15 @@ use wasm_bindgen::JsValue;
|
|||||||
/// `is_active` indicates whether this is the currently visible PDF tab; when
|
/// `is_active` indicates whether this is the currently visible PDF tab; when
|
||||||
/// `true`, keymap actions for PDF page navigation are forwarded into the iframe.
|
/// `true`, keymap actions for PDF page navigation are forwarded into the iframe.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PdfViewer(ref_id: String, is_active: Signal<bool>) -> impl IntoView {
|
pub fn PdfViewer(
|
||||||
let url = format!("brittle://app/viewer?ref_id={ref_id}");
|
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();
|
let iframe_ref = NodeRef::<leptos::html::Iframe>::new();
|
||||||
|
|
||||||
|
|||||||
@@ -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 |_| {
|
on:dblclick=move |_| {
|
||||||
cursor.set(i);
|
cursor.set(i);
|
||||||
if let Some(ctx) = open_pdf_ctx {
|
if let Some(ctx) = open_pdf_ctx {
|
||||||
|
|||||||
@@ -68,8 +68,41 @@ pub struct LayoutConfig {
|
|||||||
pub center_pane_min: i32,
|
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 ────────────────────────────────────────────────────────────
|
// ── 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
|
/// Return the last-opened project path from the global config, or `None` if
|
||||||
/// none was recorded or the directory no longer exists on disk.
|
/// none was recorded or the directory no longer exists on disk.
|
||||||
pub async fn get_last_project() -> Result<Option<String>, String> {
|
pub async fn get_last_project() -> Result<Option<String>, String> {
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ export class MessageBridge {
|
|||||||
window.addEventListener("message", this._handler);
|
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. */
|
/** Forward a keydown event to the parent window for global keybindings. */
|
||||||
forwardKeydown(ev) {
|
forwardKeydown(ev) {
|
||||||
if (window.parent === window) return;
|
if (window.parent === window) return;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export class PageManager {
|
|||||||
this._dpr = dpr;
|
this._dpr = dpr;
|
||||||
this._dispatchRender = dispatchRender;
|
this._dispatchRender = dispatchRender;
|
||||||
this._renderGen = 0;
|
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._states = new Array(viewports.length).fill(State.PLACEHOLDER);
|
||||||
this._canvases = new Array(viewports.length).fill(null);
|
this._canvases = new Array(viewports.length).fill(null);
|
||||||
this._wrappers = [];
|
this._wrappers = [];
|
||||||
@@ -49,6 +51,9 @@ export class PageManager {
|
|||||||
get numPages() { return this._viewports.length; }
|
get numPages() { return this._viewports.length; }
|
||||||
get renderGen() { return this._renderGen; }
|
get renderGen() { return this._renderGen; }
|
||||||
|
|
||||||
|
/** Called by ZoomController to suppress canvas teardown during CSS zoom. */
|
||||||
|
setZooming(z) { this._zooming = z; }
|
||||||
|
|
||||||
_buildPlaceholders() {
|
_buildPlaceholders() {
|
||||||
for (let i = 0; i < this._viewports.length; i++) {
|
for (let i = 0; i < this._viewports.length; i++) {
|
||||||
const vp = this._viewports[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++) {
|
for (let i = 0; i < this._viewports.length; i++) {
|
||||||
const pageNum = i + 1;
|
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);
|
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) {
|
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) {
|
||||||
|
this._inFlight--;
|
||||||
this._states[i] = State.PLACEHOLDER;
|
this._states[i] = State.PLACEHOLDER;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get allRendered() { return this._inFlight === 0; }
|
||||||
|
|
||||||
_startRender(i, gen) {
|
_startRender(i, gen) {
|
||||||
const vp = this._viewports[i];
|
const vp = this._viewports[i];
|
||||||
const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr);
|
const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr);
|
||||||
this._states[i] = State.RENDERING;
|
this._states[i] = State.RENDERING;
|
||||||
|
this._inFlight++;
|
||||||
this._dispatchRender(i + 1, scale, vp.width, vp.height, gen);
|
this._dispatchRender(i + 1, scale, vp.width, vp.height, gen);
|
||||||
}
|
}
|
||||||
|
|
||||||
_cleanup(i) {
|
_cleanup(i) {
|
||||||
|
if (this._states[i] === State.RENDERING) this._inFlight--;
|
||||||
this._states[i] = State.PLACEHOLDER;
|
this._states[i] = State.PLACEHOLDER;
|
||||||
const canvas = this._canvases[i];
|
const canvas = this._canvases[i];
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
canvas.remove();
|
canvas.remove();
|
||||||
this._canvases[i] = null;
|
this._canvases[i] = null;
|
||||||
}
|
}
|
||||||
// Reset wrapper size (without canvas it still holds placeholder dimensions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +150,7 @@ export class PageManager {
|
|||||||
bitmap.close();
|
bitmap.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._inFlight--;
|
||||||
|
|
||||||
const vp = this._viewports[i];
|
const vp = this._viewports[i];
|
||||||
const wrap = this._wrappers[i];
|
const wrap = this._wrappers[i];
|
||||||
@@ -160,8 +177,11 @@ export class PageManager {
|
|||||||
bitmap.close();
|
bitmap.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove CSS zoom and set explicit size (canvas is now at the right dimensions)
|
// Set explicit wrapper size. Do NOT touch wrap.style.zoom here —
|
||||||
wrap.style.zoom = "1";
|
// 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.width = cssW + "px";
|
||||||
wrap.style.height = cssH + "px";
|
wrap.style.height = cssH + "px";
|
||||||
wrap.appendChild(canvas);
|
wrap.appendChild(canvas);
|
||||||
@@ -202,9 +222,10 @@ export class PageManager {
|
|||||||
canvas.style.width = cssW + "px";
|
canvas.style.width = cssW + "px";
|
||||||
canvas.style.height = cssH + "px";
|
canvas.style.height = cssH + "px";
|
||||||
}
|
}
|
||||||
if (this._states[i] !== State.PLACEHOLDER) {
|
if (this._states[i] === State.RENDERING) {
|
||||||
this._states[i] = State.PLACEHOLDER;
|
this._inFlight--;
|
||||||
}
|
}
|
||||||
|
this._states[i] = State.PLACEHOLDER;
|
||||||
} else {
|
} else {
|
||||||
// Off-screen: clean up immediately — not visible, so no flash.
|
// Off-screen: clean up immediately — not visible, so no flash.
|
||||||
const canvas = this._canvases[i];
|
const canvas = this._canvases[i];
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ const zoomLabel = document.getElementById("zoom-label");
|
|||||||
const pageIndicator = document.getElementById("page-indicator");
|
const pageIndicator = document.getElementById("page-indicator");
|
||||||
|
|
||||||
// ── Global state ─────────────────────────────────────────────────────────────
|
// ── Global state ─────────────────────────────────────────────────────────────
|
||||||
const refId = new URLSearchParams(location.search).get("ref_id") || "";
|
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;
|
const DPR = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
let pdfDoc = null;
|
let pdfDoc = null;
|
||||||
@@ -61,12 +64,13 @@ function refreshPageIndicator() {
|
|||||||
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fitToWidth() {
|
async function fitToPage() {
|
||||||
if (!pdfDoc) return 1.0;
|
if (!pdfDoc) return 1.0;
|
||||||
const page = await pdfDoc.getPage(1);
|
const page = await pdfDoc.getPage(1);
|
||||||
const vp = page.getViewport({ scale: 1.0 });
|
const vp = page.getViewport({ scale: 1.0 });
|
||||||
const avail = container.clientWidth - 40;
|
const scaleW = (container.clientWidth - 40) / vp.width;
|
||||||
return Math.max(0.1, Math.min(5.0, avail / 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) {
|
function scrollToPage(pageNum) {
|
||||||
@@ -103,7 +107,7 @@ async function renderPage(pageNum, scale, gen) {
|
|||||||
const bitmap = offscreen.transferToImageBitmap();
|
const bitmap = offscreen.transferToImageBitmap();
|
||||||
pageManager?.onRendered(pageNum, gen, bitmap);
|
pageManager?.onRendered(pageNum, gen, bitmap);
|
||||||
refreshPageIndicator();
|
refreshPageIndicator();
|
||||||
setStatus("Ready");
|
if (pageManager?.allRendered) setStatus("Ready");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name !== "RenderingCancelledException") {
|
if (e?.name !== "RenderingCancelledException") {
|
||||||
console.warn("[viewer] render error page", pageNum, e);
|
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) ───────────────────
|
// ── Visibility change callback (called by ViewportTracker) ───────────────────
|
||||||
function onVisibilityChange(bufferSet, visibleSet) {
|
function onVisibilityChange(bufferSet, visibleSet) {
|
||||||
currentBufferSet = bufferSet;
|
currentBufferSet = bufferSet;
|
||||||
@@ -145,9 +154,12 @@ async function load() {
|
|||||||
viewports.push({ width: vp.width, height: vp.height });
|
viewports.push({ width: vp.width, height: vp.height });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Compute initial fit-to-width scale
|
// 3. Compute initial scale: use saved zoom if available, else fit full page
|
||||||
const avail = container.clientWidth - 40;
|
const fittedScale = Math.max(0.1, Math.min(5.0, Math.min(
|
||||||
const initialScale = Math.max(0.1, Math.min(5.0, avail / viewports[0].width));
|
(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
|
// 4. PageManager — creates placeholder divs
|
||||||
pageManager = new PageManager(
|
pageManager = new PageManager(
|
||||||
@@ -160,11 +172,14 @@ async function load() {
|
|||||||
container, pageManager.pageWrappers, onVisibilityChange,
|
container, pageManager.pageWrappers, onVisibilityChange,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. ZoomController
|
// 6. ZoomController — send state after each debounced re-render
|
||||||
zoomController = new ZoomController(
|
zoomController = new ZoomController(
|
||||||
container,
|
container,
|
||||||
pageManager,
|
pageManager,
|
||||||
(newScale, bufferSet, visibleSet) => pageManager.onScaleChange(newScale, bufferSet, visibleSet),
|
(newScale, bufferSet, visibleSet) => {
|
||||||
|
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
||||||
|
sendViewerState();
|
||||||
|
},
|
||||||
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
||||||
zoomLabel,
|
zoomLabel,
|
||||||
initialScale,
|
initialScale,
|
||||||
@@ -182,19 +197,29 @@ async function load() {
|
|||||||
document.getElementById("btn-zoom-in").addEventListener("click",
|
document.getElementById("btn-zoom-in").addEventListener("click",
|
||||||
() => zoomController.applyScale(zoomController.scale * 1.25));
|
() => zoomController.applyScale(zoomController.scale * 1.25));
|
||||||
document.getElementById("btn-zoom-fit").addEventListener("click",
|
document.getElementById("btn-zoom-fit").addEventListener("click",
|
||||||
async () => zoomController.applyScale(await fitToWidth()));
|
async () => zoomController.applyScale(await fitToPage()));
|
||||||
|
|
||||||
// Keyboard shortcuts + keydown forwarding to parent
|
// Keyboard shortcuts + keydown forwarding to parent
|
||||||
document.addEventListener("keydown", ev => {
|
document.addEventListener("keydown", ev => {
|
||||||
if (ev.target.tagName === "INPUT") return;
|
if (ev.target.tagName === "INPUT") return;
|
||||||
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.applyScale(zoomController.scale * 1.25); }
|
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 === "-") { 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);
|
bridge.forwardKeydown(ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scroll → update page indicator
|
// Scroll → update page indicator + debounced state save
|
||||||
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
|
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
|
// Lifecycle: tab hidden → free caches; tab visible → re-reconcile
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
|||||||
@@ -23,8 +23,21 @@ export class ViewportTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_observe(root, pageWrappers) {
|
_observe(root, pageWrappers) {
|
||||||
const notify = () => {
|
// 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._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
this._visibleObserver = new IntersectionObserver(
|
this._visibleObserver = new IntersectionObserver(
|
||||||
@@ -34,7 +47,7 @@ export class ViewportTracker {
|
|||||||
if (e.isIntersecting) this._visibleSet.add(page);
|
if (e.isIntersecting) this._visibleSet.add(page);
|
||||||
else this._visibleSet.delete(page);
|
else this._visibleSet.delete(page);
|
||||||
}
|
}
|
||||||
notify();
|
scheduleNotify();
|
||||||
},
|
},
|
||||||
{ root, rootMargin: "0px", threshold: 0 }
|
{ root, rootMargin: "0px", threshold: 0 }
|
||||||
);
|
);
|
||||||
@@ -46,7 +59,7 @@ export class ViewportTracker {
|
|||||||
if (e.isIntersecting) this._bufferSet.add(page);
|
if (e.isIntersecting) this._bufferSet.add(page);
|
||||||
else this._bufferSet.delete(page);
|
else this._bufferSet.delete(page);
|
||||||
}
|
}
|
||||||
notify();
|
scheduleNotify();
|
||||||
},
|
},
|
||||||
{ root, rootMargin: "200% 0px", threshold: 0 }
|
{ root, rootMargin: "200% 0px", threshold: 0 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export class ZoomController {
|
|||||||
const oldScale = this._scale;
|
const oldScale = this._scale;
|
||||||
this._scale = this.clamp(newScale);
|
this._scale = this.clamp(newScale);
|
||||||
this._updateLabel();
|
this._updateLabel();
|
||||||
|
this._pm.setZooming(true);
|
||||||
|
|
||||||
if (anchorY === undefined) anchorY = container.clientHeight / 2;
|
if (anchorY === undefined) anchorY = container.clientHeight / 2;
|
||||||
if (anchorX === undefined) anchorX = container.clientWidth / 2;
|
if (anchorX === undefined) anchorX = container.clientWidth / 2;
|
||||||
@@ -117,6 +118,7 @@ export class ZoomController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_triggerReRender() {
|
_triggerReRender() {
|
||||||
|
this._pm.setZooming(false); // Phase 2: onScaleChange will handle cleanup
|
||||||
const newScale = this._scale;
|
const newScale = this._scale;
|
||||||
this._renderScale = newScale;
|
this._renderScale = newScale;
|
||||||
const { bufferSet, visibleSet } = this._getBuffer();
|
const { bufferSet, visibleSet } = this._getBuffer();
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
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`.
|
/// Load the global config from `~/.config/brittle/config.toml`.
|
||||||
/// Returns the default config if the file does not yet exist.
|
/// Returns the default config if the file does not yet exist.
|
||||||
@@ -80,6 +82,32 @@ pub fn get_layout_config() -> Result<crate::config::LayoutConfig, String> {
|
|||||||
.map_err(|e| e.to_string())
|
.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<AppState>) -> Result<SessionConfig, 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())?;
|
||||||
|
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<AppState>, 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.
|
/// Return the user's keybinding overrides from the global config.
|
||||||
///
|
///
|
||||||
/// Map keys are action names in snake_case (e.g. `"tab_next"`); values are
|
/// Map keys are action names in snake_case (e.g. `"tab_next"`); values are
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ mod global;
|
|||||||
mod project;
|
mod project;
|
||||||
|
|
||||||
pub use global::GlobalConfig;
|
pub use global::GlobalConfig;
|
||||||
pub use project::ProjectConfig;
|
pub use project::{PdfTabEntry, PdfTabState, ProjectConfig, SessionConfig};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ pub struct MergedConfig {
|
|||||||
pub appearance: AppearanceConfig,
|
pub appearance: AppearanceConfig,
|
||||||
pub layout: LayoutConfig,
|
pub layout: LayoutConfig,
|
||||||
pub keybindings: KeybindingsConfig,
|
pub keybindings: KeybindingsConfig,
|
||||||
/// Reference IDs of tabs that should be restored on launch.
|
/// Tabs that should be restored on launch.
|
||||||
pub open_tabs: Vec<String>,
|
pub open_tabs: Vec<PdfTabEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MergedConfig {
|
impl MergedConfig {
|
||||||
@@ -157,7 +157,7 @@ pub enum ConfigError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::project::SessionConfig;
|
use crate::config::project::{PdfTabEntry, SessionConfig};
|
||||||
|
|
||||||
// ── AppearanceConfig ──────────────────────────────────────────────────────
|
// ── AppearanceConfig ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -273,12 +273,18 @@ mod tests {
|
|||||||
let global = GlobalConfig::default();
|
let global = GlobalConfig::default();
|
||||||
let project = ProjectConfig {
|
let project = ProjectConfig {
|
||||||
session: SessionConfig {
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = MergedConfig::merge(&global, Some(&project));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
//! Per-project configuration.
|
//! Per-project configuration.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::{AppearanceOverride, ConfigError};
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct SessionConfig {
|
pub struct SessionConfig {
|
||||||
pub open_tabs: Vec<String>,
|
/// PDF tabs to reopen, in order (Library tab is implicit).
|
||||||
|
pub open_tabs: Vec<PdfTabEntry>,
|
||||||
|
/// 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<String, PdfTabState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-project configuration, stored at `{repo}/.brittle/config.toml`.
|
/// Per-project configuration, stored at `{repo}/.brittle/config.toml`.
|
||||||
@@ -77,6 +98,8 @@ mod tests {
|
|||||||
let cfg = ProjectConfig::default();
|
let cfg = ProjectConfig::default();
|
||||||
assert!(cfg.appearance.is_none());
|
assert!(cfg.appearance.is_none());
|
||||||
assert!(cfg.session.open_tabs.is_empty());
|
assert!(cfg.session.open_tabs.is_empty());
|
||||||
|
assert_eq!(cfg.session.active_tab, 0);
|
||||||
|
assert!(cfg.session.pdf_states.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -87,7 +110,13 @@ mod tests {
|
|||||||
font_size: Some(16),
|
font_size: Some(16),
|
||||||
}),
|
}),
|
||||||
session: SessionConfig {
|
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();
|
let s = toml::to_string_pretty(&cfg).unwrap();
|
||||||
@@ -130,7 +159,8 @@ mod tests {
|
|||||||
font_size: None,
|
font_size: None,
|
||||||
}),
|
}),
|
||||||
session: SessionConfig {
|
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();
|
original.save_to(&path).unwrap();
|
||||||
@@ -146,7 +176,8 @@ mod tests {
|
|||||||
|
|
||||||
let original = ProjectConfig {
|
let original = ProjectConfig {
|
||||||
session: SessionConfig {
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub fn run() {
|
|||||||
commands::config::get_keybindings,
|
commands::config::get_keybindings,
|
||||||
commands::config::get_layout_config,
|
commands::config::get_layout_config,
|
||||||
commands::config::get_last_project,
|
commands::config::get_last_project,
|
||||||
|
commands::config::get_session,
|
||||||
|
commands::config::save_session,
|
||||||
// repository
|
// repository
|
||||||
commands::repository::create_repository,
|
commands::repository::create_repository,
|
||||||
commands::repository::open_repository,
|
commands::repository::open_repository,
|
||||||
|
|||||||
Reference in New Issue
Block a user