diff --git a/CHANGES.md b/CHANGES.md index 738dce5..8e80991 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,8 @@ + ## Unreleased +- Migrated to Webpack 5 for async WASM and modern JS tooling. +- Dropped stdweb; all browser interop now uses web-sys, wasm-bindgen, and gloo-timers. +- Updated Svelte/Webpack config for compatibility and modern plugin usage. ## v0.4 - (breaking) Changed type of positions from `u32` to `i32` (for pane and global frame). Negative offsets are valid and sometimes necessary. diff --git a/Cargo.toml b/Cargo.toml index 4553a11..87f63af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,4 +32,5 @@ features = [ [dev-dependencies] wasm-bindgen-test = "0.3" -wasm-bindgen-futures = "0.4" \ No newline at end of file +wasm-bindgen-futures = "0.4" + diff --git a/README.md b/README.md index 139e85b..be8160d 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,56 @@ The features can be summarized as: The examples in this crate are hosted online: [div-rs Examples](https://div.paddlers.ch/) -Have a look at the code in example directory. The best way is to clone the repository and run it locally, so you can play around with the code. +Below is an example of the output you can generate with the built-in GIF capture tool: -You need npm, webpack, and wasm-pack for the examples to run on your machine. -``` -git clone https://github.com/jakmeier/div-rs.git -cd div-rs/examples/www; -npm run build; -npm run start; +![Animated Example Output](./div-rs-captured-frames.gif) + +Have a look at the code in the example directory. The best way is to clone the repository and run it locally, so you can play around with the code and generate your own GIFs. + + +## Requirements (2025+) + +You need Node.js (16+ recommended), npm, and [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) for the examples to run on your machine. + +**This project now uses Webpack 5 for modern WebAssembly support.** + +### Setup and Usage + +1. Clone the repository: + ```sh + git clone https://github.com/jakmeier/div-rs.git + cd div-rs/examples/www + ``` +2. Install dependencies (this will install Webpack 5 and all required loaders/plugins): + ```sh + npm install + ``` +3. Build all Rust WASM examples and bundle with Webpack: + ```sh + npm run build + ``` +4. Start the development server: + ```sh + npm start + ``` +5. Open your browser to the address shown in the terminal (usually http://localhost:8080). + +### Cleaning builds + +To clean all Rust and JS build artifacts: +```sh +npm run clean ``` +### Notable Changes (2025) + +- **Webpack 5 migration:** The project now uses Webpack 5 for native async WebAssembly support. All config and dependencies have been updated. +- **No more stdweb:** All examples now use `web-sys`, `wasm-bindgen`, and (where needed) `gloo-timers` for browser interop. `stdweb` is no longer supported. +- **Svelte loader:** The Webpack config now includes `resolve.conditionNames` for Svelte 3+ compatibility. +- **CopyWebpackPlugin:** Updated to use the new `patterns` API. + +If you see WASM loader or Svelte warnings, make sure you have the latest dependencies and configs as above. + ## Origin and Motivation My motivation to create Div was to leverage HTML + CSS when using Rust to create games for a browser. Prior to this, the only way I knew how to do a GUI easily (in Rust running on the browser) was to render everything through general-purpose GUI crates with a WebGL backend. This seemed a bit wasteful to me, as the browser already has excellent built-in support for GUIs. diff --git a/div-rs-captured-frames.gif b/div-rs-captured-frames.gif new file mode 100644 index 0000000..16b7a07 Binary files /dev/null and b/div-rs-captured-frames.gif differ diff --git a/examples/hello_svelte/Cargo.toml b/examples/hello_svelte/Cargo.toml index b3db1c2..3a88d69 100644 --- a/examples/hello_svelte/Cargo.toml +++ b/examples/hello_svelte/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "hello_svelte" version = "0.1.0" -authors = ["Jakob Meier "] +authors = ["Jakob Meier ", "David Horner"] edition = "2018" [lib] @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] div = { path = "../../" } +gloo-timers = "0.3.0" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" @@ -22,4 +23,4 @@ features = [ "Window", "HtmlScriptElement", "HtmlHeadElement", -] \ No newline at end of file +] diff --git a/examples/hello_svelte/src/lib.rs b/examples/hello_svelte/src/lib.rs index 159158a..8761317 100644 --- a/examples/hello_svelte/src/lib.rs +++ b/examples/hello_svelte/src/lib.rs @@ -1,20 +1,24 @@ use wasm_bindgen::prelude::*; -#[wasm_bindgen(start)] +#[wasm_bindgen] pub fn main() { - div::init_to("div-root").unwrap(); set_panic_hook(); - // Create a new pane at offset (100,100) from body - // with size 500px/500px and then create a single - // text node inside it with an external class stored in TODO + // Defensive: check for double initialization + match div::init_to("div-root") { + Ok(_) => {}, + Err(e) => { + panic!("[hello_svelte] div::init_to('div-root') failed: {e:?}.\nThis usually means main() was called without a prior reset, or reset did not complete before main()."); + } + } + const X: u32 = 0; const Y: u32 = 0; const W: u32 = 500; const H: u32 = 500; let class = div::JsClass::preregistered("MyComponent") - .expect("JS class Test has not been registered properly"); - div::from_js_class(X, Y, W, H, class).unwrap(); + .unwrap_or_else(|| panic!("[hello_svelte] Svelte component 'MyComponent' is not registered.\n\nMake sure register_svelte_component('MyComponent', ...) is called in JS before calling main().")); + div::from_js_class(X as i32, Y as i32, W, H, class).expect("[hello_svelte] div::from_js_class failed"); /* Alternative that loads classes from a separate JS file instead of registering in the JS code. */ // let future = async { @@ -24,6 +28,11 @@ pub fn main() { // wasm_bindgen_futures::spawn_local(future); } +#[wasm_bindgen] +pub fn reset() { + div::reset_global_div_state(); +} + pub fn set_panic_hook() { // When the `console_error_panic_hook` feature is enabled, we can call the // `set_panic_hook` function at least once during initialization, and then @@ -31,6 +40,6 @@ pub fn set_panic_hook() { // // For more details see // https://github.com/rustwasm/console_error_panic_hook#readme - #[cfg(feature = "console_error_panic_hook")] + #[cfg(feature = "console_error_panic_hook")] console_error_panic_hook::set_once(); } diff --git a/examples/hello_world/src/lib.rs b/examples/hello_world/src/lib.rs index a7be236..07f2e11 100644 --- a/examples/hello_world/src/lib.rs +++ b/examples/hello_world/src/lib.rs @@ -1,9 +1,8 @@ -#![allow(unused_must_use)] use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] pub fn main() { - div::init_to("div-root"); + div::init_to("div-root").expect("Init failed"); // Create a new pane at offset (100,100) from body // with size 500px/500px and then create a single @@ -13,5 +12,10 @@ pub fn main() { let w = 500; let h = 500; let html = "Hello world"; - div::new(x, y, w, h, html); + div::new(x, y, w, h, html).unwrap(); } + +#[wasm_bindgen] +pub fn reset() { + div::reset_global_div_state(); +} \ No newline at end of file diff --git a/examples/reposition/Cargo.toml b/examples/reposition/Cargo.toml index 02f7022..bc3ed68 100644 --- a/examples/reposition/Cargo.toml +++ b/examples/reposition/Cargo.toml @@ -2,16 +2,17 @@ [package] name = "reposition" version = "0.1.0" -authors = ["Jakob Meier "] +authors = ["Jakob Meier ", "David Horner"] edition = "2018" [lib] crate-type = ["cdylib", "rlib"] + [dependencies] +console_error_panic_hook = "0.1.7" div = { path = "../../" } wasm-bindgen = "0.2" -stdweb = "0.4" [dependencies.web-sys] version = "0.3" @@ -22,4 +23,6 @@ features = [ "Window", "HtmlScriptElement", "HtmlHeadElement", + "Event", + "KeyboardEvent", ] diff --git a/examples/reposition/src/lib.rs b/examples/reposition/src/lib.rs index 53b7d06..1eda2a6 100644 --- a/examples/reposition/src/lib.rs +++ b/examples/reposition/src/lib.rs @@ -1,7 +1,7 @@ -use stdweb::js; -use stdweb::traits::*; -use stdweb::web::event::KeyDownEvent; +use wasm_bindgen::JsCast; +use web_sys::window; use wasm_bindgen::prelude::*; +use console_error_panic_hook; /** * This example show how @@ -20,8 +20,47 @@ use wasm_bindgen::prelude::*; * repositioned and/or resized, it can also change the arrangement of the internal HTML elements. */ +static mut KEYDOWN_CLOSURE: Option> = None; + +#[wasm_bindgen] +pub fn reset() { + // Remove the keydown event listener if present + let win = match window() { + Some(w) => w, + None => return, + }; + unsafe { + if let Some(old_closure) = KEYDOWN_CLOSURE.take() { + let _ = win.remove_event_listener_with_callback("keydown", old_closure.as_ref().unchecked_ref()); + // drop(old_closure); // dropped automatically + } + } + // Also reset the Rust global state so example can be re-initialized + div::reset_global_div_state(); + + // Explicitly clear all children of div-root (DOM cleanup) + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + if let Some(div_root) = doc.get_element_by_id("div-root") { + while let Some(child) = div_root.first_child() { + let _ = div_root.remove_child(&child); + } + } + } +} + #[wasm_bindgen(start)] pub fn main() { + // Enable better panic messages + console_error_panic_hook::set_once(); + + // Remove previous keydown listener if present + let win = window().unwrap(); + unsafe { + if let Some(old_closure) = KEYDOWN_CLOSURE.take() { + let _ = win.remove_event_listener_with_callback("keydown", old_closure.as_ref().unchecked_ref()); + // drop(old_closure); // dropped automatically + } + } // Start at position (0,0) with size (350,200) let mut x = 0; @@ -53,45 +92,60 @@ pub fn main() { let _pane_b = div::new(200, 50, 100, 100, html2).unwrap(); // Define control variables for zoom of global area and pane A - let mut f = 1.0; - let mut af = 1.0; + let mut f: f32 = 1.0; + let mut af: f32 = 1.0; - // We are using webstd here to make things easy. - // Listen to arrow key to move and reposition all div - stdweb::web::document().add_event_listener(move |e: KeyDownEvent| { - match e.key().as_str() { - "ArrowUp" => y = y.saturating_sub(10), - "ArrowDown" => y += 10, - "ArrowLeft" => x = x.saturating_sub(10), - "ArrowRight" => x += 10, - "+" => f *= 1.5, - "-" => f /= 1.5, + // Listen to keydown events to move and reposition all divs (with bounds checks) + let closure = Closure::wrap(Box::new(move |event: web_sys::Event| { + let keyboard_event = event.dyn_ref::(); + if let Some(e) = keyboard_event { + let key = e.key(); + match key.as_str() { + "ArrowUp" => { y = y.saturating_sub(10); }, + "ArrowDown" => { y += 10; }, + "ArrowLeft" => { x = x.saturating_sub(10); }, + "ArrowRight" => { x += 10; }, + "+" => { f *= 1.5; }, + "-" => { f /= 1.5; }, - "w" => ay = ay.saturating_sub(10), - "a" => ax = ax.saturating_sub(10), - "s" => ay += 10, - "d" => ax += 10, - "1" => af *= 1.5, - "2" => af /= 1.5, + "w" => { ay = ay.saturating_sub(10); }, + "a" => { ax = ax.saturating_sub(10); }, + "s" => { ay += 10; }, + "d" => { ax += 10; }, + "1" => { af *= 1.5; }, + "2" => { af /= 1.5; }, - key => { - js! { @(no_return) console.log("pressed " + @{key}); }; - return; + _ => { + web_sys::console::log_1(&format!("pressed {}", key).into()); + return; + } } + // Bounds checks to prevent panics + let safe_x = x.max(0); + let safe_y = y.max(0); + let safe_f = f.max(0.1); // Prevent zero/negative scale + let safe_af = af.max(0.1); + let safe_ax = ax.max(0); + let safe_ay = ay.max(0); + let safe_aw = (safe_af * aw as f32).max(1.0); + let safe_ah = (safe_af * ah as f32).max(1.0); + let safe_w = (safe_f * w as f32).max(1.0); + let safe_h = (safe_f * h as f32).max(1.0); + + div::reposition(safe_x, safe_y).unwrap(); + div::resize(safe_w as u32, safe_h as u32).unwrap(); + pane_a + .reposition_and_resize(safe_ax, safe_ay, safe_aw as u32, safe_ah as u32) + .unwrap(); + // Same as + // pane_a.reposition(ax,ay).unwrap(); + // pane_a.resize(aw as u32, ah as u32).unwrap(); + // but avoids extra redraw of div } - div::reposition(x, y).unwrap(); - let w = f * w as f32; - let h = f * h as f32; - div::resize(w as u32, h as u32).unwrap(); + }) as Box); - let aw = af * aw as f32; - let ah = af * ah as f32; - pane_a - .reposition_and_resize(ax, ay, aw as u32, ah as u32) - .unwrap(); - // Same as - // pane_a.reposition(ax,ay).unwrap(); - // pane_a.resize(aw as u32, ah as u32).unwrap(); - // but avoids extra redraw of div - }); -} + win.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()).unwrap(); + unsafe { + KEYDOWN_CLOSURE = Some(closure); + } +} \ No newline at end of file diff --git a/examples/styled/Cargo.toml b/examples/styled/Cargo.toml index c04394a..d8630e8 100644 --- a/examples/styled/Cargo.toml +++ b/examples/styled/Cargo.toml @@ -2,16 +2,16 @@ [package] name = "styled" version = "0.1.0" -authors = ["Jakob Meier "] +authors = ["Jakob Meier ", "David Horner"] edition = "2018" [lib] crate-type = ["cdylib", "rlib"] + [dependencies] div = { path = "../../" } wasm-bindgen = "0.2" -stdweb = "0.4" [dependencies.web-sys] version = "0.3" @@ -22,4 +22,5 @@ features = [ "Window", "HtmlScriptElement", "HtmlHeadElement", + "HtmlStyleElement", ] diff --git a/examples/styled/src/lib.rs b/examples/styled/src/lib.rs index d8de6bf..55c63f4 100644 --- a/examples/styled/src/lib.rs +++ b/examples/styled/src/lib.rs @@ -4,7 +4,8 @@ * Usually we would have your styles served from a .css or inside a svelte component. * But if you want to, you can also apply CSS to a pane from within Rust. */ -use stdweb::web::*; +use wasm_bindgen::JsCast; +use web_sys::{window, HtmlStyleElement}; use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] @@ -44,12 +45,22 @@ pub fn main() { let _pane1 = div::new_styled(125, 300, 200, 100, html1, &classes, &css).unwrap(); } +#[wasm_bindgen] +pub fn reset() { + div::reset_global_div_state(); +} + // Small helper function, only for the example. Uses stdweb, DIV-RS does not really help you with this part of CSS. // It is not recommended to add classes like this but it is useful here to keep everything in a single file. fn add_document_styles(css: &str) { - let head = document().head().unwrap(); - let style = document().create_element("style").unwrap(); - style.set_attribute("type", "text/css").unwrap(); - style.append_html(css).unwrap(); - head.append_child(&style); + let document = window().unwrap().document().unwrap(); + let head = document.head().unwrap(); + let style = document + .create_element("style") + .unwrap() + .dyn_into::() + .unwrap(); + style.set_type("text/css"); + style.set_inner_html(css); + head.append_child(&style).unwrap(); } diff --git a/examples/toggle/Cargo.toml b/examples/toggle/Cargo.toml index 9ab6a32..044dc9f 100644 --- a/examples/toggle/Cargo.toml +++ b/examples/toggle/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "toggle" version = "0.1.0" -authors = ["Jakob Meier "] +authors = ["Jakob Meier ", "David Horner"] edition = "2018" [lib] @@ -11,7 +11,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] div = { path = "../../" } wasm-bindgen = "0.2" -stdweb = "0.4" +gloo-timers = "0.2" [dependencies.web-sys] version = "0.3" diff --git a/examples/toggle/src/lib.rs b/examples/toggle/src/lib.rs index 0860399..c6ff145 100644 --- a/examples/toggle/src/lib.rs +++ b/examples/toggle/src/lib.rs @@ -1,40 +1,55 @@ use div::DivHandle; -use stdweb::web::set_timeout; - +use gloo_timers::callback::Timeout; use wasm_bindgen::prelude::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static GENERATION: AtomicUsize = AtomicUsize::new(0); #[wasm_bindgen(start)] pub fn main() { div::init_to("div-root").expect("Init failed"); + // Bump generation to invalidate old toggle loops + let gen = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + // Create two new div with some HTML in it let html0 = r#" -
-
- Hi! -
+
+
+ Hi!
- "#; +
+"#; let html1 = r#" -
-
- Bye! -
+
+
+ Bye!
- "#; +
+"#; let div0 = div::new(100, 100, 100, 100, html0).unwrap(); let div1 = div::new(200, 200, 100, 100, html1).unwrap(); - toggle(div0, div1); + toggle(div0, div1, gen); } -// Function that takes to div, shows the first and hides the second -// and then calls itself again delayed, with the two div swapped -fn toggle(a: DivHandle, b: DivHandle) { - a.show().expect("Error"); - b.hide().expect("Error"); - let closure = move || { - toggle(b, a); - }; - set_timeout(closure, 1000); +// Function that takes two divs, shows the first and hides the second +// and then calls itself again delayed, with the two divs swapped +fn toggle(a: DivHandle, b: DivHandle, gen: usize) { + // Only run if this is the current generation + if gen != GENERATION.load(Ordering::SeqCst) { + return; + } + a.show().ok(); + b.hide().ok(); + Timeout::new(1000, move || { + toggle(b, a, gen); + }).forget(); } + +#[wasm_bindgen] +pub fn reset() { + // Only bump the generation counter to invalidate old timers + GENERATION.fetch_add(1, Ordering::SeqCst); + div::reset_global_div_state(); +} \ No newline at end of file diff --git a/examples/www/highlight.js b/examples/www/highlight.js new file mode 100644 index 0000000..6d7c856 --- /dev/null +++ b/examples/www/highlight.js @@ -0,0 +1,8 @@ +import 'prismjs'; +import 'prismjs/themes/prism.css'; +import 'prismjs/components/prism-rust'; +import Prism from 'prismjs'; + +export function highlightCodeBlocks() { + Prism.highlightAll(); +} diff --git a/examples/www/index.js b/examples/www/index.js index 5725172..ea6daa9 100644 --- a/examples/www/index.js +++ b/examples/www/index.js @@ -1,3 +1,132 @@ + + +function addCycleCaptureButton() { + if (document.getElementById("cycle-capture-btn")) return; + const btn = document.createElement("button"); + btn.id = "cycle-capture-btn"; + btn.innerText = "Cycle & Capture All Examples"; + btn.style.position = "fixed"; + btn.style.top = "170px"; + btn.style.right = "10px"; + btn.style.zIndex = 10000; + btn.onclick = async () => { + const select = document.querySelector("select[name='example']"); + if (!select) return; + const images = []; + const cropRect = getCropRect(); + for (let i = 0; i < examples.length; i++) { + select.value = i; + select.dispatchEvent(new Event('change')); + await new Promise(r => setTimeout(r, 800)); + if (examples[i].name === "Toggle") { + // Capture two frames from toggle + for (let j = 0; j < 2; j++) { + await new Promise(r => setTimeout(r, 1000)); + const canvas = await html2canvas(document.body, { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + windowWidth: document.documentElement.scrollWidth, + windowHeight: document.documentElement.scrollHeight + }); + images.push(canvas.toDataURL()); + } + } else { + const canvas = await html2canvas(document.body, { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + windowWidth: document.documentElement.scrollWidth, + windowHeight: document.documentElement.scrollHeight + }); + images.push(canvas.toDataURL()); + } + } + // Show all captured images in a new window + const win = window.open(); + if (!win) { + alert("Popup blocked! Please allow popups for this site to view captured frames."); + return; + } + // Add only the normal GIF download button and all frames at the top + win.document.write(` + +

