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::KeyedList(children) => {
121            let el = document.create_element("div")?;
122            el.set_attribute("data-th-keyed-list", "true")?;
123            use wasm_bindgen::JsCast;
124            for (key, child) in children {
125                let child_node = render_view(document, child)?;
126                if let Some(child_el) = child_node.dyn_ref::<Element>() {
127                    let _ = child_el.set_attribute("data-th-key", &key);
128                }
129                el.append_child(&child_node)?;
130            }
131            Ok(el.into())
132        }
133        View::None => Ok(document.create_text_node("").into()),
134    }
135}
136
137pub fn tick() -> Result<(), JsValue> {
138    let window = web_sys::window().expect("no global `window` exists");
139    let document = window.document().expect("should have a document on window");
140
141    // run_effects() re-runs any create_effect closures whose signals changed,
142    // including dynamic attribute effects registered during render.
143    run_effects();
144
145    let pending = take_pending_boundaries();
146
147    let mut boundary_updates = Vec::new();
148
149    for id in pending {
150        let entry = BOUNDARIES.with(|b| b.borrow().get(&id).cloned());
151        if let Some((old_node, compute)) = entry {
152            let view = id.track(|| {
153                let mut comp = compute.borrow_mut();
154                comp()
155            });
156
157            let mut handled = false;
158            if let View::KeyedList(children) = &view {
159                use wasm_bindgen::JsCast;
160                if let Some(old_el) = old_node.dyn_ref::<web_sys::Element>() {
161                    if old_el.has_attribute("data-th-keyed-list") {
162                        handled = true;
163
164                        let mut old_nodes = std::collections::HashMap::new();
165                        let mut current_child = old_node.first_child();
166                        while let Some(child) = current_child {
167                            if let Some(child_el) = child.dyn_ref::<web_sys::Element>() {
168                                if let Some(key) = child_el.get_attribute("data-th-key") {
169                                    old_nodes.insert(key, child.clone());
170                                }
171                            }
172                            current_child = child.next_sibling();
173                        }
174
175                        // Temporarily hold children in an array to append them in order after clearing
176                        let mut new_ordered_children = Vec::new();
177
178                        for (key, child_view) in children {
179                            if let Some(existing_node) = old_nodes.remove(key) {
180                                new_ordered_children.push(existing_node);
181                            } else {
182                                let new_child = render_view(&document, child_view.clone())?;
183                                if let Some(child_el) = new_child.dyn_ref::<web_sys::Element>() {
184                                    let _ = child_el.set_attribute("data-th-key", key);
185                                }
186                                new_ordered_children.push(new_child);
187                            }
188                        }
189
190                        old_el.set_inner_html("");
191                        for child in new_ordered_children {
192                            old_el.append_child(&child)?;
193                        }
194
195                        boundary_updates.push((id, old_node.clone(), compute.clone()));
196                    }
197                }
198            }
199
200            if !handled {
201                let new_node = render_view(&document, view)?;
202                if let Some(parent) = old_node.parent_node() {
203                    parent.replace_child(&new_node, &old_node)?;
204                    boundary_updates.push((id, new_node, compute));
205                }
206            }
207        }
208    }
209
210    BOUNDARIES.with(|b| {
211        let mut boundaries = b.borrow_mut();
212        for (id, new_node, compute) in boundary_updates {
213            boundaries.insert(id, (new_node, compute));
214        }
215    });
216
217    Ok(())
218}
219
220#[macro_export]
221macro_rules! get_value {
222    ($id:expr) => {{
223        let mut val = String::new();
224        if let Some(w) = $crate::web_sys::window() {
225            if let Some(d) = w.document() {
226                if let Some(el) = d.get_element_by_id($id) {
227                    use $crate::wasm_bindgen::JsCast;
228                    if let Ok(input_el) = el.clone().dyn_into::<$crate::web_sys::HtmlInputElement>()
229                    {
230                        val = input_el.value();
231                    } else if let Ok(textarea_el) = el
232                        .clone()
233                        .dyn_into::<$crate::web_sys::HtmlTextAreaElement>()
234                    {
235                        val = textarea_el.value();
236                    } else if let Ok(select_el) =
237                        el.dyn_into::<$crate::web_sys::HtmlSelectElement>()
238                    {
239                        val = select_el.value();
240                    }
241                }
242            }
243        }
244        val
245    }};
246}
247
248#[macro_export]
249macro_rules! spawn {
250    ($fut:expr) => {
251        $crate::wasm_bindgen_futures::spawn_local(async move {
252            $fut.await;
253            let _ = $crate::tick();
254        });
255    };
256}
257
258#[macro_export]
259macro_rules! fetch {
260    // With body
261    ($method:ident $url:expr, $body:expr => |$text:ident| $success:block) => {
262        $crate::wasm_bindgen_futures::spawn_local(async move {
263            if let Ok(resp) = $crate::reqwasm::http::Request::$method($url).header("Content-Type", "application/json").body($body).send().await {
264                if let Ok($text) = resp.text().await {
265                    $success
266                    let _ = $crate::tick();
267                }
268            }
269        });
270    };
271    ($method:ident $url:expr, $body:expr => |$text:ident| $success:block, |$err:ident| $error:block) => {
272        $crate::wasm_bindgen_futures::spawn_local(async move {
273            match $crate::reqwasm::http::Request::$method($url).header("Content-Type", "application/json").body($body).send().await {
274                Ok(resp) => {
275                    match resp.text().await {
276                        Ok($text) => {
277                            $success
278                            let _ = $crate::tick();
279                        }
280                        Err(e) => {
281                            let $err = format!("Parse error: {:?}", e);
282                            $error
283                            let _ = $crate::tick();
284                        }
285                    }
286                }
287                Err(e) => {
288                    let $err = format!("Fetch error: {:?}", e);
289                    $error
290                    let _ = $crate::tick();
291                }
292            }
293        });
294    };
295
296    // Without body
297    ($method:ident $url:expr => |$text:ident| $success:block) => {
298        $crate::wasm_bindgen_futures::spawn_local(async move {
299            if let Ok(resp) = $crate::reqwasm::http::Request::$method($url).send().await {
300                if let Ok($text) = resp.text().await {
301                    $success
302                    let _ = $crate::tick();
303                }
304            }
305        });
306    };
307    ($method:ident $url:expr => |$text:ident| $success:block, |$err:ident| $error:block) => {
308        $crate::wasm_bindgen_futures::spawn_local(async move {
309            match $crate::reqwasm::http::Request::$method($url).send().await {
310                Ok(resp) => {
311                    match resp.text().await {
312                        Ok($text) => {
313                            $success
314                            let _ = $crate::tick();
315                        }
316                        Err(e) => {
317                            let $err = format!("Parse error: {:?}", e);
318                            $error
319                            let _ = $crate::tick();
320                        }
321                    }
322                }
323                Err(e) => {
324                    let $err = format!("Fetch error: {:?}", e);
325                    $error
326                    let _ = $crate::tick();
327                }
328            }
329        });
330    };
331
332    // Default GET
333    ($url:expr => |$text:ident| $success:block) => {
334        $crate::fetch!(get $url => |$text| $success)
335    };
336    ($url:expr => |$text:ident| $success:block, |$err:ident| $error:block) => {
337        $crate::fetch!(get $url => |$text| $success, |$err| $error)
338    };
339}
340
341#[macro_export]
342macro_rules! rpc {
343    ($call:expr => |$ok:ident| $success:block) => {
344        $crate::spawn!(async move {
345            if let Ok($ok) = $call.await {
346                $success
347            }
348        });
349    };
350    ($call:expr => |$ok:ident| $success:block, |$err:ident| $error:block) => {
351        $crate::spawn!(async move {
352            match $call.await {
353                Ok($ok) => $success,
354                Err($err) => $error,
355            }
356        });
357    };
358}
359
360#[macro_export]
361macro_rules! alert {
362    ($msg:expr) => {
363        if let Some(window) = $crate::web_sys::window() {
364            let _ = window.alert_with_message($msg);
365        }
366    };
367}
368
369#[macro_export]
370macro_rules! log {
371    ($($t:tt)*) => {
372        $crate::web_sys::console::log_1(&format!($($t)*).into());
373    }
374}
375
376pub fn toggle_html_class(class: &str, active: bool) {
377    if let Some(window) = web_sys::window() {
378        if let Some(document) = window.document() {
379            if let Some(html) = document.document_element() {
380                if active {
381                    let _ = html.set_attribute("class", class);
382                } else {
383                    let _ = html.remove_attribute("class");
384                }
385            }
386        }
387    }
388}
389
390// ponytail: keep it simple. use max-age for expiration, no complex date parsing.
391#[macro_export]
392macro_rules! get_cookie {
393    () => {{
394        let mut cookie_string = String::new();
395        if let Some(window) = $crate::web_sys::window() {
396            if let Some(document) = window.document() {
397                use $crate::wasm_bindgen::JsCast;
398                if let Ok(html_doc) = document.dyn_into::<$crate::web_sys::HtmlDocument>() {
399                    if let Ok(c) = html_doc.cookie() {
400                        cookie_string = c;
401                    }
402                }
403            }
404        }
405        cookie_string
406    }};
407    ($name:expr) => {{
408        let cookies = $crate::get_cookie!();
409        let name = $name;
410        let mut result = None;
411        for c in cookies.split(';') {
412            let c = c.trim();
413            if c.starts_with(name) && c[name.len()..].starts_with('=') {
414                result = Some(c[name.len() + 1..].to_string());
415                break;
416            }
417        }
418        result
419    }};
420}
421
422#[macro_export]
423macro_rules! set_cookie {
424    ($name:expr, $value:expr) => {
425        if let Some(window) = $crate::web_sys::window() {
426            if let Some(document) = window.document() {
427                use $crate::wasm_bindgen::JsCast;
428                if let Ok(html_doc) = document.dyn_into::<$crate::web_sys::HtmlDocument>() {
429                    let cookie_str = format!("{}={}; path=/", $name, $value);
430                    let _ = html_doc.set_cookie(&cookie_str);
431                }
432            }
433        }
434    };
435    ($name:expr, $value:expr, $max_age:expr) => {
436        if let Some(window) = $crate::web_sys::window() {
437            if let Some(document) = window.document() {
438                use $crate::wasm_bindgen::JsCast;
439                if let Ok(html_doc) = document.dyn_into::<$crate::web_sys::HtmlDocument>() {
440                    let cookie_str = format!("{}={}; max-age={}; path=/", $name, $value, $max_age);
441                    let _ = html_doc.set_cookie(&cookie_str);
442                }
443            }
444        }
445    };
446}
447
448#[macro_export]
449macro_rules! navigate {
450    ($path:expr) => {
451        if let Some(window) = $crate::web_sys::window() {
452            let _ = window.history().unwrap().push_state_with_url(
453                &$crate::wasm_bindgen::JsValue::NULL,
454                "",
455                Some($path),
456            );
457            let path_str = $path;
458            let route = path_str.split(['?', '#']).next().unwrap_or(path_str);
459            $crate::ROUTER_SETTER.with(|s| {
460                if let Some(setter) = *s.borrow() {
461                    setter.set(route.to_string());
462                }
463            });
464            let _ = $crate::tick();
465            window.scroll_to_with_x_and_y(0.0, 0.0);
466        }
467    };
468}
469
470#[macro_export]
471macro_rules! animate {
472    ($selector:expr, $config:expr) => {
473        if let Some(_) = $crate::web_sys::window() {
474            let script = format!(
475                "if (window.gsap) {{ gsap.to('{}', {}) }}",
476                $selector, $config
477            );
478            if let Err(e) = $crate::js_sys::eval(&script) {
479                $crate::web_sys::console::error_2(&"GSAP animate! error:".into(), &e);
480            }
481        }
482    };
483    (from $selector:expr, $config:expr) => {
484        if let Some(_) = $crate::web_sys::window() {
485            let script = format!(
486                "if (window.gsap) {{ gsap.from('{}', {}) }}",
487                $selector, $config
488            );
489            if let Err(e) = $crate::js_sys::eval(&script) {
490                $crate::web_sys::console::error_2(&"GSAP animate! from error:".into(), &e);
491            }
492        }
493    };
494    (fromTo $selector:expr, $from:expr, $to:expr) => {
495        if let Some(_) = $crate::web_sys::window() {
496            let script = format!(
497                "if (window.gsap) {{ gsap.fromTo('{}', {}, {}) }}",
498                $selector, $from, $to
499            );
500            if let Err(e) = $crate::js_sys::eval(&script) {
501                $crate::web_sys::console::error_2(&"GSAP animate! fromTo error:".into(), &e);
502            }
503        }
504    };
505    (timeline $script:expr) => {
506        if let Some(_) = $crate::web_sys::window() {
507            let script = format!(
508                "if (window.gsap) {{ let tl = gsap.timeline(); {} }}",
509                $script
510            );
511            if let Err(e) = $crate::js_sys::eval(&script) {
512                $crate::web_sys::console::error_2(&"GSAP animate! timeline error:".into(), &e);
513            }
514        }
515    };
516}
517
518#[macro_export]
519macro_rules! redirect {
520    ($url:expr) => {
521        if let Some(w) = $crate::web_sys::window() {
522            let _ = w.location().assign($url);
523        }
524    };
525}
526
527#[macro_export]
528macro_rules! back {
529    () => {
530        if let Some(w) = $crate::web_sys::window() {
531            if let Ok(h) = w.history() {
532                let _ = h.back();
533            }
534        }
535    };
536}