Skip to main content

threadloom_dom/
lib.rs

1#![allow(warnings)]
2pub use js_sys;
3pub use reqwasm;
4use std::cell::RefCell;
5use std::collections::HashMap;
6use std::rc::Rc;
7use threadloom_core::{
8    create_effect, run_effects, take_pending_boundaries, AttributeValue, Boundary, NodeId, View,
9};
10pub use wasm_bindgen;
11use wasm_bindgen::prelude::*;
12pub use wasm_bindgen_futures;
13pub use web_sys;
14use web_sys::{Document, Element, Node};
15
16thread_local! {
17    static BOUNDARIES: RefCell<HashMap<NodeId, (Node, Rc<RefCell<dyn FnMut() -> View>>)>> = RefCell::new(HashMap::new());
18    pub static ROUTER_SETTER: std::cell::RefCell<Option<threadloom_core::WriteSignal<String>>> = std::cell::RefCell::new(None);
19}
20
21pub mod storage;
22pub mod ws;
23
24pub fn mount(view: View, container: &Element) -> Result<(), JsValue> {
25    let window = web_sys::window().expect("no global `window` exists");
26    let document = window.document().expect("should have a document on window");
27
28    let node = render_view(&document, view)?;
29    container.append_child(&node)?;
30    Ok(())
31}
32
33fn render_view(document: &Document, view: View) -> Result<Node, JsValue> {
34    match view {
35        View::Text(text) => Ok(document.create_text_node(&text).into()),
36        View::Element {
37            tag,
38            attrs,
39            children,
40        } => {
41            let el = if tag == "svg"
42                || tag == "path"
43                || tag == "circle"
44                || tag == "rect"
45                || tag == "g"
46                || tag == "line"
47            {
48                document.create_element_ns(Some("http://www.w3.org/2000/svg"), &tag)?
49            } else {
50                document.create_element(&tag)?
51            };
52            for (k, v) in attrs {
53                match v {
54                    AttributeValue::String(s) => el.set_attribute(&k, &s)?,
55                    AttributeValue::Bool(b) => {
56                        if b {
57                            el.set_attribute(&k, "")?;
58                        }
59                    }
60                    AttributeValue::Dynamic(f) => {
61                        // Evaluate once initially to set the attr, then register a reactive effect
62                        // that re-runs (and calls set_attribute again) whenever any signal read
63                        // inside the closure changes.
64                        let el_clone = el.clone();
65                        let k_clone = k.clone();
66                        let val = f();
67                        if let AttributeValue::String(s) = &val {
68                            let _ = el.set_attribute(&k, s);
69                        }
70                        // Reactive update effect
71                        create_effect(move || {
72                            let val = f();
73                            if let AttributeValue::String(s) = val {
74                                let _ = el_clone.set_attribute(&k_clone, &s);
75                            }
76                        });
77                    }
78                    AttributeValue::Event(cb) => {
79                        use wasm_bindgen::JsCast;
80                        let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move || {
81                            cb();
82                            let _ = crate::tick();
83                        })
84                            as Box<dyn FnMut()>);
85                        el.add_event_listener_with_callback(&k, closure.as_ref().unchecked_ref())?;
86                        closure.forget();
87                    }
88                }
89            }
90            for child in children {
91                let child_node = render_view(document, child)?;
92                el.append_child(&child_node)?;
93            }
94            Ok(el.into())
95        }
96        View::DynamicNode(boundary) => {
97            let view = boundary.id.track(|| {
98                let mut compute = boundary.compute.borrow_mut();
99                compute()
100            });
101            let node = render_view(document, view)?;
102
103            let compute_rc = boundary.compute.clone();
104            BOUNDARIES.with(|b| {
105                b.borrow_mut()
106                    .insert(boundary.id, (node.clone(), compute_rc));
107            });
108
109            Ok(node)
110        }
111        View::Fragment(children) => {
112            // Very simple stub: return a div wrapping the fragment to avoid complex multi-node tracking
113            let el = document.create_element("div")?;
114            for child in children {
115                let child_node = render_view(document, child)?;
116                el.append_child(&child_node)?;
117            }
118            Ok(el.into())
119        }
120        View::None => Ok(document.create_text_node("").into()),
121    }
122}
123
124pub fn tick() -> Result<(), JsValue> {
125    let window = web_sys::window().expect("no global `window` exists");
126    let document = window.document().expect("should have a document on window");
127
128    // run_effects() re-runs any create_effect closures whose signals changed,
129    // including dynamic attribute effects registered during render.
130    run_effects();
131
132    let pending = take_pending_boundaries();
133
134    let mut boundary_updates = Vec::new();
135
136    for id in pending {
137        let entry = BOUNDARIES.with(|b| b.borrow().get(&id).cloned());
138        if let Some((old_node, compute)) = entry {
139            let view = id.track(|| {
140                let mut comp = compute.borrow_mut();
141                comp()
142            });
143            let new_node = render_view(&document, view)?;
144            if let Some(parent) = old_node.parent_node() {
145                parent.replace_child(&new_node, &old_node)?;
146                boundary_updates.push((id, new_node, compute));
147            }
148        }
149    }
150
151    BOUNDARIES.with(|b| {
152        let mut boundaries = b.borrow_mut();
153        for (id, new_node, compute) in boundary_updates {
154            boundaries.insert(id, (new_node, compute));
155        }
156    });
157
158    Ok(())
159}
160
161#[macro_export]
162macro_rules! get_value {
163    ($id:expr) => {{
164        let mut val = String::new();
165        if let Some(w) = $crate::web_sys::window() {
166            if let Some(d) = w.document() {
167                if let Some(el) = d.get_element_by_id($id) {
168                    use $crate::wasm_bindgen::JsCast;
169                    if let Ok(input_el) = el.clone().dyn_into::<$crate::web_sys::HtmlInputElement>()
170                    {
171                        val = input_el.value();
172                    } else if let Ok(textarea_el) = el
173                        .clone()
174                        .dyn_into::<$crate::web_sys::HtmlTextAreaElement>()
175                    {
176                        val = textarea_el.value();
177                    } else if let Ok(select_el) =
178                        el.dyn_into::<$crate::web_sys::HtmlSelectElement>()
179                    {
180                        val = select_el.value();
181                    }
182                }
183            }
184        }
185        val
186    }};
187}
188
189#[macro_export]
190macro_rules! spawn {
191    ($fut:expr) => {
192        $crate::wasm_bindgen_futures::spawn_local(async move {
193            $fut.await;
194            let _ = $crate::tick();
195        });
196    };
197}
198
199#[macro_export]
200macro_rules! fetch {
201    // With body
202    ($method:ident $url:expr, $body:expr => |$text:ident| $success:block) => {
203        $crate::wasm_bindgen_futures::spawn_local(async move {
204            if let Ok(resp) = $crate::reqwasm::http::Request::$method($url).header("Content-Type", "application/json").body($body).send().await {
205                if let Ok($text) = resp.text().await {
206                    $success
207                    let _ = $crate::tick();
208                }
209            }
210        });
211    };
212    ($method:ident $url:expr, $body:expr => |$text:ident| $success:block, |$err:ident| $error:block) => {
213        $crate::wasm_bindgen_futures::spawn_local(async move {
214            match $crate::reqwasm::http::Request::$method($url).header("Content-Type", "application/json").body($body).send().await {
215                Ok(resp) => {
216                    match resp.text().await {
217                        Ok($text) => {
218                            $success
219                            let _ = $crate::tick();
220                        }
221                        Err(e) => {
222                            let $err = format!("Parse error: {:?}", e);
223                            $error
224                            let _ = $crate::tick();
225                        }
226                    }
227                }
228                Err(e) => {
229                    let $err = format!("Fetch error: {:?}", e);
230                    $error
231                    let _ = $crate::tick();
232                }
233            }
234        });
235    };
236
237    // Without body
238    ($method:ident $url:expr => |$text:ident| $success:block) => {
239        $crate::wasm_bindgen_futures::spawn_local(async move {
240            if let Ok(resp) = $crate::reqwasm::http::Request::$method($url).send().await {
241                if let Ok($text) = resp.text().await {
242                    $success
243                    let _ = $crate::tick();
244                }
245            }
246        });
247    };
248    ($method:ident $url:expr => |$text:ident| $success:block, |$err:ident| $error:block) => {
249        $crate::wasm_bindgen_futures::spawn_local(async move {
250            match $crate::reqwasm::http::Request::$method($url).send().await {
251                Ok(resp) => {
252                    match resp.text().await {
253                        Ok($text) => {
254                            $success
255                            let _ = $crate::tick();
256                        }
257                        Err(e) => {
258                            let $err = format!("Parse error: {:?}", e);
259                            $error
260                            let _ = $crate::tick();
261                        }
262                    }
263                }
264                Err(e) => {
265                    let $err = format!("Fetch error: {:?}", e);
266                    $error
267                    let _ = $crate::tick();
268                }
269            }
270        });
271    };
272
273    // Default GET
274    ($url:expr => |$text:ident| $success:block) => {
275        $crate::fetch!(get $url => |$text| $success)
276    };
277    ($url:expr => |$text:ident| $success:block, |$err:ident| $error:block) => {
278        $crate::fetch!(get $url => |$text| $success, |$err| $error)
279    };
280}
281
282#[macro_export]
283macro_rules! rpc {
284    ($call:expr => |$ok:ident| $success:block) => {
285        $crate::spawn!(async move {
286            if let Ok($ok) = $call.await {
287                $success
288            }
289        });
290    };
291    ($call:expr => |$ok:ident| $success:block, |$err:ident| $error:block) => {
292        $crate::spawn!(async move {
293            match $call.await {
294                Ok($ok) => $success,
295                Err($err) => $error,
296            }
297        });
298    };
299}
300
301#[macro_export]
302macro_rules! alert {
303    ($msg:expr) => {
304        if let Some(window) = $crate::web_sys::window() {
305            let _ = window.alert_with_message($msg);
306        }
307    };
308}
309
310#[macro_export]
311macro_rules! log {
312    ($($t:tt)*) => {
313        $crate::web_sys::console::log_1(&format!($($t)*).into());
314    }
315}
316
317pub fn toggle_html_class(class: &str, active: bool) {
318    if let Some(window) = web_sys::window() {
319        if let Some(document) = window.document() {
320            if let Some(html) = document.document_element() {
321                if active {
322                    let _ = html.set_attribute("class", class);
323                } else {
324                    let _ = html.remove_attribute("class");
325                }
326            }
327        }
328    }
329}
330
331// ponytail: keep it simple. use max-age for expiration, no complex date parsing.
332#[macro_export]
333macro_rules! get_cookie {
334    () => {{
335        let mut cookie_string = String::new();
336        if let Some(window) = $crate::web_sys::window() {
337            if let Some(document) = window.document() {
338                use $crate::wasm_bindgen::JsCast;
339                if let Ok(html_doc) = document.dyn_into::<$crate::web_sys::HtmlDocument>() {
340                    if let Ok(c) = html_doc.cookie() {
341                        cookie_string = c;
342                    }
343                }
344            }
345        }
346        cookie_string
347    }};
348    ($name:expr) => {{
349        let cookies = $crate::get_cookie!();
350        let name = $name;
351        let mut result = None;
352        for c in cookies.split(';') {
353            let c = c.trim();
354            if c.starts_with(name) && c[name.len()..].starts_with('=') {
355                result = Some(c[name.len() + 1..].to_string());
356                break;
357            }
358        }
359        result
360    }};
361}
362
363#[macro_export]
364macro_rules! set_cookie {
365    ($name:expr, $value:expr) => {
366        if let Some(window) = $crate::web_sys::window() {
367            if let Some(document) = window.document() {
368                use $crate::wasm_bindgen::JsCast;
369                if let Ok(html_doc) = document.dyn_into::<$crate::web_sys::HtmlDocument>() {
370                    let cookie_str = format!("{}={}; path=/", $name, $value);
371                    let _ = html_doc.set_cookie(&cookie_str);
372                }
373            }
374        }
375    };
376    ($name:expr, $value:expr, $max_age:expr) => {
377        if let Some(window) = $crate::web_sys::window() {
378            if let Some(document) = window.document() {
379                use $crate::wasm_bindgen::JsCast;
380                if let Ok(html_doc) = document.dyn_into::<$crate::web_sys::HtmlDocument>() {
381                    let cookie_str = format!("{}={}; max-age={}; path=/", $name, $value, $max_age);
382                    let _ = html_doc.set_cookie(&cookie_str);
383                }
384            }
385        }
386    };
387}
388
389#[macro_export]
390macro_rules! navigate {
391    ($path:expr) => {
392        if let Some(window) = $crate::web_sys::window() {
393            let _ = window.history().unwrap().push_state_with_url(
394                &$crate::wasm_bindgen::JsValue::NULL,
395                "",
396                Some($path),
397            );
398            let path_str = $path;
399            let route = path_str.split(['?', '#']).next().unwrap_or(path_str);
400            $crate::ROUTER_SETTER.with(|s| {
401                if let Some(setter) = *s.borrow() {
402                    setter.set(route.to_string());
403                }
404            });
405            let _ = $crate::tick();
406            window.scroll_to_with_x_and_y(0.0, 0.0);
407        }
408    };
409}
410
411#[macro_export]
412macro_rules! animate {
413    ($selector:expr, $config:expr) => {
414        if let Some(_) = $crate::web_sys::window() {
415            let script = format!("if (window.gsap) {{ gsap.to('{}', {}) }}", $selector, $config);
416            if let Err(e) = $crate::js_sys::eval(&script) {
417                $crate::web_sys::console::error_2(&"GSAP animate! error:".into(), &e);
418            }
419        }
420    };
421    (from $selector:expr, $config:expr) => {
422        if let Some(_) = $crate::web_sys::window() {
423            let script = format!("if (window.gsap) {{ gsap.from('{}', {}) }}", $selector, $config);
424            if let Err(e) = $crate::js_sys::eval(&script) {
425                $crate::web_sys::console::error_2(&"GSAP animate! from error:".into(), &e);
426            }
427        }
428    };
429    (fromTo $selector:expr, $from:expr, $to:expr) => {
430        if let Some(_) = $crate::web_sys::window() {
431            let script = format!("if (window.gsap) {{ gsap.fromTo('{}', {}, {}) }}", $selector, $from, $to);
432            if let Err(e) = $crate::js_sys::eval(&script) {
433                $crate::web_sys::console::error_2(&"GSAP animate! fromTo error:".into(), &e);
434            }
435        }
436    };
437    (timeline $script:expr) => {
438        if let Some(_) = $crate::web_sys::window() {
439            let script = format!("if (window.gsap) {{ let tl = gsap.timeline(); {} }}", $script);
440            if let Err(e) = $crate::js_sys::eval(&script) {
441                $crate::web_sys::console::error_2(&"GSAP animate! timeline error:".into(), &e);
442            }
443        }
444    };
445}
446
447#[macro_export]
448macro_rules! redirect {
449    ($url:expr) => {
450        if let Some(w) = $crate::web_sys::window() {
451            let _ = w.location().assign($url);
452        }
453    };
454}
455
456#[macro_export]
457macro_rules! back {
458    () => {
459        if let Some(w) = $crate::web_sys::window() {
460            if let Ok(h) = w.history() {
461                let _ = h.back();
462            }
463        }
464    };
465}