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
}