diff --git a/Cargo.toml b/Cargo.toml index 44d313b..1ca6f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ env_logger = "0.11.8" # web: [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4.50" -web-sys = "0.3.70" # to access the DOM (to hide the loading text) +web-sys = { version = "0.3.70", features = ["Performance", "Response", "Window"] } # to access the DOM (to hide the loading text) [profile.release] opt-level = 2 # fast and small wasm diff --git a/index.html b/index.html index b022f84..43f1a83 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index 4536964..e4d887e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,11 @@ use crate::svg::SvgContent; use crate::text_cache::TextCache; +#[cfg(target_arch = "wasm32")] +use std::cell::RefCell; +#[cfg(target_arch = "wasm32")] +use std::rc::Rc; + mod animation; mod input; mod render; @@ -61,6 +66,10 @@ pub struct LasApp { /// Text rendering cache for smooth scaling. #[serde(skip)] text_cache: Option, + + #[cfg(target_arch = "wasm32")] + #[serde(skip)] + svg_loader: Option>>>>, } impl Default for LasApp { @@ -81,6 +90,8 @@ impl Default for LasApp { last_cursor_pos: None, reset_view_requested: false, view_animation: None, + #[cfg(target_arch = "wasm32")] + svg_loader: None, } } } @@ -101,34 +112,62 @@ impl LasApp { .and_then(|s| eframe::get_value(s, eframe::APP_KEY)) .unwrap_or_default(); - // Load SVG content - let svg_path = "../line-and-surface/content/intro.svg"; - log::info!("Loading SVG from: {svg_path}"); - let start = std::time::Instant::now(); + #[cfg(not(target_arch = "wasm32"))] + { + // Load SVG content + let svg_path = "../line-and-surface/content/intro.svg"; + log::info!("Loading SVG from: {svg_path}"); + let start = std::time::Instant::now(); - match SvgContent::from_file(svg_path) { - Ok(content) => { - let elapsed = start.elapsed(); - log::info!( - "Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts", - elapsed, - content.video_scrolls.len(), - content.audio_areas.len(), - content.anchors.len(), - content.texts.len() - ); - if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox { - log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})"); - app.pan_x = -vb_x; - app.pan_y = -vb_y; + match SvgContent::from_file(svg_path) { + Ok(content) => { + let elapsed = start.elapsed(); + log::info!( + "Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts", + elapsed, + content.video_scrolls.len(), + content.audio_areas.len(), + content.anchors.len(), + content.texts.len() + ); + if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox { + log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})"); + app.pan_x = -vb_x; + app.pan_y = -vb_y; + } + app.svg_content = Some(content); + } + Err(e) => { + log::error!("Failed to load SVG: {e}"); } - app.svg_content = Some(content); - } - Err(e) => { - log::error!("Failed to load SVG: {e}"); } } + #[cfg(target_arch = "wasm32")] + { + let svg_path = "./content/intro.svg"; + log::info!("Loading SVG from: {svg_path}"); + let loader = Rc::new(RefCell::new(None)); + let loader_handle = loader.clone(); + wasm_bindgen_futures::spawn_local(async move { + let start = wasm_now(); + let result = load_svg_from_url(svg_path).await; + if let Ok(ref content) = result { + let elapsed_ms = (wasm_now() - start).max(0.0); + log::info!( + "Loaded SVG in {:.2}ms: {} video scrolls, {} audio areas, {} anchors, {} texts", + elapsed_ms, + content.video_scrolls.len(), + content.audio_areas.len(), + content.anchors.len(), + content.texts.len() + ); + } + *loader_handle.borrow_mut() = Some(result); + }); + app.svg_loader = Some(loader); + } + log::info!("Application initialized"); app } @@ -139,6 +178,38 @@ impl LasApp { self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema; } } + + #[cfg(target_arch = "wasm32")] + fn poll_svg_loader(&mut self, ctx: &egui::Context) { + if self.svg_content.is_some() { + return; + } + + let Some(loader) = self.svg_loader.as_ref() else { + return; + }; + + let Some(result) = loader.borrow_mut().take() else { + return; + }; + + match result { + Ok(content) => { + if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox { + log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})"); + self.pan_x = -vb_x; + self.pan_y = -vb_y; + } + self.svg_content = Some(content); + ctx.request_repaint(); + } + Err(err) => { + log::error!("Failed to load SVG: {err}"); + } + } + + self.svg_loader = None; + } } impl eframe::App for LasApp { @@ -147,6 +218,9 @@ impl eframe::App for LasApp { } fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_arch = "wasm32")] + self.poll_svg_loader(ctx); + let input = InputSnapshot::collect(ctx); if input.primary_pressed @@ -184,3 +258,46 @@ impl eframe::App for LasApp { } } } + +#[cfg(target_arch = "wasm32")] +async fn load_svg_from_url(url: &str) -> Result { + use wasm_bindgen_futures::JsFuture; + use web_sys::Response; + use web_sys::wasm_bindgen::JsCast; + + let window = web_sys::window().ok_or("missing window".to_string())?; + let resp_value = JsFuture::from(window.fetch_with_str(url)) + .await + .map_err(|err| format!("fetch failed: {err:?}"))?; + let response: Response = resp_value + .dyn_into() + .map_err(|_| "fetch response cast failed".to_string())?; + + if !response.ok() { + return Err(format!( + "fetch failed: {} {}", + response.status(), + response.status_text() + )); + } + + let text_promise = response + .text() + .map_err(|err| format!("read text failed: {err:?}"))?; + let text_value = JsFuture::from(text_promise) + .await + .map_err(|err| format!("read text failed: {err:?}"))?; + let text = text_value + .as_string() + .ok_or("response text missing".to_string())?; + + SvgContent::parse(&text).map_err(|err| format!("parse SVG failed: {err}")) +} + +#[cfg(target_arch = "wasm32")] +fn wasm_now() -> f64 { + web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or(0.0) +} diff --git a/src/app/render.rs b/src/app/render.rs index 954a648..2165bc5 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -122,10 +122,10 @@ impl LasApp { self.pan_y = pan_y; self.zoom = zoom; } + self.did_fit_start = true; + ctx.request_repaint(); } } - self.did_fit_start = true; - ctx.request_repaint(); } if input.space_pressed { diff --git a/src/main.rs b/src/main.rs index a8c4ccf..e5aa3b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![warn(clippy::all, rust_2018_idioms)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +#[cfg(not(target_arch = "wasm32"))] use log::LevelFilter; // When compiling natively: diff --git a/src/svg/parser.rs b/src/svg/parser.rs index e5913ce..d5989a0 100644 --- a/src/svg/parser.rs +++ b/src/svg/parser.rs @@ -3,6 +3,7 @@ use super::color::parse_background_color; use super::types::{Anchor, AnchorLink, AudioArea, SvgContent, TextElement, TextLine, VideoScroll}; use quick_xml::Reader; use quick_xml::events::Event; +#[cfg(not(target_arch = "wasm32"))] use std::fs; /// State for tracking current element during parsing. @@ -79,6 +80,7 @@ impl LinkAccumulator { impl SvgContent { /// Parse an SVG file and extract special elements. + #[cfg(not(target_arch = "wasm32"))] pub fn from_file(path: &str) -> Result> { log::debug!("Reading SVG file: {path}"); let content = fs::read_to_string(path)?; diff --git a/src/svg/tests.rs b/src/svg/tests.rs index a2d9cc0..ad8b134 100644 --- a/src/svg/tests.rs +++ b/src/svg/tests.rs @@ -1,4 +1,4 @@ -use super::{Renderable, SvgContent}; +use super::{Renderable as _, SvgContent}; #[test] fn test_parse_video_scroll() { diff --git a/src/text_cache.rs b/src/text_cache.rs index bc13d40..a9ef13d 100644 --- a/src/text_cache.rs +++ b/src/text_cache.rs @@ -193,7 +193,10 @@ impl TextCache { size_key: u32, render_scale: f32, ) -> CachedText { + #[cfg(not(target_arch = "wasm32"))] let start_time = std::time::Instant::now(); + #[cfg(target_arch = "wasm32")] + let start_time = wasm_now(); trace!( "Rendering text '{}' at size {}px (scale {})...", &text[..text.len().min(20)], @@ -322,15 +325,31 @@ impl TextCache { TEXTURE_OPTIONS, ); - let duration = start_time.elapsed(); - trace!( - "Rendered text '{}' ({}x{} @{}) in {:.2?}", - &text[..text.len().min(20)], - img_width, - img_height, - chosen_scale, - duration - ); + #[cfg(not(target_arch = "wasm32"))] + { + let duration = start_time.elapsed(); + trace!( + "Rendered text '{}' ({}x{} @{}) in {:.2?}", + &text[..text.len().min(20)], + img_width, + img_height, + chosen_scale, + duration + ); + } + + #[cfg(target_arch = "wasm32")] + { + let duration_ms = (wasm_now() - start_time).max(0.0); + trace!( + "Rendered text '{}' ({}x{} @{}) in {:.2}ms", + &text[..text.len().min(20)], + img_width, + img_height, + chosen_scale, + duration_ms + ); + } CachedText { texture, @@ -468,6 +487,14 @@ impl TextCache { } } +#[cfg(target_arch = "wasm32")] +fn wasm_now() -> f64 { + web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or(0.0) +} + fn texture_bytes(cached: &CachedText) -> usize { cached.width as usize * cached.height as usize * 4 }