Captured Frames

+ `); + images.forEach((src, idx) => { + win.document.write(`
Frame ${idx+1}
`); + }); + // Images are already cropped, no need to pass cropRect + win.document.close(); + const script = win.document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/gif.js.optimized/dist/gif.js'; + script.onload = function() { + // Normal GIF + win.document.getElementById('download-gif-btn').onclick = function() { + const imgs = Array.from(win.document.querySelectorAll('.frame-img')); + if (!imgs.length) return; + const gif = new win.GIF({ workers: 2, quality: 10, workerScript: 'gif.worker.js' }); + let loaded = 0; + imgs.forEach(img => { + const image = new win.Image(); + image.crossOrigin = 'Anonymous'; + image.onload = function() { + gif.addFrame(image, { delay: 800 }); + loaded++; + if (loaded === imgs.length) { + gif.on('finished', function(blob) { + const url = win.URL.createObjectURL(blob); + const a = win.document.createElement('a'); + a.href = url; + a.download = 'div-rs-captured-frames.gif'; + win.document.body.appendChild(a); + a.click(); + setTimeout(() => win.document.body.removeChild(a), 100); + }); + gif.render(); + } + }; + image.src = img.src; + }); + }; + // Removed cropped GIF button and logic + }; + win.document.body.appendChild(script); + }; + document.body.appendChild(btn); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + addCycleCaptureButton(); + addGifCaptureButtonWhenReady(); + }); +} else { + addCycleCaptureButton(); + addGifCaptureButtonWhenReady(); +} +// Helper to get crop rectangle (top of h1 to bottom of footer, 20% width border) +function getCropRect() { + const h1 = document.querySelector('h1'); + const footer = document.querySelector('footer'); + if (!h1 || !footer) { + return { x: 0, y: 0, width: document.body.clientWidth - 320, height: document.body.clientHeight }; + } + const rect1 = h1.getBoundingClientRect(); + const rect2 = footer.getBoundingClientRect(); + // Calculate crop area from just below h1 to bottom of footer + const scrollY = window.scrollY || window.pageYOffset; + const scrollX = window.scrollX || window.pageXOffset; + let x = rect1.left + scrollX; + let y = rect1.bottom + scrollY + 4; // 4px below h1 + let width = rect1.width; + let height = (rect2.top + rect2.height) - (rect1.bottom + 4); + // Exclude the rightmost 320px (button area) + width = Math.max(1, width - 320); + return { x, y, width, height }; +} /** * The code in here is not really all that much about DIV-RS. * It just some JS code to load the different examples defined in their own Rust crate. @@ -7,78 +136,299 @@ import "./styles.css"; -import * as hello_world from "../hello_world/pkg/hello_world_bg.wasm"; -import * as reposition from "../reposition/pkg/reposition_bg.wasm"; -import * as styled from "../styled/pkg/styled_bg.wasm"; -import * as toggle from "../toggle/pkg/toggle_bg.wasm"; +import * as hello_world from "../hello_world/pkg/hello_world.js"; +import * as reposition from "../reposition/pkg/reposition.js"; +import * as styled from "../styled/pkg/styled.js"; +import * as toggle from "../toggle/pkg/toggle.js"; + +import * as hello_svelte from "../hello_svelte/pkg/hello_svelte.js"; -import * as hello_svelte from "../hello_svelte/pkg/hello_svelte_bg.wasm"; import { register_svelte_component, init_div_rs } from "../../div-rs.js"; import MyComponent from "../hello_svelte/src/MyComponent.svelte"; -const examples = []; +// Always register Svelte component before any WASM main() call +init_div_rs(); +register_svelte_component("MyComponent", MyComponent); function example(name, help, fn) { return { name, help, fn }; } +const examples = []; +// --- GIF CAPTURE BUTTON --- +examples.push( + example( + "Hello World", + "A minimal example that renders a single pane with a text node.\n\nNo controls. Just displays 'Hello world' in a pane.\n\n---\nRust:\n```rust\ndiv::init_to(\"div-root\")?;\ndiv::new(x, y, w, h, \"Hello world\")?;\n```", + () => hello_world.main() + ) +); +examples.push( + example( + "Reposition", + "Demonstrates repositioning panes dynamically.\n\n" + + "Controls:\n" + + " Arrow keys: Move the whole black border area.\n" + + " + / - : Zoom the whole area.\n" + + " w/a/s/d: Move the red 'A' pane.\n" + + " 1 / 2: Resize the red 'A' pane.\n\n" + + "The black border shows the main pane. 'A' (red) and 'B' (blue) are sub-panes.\n" + + "Use the controls to see repositioning and resizing in action.\n\n---\nRust:\n```rust\ndiv::reposition(x, y)?;\ndiv::resize(w, h)?;\npane.reposition_and_resize(ax, ay, aw, ah)?;\n```", + () => reposition.main() + ) +); +examples.push( + example( + "Styled", + "Shows how to use custom styles with div-rs.\n\nNo controls. Demonstrates CSS classes and inline styles applied to panes.\n\n---\nRust:\n```rust\ndiv::new_styled(x, y, w, h, html, &[\"my-class\"], &[(\"color\", \"red\")])?;\n```", + () => styled.main() + ) +); +import html2canvas from "html2canvas"; +import GIF from "gif.js"; +import { highlightCodeBlocks } from "./highlight"; -// Add examples one-by-one with name and call to main -examples.push(example( - "Hello World", - "let x = 100;\n" + - "let y = 100;\n" + - "let w = 500;\n" + - "let h = 500;\n" + - "let html = \"Hello world\";\n" + - "div::new(x, y, w, h, html);", - () => { - hello_world.main(); - })); -examples.push(example( - "Reposition", - "Use arrow keys to move all divs and +,- to change scaling factor.\n\n" + - "div::reposition(x, y)\n" + - "div::resize(w, h)\n\n" + - " Use W,A,S,D to move only one div and 1,2 for scaling.\n\n" + - "div_a.reposition(x,y)\n" + - "div_a.resize(w,h)\n" + - "\n\nThis is meant to be used as hovering test over a canvas that needs to be rescaled when the window size changes.", - () => { - reposition.main(); - })); -examples.push(example( - "Styled", - "These divs are dynamically styled from within Rust, using CSS.", - () => { - styled.main(); - })); -examples.push(example( - "Toggle", - "hi.show()\nbye.hide()\n\n" + - "Two divs are periodically displayed and hidden, as controlled by Rust code.", - () => { - toggle.main(); - })); - -// Svelte example need some more initialization -examples.push(example( - "Hello Svelte", - "This component is defined within a *.svelte file and loaded dynamically through Rust.\n\n" + - "let class = JsClass::preregistered(\"Component\")?;\n" + - "div::from_js_class(X, Y, W, H, class)?;", - () => { - init_div_rs(); - register_svelte_component("MyComponent", MyComponent); - hello_svelte.main(); - })); +function addGifCaptureButtonWhenReady() { + // Cropped GIF Button + if (!document.getElementById("crop-gif-capture-btn")) { + const cropGifBtn = document.createElement("button"); + cropGifBtn.id = "crop-gif-capture-btn"; + cropGifBtn.innerText = "Capture Cropped GIF of body"; + cropGifBtn.style.position = "fixed"; + cropGifBtn.style.top = "210px"; + cropGifBtn.style.right = "10px"; + cropGifBtn.style.zIndex = 10000; + cropGifBtn.onclick = async () => { + const cropRect = getCropRect(); + const canvas1 = await html2canvas(document.body, { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight }); + await new Promise(r => setTimeout(r, 500)); + const canvas2 = await html2canvas(document.body, { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight }); + const gif = new GIF({ workers: 2, quality: 10, workerScript: 'gif.worker.js' }); + gif.addFrame(canvas1, { delay: 500 }); + gif.addFrame(canvas2, { delay: 500 }); + gif.on('finished', function(blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'div-rs_body-cropped.gif'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + if (!navigator.userActivation || !navigator.userActivation.hasBeenActive) { + window.open(url, '_blank'); + } + }, 100); + }); + gif.render(); + }; + document.body.appendChild(cropGifBtn); + } + function tryAddButton() { + const captureTarget = document.body; + if (!captureTarget) { + setTimeout(tryAddButton, 300); // Try again soon + return; + } + if (document.getElementById("gif-capture-btn")) return; // Already added + // GIF Button + const gifBtn = document.createElement("button"); + gifBtn.id = "gif-capture-btn"; + gifBtn.innerText = "Capture 2-frame GIF of body"; + gifBtn.style.position = "fixed"; + gifBtn.style.top = "10px"; + gifBtn.style.right = "10px"; + gifBtn.style.zIndex = 10000; + gifBtn.onclick = async () => { + const cropRect = getCropRect(); + const canvas1 = await html2canvas(document.body, { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight }); + await new Promise(r => setTimeout(r, 500)); + const canvas2 = await html2canvas(document.body, { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight }); + const gif = new GIF({ workers: 2, quality: 10, workerScript: 'gif.worker.js' }); + gif.addFrame(canvas1, { delay: 500 }); + gif.addFrame(canvas2, { delay: 500 }); + gif.on('finished', function(blob) { + const url = URL.createObjectURL(blob); + // Try download + const a = document.createElement('a'); + a.href = url; + a.download = 'div-rs_body.gif'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + // Fallback: open in new tab if not downloaded + if (!navigator.userActivation || !navigator.userActivation.hasBeenActive) { + window.open(url, '_blank'); + } + }, 100); + }); + gif.render(); + }; + document.body.appendChild(gifBtn); + // PNG Button + if (!document.getElementById("png-capture-btn")) { + const pngBtn = document.createElement("button"); + pngBtn.id = "png-capture-btn"; + pngBtn.innerText = "Capture PNG of body"; + pngBtn.style.position = "fixed"; + pngBtn.style.top = "50px"; + pngBtn.style.right = "10px"; + pngBtn.style.zIndex = 10000; + pngBtn.onclick = async () => { + const cropRect = getCropRect(); + const canvas = await html2canvas(document.body, { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight }); + canvas.toBlob(function(blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'div-rs_body.png'; + document.body.appendChild(a); + a.click(); + setTimeout(() => document.body.removeChild(a), 100); + }); + }; + document.body.appendChild(pngBtn); + } + // Custom Crop Button + if (!document.getElementById("crop-capture-btn")) { + const cropBtn = document.createElement("button"); + cropBtn.id = "crop-capture-btn"; + cropBtn.innerText = "Capture Cropped PNG of body"; + cropBtn.style.position = "fixed"; + cropBtn.style.top = "130px"; + cropBtn.style.right = "10px"; + cropBtn.style.zIndex = 10000; + cropBtn.onclick = async () => { + const cropRect = getCropRect(); + const canvas = await html2canvas(document.body, { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight }); + canvas.toBlob(function(blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'div-rs_body-cropped.png'; + document.body.appendChild(a); + a.click(); + setTimeout(() => document.body.removeChild(a), 100); + }); + }; + document.body.appendChild(cropBtn); + } + // Helper to get crop rectangle + function getCropRect() { + const h1 = document.querySelector('h1'); + const footer = document.querySelector('footer'); + if (!h1 || !footer) { + return { x: 0, y: 0, width: document.body.clientWidth - 120, height: document.body.clientHeight }; + } + const rect1 = h1.getBoundingClientRect(); + const rect2 = footer.getBoundingClientRect(); + // Calculate crop area from just below h1 to bottom of footer + const scrollY = window.scrollY || window.pageYOffset; + const scrollX = window.scrollX || window.pageXOffset; + let x = rect1.left + scrollX; + let y = rect1.bottom + scrollY + 4; // 4px below h1 + let width = rect1.width; + let height = (rect2.top + rect2.height) - (rect1.bottom + 4); + // Exclude the rightmost 120px (button area) + width = Math.max(1, width - 120); + return { x, y, width, height }; + } + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", tryAddButton); + // Cropped GIF + // Always call once in case DOM is already ready + tryAddButton(); + win.document.getElementById('download-cropped-gif-btn').onclick = function() { + const imgs = Array.from(win.document.querySelectorAll('.frame-img')); + if (!imgs.length) return; + const gif = new win.GIF({ workers: 2, quality: 10, workerScript: 'gif.worker.js' }); + let loaded = 0; + imgs.forEach(img => { + const canvas = win.document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + // Use the same crop as getCropRect + const h1 = win.document.querySelector('h1'); + const footer = win.document.querySelector('footer'); + let cropX = 0, cropY = 0, cropW = img.naturalWidth, cropH = img.naturalHeight; + if (h1 && footer) { + const rect1 = h1.getBoundingClientRect(); + const rect2 = footer.getBoundingClientRect(); + cropY = rect1.bottom + 4; + cropH = (rect2.top + rect2.height) - cropY; + cropW = img.naturalWidth - 120; + } else { + cropW = img.naturalWidth - 120; + } + canvas.width = cropW; + canvas.height = cropH; + ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); + gif.addFrame(canvas, { delay: 800 }); + loaded++; + if (loaded === imgs.length) { + gif.on('finished', function(blob) { + const url = win.URL.createObjectURL(blob); + const a = win.document.createElement('a'); + a.href = url; + a.download = 'div-rs-cropped-frames.gif'; + win.document.body.appendChild(a); + a.click(); + setTimeout(() => win.document.body.removeChild(a), 100); + }); + gif.render(); + } + }); + }; + }; + } +examples.push( + example( + "Toggle", + "An example with a toggleable pane.\n\nTwo grey panes labeled 'Hi!' and 'Bye!' automatically alternate visibility every second.\nNo user controls; keyboard input has no effect.\n\n---\nRust:\n```rust\ndiv0.show()?;\ndiv1.hide()?;\n```", + () => toggle.main() + ) +); +examples.push( + example( + "Hello Svelte", + "Demonstrates loading a Svelte component as a pane using Rust and JS interop.\n\nNo controls. This example shows a Svelte UI rendered inside a div-rs pane.\n\n---\nRust:\n```rust\nlet class = div::JsClass::preregistered(\"MyComponent\")?;\ndiv::from_js_class(x, y, w, h, class)?;\n```", + () => { + const divRoot = document.getElementById('div-root'); + if (!divRoot) { + console.error("'div-root' element is missing before running hello_svelte.main()."); + } else { + console.log("'div-root' element found, running hello_svelte.main()."); + } + hello_svelte.main(); + } + ) +); loadExampleSelection(examples); + let params = new URLSearchParams(location.search); let displayedExample = params.get('example'); +function resetDivRoot() { + let main = document.querySelector('main'); + let old = document.getElementById('div-root'); + if (old) old.remove(); + let div = document.createElement('div'); + div.id = 'div-root'; + div.style.position = 'relative'; + main.insertBefore(div, main.firstChild); +} + if (displayedExample) { + resetDivRoot(); + // Call div.reset() on all modules before running example + for (const mod of [hello_world, reposition, styled, toggle, hello_svelte]) { + if (typeof mod.reset === 'function') { + try { mod.reset(); } catch (e) { /* ignore */ } + } + } const example = examples[displayedExample]; example.fn(); displayHint(example.help); @@ -93,6 +443,10 @@ function loadExampleSelection(examples) { button.setAttribute("type", "submit"); button.setAttribute("value", "Show"); form.appendChild(button); + // Prevent default form submission (which reloads the page) + form.addEventListener('submit', function(event) { + event.preventDefault(); + }); const select = document.createElement("select"); select.setAttribute("name", "example") @@ -103,16 +457,78 @@ function loadExampleSelection(examples) { option.text = examples[i].name; select.appendChild(option); } - const main = document.getElementsByTagName("main")[0]; - main.prepend(form); -} + // Set combobox to match displayed example if present in URL + let params = new URLSearchParams(location.search); + let displayedExample = params.get('example'); + if (displayedExample !== null && select.options[displayedExample]) { + select.value = displayedExample; + } + // Add a Random button to cycle through all modes + const randomBtn = document.createElement('button'); + randomBtn.innerText = 'Random'; + randomBtn.style.marginLeft = '10px'; + form.appendChild(randomBtn); + let randomInterval = null; + randomBtn.addEventListener('click', function() { + if (randomInterval) { + clearInterval(randomInterval); + randomInterval = null; + randomBtn.innerText = 'Random'; + return; + } + randomBtn.innerText = 'Stop Random'; + randomInterval = setInterval(() => { + // Pick a random example index + const idx = Math.floor(Math.random() * examples.length); + select.value = idx; + // Trigger change event + select.dispatchEvent(new Event('change')); + }, 2000); + }); + // Insert form after the

