diff --git a/Cargo.toml b/Cargo.toml index 2530ae7..a1aca14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ web-sys = { version = "0.3.76", features = [ "Element", "Window", "FocusEvent", + "OffscreenCanvasRenderingContext2d", + "OffscreenCanvas", + "ImageEncodeOptions", ] } js-sys = "0.3.76" wasm-bindgen = { version = "0.2.99" } diff --git a/src/algorithm/drawing/canvas_context.rs b/src/algorithm/drawing/canvas_context.rs index ae87c0a..aaeeb68 100644 --- a/src/algorithm/drawing/canvas_context.rs +++ b/src/algorithm/drawing/canvas_context.rs @@ -2,10 +2,7 @@ //! to the html canvas. #[cfg(all(not(test), not(feature = "benchmarking")))] -use std::{ - borrow::Cow, - ops::Deref, -}; +use std::borrow::Cow; #[cfg(any(test, feature = "benchmarking"))] use std::{ cell::RefCell, @@ -22,15 +19,25 @@ use wasm_bindgen::{ }; #[cfg(all(not(test), not(feature = "benchmarking")))] use web_sys::js_sys::Uint8Array; -use web_sys::HtmlCanvasElement; +use web_sys::{ + HtmlCanvasElement, + OffscreenCanvas, +}; + +#[cfg(all(not(test), not(feature = "benchmarking")))] +enum InnerCanvasContext<'a> { + OffScreen(Cow<'a, web_sys::OffscreenCanvasRenderingContext2d>), + OnScreen(Cow<'a, web_sys::CanvasRenderingContext2d>), +} -/// A wrapper around the [`web_sys::CanvasRenderingContext2d`]. This struct -/// provides the ability to mock and unit-test the drawing functions. +/// A wrapper around the [`web_sys::CanvasRenderingContext2d`] or [`web_sys::]. +/// This struct provides the ability to mock and unit-test the drawing +/// functions. pub struct CanvasContext<'a> { /// The inner [`web_sys::CanvasRenderingContext2d`] object wrapped by this /// struct. #[cfg(all(not(test), not(feature = "benchmarking")))] - inner: Cow<'a, web_sys::CanvasRenderingContext2d>, + inner: InnerCanvasContext<'a>, #[cfg(any(test, feature = "benchmarking"))] inner: PhantomData<&'a ()>, #[cfg(any(test, feature = "benchmarking"))] @@ -41,7 +48,7 @@ pub struct CanvasContext<'a> { impl From for CanvasContext<'static> { fn from(context: web_sys::CanvasRenderingContext2d) -> Self { Self { - inner: Cow::Owned(context), + inner: InnerCanvasContext::OnScreen(Cow::Owned(context)), } } } @@ -67,7 +74,23 @@ impl<'a> From<&'a HtmlCanvasElement> for CanvasContext<'a> { .expect("Failed to convert to CanvasRenderingContext2d"); Self { - inner: Cow::Owned(context), + inner: InnerCanvasContext::OnScreen(Cow::Owned(context)), + } + } +} + +#[cfg(all(not(test), not(feature = "benchmarking")))] +impl<'a> From<&'a OffscreenCanvas> for CanvasContext<'a> { + fn from(canvas: &'a OffscreenCanvas) -> Self { + let context = canvas + .get_context("2d") + .expect("Failed to get 2d context") + .expect("offscreen 2d context is null") + .dyn_into::() + .expect("Failed to convert to OffscreenCanvasRenderingContext2d"); + + Self { + inner: InnerCanvasContext::OffScreen(Cow::Owned(context)), } } } @@ -82,11 +105,21 @@ impl<'a> From<&'a HtmlCanvasElement> for CanvasContext<'a> { } } +#[cfg(any(test, feature = "benchmarking"))] +impl<'a> From<&'a OffscreenCanvas> for CanvasContext<'a> { + fn from(_: &'a OffscreenCanvas) -> Self { + Self { + inner: PhantomData, + recorder: RefCell::new(HashMap::new()), + } + } +} + #[cfg(all(not(test), not(feature = "benchmarking")))] impl<'a> From<&'a web_sys::CanvasRenderingContext2d> for CanvasContext<'a> { fn from(context: &'a web_sys::CanvasRenderingContext2d) -> Self { Self { - inner: Cow::Borrowed(context), + inner: InnerCanvasContext::OnScreen(Cow::Borrowed(context)), } } } @@ -102,26 +135,59 @@ impl<'a> From<&'a web_sys::CanvasRenderingContext2d> for CanvasContext<'a> { } #[cfg(all(not(test), not(feature = "benchmarking")))] -impl AsRef for CanvasContext<'_> { - fn as_ref(&self) -> &web_sys::CanvasRenderingContext2d { - &self.inner - } -} - -#[cfg(all(not(test), not(feature = "benchmarking")))] -impl Deref for CanvasContext<'_> { - type Target = web_sys::CanvasRenderingContext2d; +macro_rules! impl_canvas_context_method { + ($method:ident($($arg:ident: $arg_ty:ty),*) -> $res:ty) => { + pub fn $method(&self, $($arg: $arg_ty),*) -> $res { + match &self.inner { + InnerCanvasContext::OffScreen(context) => { + context.$method($($arg),*) + }, + InnerCanvasContext::OnScreen(context) => { + context.$method($($arg),*) + }, + } + } - fn deref(&self) -> &Self::Target { - &self.inner } } #[cfg(all(not(test), not(feature = "benchmarking")))] impl CanvasContext<'_> { + impl_canvas_context_method!(move_to(x: f64, y: f64) -> ()); + + impl_canvas_context_method!(line_to(x: f64, y: f64) -> ()); + + impl_canvas_context_method!(stroke() -> ()); + + impl_canvas_context_method!(begin_path() -> ()); + + impl_canvas_context_method!(set_line_width(f: f64) -> ()); + + impl_canvas_context_method!(set_stroke_style_str(s: &str) -> ()); + + impl_canvas_context_method!(set_global_alpha(a: f64) -> ()); + + impl_canvas_context_method!(rect(x: f64, y: f64, width: f64, height: f64) -> ()); + + impl_canvas_context_method!(arc(x: f64, y: f64, radius: f64, start_angle: f64, end_angle: f64) -> Result<(), JsValue>); + + impl_canvas_context_method!(fill() -> ()); + + impl_canvas_context_method!(set_fill_style_str(style: &str) -> ()); + pub fn set_line_dash(&self, segments: &[u8]) -> Result<(), JsValue> { - self.inner - .set_line_dash(&Uint8Array::from(segments)) + let array = Uint8Array::from(segments); + match &self.inner { + InnerCanvasContext::OffScreen(context) => context.set_line_dash(&array), + InnerCanvasContext::OnScreen(context) => context.set_line_dash(&array), + } + } + + pub fn is_onscreen(&self) -> bool { + match &self.inner { + InnerCanvasContext::OffScreen(_) => false, + InnerCanvasContext::OnScreen(_) => true, + } } } @@ -134,6 +200,10 @@ impl<'a> CanvasContext<'a> { } } + pub fn is_onscreen(&self) -> bool { + true + } + pub fn move_to(&self, x: f64, y: f64) { self.record( "move_to", diff --git a/src/algorithm/drawing/mod.rs b/src/algorithm/drawing/mod.rs index 87d0313..b89e225 100644 --- a/src/algorithm/drawing/mod.rs +++ b/src/algorithm/drawing/mod.rs @@ -21,9 +21,21 @@ pub fn redraw_canvas<'a, C>(canvas: C, state: &MapState) where C: Into>, { - // Get a 2d canvas rendering context + // Get a canvas rendering context let context: CanvasContext = canvas.into(); + // If we're offscreen, then this is for an image of the map to get downloaded, + // so we only need to draw the map and no grid. + if !context.is_onscreen() { + let map = state + .get_map() + .without_checkpoints(); + + map.draw(&context, state.get_canvas_state(), 1.0); + + return; + } + draw_grid(&context, state.get_canvas_state()); if state.is_original_overlay_enabled() { diff --git a/src/components/canvas/mouse_up.rs b/src/components/canvas/mouse_up.rs index 094dab1..9c6c8b8 100644 --- a/src/components/canvas/mouse_up.rs +++ b/src/components/canvas/mouse_up.rs @@ -133,8 +133,8 @@ pub fn on_mouse_up( } if added_station { + map_state.clear_all_selections(); map_state.set_map(map); - map_state.clear_selected_lines(); return; } diff --git a/src/components/molecules/file_downloader.rs b/src/components/molecules/file_downloader.rs index a549768..2a6de34 100644 --- a/src/components/molecules/file_downloader.rs +++ b/src/components/molecules/file_downloader.rs @@ -23,7 +23,7 @@ use crate::{ utils::json::encode_map, }; -/// A modal that lets the user download a file representing the map. +/// A button that lets the user download a file representing the map. #[component] pub fn FileDownloader() -> impl IntoView { let map_state = diff --git a/src/components/molecules/map_exporter.rs b/src/components/molecules/map_exporter.rs new file mode 100644 index 0000000..a16c454 --- /dev/null +++ b/src/components/molecules/map_exporter.rs @@ -0,0 +1,73 @@ +//! Contains the [`FileDownloader`] component. + +use leptos::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + ImageEncodeOptions, + OffscreenCanvas, + Url, +}; + +use crate::{ + algorithm::drawing::redraw_canvas, + components::{ + atoms::Button, + MapState, + }, +}; + +/// A button that lets the user export and download the map as a png file. +#[component] +pub fn MapExporter() -> impl IntoView { + let map_state = + use_context::>().expect("to have found the global map state"); + + let export_map = Action::new_local(move |_| { + async move { + let blob_promise = { + let state = map_state.get_untracked(); + let (y_size, x_size) = state + .get_canvas_state() + .get_size(); + + let canvas = OffscreenCanvas::new(x_size as u32, y_size as u32) // Full HD size (common resolution and not too big) + .expect("to create an offscreen canvas"); + + redraw_canvas(&canvas, &state); + + let blob_options = ImageEncodeOptions::new(); + blob_options.set_type("image/png"); + + canvas + .convert_to_blob_with_options(&blob_options) + .expect("to convert the canvas to a blob promise") + }; + + let blob = JsFuture::from(blob_promise) + .await + .expect("to await the promise") + .dyn_into::() + .expect("to convert the promise to a blob"); + + let url = Url::create_object_url_with_blob(&blob) + .expect("to create an object URL from the blob"); + + let elem = document() + .create_element("a") + .expect("to create an anchor element") + .dyn_into::() + .expect("to convert the element to an anchor element"); + + elem.set_href(&url); + elem.set_download("metro-map.png"); + elem.click(); + + Url::revoke_object_url(&url).unwrap(); + } + }); + + view! { +