Compare commits
4 Commits
7f9d766ce0
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| 96ba5d35c7 | |||
| 0d0e9fe043 | |||
| 4613b8e5dd | |||
| d1bb79570d |
@@ -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())));
|
||||||
@@ -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.
|
/// Returns: `(tabs, active_tab)` signals for the view layer.
|
||||||
fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
|
fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
|
||||||
// The Library tab is always present at index 0 and cannot be closed.
|
// 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);
|
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 |_| {
|
||||||
@@ -361,7 +448,7 @@ fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
|
|||||||
let new_idx = tabs.with_untracked(Vec::len);
|
let new_idx = tabs.with_untracked(Vec::len);
|
||||||
tabs.update(|t| t.push(AppTab::Pdf {
|
tabs.update(|t| t.push(AppTab::Pdf {
|
||||||
ref_id: req.ref_id.clone(),
|
ref_id: req.ref_id.clone(),
|
||||||
title: req.title.clone(),
|
title: req.title.clone(),
|
||||||
}));
|
}));
|
||||||
active_tab.set(new_idx);
|
active_tab.set(new_idx);
|
||||||
}
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
6
scripts/dev.sh
Executable file
6
scripts/dev.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# dev.sh — start both the viewer TypeScript watcher and the Leptos trunk server.
|
||||||
|
# Run from the project root (cargo tauri dev sets cwd to src-tauri/../ = project root).
|
||||||
|
set -e
|
||||||
|
npm --prefix src-tauri/assets/viewer-src run dev &
|
||||||
|
cd brittle-ui && trunk serve
|
||||||
1
src-tauri/assets/assets/viewer/viewer.bundle.js
Normal file
1
src-tauri/assets/assets/viewer/viewer.bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/assets/viewer-src/.gitignore
vendored
Normal file
1
src-tauri/assets/viewer-src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
115
src-tauri/assets/viewer-src/build.cjs
Normal file
115
src-tauri/assets/viewer-src/build.cjs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* build.cjs — esbuild script for the PDF viewer TypeScript bundle.
|
||||||
|
*
|
||||||
|
* Produces two output files in ../assets/viewer/:
|
||||||
|
* viewer.bundle.js — main viewer (IIFE, no PDF.js included)
|
||||||
|
* render-worker.bundle.js — render worker (pdf.min.js prepended + IIFE)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node build.cjs — one-shot production build (minified)
|
||||||
|
* node build.cjs --watch — watch mode for development (unminified)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const esbuild = require("esbuild");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const isWatch = process.argv.includes("--watch");
|
||||||
|
const minify = !isWatch;
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PDFJS_WORKER_MIN = path.join(ROOT, "../viewer/pdfjs/pdf.worker.min.js");
|
||||||
|
const PDFJS_MIN = path.join(ROOT, "../viewer/pdfjs/pdf.min.js");
|
||||||
|
const OUT_DIR = path.join(ROOT, "../viewer");
|
||||||
|
|
||||||
|
// Preamble prepended to the render-worker bundle.
|
||||||
|
//
|
||||||
|
// Sets `globalThis.window = globalThis` before pdf.worker.min.js runs so that:
|
||||||
|
// 1. pdf.worker.min.js does NOT auto-call WorkerMessageHandler.initializeFromPort(self)
|
||||||
|
// (which would hijack our render-worker's own onmessage handler).
|
||||||
|
// 2. pdf.worker.min.js DOES set globalThis.pdfjsWorker.WorkerMessageHandler as usual.
|
||||||
|
// 3. pdf.min.js's _mainThreadWorkerMessageHandler getter then finds the handler via
|
||||||
|
// globalThis.pdfjsWorker and uses it inline — no document.createElement needed.
|
||||||
|
const RENDER_WORKER_PREAMBLE = Buffer.from("globalThis.window=globalThis;\n");
|
||||||
|
|
||||||
|
// ── Render-worker plugin: prepend preamble + pdf.worker.min.js + pdf.min.js ───
|
||||||
|
//
|
||||||
|
// Bundle order matters:
|
||||||
|
// 1. Preamble — sets globalThis.window = globalThis
|
||||||
|
// 2. pdf.worker.min.js — sets globalThis.pdfjsWorker.WorkerMessageHandler
|
||||||
|
// (skips auto-setup because window is now defined)
|
||||||
|
// 3. pdf.min.js — sets globalThis.pdfjsLib; fake-worker path reads pdfjsWorker
|
||||||
|
// 4. Compiled TS — our render-worker code
|
||||||
|
function prependPdfjsPlugin() {
|
||||||
|
const pdfjsWorkerMin = fs.readFileSync(PDFJS_WORKER_MIN);
|
||||||
|
const pdfjsMin = fs.readFileSync(PDFJS_MIN);
|
||||||
|
const NL = Buffer.from("\n");
|
||||||
|
return {
|
||||||
|
name: "prepend-pdfjs",
|
||||||
|
setup(build) {
|
||||||
|
build.onEnd(result => {
|
||||||
|
if (result.errors.length > 0 || !result.outputFiles) return;
|
||||||
|
const compiled = Buffer.from(result.outputFiles[0].contents);
|
||||||
|
const combined = Buffer.concat([
|
||||||
|
RENDER_WORKER_PREAMBLE,
|
||||||
|
pdfjsWorkerMin, NL,
|
||||||
|
pdfjsMin, NL,
|
||||||
|
compiled,
|
||||||
|
]);
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, "render-worker.bundle.js"), combined);
|
||||||
|
if (!isWatch) console.log("[viewer-src] render-worker.bundle.js written");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sharedOptions = { bundle: true, format: "iife", target: "es2020", minify };
|
||||||
|
|
||||||
|
if (isWatch) {
|
||||||
|
const viewerCtx = await esbuild.context({
|
||||||
|
...sharedOptions,
|
||||||
|
entryPoints: ["src/viewer.ts"],
|
||||||
|
outfile: path.join(OUT_DIR, "viewer.bundle.js"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workerCtx = await esbuild.context({
|
||||||
|
...sharedOptions,
|
||||||
|
entryPoints: ["src/render-worker.ts"],
|
||||||
|
write: false,
|
||||||
|
plugins: [prependPdfjsPlugin()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await viewerCtx.watch();
|
||||||
|
await workerCtx.watch();
|
||||||
|
console.log("[viewer-src] watching for changes…");
|
||||||
|
// Keep the process alive
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot build
|
||||||
|
await esbuild.build({
|
||||||
|
...sharedOptions,
|
||||||
|
entryPoints: ["src/viewer.ts"],
|
||||||
|
outfile: path.join(OUT_DIR, "viewer.bundle.js"),
|
||||||
|
});
|
||||||
|
console.log("[viewer-src] viewer.bundle.js written");
|
||||||
|
|
||||||
|
// Render worker: build to memory then prepend preamble + worker + pdf.min.js
|
||||||
|
const workerResult = await esbuild.build({
|
||||||
|
...sharedOptions,
|
||||||
|
entryPoints: ["src/render-worker.ts"],
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
const pdfjsWorkerMin = fs.readFileSync(PDFJS_WORKER_MIN);
|
||||||
|
const pdfjsMin = fs.readFileSync(PDFJS_MIN);
|
||||||
|
const NL = Buffer.from("\n");
|
||||||
|
const compiled = Buffer.from(workerResult.outputFiles[0].contents);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUT_DIR, "render-worker.bundle.js"),
|
||||||
|
Buffer.concat([RENDER_WORKER_PREAMBLE, pdfjsWorkerMin, NL, pdfjsMin, NL, compiled]),
|
||||||
|
);
|
||||||
|
console.log("[viewer-src] render-worker.bundle.js written");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
803
src-tauri/assets/viewer-src/package-lock.json
generated
Normal file
803
src-tauri/assets/viewer-src/package-lock.json
generated
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
{
|
||||||
|
"name": "brittle-viewer",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "brittle-viewer",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "4.10.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
|
||||||
|
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.65"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src-tauri/assets/viewer-src/package.json
Normal file
15
src-tauri/assets/viewer-src/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "brittle-viewer",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit && node build.cjs",
|
||||||
|
"build:no-check": "node build.cjs",
|
||||||
|
"dev": "node build.cjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
415
src-tauri/assets/viewer-src/src/page-manager.ts
Normal file
415
src-tauri/assets/viewer-src/src/page-manager.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
/**
|
||||||
|
* PageManager — page lifecycle: PLACEHOLDER → RENDERING → RENDERED → PLACEHOLDER
|
||||||
|
*
|
||||||
|
* Each page is a sized div (placeholder). When a page enters the buffer zone,
|
||||||
|
* a render job is dispatched to the render worker. When the worker returns an
|
||||||
|
* ImageBitmap, it is swapped into the DOM atomically (old canvas removed and
|
||||||
|
* new canvas appended in the same synchronous turn) so there is never a blank
|
||||||
|
* frame between the old blurry canvas and the new sharp one.
|
||||||
|
*
|
||||||
|
* During CSS zoom (Phase 1 of ZoomController), canvas teardown is suppressed
|
||||||
|
* to prevent dark flashes while intersection data is still settling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PageState } from "./types.js";
|
||||||
|
import type { PageDimensions, TextItem } from "./types.js";
|
||||||
|
|
||||||
|
const MAX_CANVAS_PIXELS = 16_777_216; // 4096 × 4096
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose two 2D affine transforms (each represented as a 6-element array
|
||||||
|
* [a, b, c, d, e, f] matching the CSS matrix() order).
|
||||||
|
*/
|
||||||
|
function composeTransform(m1: number[], m2: number[]): number[] {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const [a1, b1, c1, d1, e1, f1] = [m1[0]!, m1[1]!, m1[2]!, m1[3]!, m1[4]!, m1[5]!];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const [a2, b2, c2, d2, e2, f2] = [m2[0]!, m2[1]!, m2[2]!, m2[3]!, m2[4]!, m2[5]!];
|
||||||
|
return [
|
||||||
|
a1*a2 + c1*b2,
|
||||||
|
b1*a2 + d1*b2,
|
||||||
|
a1*c2 + c1*d2,
|
||||||
|
b1*c2 + d1*d2,
|
||||||
|
a1*e2 + c1*f2 + e1,
|
||||||
|
b1*e2 + d1*f2 + f1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp render scale so the canvas pixel count stays within budget.
|
||||||
|
* CSS dimensions stay correct — pages appear at the right size, just at
|
||||||
|
* reduced effective DPI when zoomed very high. Same strategy as Chrome's viewer.
|
||||||
|
*/
|
||||||
|
function clampedRenderScale(
|
||||||
|
vpWidth: number,
|
||||||
|
vpHeight: number,
|
||||||
|
desiredScale: number,
|
||||||
|
dpr: number,
|
||||||
|
): number {
|
||||||
|
const w = vpWidth * desiredScale * dpr;
|
||||||
|
const h = vpHeight * desiredScale * dpr;
|
||||||
|
const pixels = w * h;
|
||||||
|
if (pixels <= MAX_CANVAS_PIXELS) return desiredScale * dpr;
|
||||||
|
return desiredScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by viewer.ts to send a render job to the worker. */
|
||||||
|
export type DispatchRender = (pageNum: number, scale: number, gen: number) => void;
|
||||||
|
|
||||||
|
export class PageManager {
|
||||||
|
private readonly _wrapper: HTMLElement;
|
||||||
|
private readonly _dims: PageDimensions[];
|
||||||
|
private readonly _dpr: number;
|
||||||
|
private readonly _dispatchRender: DispatchRender;
|
||||||
|
private readonly _wrappers: HTMLElement[] = [];
|
||||||
|
// _states[i] tracks the render state of page i+1 (0-indexed).
|
||||||
|
private readonly _states: PageState[];
|
||||||
|
// _canvases[i] is the canvas currently in the DOM for page i+1, or null.
|
||||||
|
private readonly _canvases: (HTMLCanvasElement | null)[];
|
||||||
|
// Text selection overlay: raw items from the worker, and the live DOM div.
|
||||||
|
private readonly _rawTextItems: (TextItem[] | null)[];
|
||||||
|
private readonly _textLayers: (HTMLDivElement | null)[];
|
||||||
|
// Handle for any in-progress requestIdleCallback text-layer build (per page).
|
||||||
|
private readonly _textBuildHandles: (number | null)[];
|
||||||
|
|
||||||
|
private _scale: number;
|
||||||
|
private _renderGen: number = 0;
|
||||||
|
private _inFlight: number = 0;
|
||||||
|
private _zooming: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
wrapper: HTMLElement,
|
||||||
|
dims: PageDimensions[],
|
||||||
|
initialScale: number,
|
||||||
|
dpr: number,
|
||||||
|
dispatchRender: DispatchRender,
|
||||||
|
) {
|
||||||
|
this._wrapper = wrapper;
|
||||||
|
this._dims = dims;
|
||||||
|
this._scale = initialScale;
|
||||||
|
this._dpr = dpr;
|
||||||
|
this._dispatchRender = dispatchRender;
|
||||||
|
this._states = new Array<PageState>(dims.length).fill(PageState.PLACEHOLDER);
|
||||||
|
this._canvases = new Array<HTMLCanvasElement | null>(dims.length).fill(null);
|
||||||
|
this._rawTextItems = new Array<TextItem[] | null>(dims.length).fill(null);
|
||||||
|
this._textLayers = new Array<HTMLDivElement | null>(dims.length).fill(null);
|
||||||
|
this._textBuildHandles = new Array<number | null>(dims.length).fill(null);
|
||||||
|
|
||||||
|
this._buildPlaceholders();
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageWrappers(): readonly HTMLElement[] { return this._wrappers; }
|
||||||
|
get numPages(): number { return this._dims.length; }
|
||||||
|
get renderGen(): number { return this._renderGen; }
|
||||||
|
/** True when no renders are currently in flight (all visible pages are sharp). */
|
||||||
|
get allRendered(): boolean { return this._inFlight === 0; }
|
||||||
|
|
||||||
|
/** Called by ZoomController to suppress canvas teardown during Phase 1 CSS zoom. */
|
||||||
|
setZooming(z: boolean): void {
|
||||||
|
this._zooming = z;
|
||||||
|
if (!z) return;
|
||||||
|
// Suppress text-layer DOM work for the duration of Phase 1 CSS zoom.
|
||||||
|
// Hiding layers removes their spans from the browser's layout tree so the
|
||||||
|
// CSS zoom loop in applyScale doesn't have to reflow thousands of spans per
|
||||||
|
// page. Cancelling in-progress builds prevents setTimeout chunks from
|
||||||
|
// injecting layout work between wheel events.
|
||||||
|
// setZooming(false) needs no restore — onScaleChange calls _cleanupTextLayer
|
||||||
|
// for every page immediately after, so the hidden divs are removed anyway.
|
||||||
|
for (let i = 0; i < this._dims.length; i++) {
|
||||||
|
this._cancelTextBuild(i);
|
||||||
|
const layer = this._textLayers[i];
|
||||||
|
if (layer) layer.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildPlaceholders(): void {
|
||||||
|
for (let i = 0; i < this._dims.length; i++) {
|
||||||
|
const dim = this._dims[i]!;
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "page-wrapper";
|
||||||
|
wrap.dataset["page"] = String(i + 1);
|
||||||
|
wrap.style.width = dim.width * this._scale + "px";
|
||||||
|
wrap.style.height = dim.height * this._scale + "px";
|
||||||
|
this._wrapper.appendChild(wrap);
|
||||||
|
this._wrappers.push(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on each IntersectionObserver tick.
|
||||||
|
* Dispatches renders for pages entering the buffer (visible pages first).
|
||||||
|
* Cleans up pages that have left the buffer (unless zooming).
|
||||||
|
*/
|
||||||
|
reconcile(
|
||||||
|
bufferSet: ReadonlySet<number>,
|
||||||
|
visibleSet: ReadonlySet<number>,
|
||||||
|
): void {
|
||||||
|
const gen = this._renderGen;
|
||||||
|
|
||||||
|
// Visible pages first, then off-screen buffered pages.
|
||||||
|
const toRender: number[] = [
|
||||||
|
...[...visibleSet].sort((a, b) => a - b),
|
||||||
|
...[...bufferSet].filter(p => !visibleSet.has(p)).sort((a, b) => a - b),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pageNum of toRender) {
|
||||||
|
const i = pageNum - 1;
|
||||||
|
if (i < 0 || i >= this._dims.length) continue;
|
||||||
|
if (this._states[i] === PageState.PLACEHOLDER) {
|
||||||
|
this._startRender(i, gen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < this._dims.length; i++) {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
|
||||||
|
// Skip teardown during active CSS zoom — IntersectionObserver may report
|
||||||
|
// stale data while layout is still settling, causing premature canvas removal.
|
||||||
|
// onScaleChange (Phase 2) performs the authoritative cleanup.
|
||||||
|
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === PageState.RENDERED) {
|
||||||
|
this._cleanup(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel in-flight renders that are now out of the buffer.
|
||||||
|
// Leave the canvas in place — removing it would cause a dark flash between
|
||||||
|
// Phase 1 (CSS zoom) and Phase 2 (debounced re-render).
|
||||||
|
if (!bufferSet.has(pageNum) && this._states[i] === PageState.RENDERING) {
|
||||||
|
this._inFlight--;
|
||||||
|
this._states[i] = PageState.PLACEHOLDER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _startRender(i: number, gen: number): void {
|
||||||
|
const dim = this._dims[i]!;
|
||||||
|
const scale = clampedRenderScale(dim.width, dim.height, this._scale, this._dpr);
|
||||||
|
this._states[i] = PageState.RENDERING;
|
||||||
|
this._inFlight++;
|
||||||
|
this._dispatchRender(i + 1, scale, gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanup(i: number): void {
|
||||||
|
if (this._states[i] === PageState.RENDERING) this._inFlight--;
|
||||||
|
this._states[i] = PageState.PLACEHOLDER;
|
||||||
|
const canvas = this._canvases[i];
|
||||||
|
if (canvas) {
|
||||||
|
canvas.remove();
|
||||||
|
this._canvases[i] = null;
|
||||||
|
}
|
||||||
|
this._cleanupTextLayer(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancelTextBuild(i: number): void {
|
||||||
|
const h = this._textBuildHandles[i];
|
||||||
|
if (h != null) { clearTimeout(h); this._textBuildHandles[i] = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanupTextLayer(i: number): void {
|
||||||
|
this._cancelTextBuild(i);
|
||||||
|
this._textLayers[i]?.remove();
|
||||||
|
this._textLayers[i] = null;
|
||||||
|
// _rawTextItems[i] is intentionally kept — text content is scale-independent
|
||||||
|
// and can be reused to rebuild the overlay after zoom without a worker round-trip.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the render worker delivers text content for a page.
|
||||||
|
* Stores the raw items and builds the invisible selection overlay.
|
||||||
|
* If items are already cached (e.g. re-render after zoom), the message is a
|
||||||
|
* no-op — the overlay was already rebuilt from the cache in onScaleChange/onRendered.
|
||||||
|
*/
|
||||||
|
onTextContent(pageNum: number, gen: number, items: TextItem[]): void {
|
||||||
|
if (gen !== this._renderGen) return; // stale — a zoom reset superseded this render
|
||||||
|
|
||||||
|
const i = pageNum - 1;
|
||||||
|
if (i < 0 || i >= this._dims.length) return;
|
||||||
|
|
||||||
|
// Already cached from a prior render at this zoom level — skip redundant rebuild.
|
||||||
|
if (this._rawTextItems[i] !== null) return;
|
||||||
|
|
||||||
|
this._rawTextItems[i] = items;
|
||||||
|
this._buildTextLayer(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildTextLayer(i: number): void {
|
||||||
|
const items = this._rawTextItems[i];
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
this._cancelTextBuild(i);
|
||||||
|
|
||||||
|
const dim = this._dims[i]!;
|
||||||
|
const wrap = this._wrappers[i]!;
|
||||||
|
|
||||||
|
// Viewport transform: maps PDF coordinates (origin bottom-left, y up) to
|
||||||
|
// CSS pixel coordinates (origin top-left, y down) at the current scale.
|
||||||
|
const S = this._scale;
|
||||||
|
const vt = [S, 0, 0, -S, 0, dim.height * S];
|
||||||
|
|
||||||
|
// Create and attach the layer div immediately so it is in the DOM even
|
||||||
|
// before any spans are appended (spans are added in idle-time chunks).
|
||||||
|
const layer = document.createElement("div");
|
||||||
|
layer.className = "textLayer";
|
||||||
|
wrap.appendChild(layer);
|
||||||
|
this._textLayers[i] = layer;
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
// Fixed items-per-chunk keeps each callback well under one frame regardless
|
||||||
|
// of how much idle time the scheduler reports. The requestIdleCallback
|
||||||
|
// polyfill always returns timeRemaining()=50, so a time-budget loop would
|
||||||
|
// process every item in one shot — defeating chunking entirely.
|
||||||
|
const CHUNK_SIZE = 150;
|
||||||
|
|
||||||
|
const buildChunk = (): void => {
|
||||||
|
// Bail if the layer was replaced or removed since this chunk was scheduled.
|
||||||
|
if (this._textLayers[i] !== layer) return;
|
||||||
|
|
||||||
|
const end = Math.min(offset + CHUNK_SIZE, items.length);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
while (offset < end) {
|
||||||
|
const item = items[offset++]!;
|
||||||
|
if (!item.str) continue;
|
||||||
|
const m = composeTransform(vt, item.transform);
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = item.str;
|
||||||
|
span.style.transform =
|
||||||
|
`matrix(${m[0]},${m[1]},${m[2]},${m[3]},${m[4]},${m[5]})`;
|
||||||
|
frag.appendChild(span);
|
||||||
|
}
|
||||||
|
layer.appendChild(frag);
|
||||||
|
|
||||||
|
if (offset < items.length) {
|
||||||
|
this._textBuildHandles[i] = setTimeout(buildChunk, 0);
|
||||||
|
} else {
|
||||||
|
this._textBuildHandles[i] = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._textBuildHandles[i] = setTimeout(buildChunk, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the render worker returns a finished bitmap.
|
||||||
|
*
|
||||||
|
* Double-buffer swap: the old canvas stays in the DOM until the new one is
|
||||||
|
* ready, then both mutations happen in the same synchronous JS turn so the
|
||||||
|
* browser produces exactly one paint frame for the transition.
|
||||||
|
*/
|
||||||
|
onRendered(pageNum: number, gen: number, bitmap: ImageBitmap): void {
|
||||||
|
if (gen !== this._renderGen) { bitmap.close(); return; }
|
||||||
|
|
||||||
|
const i = pageNum - 1;
|
||||||
|
if (
|
||||||
|
i < 0 ||
|
||||||
|
i >= this._dims.length ||
|
||||||
|
this._states[i] !== PageState.RENDERING
|
||||||
|
) {
|
||||||
|
bitmap.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._inFlight--;
|
||||||
|
|
||||||
|
const dim = this._dims[i]!;
|
||||||
|
const wrap = this._wrappers[i]!;
|
||||||
|
const cssW = dim.width * this._scale;
|
||||||
|
const cssH = dim.height * this._scale;
|
||||||
|
|
||||||
|
// Build new canvas off-screen (not in DOM yet).
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.style.width = cssW + "px";
|
||||||
|
canvas.style.height = cssH + "px";
|
||||||
|
canvas.style.display = "block";
|
||||||
|
|
||||||
|
// Zero-copy display via bitmaprenderer; fall back to drawImage if unavailable.
|
||||||
|
const bitmapCtx = canvas.getContext("bitmaprenderer");
|
||||||
|
if (bitmapCtx) {
|
||||||
|
bitmapCtx.transferFromImageBitmap(bitmap);
|
||||||
|
} else {
|
||||||
|
(canvas.getContext("2d") as CanvasRenderingContext2D).drawImage(bitmap, 0, 0);
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.style.width = cssW + "px";
|
||||||
|
wrap.style.height = cssH + "px";
|
||||||
|
// Do NOT reset wrap's CSS zoom here — ZoomController may have advanced it
|
||||||
|
// since the last onScaleChange. The explicit canvas CSS dimensions already
|
||||||
|
// counteract the zoom; resetting it would cause a brief visual scale jump.
|
||||||
|
|
||||||
|
// Atomic DOM swap: old removed + new appended in the same synchronous turn
|
||||||
|
// → the browser paints exactly one frame for the change.
|
||||||
|
const old = this._canvases[i];
|
||||||
|
if (old) old.remove();
|
||||||
|
wrap.appendChild(canvas);
|
||||||
|
|
||||||
|
this._canvases[i] = canvas;
|
||||||
|
this._states[i] = PageState.RENDERED;
|
||||||
|
|
||||||
|
// Rebuild text layer from cache if available (covers pages that left the
|
||||||
|
// buffer and re-entered, where _cleanupTextLayer removed the DOM div but
|
||||||
|
// kept the raw items — no worker round-trip needed).
|
||||||
|
this._cleanupTextLayer(i);
|
||||||
|
this._buildTextLayer(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after the zoom debounce fires (Phase 2).
|
||||||
|
* Increments renderGen to cancel all stale in-flight renders, resizes
|
||||||
|
* placeholders, then re-dispatches renders for the buffer zone.
|
||||||
|
*/
|
||||||
|
onScaleChange(
|
||||||
|
newScale: number,
|
||||||
|
bufferSet: ReadonlySet<number>,
|
||||||
|
visibleSet: ReadonlySet<number>,
|
||||||
|
): void {
|
||||||
|
this._scale = newScale;
|
||||||
|
this._renderGen++;
|
||||||
|
|
||||||
|
for (let i = 0; i < this._dims.length; i++) {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
const dim = this._dims[i]!;
|
||||||
|
const wrap = this._wrappers[i]!;
|
||||||
|
const cssW = dim.width * newScale;
|
||||||
|
const cssH = dim.height * newScale;
|
||||||
|
wrap.style.width = cssW + "px";
|
||||||
|
wrap.style.height = cssH + "px";
|
||||||
|
wrap.style.setProperty("zoom", "1");
|
||||||
|
|
||||||
|
if (bufferSet.has(pageNum)) {
|
||||||
|
// Keep old canvas stretched to the new CSS size so there is no blank
|
||||||
|
// flash while the new render is in flight. onRendered() will replace
|
||||||
|
// it atomically in the same synchronous JS turn when done.
|
||||||
|
const canvas = this._canvases[i];
|
||||||
|
if (canvas) {
|
||||||
|
canvas.style.width = cssW + "px";
|
||||||
|
canvas.style.height = cssH + "px";
|
||||||
|
}
|
||||||
|
if (this._states[i] === PageState.RENDERING) this._inFlight--;
|
||||||
|
this._states[i] = PageState.PLACEHOLDER;
|
||||||
|
this._cleanupTextLayer(i);
|
||||||
|
} else {
|
||||||
|
// Off-screen: safe to discard immediately (no visible flash).
|
||||||
|
const canvas = this._canvases[i];
|
||||||
|
if (canvas) { canvas.remove(); this._canvases[i] = null; }
|
||||||
|
this._states[i] = PageState.PLACEHOLDER;
|
||||||
|
// Remove the DOM overlay; raw items are kept for when the page re-enters.
|
||||||
|
this._cleanupTextLayer(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconcile(bufferSet, visibleSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the 1-based page number of the topmost visible page. */
|
||||||
|
getCurrentPage(visibleSet: ReadonlySet<number>): number {
|
||||||
|
if (visibleSet.size > 0) return Math.min(...visibleSet);
|
||||||
|
// Fall back to scroll position when no page is intersecting.
|
||||||
|
const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0;
|
||||||
|
for (const wrap of this._wrappers) {
|
||||||
|
if (wrap.getBoundingClientRect().bottom > top + 4) {
|
||||||
|
return parseInt(wrap.dataset["page"]!, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src-tauri/assets/viewer-src/src/render-worker.ts
Normal file
220
src-tauri/assets/viewer-src/src/render-worker.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* render-worker.ts — off-main-thread PDF rendering via Web Worker.
|
||||||
|
*
|
||||||
|
* NOTE: pdf.min.js is prepended to the compiled output of this file at build
|
||||||
|
* time by build.cjs (see prependPdfjsPlugin). It sets `self.pdfjsLib` as a
|
||||||
|
* global, which this module reads via the declaration below.
|
||||||
|
*
|
||||||
|
* Worker message protocol
|
||||||
|
* ────────────────────────
|
||||||
|
* Main → Worker:
|
||||||
|
* { type: "init", pdfData: ArrayBuffer } (transferred, not copied)
|
||||||
|
* { type: "render", pageNum, scale, gen }
|
||||||
|
* { type: "cleanup" } — pdfDoc.cleanup(): free internal caches
|
||||||
|
* { type: "destroy" } — pdfDoc.destroy() + self.close()
|
||||||
|
*
|
||||||
|
* Worker → Main:
|
||||||
|
* { type: "ready", numPages, dims } — page-1 dim used as stub for all pages
|
||||||
|
* { type: "rendered", pageNum, gen, bitmap } (bitmap as transferable)
|
||||||
|
* { type: "error", message }
|
||||||
|
*
|
||||||
|
* WebKit nested-worker constraint
|
||||||
|
* ────────────────────────────────
|
||||||
|
* WebKit forbids nested workers (a worker spawning a worker). PDF.js normally
|
||||||
|
* spawns pdf.worker.min.js from wherever it is used — which here would be a
|
||||||
|
* nested worker. Instead, pdf.worker.min.js is bundled into this file (by
|
||||||
|
* build.cjs) preceded by a preamble that sets globalThis.window = globalThis.
|
||||||
|
* This causes pdf.worker.min.js to expose its WorkerMessageHandler on
|
||||||
|
* globalThis.pdfjsWorker without auto-calling initializeFromPort(self). PDF.js
|
||||||
|
* detects the pre-loaded handler via _mainThreadWorkerMessageHandler and runs
|
||||||
|
* inline in this thread — no nested worker creation attempted at all.
|
||||||
|
*
|
||||||
|
* Render queue
|
||||||
|
* ────────────
|
||||||
|
* With disableFontFace: true, every text glyph becomes canvas path operations.
|
||||||
|
* A text-heavy page can require 20 000+ synchronous canvas calls. Running
|
||||||
|
* multiple page renders concurrently exhausts the worker thread and causes
|
||||||
|
* system-wide CPU saturation (perceived UI lock). The queue serialises renders
|
||||||
|
* so exactly one page is active at a time. Incoming render messages for the
|
||||||
|
* same page supersede any queued (but not yet started) request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||||
|
import type { WorkerInbound, WorkerOutbound, PageDimensions, TextItem } from "./types.js";
|
||||||
|
|
||||||
|
// pdf.min.js is prepended at build time and sets globalThis.pdfjsLib.
|
||||||
|
declare const pdfjsLib: typeof import("pdfjs-dist");
|
||||||
|
|
||||||
|
let pdfDoc: PDFDocumentProxy | null = null;
|
||||||
|
|
||||||
|
// Custom canvas factory that uses OffscreenCanvas instead of
|
||||||
|
// document.createElement("canvas"). Required because this file runs in a Web
|
||||||
|
// Worker where `document` is not available. PDF.js uses the factory to create
|
||||||
|
// intermediate canvases (e.g. for scaling inline images during page rendering).
|
||||||
|
const offscreenCanvasFactory = {
|
||||||
|
create(width: number, height: number) {
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
return { canvas, context };
|
||||||
|
},
|
||||||
|
reset(
|
||||||
|
item: { canvas: OffscreenCanvas; context: OffscreenCanvasRenderingContext2D | null },
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
item.canvas.width = width;
|
||||||
|
item.canvas.height = height;
|
||||||
|
},
|
||||||
|
destroy(
|
||||||
|
item: { canvas: OffscreenCanvas; context: OffscreenCanvasRenderingContext2D | null },
|
||||||
|
) {
|
||||||
|
// Release memory by shrinking the canvas; nulling context is cosmetic.
|
||||||
|
item.canvas.width = 1;
|
||||||
|
item.canvas.height = 1;
|
||||||
|
item.context = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render queue ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RenderJob { pageNum: number; scale: number; gen: number; }
|
||||||
|
|
||||||
|
const renderQueue: RenderJob[] = [];
|
||||||
|
let activeRenders = 0;
|
||||||
|
const MAX_CONCURRENT = 1;
|
||||||
|
|
||||||
|
function enqueueRender(pageNum: number, scale: number, gen: number): void {
|
||||||
|
// Supersede any already-queued (not yet started) job for the same page.
|
||||||
|
const dup = renderQueue.findIndex(j => j.pageNum === pageNum);
|
||||||
|
if (dup >= 0) renderQueue.splice(dup, 1);
|
||||||
|
renderQueue.push({ pageNum, scale, gen });
|
||||||
|
drainQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainQueue(): void {
|
||||||
|
while (activeRenders < MAX_CONCURRENT && renderQueue.length > 0) {
|
||||||
|
const job = renderQueue.shift()!;
|
||||||
|
activeRenders++;
|
||||||
|
handleRender(job.pageNum, job.scale, job.gen).finally(() => {
|
||||||
|
activeRenders--;
|
||||||
|
drainQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message dispatch ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.onmessage = async (ev: MessageEvent<WorkerInbound>): Promise<void> => {
|
||||||
|
const msg = ev.data;
|
||||||
|
switch (msg.type) {
|
||||||
|
case "init":
|
||||||
|
await handleInit(msg.pdfData);
|
||||||
|
break;
|
||||||
|
case "render":
|
||||||
|
// Enqueue and return immediately; drainQueue() handles concurrency.
|
||||||
|
enqueueRender(msg.pageNum, msg.scale, msg.gen);
|
||||||
|
break;
|
||||||
|
case "cleanup":
|
||||||
|
await pdfDoc?.cleanup();
|
||||||
|
break;
|
||||||
|
case "destroy":
|
||||||
|
renderQueue.length = 0; // cancel pending jobs
|
||||||
|
if (pdfDoc) { await pdfDoc.destroy(); pdfDoc = null; }
|
||||||
|
self.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleInit(pdfData: ArrayBuffer): Promise<void> {
|
||||||
|
try {
|
||||||
|
// pdf.worker.min.js is bundled into this file (prepended by build.cjs).
|
||||||
|
// It sets globalThis.pdfjsWorker.WorkerMessageHandler, which PDF.js detects
|
||||||
|
// via its _mainThreadWorkerMessageHandler getter and uses as an inline fake
|
||||||
|
// worker — no nested-worker creation attempted.
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
"brittle://app/pdfjs/build/pdf.worker.min.js";
|
||||||
|
|
||||||
|
// disableFontFace: PDF.js normally registers custom fonts via
|
||||||
|
// document.fonts.add() (the Font Loading API). In a Web Worker, `document`
|
||||||
|
// is undefined, so font registration fails and text is invisible. Setting
|
||||||
|
// disableFontFace: true makes PDF.js render all glyphs as canvas vector
|
||||||
|
// paths instead — no browser font API needed, text renders correctly.
|
||||||
|
//
|
||||||
|
// canvasFactory: the installed pdfjs-dist types omit this parameter even
|
||||||
|
// though the runtime API accepts it. `any` cast bypasses the type check.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pdfDoc = await pdfjsLib.getDocument({
|
||||||
|
data: new Uint8Array(pdfData),
|
||||||
|
canvasFactory: offscreenCanvasFactory,
|
||||||
|
disableFontFace: true,
|
||||||
|
} as any).promise;
|
||||||
|
const numPages = pdfDoc.numPages;
|
||||||
|
|
||||||
|
// Fetch page-1 dimensions so the main thread can lay out placeholders.
|
||||||
|
// Page-1 dims are used as a uniform stub for all pages — accurate for most
|
||||||
|
// academic PDFs (uniform paper size). We deliberately do NOT fetch dims for
|
||||||
|
// every page here: that would tie up the worker with N getPage() calls while
|
||||||
|
// render jobs are already arriving, causing further delays.
|
||||||
|
const firstPage = await pdfDoc.getPage(1);
|
||||||
|
const firstVp = firstPage.getViewport({ scale: 1.0 });
|
||||||
|
firstPage.cleanup();
|
||||||
|
const stubDim: PageDimensions = { width: firstVp.width, height: firstVp.height };
|
||||||
|
|
||||||
|
const stubDims: PageDimensions[] = Array.from({ length: numPages }, () => stubDim);
|
||||||
|
const out: WorkerOutbound = { type: "ready", numPages, dims: stubDims };
|
||||||
|
self.postMessage(out);
|
||||||
|
} catch (e) {
|
||||||
|
const out: WorkerOutbound = { type: "error", message: String(e) };
|
||||||
|
self.postMessage(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRender(
|
||||||
|
pageNum: number,
|
||||||
|
scale: number,
|
||||||
|
gen: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!pdfDoc) return;
|
||||||
|
let page = null;
|
||||||
|
try {
|
||||||
|
page = await pdfDoc.getPage(pageNum);
|
||||||
|
|
||||||
|
const vp = page.getViewport({ scale });
|
||||||
|
const width = Math.round(vp.width);
|
||||||
|
const height = Math.round(vp.height);
|
||||||
|
|
||||||
|
const offscreen = new OffscreenCanvas(width, height);
|
||||||
|
// OffscreenCanvasRenderingContext2D is assignable to the canvasContext
|
||||||
|
// parameter of page.render(); the cast satisfies the type checker.
|
||||||
|
const ctx = offscreen.getContext("2d") as unknown as CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
// Run canvas rendering and text extraction in parallel — they are independent.
|
||||||
|
const [, textContent] = await Promise.all([
|
||||||
|
page.render({ canvasContext: ctx, viewport: vp }).promise,
|
||||||
|
page.getTextContent(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bitmap = offscreen.transferToImageBitmap();
|
||||||
|
const renderedOut: WorkerOutbound = { type: "rendered", pageNum, gen, bitmap };
|
||||||
|
(self as unknown as Worker).postMessage(renderedOut, [bitmap]);
|
||||||
|
|
||||||
|
// Send text items for the selection overlay. Filter out TextMarkedContent
|
||||||
|
// entries (which lack a `str` field) — we only need actual text runs.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const items: TextItem[] = (textContent.items as any[])
|
||||||
|
.filter((item) => typeof item.str === "string")
|
||||||
|
.map(({ str, transform, width, height, hasEOL }: {
|
||||||
|
str: string; transform: number[]; width: number; height: number; hasEOL: boolean;
|
||||||
|
}) => ({ str, transform: Array.from(transform), width, height, hasEOL }));
|
||||||
|
const textOut: WorkerOutbound = { type: "textcontent", pageNum, gen, items };
|
||||||
|
self.postMessage(textOut);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error)?.name !== "RenderingCancelledException") {
|
||||||
|
console.warn("[render-worker] render error page", pageNum, e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
page?.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src-tauri/assets/viewer-src/src/types.ts
Normal file
53
src-tauri/assets/viewer-src/src/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// ── Text layer ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A single text item extracted from a PDF page (subset of pdfjs-dist TextItem). */
|
||||||
|
export interface TextItem {
|
||||||
|
str: string;
|
||||||
|
transform: number[]; // [a, b, c, d, tx, ty] in PDF coordinate space
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
hasEOL: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PageState = {
|
||||||
|
PLACEHOLDER: 0,
|
||||||
|
RENDERING: 1,
|
||||||
|
RENDERED: 2,
|
||||||
|
} as const;
|
||||||
|
export type PageState = typeof PageState[keyof typeof PageState];
|
||||||
|
|
||||||
|
/** Page dimensions at scale=1 (fetched from the render worker at init). */
|
||||||
|
export interface PageDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render worker message protocol ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Messages the main thread sends to the render worker. */
|
||||||
|
export type WorkerInbound =
|
||||||
|
| { type: "init"; pdfData: ArrayBuffer }
|
||||||
|
| { type: "render"; pageNum: number; scale: number; gen: number }
|
||||||
|
| { type: "cleanup" }
|
||||||
|
| { type: "destroy" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages the render worker sends to the main thread.
|
||||||
|
*
|
||||||
|
* "ready" includes all page dims so the main thread can lay out placeholders
|
||||||
|
* without a second round-trip.
|
||||||
|
*/
|
||||||
|
export type WorkerOutbound =
|
||||||
|
| { type: "ready"; numPages: number; dims: PageDimensions[] }
|
||||||
|
| { type: "rendered"; pageNum: number; gen: number; bitmap: ImageBitmap }
|
||||||
|
| { type: "textcontent"; pageNum: number; gen: number; items: TextItem[] }
|
||||||
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
|
// ── iframe ↔ parent postMessage protocol ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Messages the PDF viewer iframe sends up to the parent Leptos app. */
|
||||||
|
export type OutboundMessage =
|
||||||
|
| { type: "brittle:viewer-state"; refId: string; zoom: number; scrollTop: number }
|
||||||
|
| { type: "brittle:keydown"; key: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; metaKey: boolean };
|
||||||
297
src-tauri/assets/viewer-src/src/viewer.ts
Normal file
297
src-tauri/assets/viewer-src/src/viewer.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* viewer.ts — PDF viewer orchestrator.
|
||||||
|
*
|
||||||
|
* Rendering is entirely off the main thread: a render worker (render-worker.ts,
|
||||||
|
* with pdf.min.js bundled in) handles all PDF.js calls and canvas rasterisation
|
||||||
|
* via OffscreenCanvas. The main thread only performs DOM manipulation and
|
||||||
|
* receives finished ImageBitmaps for zero-copy display.
|
||||||
|
*
|
||||||
|
* Init sequence:
|
||||||
|
* 1. Fetch render-worker.bundle.js → blob URL → new Worker()
|
||||||
|
* 2. Fetch raw PDF bytes → transfer ArrayBuffer to worker
|
||||||
|
* 3. Worker loads PDF.js, fetches page dims → posts "ready" with dims
|
||||||
|
* 4. Create PageManager (placeholder divs) with received dims
|
||||||
|
* 5. Wire render worker onmessage → pageManager.onRendered()
|
||||||
|
* 6. Create ViewportTracker → IntersectionObserver fires → initial reconcile
|
||||||
|
* 7. Create ZoomController
|
||||||
|
* 8. Register toolbar, keyboard, scroll, and lifecycle handlers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PageManager } from "./page-manager.js";
|
||||||
|
import { ViewportTracker } from "./viewport-tracker.js";
|
||||||
|
import { ZoomController } from "./zoom-controller.js";
|
||||||
|
import type { WorkerInbound, WorkerOutbound, OutboundMessage, PageDimensions } from "./types.js";
|
||||||
|
|
||||||
|
// requestIdleCallback / cancelIdleCallback polyfills for older WebKitGTK builds.
|
||||||
|
if (typeof requestIdleCallback === "undefined") {
|
||||||
|
(self as unknown as Record<string, unknown>)["requestIdleCallback"] =
|
||||||
|
(cb: IdleRequestCallback): ReturnType<typeof setTimeout> =>
|
||||||
|
setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1);
|
||||||
|
}
|
||||||
|
if (typeof cancelIdleCallback === "undefined") {
|
||||||
|
(self as unknown as Record<string, unknown>)["cancelIdleCallback"] =
|
||||||
|
(id: ReturnType<typeof setTimeout>): void => clearTimeout(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
const container = document.getElementById("canvas-container")!;
|
||||||
|
const pagesWrapper = document.getElementById("pages-wrapper")!;
|
||||||
|
const statusEl = document.getElementById("status")!;
|
||||||
|
const zoomLabel = document.getElementById("zoom-label")!;
|
||||||
|
const pageIndicator = document.getElementById("page-indicator")!;
|
||||||
|
|
||||||
|
// ── URL params ─────────────────────────────────────────────────────────────────
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const refId = params.get("ref_id") ?? "";
|
||||||
|
const savedZoom = parseFloat(params.get("zoom") ?? "");
|
||||||
|
const savedScrollTop = parseFloat(params.get("scroll_top") ?? "");
|
||||||
|
const DPR = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// ── Mutable state ─────────────────────────────────────────────────────────────
|
||||||
|
let pageManager: PageManager | null = null;
|
||||||
|
let viewportTracker: ViewportTracker | null = null;
|
||||||
|
let zoomController: ZoomController | null = null;
|
||||||
|
let renderWorker: Worker | null = null;
|
||||||
|
|
||||||
|
let currentBufferSet: ReadonlySet<number> = new Set();
|
||||||
|
let currentVisibleSet: ReadonlySet<number> = new Set();
|
||||||
|
|
||||||
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
||||||
|
function setStatus(msg: string): void { statusEl.textContent = msg; }
|
||||||
|
|
||||||
|
function showError(msg: string): void {
|
||||||
|
const b = document.getElementById("error-banner")!;
|
||||||
|
b.textContent = msg;
|
||||||
|
b.style.display = "block";
|
||||||
|
setStatus("Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPageIndicator(): void {
|
||||||
|
if (!pageManager) return;
|
||||||
|
const cur = pageManager.getCurrentPage(currentVisibleSet);
|
||||||
|
pageIndicator.textContent = `${cur} / ${pageManager.numPages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToPage(pageNum: number): void {
|
||||||
|
if (!pageManager) return;
|
||||||
|
const wrap = pageManager.pageWrappers[pageNum - 1];
|
||||||
|
if (!wrap) return;
|
||||||
|
container.scrollTop +=
|
||||||
|
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendViewerState(): void {
|
||||||
|
if (!zoomController || window.parent === window) return;
|
||||||
|
const msg: OutboundMessage = {
|
||||||
|
type: "brittle:viewer-state",
|
||||||
|
refId,
|
||||||
|
zoom: zoomController.scale,
|
||||||
|
scrollTop: container.scrollTop,
|
||||||
|
};
|
||||||
|
window.parent.postMessage(msg, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a render job to the worker. Called by PageManager via dispatchRender. */
|
||||||
|
function dispatchRender(pageNum: number, scale: number, gen: number): void {
|
||||||
|
const msg: WorkerInbound = { type: "render", pageNum, scale, gen };
|
||||||
|
renderWorker?.postMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visibility change callback ─────────────────────────────────────────────────
|
||||||
|
function onVisibilityChange(
|
||||||
|
bufferSet: ReadonlySet<number>,
|
||||||
|
visibleSet: ReadonlySet<number>,
|
||||||
|
): void {
|
||||||
|
currentBufferSet = bufferSet;
|
||||||
|
currentVisibleSet = visibleSet;
|
||||||
|
pageManager?.reconcile(bufferSet, visibleSet);
|
||||||
|
refreshPageIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fit-to-page scale computation ─────────────────────────────────────────────
|
||||||
|
function fitScale(dims: PageDimensions[]): number {
|
||||||
|
const dim = dims[0] ?? { width: 595, height: 842 }; // A4 fallback
|
||||||
|
return Math.max(0.1, Math.min(5.0, Math.min(
|
||||||
|
(container.clientWidth - 40) / dim.width,
|
||||||
|
(container.clientHeight - 40) / dim.height,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main init ──────────────────────────────────────────────────────────────────
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (!refId) { showError("No ref_id in URL."); return; }
|
||||||
|
setStatus("Loading…");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch the worker bundle and the PDF bytes in parallel — they are
|
||||||
|
// independent, so there is no reason to wait for one before starting
|
||||||
|
// the other. The worker is created from a blob URL because Tauri's
|
||||||
|
// WebView requires workers to be same-origin; blob URLs satisfy that.
|
||||||
|
const [workerBlob, pdfBuf] = await Promise.all([
|
||||||
|
fetch("brittle://app/viewer/render-worker.bundle.js").then(r => r.blob()),
|
||||||
|
fetch(`brittle://app/pdf?ref_id=${encodeURIComponent(refId)}`).then(r => r.arrayBuffer()),
|
||||||
|
]);
|
||||||
|
renderWorker = new Worker(URL.createObjectURL(workerBlob));
|
||||||
|
|
||||||
|
// 2. Send init message; wait for "ready" (stub dims based on page 1).
|
||||||
|
// The worker posts "ready" immediately after loading the PDF so the
|
||||||
|
// main thread unblocks and creates placeholders right away. A follow-up
|
||||||
|
// "dims" message with real per-page dimensions arrives shortly after.
|
||||||
|
const { numPages, dims } = await new Promise<{
|
||||||
|
numPages: number;
|
||||||
|
dims: PageDimensions[];
|
||||||
|
}>((resolve, reject) => {
|
||||||
|
renderWorker!.onmessage = (ev: MessageEvent<WorkerOutbound>) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
if (msg.type === "ready") resolve({ numPages: msg.numPages, dims: msg.dims });
|
||||||
|
if (msg.type === "error") reject(new Error(msg.message));
|
||||||
|
};
|
||||||
|
renderWorker!.onerror = e => reject(new Error(e.message));
|
||||||
|
const initMsg: WorkerInbound = { type: "init", pdfData: pdfBuf };
|
||||||
|
renderWorker!.postMessage(initMsg, [pdfBuf]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Compute initial scale (saved zoom if valid, otherwise fit-to-page).
|
||||||
|
const fitted = fitScale(dims);
|
||||||
|
const initialScale = (savedZoom > 0)
|
||||||
|
? Math.max(0.1, Math.min(5.0, savedZoom))
|
||||||
|
: fitted;
|
||||||
|
|
||||||
|
// 4. PageManager — creates N placeholder divs in #pages-wrapper.
|
||||||
|
pageManager = new PageManager(
|
||||||
|
pagesWrapper, dims, initialScale, DPR, dispatchRender,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Wire worker onmessage for ongoing renders and dim corrections.
|
||||||
|
renderWorker.onmessage = (ev: MessageEvent<WorkerOutbound>) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
if (msg.type === "rendered") {
|
||||||
|
pageManager?.onRendered(msg.pageNum, msg.gen, msg.bitmap);
|
||||||
|
refreshPageIndicator();
|
||||||
|
if (pageManager?.allRendered) setStatus("Ready");
|
||||||
|
} else if (msg.type === "textcontent") {
|
||||||
|
pageManager?.onTextContent(msg.pageNum, msg.gen, msg.items);
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
console.warn("[viewer] worker error:", msg.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. ViewportTracker — IntersectionObserver fires once placeholders are in
|
||||||
|
// the DOM, triggering the initial reconcile automatically.
|
||||||
|
viewportTracker = new ViewportTracker(
|
||||||
|
container,
|
||||||
|
[...pageManager.pageWrappers],
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. ZoomController — on Phase 2 re-render, also save state.
|
||||||
|
zoomController = new ZoomController(
|
||||||
|
container,
|
||||||
|
pageManager,
|
||||||
|
(newScale, bufferSet, visibleSet) => {
|
||||||
|
pageManager!.onScaleChange(newScale, bufferSet, visibleSet);
|
||||||
|
sendViewerState();
|
||||||
|
},
|
||||||
|
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
||||||
|
zoomLabel,
|
||||||
|
initialScale,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. Toolbar buttons
|
||||||
|
document.getElementById("btn-zoom-out")!.addEventListener("click",
|
||||||
|
() => zoomController!.applyScale(zoomController!.scale / 1.25));
|
||||||
|
document.getElementById("btn-zoom-in")!.addEventListener("click",
|
||||||
|
() => zoomController!.applyScale(zoomController!.scale * 1.25));
|
||||||
|
document.getElementById("btn-zoom-fit")!.addEventListener("click",
|
||||||
|
() => zoomController!.applyScale(fitScale(dims)));
|
||||||
|
|
||||||
|
// 10. Keyboard shortcuts + forwarding to parent for global keybindings.
|
||||||
|
document.addEventListener("keydown", ev => {
|
||||||
|
if ((ev.target as HTMLElement).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();
|
||||||
|
zoomController!.applyScale(fitScale(dims));
|
||||||
|
}
|
||||||
|
// Forward to parent Leptos app for global keybinding dispatch.
|
||||||
|
if (window.parent !== window) {
|
||||||
|
const msg: OutboundMessage = {
|
||||||
|
type: "brittle:keydown",
|
||||||
|
key: ev.key,
|
||||||
|
ctrlKey: ev.ctrlKey,
|
||||||
|
shiftKey: ev.shiftKey,
|
||||||
|
altKey: ev.altKey,
|
||||||
|
metaKey: ev.metaKey,
|
||||||
|
};
|
||||||
|
window.parent.postMessage(msg, "*");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 11. Scroll → update page indicator + debounced state save.
|
||||||
|
let scrollSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
container.addEventListener("scroll", () => {
|
||||||
|
refreshPageIndicator();
|
||||||
|
if (scrollSaveTimer !== null) clearTimeout(scrollSaveTimer);
|
||||||
|
scrollSaveTimer = setTimeout(sendViewerState, 500);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// 12. Restore saved scroll position (rAF ensures layout is ready).
|
||||||
|
if (savedScrollTop > 0) {
|
||||||
|
requestAnimationFrame(() => { container.scrollTop = savedScrollTop; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Inbound postMessage from parent Leptos app (page navigation commands).
|
||||||
|
window.addEventListener("message", ev => {
|
||||||
|
if (ev.data === "pdf.page.next") {
|
||||||
|
scrollToPage(Math.min(
|
||||||
|
pageManager!.getCurrentPage(currentVisibleSet) + 1, numPages,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (ev.data === "pdf.page.prev") {
|
||||||
|
scrollToPage(Math.max(
|
||||||
|
pageManager!.getCurrentPage(currentVisibleSet) - 1, 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 14. Tab visibility lifecycle: free caches when hidden, re-reconcile on show.
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
renderWorker?.postMessage({ type: "cleanup" } satisfies WorkerInbound);
|
||||||
|
} else {
|
||||||
|
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 15. DPR change (e.g., window moved to a different-DPI monitor).
|
||||||
|
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
|
||||||
|
if (pageManager && zoomController) {
|
||||||
|
pageManager.onScaleChange(
|
||||||
|
zoomController.scale, currentBufferSet, currentVisibleSet,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 16. Teardown on unload.
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
viewportTracker?.disconnect();
|
||||||
|
renderWorker?.postMessage({ type: "destroy" } satisfies WorkerInbound);
|
||||||
|
renderWorker = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
pageIndicator.textContent = `1 / ${numPages}`;
|
||||||
|
setStatus("Rendering…");
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
showError("Could not load PDF: " + ((e as Error).message ?? String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
88
src-tauri/assets/viewer-src/src/viewport-tracker.ts
Normal file
88
src-tauri/assets/viewer-src/src/viewport-tracker.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* ViewportTracker — dual IntersectionObserver for page visibility detection.
|
||||||
|
*
|
||||||
|
* - visibleSet: pages currently on screen (rootMargin "0px")
|
||||||
|
* - bufferSet: pages within ~2 viewport heights above/below (rootMargin "200% 0px")
|
||||||
|
*
|
||||||
|
* Both observers schedule a single rAF-deferred notification to prevent stale
|
||||||
|
* data when the two observers fire in separate microtasks for the same layout
|
||||||
|
* change (e.g. buffer removes a page before visible has also removed it).
|
||||||
|
*
|
||||||
|
* Hard invariant enforced before every callback: every visible page is also
|
||||||
|
* in the buffer set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type OnVisibilityChange = (
|
||||||
|
bufferSet: ReadonlySet<number>,
|
||||||
|
visibleSet: ReadonlySet<number>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export class ViewportTracker {
|
||||||
|
private readonly _onChange: OnVisibilityChange;
|
||||||
|
private readonly _visibleSet: Set<number> = new Set();
|
||||||
|
private readonly _bufferSet: Set<number> = new Set();
|
||||||
|
private _visibleObserver: IntersectionObserver | null = null;
|
||||||
|
private _bufferObserver: IntersectionObserver | null = null;
|
||||||
|
private _rafPending: number | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
root: HTMLElement,
|
||||||
|
pageWrappers: HTMLElement[],
|
||||||
|
onChange: OnVisibilityChange,
|
||||||
|
) {
|
||||||
|
this._onChange = onChange;
|
||||||
|
this._observe(root, pageWrappers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _observe(root: HTMLElement, pageWrappers: HTMLElement[]): void {
|
||||||
|
const scheduleNotify = (): void => {
|
||||||
|
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._onChange(new Set(this._bufferSet), new Set(this._visibleSet));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this._visibleObserver = new IntersectionObserver(entries => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const page = parseInt((e.target as HTMLElement).dataset["page"]!, 10);
|
||||||
|
if (e.isIntersecting) this._visibleSet.add(page);
|
||||||
|
else this._visibleSet.delete(page);
|
||||||
|
}
|
||||||
|
scheduleNotify();
|
||||||
|
}, { root, rootMargin: "0px", threshold: 0 });
|
||||||
|
|
||||||
|
this._bufferObserver = new IntersectionObserver(entries => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const page = parseInt((e.target as HTMLElement).dataset["page"]!, 10);
|
||||||
|
if (e.isIntersecting) this._bufferSet.add(page);
|
||||||
|
else this._bufferSet.delete(page);
|
||||||
|
}
|
||||||
|
scheduleNotify();
|
||||||
|
}, { root, rootMargin: "200% 0px", threshold: 0 });
|
||||||
|
|
||||||
|
for (const wrap of pageWrappers) {
|
||||||
|
this._visibleObserver.observe(wrap);
|
||||||
|
this._bufferObserver.observe(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Observe a newly-added page wrapper (unused currently, kept for extensibility). */
|
||||||
|
observe(wrap: HTMLElement): void {
|
||||||
|
this._visibleObserver?.observe(wrap);
|
||||||
|
this._bufferObserver?.observe(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this._visibleObserver?.disconnect();
|
||||||
|
this._bufferObserver?.disconnect();
|
||||||
|
this._visibleSet.clear();
|
||||||
|
this._bufferSet.clear();
|
||||||
|
if (this._rafPending !== null) {
|
||||||
|
cancelAnimationFrame(this._rafPending);
|
||||||
|
this._rafPending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src-tauri/assets/viewer-src/src/zoom-controller.ts
Normal file
161
src-tauri/assets/viewer-src/src/zoom-controller.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* ZoomController — two-phase zoom pipeline.
|
||||||
|
*
|
||||||
|
* Phase 1 (instant, every event):
|
||||||
|
* CSS `zoom` property on each .page-wrapper = newScale / renderScale
|
||||||
|
* Scroll position adjusted to keep the anchor point fixed
|
||||||
|
* Zoom label updated immediately
|
||||||
|
*
|
||||||
|
* Phase 2 (debounced, 250ms after last event):
|
||||||
|
* pageManager.onScaleChange(newScale, bufferSet, visibleSet)
|
||||||
|
* Pages re-rendered at native resolution via the render worker
|
||||||
|
*
|
||||||
|
* Ctrl+Scroll events within a single frame are coalesced via rAF; the 250ms
|
||||||
|
* debounce starts from inside the rAF callback so it begins after the last
|
||||||
|
* event in a burst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PageManager } from "./page-manager.js";
|
||||||
|
|
||||||
|
// Must match the CSS constants in index.html:
|
||||||
|
// #pages-wrapper { padding: 20px 0; gap: 12px; }
|
||||||
|
const CONTENT_PADDING_TOP = 20;
|
||||||
|
const PAGE_GAP = 12;
|
||||||
|
|
||||||
|
export interface BufferSnapshot {
|
||||||
|
bufferSet: ReadonlySet<number>;
|
||||||
|
visibleSet: ReadonlySet<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnReRender = (
|
||||||
|
newScale: number,
|
||||||
|
bufferSet: ReadonlySet<number>,
|
||||||
|
visibleSet: ReadonlySet<number>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export class ZoomController {
|
||||||
|
static readonly ZOOM_MIN = 0.1;
|
||||||
|
static readonly ZOOM_MAX = 5.0;
|
||||||
|
|
||||||
|
private readonly _container: HTMLElement;
|
||||||
|
private readonly _pm: PageManager;
|
||||||
|
private readonly _onReRender: OnReRender;
|
||||||
|
private readonly _getBuffer: () => BufferSnapshot;
|
||||||
|
private readonly _zoomLabel: HTMLElement;
|
||||||
|
|
||||||
|
private _scale: number;
|
||||||
|
private _renderScale: number;
|
||||||
|
private _debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private _rafPending: number | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
container: HTMLElement,
|
||||||
|
pageManager: PageManager,
|
||||||
|
onReRender: OnReRender,
|
||||||
|
getBuffer: () => BufferSnapshot,
|
||||||
|
zoomLabel: HTMLElement,
|
||||||
|
initialScale: number,
|
||||||
|
) {
|
||||||
|
this._container = container;
|
||||||
|
this._pm = pageManager;
|
||||||
|
this._onReRender = onReRender;
|
||||||
|
this._getBuffer = getBuffer;
|
||||||
|
this._zoomLabel = zoomLabel;
|
||||||
|
this._scale = initialScale;
|
||||||
|
this._renderScale = initialScale;
|
||||||
|
|
||||||
|
this._updateLabel();
|
||||||
|
this._bindScrollZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
get scale(): number { return this._scale; }
|
||||||
|
|
||||||
|
clamp(s: number): number {
|
||||||
|
return Math.max(ZoomController.ZOOM_MIN, Math.min(ZoomController.ZOOM_MAX, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateLabel(): void {
|
||||||
|
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a new zoom level.
|
||||||
|
*
|
||||||
|
* @param newScale - Target zoom scale.
|
||||||
|
* @param anchorY - Pixel offset within container to hold fixed (default: center).
|
||||||
|
* @param anchorX - Pixel offset within container to hold fixed (default: center).
|
||||||
|
*/
|
||||||
|
applyScale(newScale: number, anchorY?: number, anchorX?: number): void {
|
||||||
|
const container = this._container;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Compute the non-scaling portion of content above the anchor point.
|
||||||
|
// Gaps and padding are fixed-size (do not scale with zoom), so a naive
|
||||||
|
// `(scrollTop + anchorY) × ratio` formula accumulates one error of
|
||||||
|
// `GAP × (ratio − 1)` per gap above the anchor — enough to visibly drift
|
||||||
|
// the anchor point on long documents.
|
||||||
|
const anchorContentY = container.scrollTop + anchorY;
|
||||||
|
let fixedAbove = CONTENT_PADDING_TOP;
|
||||||
|
let cumY = CONTENT_PADDING_TOP;
|
||||||
|
for (const wrap of this._pm.pageWrappers) {
|
||||||
|
const zoom = parseFloat(wrap.style.getPropertyValue("zoom") || "1") || 1;
|
||||||
|
const h = parseFloat(wrap.style.height) * zoom;
|
||||||
|
if (cumY + h > anchorContentY) break;
|
||||||
|
cumY += h + PAGE_GAP;
|
||||||
|
fixedAbove += PAGE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: instant CSS zoom feedback (no re-render yet)
|
||||||
|
const cssZoom = this._scale / this._renderScale;
|
||||||
|
for (const wrap of this._pm.pageWrappers) {
|
||||||
|
wrap.style.setProperty("zoom", String(cssZoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact scroll anchor: scale only the page-content portion; the fixed
|
||||||
|
// portion (gaps + padding) does not change.
|
||||||
|
// T_new = fixedAbove + (T_old + anchorY − fixedAbove) × ratio − anchorY
|
||||||
|
const ratio = this._scale / oldScale;
|
||||||
|
const scalable = container.scrollTop + anchorY - fixedAbove;
|
||||||
|
container.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY);
|
||||||
|
container.scrollLeft = Math.max(0, (container.scrollLeft + anchorX) * ratio - anchorX);
|
||||||
|
|
||||||
|
this._scheduleReRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleReRender(): void {
|
||||||
|
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
|
||||||
|
this._rafPending = requestAnimationFrame(() => {
|
||||||
|
this._rafPending = null;
|
||||||
|
if (this._debounce !== null) clearTimeout(this._debounce);
|
||||||
|
this._debounce = setTimeout(() => this._triggerReRender(), 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _triggerReRender(): void {
|
||||||
|
this._pm.setZooming(false); // Phase 2: onScaleChange handles the cleanup
|
||||||
|
this._renderScale = this._scale;
|
||||||
|
const { bufferSet, visibleSet } = this._getBuffer();
|
||||||
|
this._onReRender(this._scale, bufferSet, visibleSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bindScrollZoom(): void {
|
||||||
|
this._container.addEventListener("wheel", ev => {
|
||||||
|
if (!ev.ctrlKey) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const rect = this._container.getBoundingClientRect();
|
||||||
|
const anchorY = ev.clientY - rect.top;
|
||||||
|
const anchorX = ev.clientX - rect.left;
|
||||||
|
this.applyScale(
|
||||||
|
this._scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1),
|
||||||
|
anchorY,
|
||||||
|
anchorX,
|
||||||
|
);
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src-tauri/assets/viewer-src/tsconfig.json
Normal file
12
src-tauri/assets/viewer-src/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollable viewport — contains the zoomed pages wrapper */
|
/* Scrollable viewport — contains the pages wrapper */
|
||||||
#canvas-container {
|
#canvas-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -79,8 +79,7 @@
|
|||||||
background: #1d2021;
|
background: #1d2021;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner column that receives the CSS zoom for instant visual feedback.
|
/* Column of page placeholders/canvases */
|
||||||
CSS zoom (unlike transform) affects layout, so scrollbars stay correct. */
|
|
||||||
#pages-wrapper {
|
#pages-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -91,17 +90,49 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-wrapper { flex-shrink: 0; }
|
/* Each page: sized div that holds a canvas once rendered.
|
||||||
|
White background makes unrendered placeholders look like blank pages. */
|
||||||
|
.page-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
.page-wrapper canvas {
|
.page-wrapper canvas {
|
||||||
display: block;
|
display: block;
|
||||||
background: #fff;
|
}
|
||||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55);
|
|
||||||
|
/* Invisible text overlay for native selection. Spans are positioned via
|
||||||
|
CSS matrix() derived from PDF text transforms; color:transparent keeps
|
||||||
|
the canvas visible while the browser handles selection normally. */
|
||||||
|
.textLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 2;
|
||||||
|
line-height: 1;
|
||||||
|
text-size-adjust: none;
|
||||||
|
forced-color-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer span {
|
||||||
|
color: transparent;
|
||||||
|
position: absolute;
|
||||||
|
white-space: pre;
|
||||||
|
cursor: text;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
font-size: 1px;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer ::selection {
|
||||||
|
background: rgba(100, 160, 255, 0.35);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="error-banner"></div>
|
<div id="error-banner"></div>
|
||||||
|
|
||||||
<div id="toolbar">
|
<div id="toolbar">
|
||||||
@@ -112,309 +143,13 @@
|
|||||||
<button id="btn-zoom-fit" title="Fit width [ 0 ]">Fit</button>
|
<button id="btn-zoom-fit" title="Fit width [ 0 ]">Fit</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="page-indicator">— / —</span>
|
<span id="page-indicator">— / —</span>
|
||||||
<span id="status">Loading PDF.js…</span>
|
<span id="status">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="canvas-container"></div>
|
<div id="canvas-container">
|
||||||
|
<div id="pages-wrapper"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
|
<script src="brittle://app/viewer/viewer.bundle.js"></script>
|
||||||
<script>
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const refId = new URLSearchParams(location.search).get("ref_id") || "";
|
|
||||||
|
|
||||||
const pdfjsLib = window.pdfjsLib;
|
|
||||||
if (!pdfjsLib) {
|
|
||||||
showError("PDF.js failed to load. Make sure the app is running inside Brittle.");
|
|
||||||
} else {
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
|
||||||
"brittle://app/pdfjs/build/pdf.worker.min.js";
|
|
||||||
|
|
||||||
const container = document.getElementById("canvas-container");
|
|
||||||
const statusEl = document.getElementById("status");
|
|
||||||
const zoomLabel = document.getElementById("zoom-label");
|
|
||||||
const pageIndicator = document.getElementById("page-indicator");
|
|
||||||
|
|
||||||
const DPR = window.devicePixelRatio || 1;
|
|
||||||
const ZOOM_MIN = 0.1;
|
|
||||||
const ZOOM_MAX = 5.0;
|
|
||||||
|
|
||||||
let pdfDoc = null;
|
|
||||||
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
|
|
||||||
let pendingRenderTask = null; // the active page.render() RenderTask, if any
|
|
||||||
|
|
||||||
// ── Utilities ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function setStatus(msg) { statusEl.textContent = msg; }
|
|
||||||
|
|
||||||
function clampScale(s) {
|
|
||||||
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateZoomLabel() {
|
|
||||||
zoomLabel.textContent = Math.round(scale * 100) + "%";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zoom ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// 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 scale;
|
|
||||||
const page = await pdfDoc.getPage(1);
|
|
||||||
const vp = page.getViewport({ scale: 1.0 });
|
|
||||||
const avail = container.clientWidth - 40;
|
|
||||||
return clampScale(avail / vp.width);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function scheduleRender() {
|
|
||||||
clearTimeout(renderTimer);
|
|
||||||
renderTimer = setTimeout(renderAll, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderAll() {
|
|
||||||
if (!pdfDoc) return;
|
|
||||||
pendingRenderTask?.cancel();
|
|
||||||
pendingRenderTask = null;
|
|
||||||
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";
|
|
||||||
|
|
||||||
const wrappers = [];
|
|
||||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
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);
|
|
||||||
newWrapper.appendChild(wrap);
|
|
||||||
wrappers.push(wrap);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, targetScale) {
|
|
||||||
let task = null;
|
|
||||||
try {
|
|
||||||
const page = await pdfDoc.getPage(pageNum);
|
|
||||||
const vp = page.getViewport({ scale: targetScale * DPR });
|
|
||||||
const canvas = wrapper.querySelector("canvas");
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
canvas.width = vp.width;
|
|
||||||
canvas.height = vp.height;
|
|
||||||
canvas.style.width = Math.round(vp.width / DPR) + "px";
|
|
||||||
canvas.style.height = Math.round(vp.height / DPR) + "px";
|
|
||||||
|
|
||||||
task = page.render({ canvasContext: canvas.getContext("2d"), viewport: vp });
|
|
||||||
pendingRenderTask = task;
|
|
||||||
await task.promise;
|
|
||||||
} catch (e) {
|
|
||||||
if (e?.name !== "RenderingCancelledException") console.warn("render:", e);
|
|
||||||
} finally {
|
|
||||||
if (pendingRenderTask === task) pendingRenderTask = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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;
|
|
||||||
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() {
|
|
||||||
if (!refId) { showError("No ref_id in URL."); return; }
|
|
||||||
setStatus("Loading…");
|
|
||||||
try {
|
|
||||||
const url = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
|
|
||||||
pdfDoc = await pdfjsLib.getDocument({ url }).promise;
|
|
||||||
|
|
||||||
scale = await fitToWidth();
|
|
||||||
renderScale = scale;
|
|
||||||
updateZoomLabel();
|
|
||||||
setStatus("Rendering…");
|
|
||||||
await renderAll();
|
|
||||||
} catch (e) {
|
|
||||||
showError("Could not load PDF: " + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toolbar buttons ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
document.getElementById("btn-zoom-out").addEventListener("click",
|
|
||||||
() => applyScale(scale / 1.25));
|
|
||||||
document.getElementById("btn-zoom-in").addEventListener("click",
|
|
||||||
() => applyScale(scale * 1.25));
|
|
||||||
document.getElementById("btn-zoom-fit").addEventListener("click",
|
|
||||||
async () => applyScale(await fitToWidth()));
|
|
||||||
|
|
||||||
// Ctrl+Scroll — anchor at the cursor position.
|
|
||||||
container.addEventListener("wheel", ev => {
|
|
||||||
if (!ev.ctrlKey) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
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).
|
|
||||||
// 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); }
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(msg) {
|
|
||||||
const b = document.getElementById("error-banner");
|
|
||||||
b.textContent = msg;
|
|
||||||
b.style.display = "block";
|
|
||||||
document.getElementById("status").textContent = "Error";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
46
src-tauri/assets/viewer/render-worker.bundle.js
Normal file
46
src-tauri/assets/viewer/render-worker.bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/assets/viewer/viewer.bundle.js
Normal file
1
src-tauri/assets/viewer/viewer.bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -201,6 +201,7 @@ mod tests {
|
|||||||
let original = LayoutConfig {
|
let original = LayoutConfig {
|
||||||
left_pane_fraction: 0.25,
|
left_pane_fraction: 0.25,
|
||||||
right_pane_fraction: 0.40,
|
right_pane_fraction: 0.40,
|
||||||
|
..LayoutConfig::default()
|
||||||
};
|
};
|
||||||
let s = toml::to_string_pretty(&original).unwrap();
|
let s = toml::to_string_pretty(&original).unwrap();
|
||||||
let parsed: LayoutConfig = toml::from_str(&s).unwrap();
|
let parsed: LayoutConfig = toml::from_str(&s).unwrap();
|
||||||
@@ -272,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]
|
||||||
@@ -286,6 +293,7 @@ mod tests {
|
|||||||
layout: LayoutConfig {
|
layout: LayoutConfig {
|
||||||
left_pane_fraction: 0.30,
|
left_pane_fraction: 0.30,
|
||||||
right_pane_fraction: 0.40,
|
right_pane_fraction: 0.40,
|
||||||
|
..LayoutConfig::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ static PDFJS_WORKER_JS: &[u8] = include_bytes!(
|
|||||||
"../assets/viewer/pdfjs/pdf.worker.min.js"
|
"../assets/viewer/pdfjs/pdf.worker.min.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Viewer JS bundles (served at brittle://app/viewer/<file>)
|
||||||
|
// Source: src-tauri/assets/viewer-src/src/ — build with:
|
||||||
|
// cd src-tauri/assets/viewer-src && npm run build
|
||||||
|
static VIEWER_BUNDLE_JS: &[u8] =
|
||||||
|
include_bytes!("../assets/viewer/viewer.bundle.js");
|
||||||
|
static RENDER_WORKER_BUNDLE_JS: &[u8] =
|
||||||
|
include_bytes!("../assets/viewer/render-worker.bundle.js");
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Entry point registered with `tauri::Builder::register_uri_scheme_protocol`.
|
/// Entry point registered with `tauri::Builder::register_uri_scheme_protocol`.
|
||||||
@@ -37,6 +45,7 @@ pub fn handle<R: Runtime>(app: &AppHandle<R>, req: &Request<Vec<u8>>) -> Respons
|
|||||||
|
|
||||||
match routing::classify(uri.path(), uri.query()) {
|
match routing::classify(uri.path(), uri.query()) {
|
||||||
routing::Route::Viewer { ref_id } => serve_viewer(&ref_id),
|
routing::Route::Viewer { ref_id } => serve_viewer(&ref_id),
|
||||||
|
routing::Route::ViewerAsset { file_name } => serve_viewer_asset(&file_name),
|
||||||
routing::Route::PdfjsAsset { rel_path } => {
|
routing::Route::PdfjsAsset { rel_path } => {
|
||||||
if rel_path.contains("..") {
|
if rel_path.contains("..") {
|
||||||
return response_403();
|
return response_403();
|
||||||
@@ -57,6 +66,15 @@ fn serve_viewer(_ref_id: &str) -> Response<Vec<u8>> {
|
|||||||
response_ok(VIEWER_HTML.to_vec(), "text/html; charset=utf-8")
|
response_ok(VIEWER_HTML.to_vec(), "text/html; charset=utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serve_viewer_asset(file_name: &str) -> Response<Vec<u8>> {
|
||||||
|
let bytes: &[u8] = match file_name {
|
||||||
|
"viewer.bundle.js" => VIEWER_BUNDLE_JS,
|
||||||
|
"render-worker.bundle.js" => RENDER_WORKER_BUNDLE_JS,
|
||||||
|
_ => return response_404(),
|
||||||
|
};
|
||||||
|
response_ok(bytes.to_vec(), "application/javascript; charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
fn serve_pdfjs_file(rel_path: &str) -> Response<Vec<u8>> {
|
fn serve_pdfjs_file(rel_path: &str) -> Response<Vec<u8>> {
|
||||||
let bytes: &[u8] = match rel_path {
|
let bytes: &[u8] = match rel_path {
|
||||||
"build/pdf.min.js" => PDFJS_MIN_JS,
|
"build/pdf.min.js" => PDFJS_MIN_JS,
|
||||||
@@ -152,17 +170,31 @@ pub mod routing {
|
|||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
Viewer { ref_id: String },
|
Viewer { ref_id: String },
|
||||||
|
ViewerAsset { file_name: String },
|
||||||
PdfjsAsset { rel_path: String },
|
PdfjsAsset { rel_path: String },
|
||||||
Pdf { ref_id: String },
|
Pdf { ref_id: String },
|
||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a `brittle://app/{path}?{query}` request into a `Route`.
|
/// Classify a `brittle://app/{path}?{query}` request into a `Route`.
|
||||||
|
///
|
||||||
|
/// - `/viewer` (no further path segments) → HTML shell
|
||||||
|
/// - `/viewer/<file>` (one path segment) → viewer JS module
|
||||||
|
/// - `/pdfjs/<rel>` → PDF.js asset
|
||||||
|
/// - `/pdf` → raw PDF bytes
|
||||||
pub fn classify(path: &str, query: Option<&str>) -> Route {
|
pub fn classify(path: &str, query: Option<&str>) -> Route {
|
||||||
let ref_id = extract_ref_id(query);
|
let ref_id = extract_ref_id(query);
|
||||||
|
|
||||||
if path == "/viewer" {
|
if path == "/viewer" {
|
||||||
Route::Viewer { ref_id }
|
Route::Viewer { ref_id }
|
||||||
|
} else if let Some(rest) = path.strip_prefix("/viewer/") {
|
||||||
|
// Reject any path that contains directory separators or dots
|
||||||
|
// at the start, to prevent traversal.
|
||||||
|
if rest.contains('/') || rest.starts_with('.') {
|
||||||
|
Route::NotFound
|
||||||
|
} else {
|
||||||
|
Route::ViewerAsset { file_name: rest.to_owned() }
|
||||||
|
}
|
||||||
} else if let Some(rel) = path.strip_prefix("/pdfjs/") {
|
} else if let Some(rel) = path.strip_prefix("/pdfjs/") {
|
||||||
Route::PdfjsAsset {
|
Route::PdfjsAsset {
|
||||||
rel_path: rel.to_owned(),
|
rel_path: rel.to_owned(),
|
||||||
@@ -239,6 +271,30 @@ pub mod routing {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_asset_route() {
|
||||||
|
let r = classify("/viewer/viewer.js", None);
|
||||||
|
assert_eq!(r, Route::ViewerAsset { file_name: "viewer.js".into() });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_asset_route_bundles() {
|
||||||
|
for name in &["viewer.bundle.js", "render-worker.bundle.js"] {
|
||||||
|
let path = format!("/viewer/{}", name);
|
||||||
|
assert_eq!(
|
||||||
|
classify(&path, None),
|
||||||
|
Route::ViewerAsset { file_name: name.to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_asset_traversal_blocked() {
|
||||||
|
// Directory traversal via nested path → NotFound
|
||||||
|
assert_eq!(classify("/viewer/../secret.js", None), Route::NotFound);
|
||||||
|
assert_eq!(classify("/viewer/sub/file.js", None), Route::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_paths_are_not_found() {
|
fn unknown_paths_are_not_found() {
|
||||||
assert_eq!(classify("/unknown", None), Route::NotFound);
|
assert_eq!(classify("/unknown", None), Route::NotFound);
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"identifier": "dev.brittle.app",
|
"identifier": "dev.brittle.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": {
|
"beforeDevCommand": {
|
||||||
"script": "trunk serve",
|
"script": "sh scripts/dev.sh",
|
||||||
"cwd": "../brittle-ui"
|
"cwd": ".."
|
||||||
},
|
},
|
||||||
"beforeBuildCommand": {
|
"beforeBuildCommand": {
|
||||||
"script": "trunk build --release",
|
"script": "npm --prefix src-tauri/assets/viewer-src run build && trunk build --release",
|
||||||
"cwd": "../brittle-ui"
|
"cwd": ".."
|
||||||
},
|
},
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
|
|||||||
Reference in New Issue
Block a user