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