header + const h1 = document.querySelector('h1'); + if (h1 && h1.parentNode) { + h1.parentNode.insertBefore(form, h1.nextSibling); + } else { + document.body.prepend(form); + } + + // Reset div-root and run example on selection change + select.addEventListener('change', async function() { + const idx = select.value; + // No longer force reload for 'Reposition'; robust reset is now used for all examples + resetDivRoot(); + // Call div.reset() if available (WASM) + for (const mod of [hello_world, reposition, styled, toggle, hello_svelte]) { + if (typeof mod.reset === 'function') { + try { mod.reset(); } catch (e) { /* ignore */ } + } + } + examples[idx].fn(); + displayHint(examples[idx].help); + }); +} function displayHint(text) { - const floatingText = document.createElement("p"); + // Remove all previous .hint elements + document.querySelectorAll('p.hint').forEach(e => e.remove()); if (text) { + // Convert markdown-style code blocks to
...
+ let html = text.replace(/```rust([\s\S]*?)```/g, (match, code) => { + return `
${escapeHtml(code.trim())}
`; + }); + // Replace newlines with
outside code blocks only + html = html.replace(/(?!
|)(\n)/g, '
'); + const floatingText = document.createElement("p"); floatingText.className = "hint"; - floatingText.innerText = text; + floatingText.innerHTML = html; const body = document.getElementsByTagName("body")[0]; body.appendChild(floatingText); + highlightCodeBlocks(); } -} \ No newline at end of file +} + +// Escape HTML for code blocks +function escapeHtml(str) { + return str.replace(/[&<>]/g, tag => ({'&':'&','<':'<','>':'>'}[tag])); +} diff --git a/examples/www/package.json b/examples/www/package.json index d3ad7f3..b591bf6 100644 --- a/examples/www/package.json +++ b/examples/www/package.json @@ -7,9 +7,21 @@ "create-wasm-app": ".bin/create-wasm-app.js" }, "scripts": { - "build": "wasm-pack build ../hello_svelte; wasm-pack build ../hello_world; wasm-pack build ../reposition/; wasm-pack build ../styled/; wasm-pack build ../toggle; webpack --config webpack.config.js", + "clean:wasm": "cargo clean --manifest-path ../hello_svelte/Cargo.toml && cargo clean --manifest-path ../hello_world/Cargo.toml && cargo clean --manifest-path ../reposition/Cargo.toml && cargo clean --manifest-path ../styled/Cargo.toml && cargo clean --manifest-path ../toggle/Cargo.toml", + "clean:js": "rimraf dist pkg", + "clean": "npm-run-all clean:wasm clean:js", + "build:wasm": "run-s build:hello_svelte build:hello_world build:reposition build:styled build:toggle", + "build:hello_svelte": "wasm-pack build ../hello_svelte", + "build:hello_world": "wasm-pack build ../hello_world", + "build:reposition": "wasm-pack build ../reposition", + "build:styled": "wasm-pack build ../styled", + "build:toggle": "wasm-pack build ../toggle", + "build": "npm-run-all build:wasm build:webpack", + "build:webpack": "webpack --config webpack.config.js", "start": "webpack-dev-server", - "release": "wasm-pack build ../hello_svelte; wasm-pack build ../hello_world; wasm-pack build ../reposition/; wasm-pack build ../styled/; wasm-pack build ../toggle; webpack --config webpack.prod.js" + "release": "npm-run-all build:wasm release:webpack", + "release:webpack": "webpack --config webpack.prod.js", + "rebuild": "npm run clean && npm run build && npm start" }, "repository": { "type": "git", @@ -21,28 +33,35 @@ "rust", "webpack" ], - "author": "Jakob Meier ", + "contributors": [ + "David Horner" + ], "license": "(MIT OR Apache-2.0)", "bugs": { "url": "https://github.com/jakmeier/div-rs/issues" }, "homepage": "https://github.com/jakmeier/div-rs#readme", "dependencies": { + "gif.js": "^0.2.0", "hello_svelte": "file:../hello_svelte/pkg", "hello_world": "file:../hello_world/pkg", + "html2canvas": "^1.4.1", + "prismjs": "^1.30.0", "reposition": "file:../reposition/pkg/", "styled": "file:../styled/pkg/", "toggle": "file:../toggle/pkg/" }, "devDependencies": { - "copy-webpack-plugin": "^5.0.0", - "css-loader": "^4.3.0", - "style-loader": "^1.2.1", - "svelte": "^3.29.0", - "svelte-loader": "^2.13.6", - "webpack": "^4.29.3", - "webpack-cli": "^3.1.0", - "webpack-dev-server": "^3.1.5", - "webpack-merge": "^5.2.0" + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.8.1", + "npm-run-all": "^4.1.5", + "style-loader": "^3.3.4", + "svelte": "^3.59.2", + "svelte-loader": "^3.1.6", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.10.0" } } diff --git a/examples/www/styles.css b/examples/www/styles.css index 613e02a..11547f3 100644 --- a/examples/www/styles.css +++ b/examples/www/styles.css @@ -1,3 +1,8 @@ +pre, pre code { + white-space: pre-wrap; + word-break: break-word; + overflow-x: hidden; +} body { margin: auto; text-align: center; diff --git a/examples/www/webpack.common.js b/examples/www/webpack.common.js index 34377d2..2d67f92 100644 --- a/examples/www/webpack.common.js +++ b/examples/www/webpack.common.js @@ -10,7 +10,8 @@ module.exports = { resolve: { extensions: ['.mjs', '.js', '.svelte'], mainFields: ['svelte', 'browser', 'module', 'main'], - modules: [path.resolve(__dirname, 'node_modules'), 'node_modules'] + modules: [path.resolve(__dirname, 'node_modules'), 'node_modules'], + conditionNames: ['svelte', 'import', 'require', 'node'] }, module: { rules: [{ @@ -26,6 +27,14 @@ module.exports = { }] }, plugins: [ - new CopyWebpackPlugin(['index.html']) + new CopyWebpackPlugin({ + patterns: [ + 'index.html', + { from: 'node_modules/gif.js/dist/gif.worker.js', to: '.' } + ] + }) ], + experiments: { + asyncWebAssembly: true, + }, }; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index aaa6cfc..c7430bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +// use wasm_bindgen::prelude::*; // No longer needed: no #[wasm_bindgen] exports in this crate +// No public reset/unmount API. Examples must use only the public API and manage their own cleanup. use std::{future::Future, sync::RwLock}; use web_sys::Element; @@ -206,3 +208,11 @@ impl JsClass { state::exec_mut(|state| Ok(state.classes.preloaded(name))).unwrap() } } + +use wasm_bindgen::prelude::*; + +/// Resets the global div state, allowing re-initialization (for example switching) +#[wasm_bindgen] +pub fn reset_global_div_state() { + state::clear_state(); +} diff --git a/src/pane.rs b/src/pane.rs index d73d949..bce8ccd 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -73,7 +73,7 @@ impl GlobalState { Ok(()) } pub(crate) fn show_pane(&mut self, p: &DivHandle) -> Result<(), DivError> { - let mut v = self.nodes.get_mut(&p)?; + let v = self.nodes.get_mut(&p)?; if !v.displayed { self.root.append_child(&v.node)?; v.displayed = true; @@ -100,7 +100,7 @@ impl GlobalState { w: Option, h: Option, ) -> Result<(), DivError> { - let mut v = self.nodes.get_mut(&pane_handle)?; + let v = self.nodes.get_mut(&pane_handle)?; v.x = x.unwrap_or(v.x); v.y = y.unwrap_or(v.y); v.w = w.unwrap_or(v.w); diff --git a/src/state.rs b/src/state.rs index 04e002d..038b3bf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -16,11 +16,6 @@ thread_local! { static S_STATE: RwLock>> = RwLock::default(); } -// pub (crate) fn get<'a>() -> Result>>, DivError> -// pub(crate) fn get( -// ) -> Result>>, DivError> { -// S_STATE.with(|state| state.read().map_err(|_e| DivError::Locked)) -// } pub(crate) fn set_state( new_state: GlobalState, @@ -65,3 +60,10 @@ where f(state.as_mut().as_mut().ok_or(DivError::NotInitialized)?) }) } +/// Clears the global state, allowing re-initialization. +pub(crate) fn clear_state() { + S_STATE.with(|state| { + let mut state = state.write().expect("Lock failed"); + *state = None; + }); +}