diff --git a/crates/handler-common/src/output_format.rs b/crates/handler-common/src/output_format.rs index 0ebc518..1f3d48d 100644 --- a/crates/handler-common/src/output_format.rs +++ b/crates/handler-common/src/output_format.rs @@ -17,6 +17,11 @@ pub struct ViewOptions { pub cols: Option>, /// Page filter string (e.g. "1", "2-5", "1,3,5"). Parsed by each handler. pub page: Option, + /// Whether HTML previews may load page content from the watch server. + /// + /// Standalone output must leave this disabled so the generated HTML has no + /// runtime dependency on a server. + pub lazy_load: bool, } /// Options for raw commands. diff --git a/crates/officecli/src/commands/view.rs b/crates/officecli/src/commands/view.rs index 03b7e3a..bd76a0b 100644 --- a/crates/officecli/src/commands/view.rs +++ b/crates/officecli/src/commands/view.rs @@ -85,6 +85,7 @@ pub fn handle_view(cmd: ViewCommand, format: OutputFormat) -> Result) -> Result) -> ViewOpt .get("page") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + lazy_load: false, } } diff --git a/crates/officecli/src/watch.rs b/crates/officecli/src/watch.rs index 483d1f0..f09e72d 100644 --- a/crates/officecli/src/watch.rs +++ b/crates/officecli/src/watch.rs @@ -755,7 +755,10 @@ async fn handle_index( let result = send_op_for_doc( &doc, HandlerOp::ViewHtml { - opts: ViewOptions::default(), + opts: ViewOptions { + lazy_load: true, + ..Default::default() + }, }, ) .await; @@ -1033,6 +1036,7 @@ async fn handle_view( .get("cols") .map(|c| c.split(',').map(|s| s.to_string()).collect()), page: params.get("page").cloned(), + lazy_load: mode == "html", }; let op = match mode.as_str() { diff --git a/crates/pdf-handler/src/html_preview.rs b/crates/pdf-handler/src/html_preview.rs index b837c4c..c0326af 100644 --- a/crates/pdf-handler/src/html_preview.rs +++ b/crates/pdf-handler/src/html_preview.rs @@ -428,18 +428,85 @@ pub fn view_as_html(reader: &PdfReader, opts: ViewOptions) -> Result + if opts.lazy_load { + pages_html.push_str(&format!( + "
Page {}
Loading page {}...
\n", - i, width, height, i, i - )); + i, width, height, i, i + )); + } else { + let inner_html = view_page_as_html(reader, i)?; + pages_html.push_str(&format!( + "
\n{}\n
\n", + i, width, height, inner_html + )); + } } + let lazy_loader_script = if opts.lazy_load { + r#" +// Lazy Loading IntersectionObserver +(function() { + const base = window.location.pathname.endsWith('/') ? window.location.pathname : window.location.pathname + '/'; + + const observerOptions = { + root: null, + rootMargin: "300px 0px", // pre-load pages 300px before they enter viewport + threshold: 0.01 + }; + + const loadPage = (placeholder) => { + if (placeholder.dataset.loading) return; + placeholder.dataset.loading = "true"; + const pageNum = placeholder.dataset.page; + + fetch(base + 'page/' + pageNum + '/html') + .then(res => { + if (!res.ok) throw new Error("HTTP error " + res.status); + return res.text(); + }) + .then(html => { + placeholder.outerHTML = html; + adjustTextScaling(); + }) + .catch(err => { + console.error("Failed to load page " + pageNum, err); + placeholder.dataset.loading = "false"; + const errorLoader = placeholder.querySelector(".skeleton-loader"); + if (errorLoader) { + errorLoader.innerHTML = 'Failed to load. Click to retry.'; + errorLoader.style.cursor = "pointer"; + errorLoader.onclick = () => { + errorLoader.innerHTML = '
Retrying page ' + pageNum + '...'; + loadPage(placeholder); + }; + } + }); + }; + + const observer = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + loadPage(entry.target); + observer.unobserve(entry.target); + } + }); + }, observerOptions); + + document.querySelectorAll(".page.placeholder").forEach(el => { + observer.observe(el); + }); +})(); +"# + } else { + "" + }; + Ok(format!( r#" @@ -539,63 +606,11 @@ window.addEventListener("load", adjustTextScaling); if (document.fonts && document.fonts.ready) {{ document.fonts.ready.then(adjustTextScaling); }} - -// Lazy Loading IntersectionObserver -(function() {{ - const base = window.location.pathname.endsWith('/') ? window.location.pathname : window.location.pathname + '/'; - - const observerOptions = {{ - root: null, - rootMargin: "300px 0px", // pre-load pages 300px before they enter viewport - threshold: 0.01 - }}; - - const loadPage = (placeholder) => {{ - if (placeholder.dataset.loading) return; - placeholder.dataset.loading = "true"; - const pageNum = placeholder.dataset.page; - - fetch(base + 'page/' + pageNum + '/html') - .then(res => {{ - if (!res.ok) throw new Error("HTTP error " + res.status); - return res.text(); - }}) - .then(html => {{ - placeholder.outerHTML = html; - adjustTextScaling(); - }}) - .catch(err => {{ - console.error("Failed to load page " + pageNum, err); - placeholder.dataset.loading = "false"; - const errorLoader = placeholder.querySelector(".skeleton-loader"); - if (errorLoader) {{ - errorLoader.innerHTML = 'Failed to load. Click to retry.'; - errorLoader.style.cursor = "pointer"; - errorLoader.onclick = () => {{ - errorLoader.innerHTML = '
Retrying page ' + pageNum + '...'; - loadPage(placeholder); - }}; - }} - }}); - }}; - - const observer = new IntersectionObserver((entries, observer) => {{ - entries.forEach(entry => {{ - if (entry.isIntersecting) {{ - loadPage(entry.target); - observer.unobserve(entry.target); - }} - }}); - }}, observerOptions); - - document.querySelectorAll(".page.placeholder").forEach(el => {{ - observer.observe(el); - }}); -}})(); +{} "#, - file_name, pages_html + file_name, pages_html, lazy_loader_script )) } @@ -605,3 +620,37 @@ fn html_escape(s: &str) -> String { .replace('>', ">") .replace('"', """) } + +#[cfg(test)] +mod tests { + use super::view_as_html; + use crate::reader::PdfReader; + use handler_common::ViewOptions; + + #[test] + fn standalone_html_inlines_all_pages_without_server_requests() { + let reader = PdfReader::fallback(2, "standalone.pdf"); + let html = view_as_html(&reader, ViewOptions::default()).unwrap(); + + assert!(html.contains("data-path=\"/page[1]\"")); + assert!(html.contains("data-path=\"/page[2]\"")); + assert!(!html.contains("fetch(")); + assert!(!html.contains("page placeholder")); + } + + #[test] + fn watch_html_keeps_lazy_page_loading() { + let reader = PdfReader::fallback(2, "watch.pdf"); + let html = view_as_html( + &reader, + ViewOptions { + lazy_load: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!(html.contains("fetch(")); + assert!(html.contains("page placeholder")); + } +}