canonrs-interactions-content 0.1.0

CanonRS content interaction handlers
Documentation
//! Markdown Interaction Engine
//! Core: dom/ + clipboard + toc scrollspy

use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use web_sys::Element;
use canonrs_interactions_core::dom::state;
use canonrs_interactions_core::runtime::{listeners, timers, scrollspy};

fn copy_code_to_clipboard(code: String, btn: Element) {
    let window = match web_sys::window() { Some(w) => w, None => return };
    let promise = window.navigator().clipboard().write_text(&code);
    spawn_local(async move {
        let result = wasm_bindgen_futures::JsFuture::from(promise).await;
        if result.is_ok() {
            state::add(&btn, "copied");
            if let Ok(Some(label)) = btn.query_selector("[data-rs-copy-label]") {
                label.set_text_content(Some("Copied!"));
            }
        } else {
            state::add(&btn, "error");
        }
        let btn_reset = btn.clone();
        timers::timeout(2000, move || {
            state::remove(&btn_reset, "active");
            state::remove(&btn_reset, "copied");
            state::remove(&btn_reset, "error");
            if let Ok(Some(label)) = btn_reset.query_selector("[data-rs-copy-label]") {
                label.set_text_content(Some("Copy"));
            }
        });
    });
}

fn setup_copy_buttons(root: &Element) {
    let Ok(btns) = root.query_selector_all("[data-rs-copy-button]") else { return };
    for i in 0..btns.length() {
        let Some(node) = btns.item(i) else { continue };
        let Ok(btn) = node.dyn_into::<Element>() else { continue };
        if btn.has_attribute("data-rs-md-copy-attached") { continue; }
        btn.set_attribute("data-rs-md-copy-attached", "1").ok();

        let uid = format!("md-copy:{}", btn.get_attribute("data-rs-uid").unwrap_or_else(|| i.to_string()));
        listeners::listen(&uid, &btn, "click", {
            let btn_c = btn.clone();
            move |_: web_sys::Event| {
                let code = btn_c.closest("[data-rs-code-block]").ok().flatten()
                    .and_then(|block| block.query_selector("[data-rs-code-pre]").ok().flatten())
                    .map(|pre| pre.text_content().unwrap_or_default())
                    .unwrap_or_default();
                if code.is_empty() { return; }
                copy_code_to_clipboard(code, btn_c.clone());
            }
        });
    }
}

pub fn init(root: Element) {
    setup_copy_buttons(&root);
    setup_toc(&root);
}

fn setup_toc(root: &Element) {
    let Ok(tocs) = root.query_selector_all("[data-rs-toc]") else { return };
    for i in 0..tocs.length() {
        if let Some(node) = tocs.item(i) {
            if let Ok(toc) = node.dyn_into::<Element>() {
                if toc.has_attribute("data-rs-toc-md-attached") { continue; }
                toc.set_attribute("data-rs-toc-md-attached", "1").ok();
                setup_toc_anchor_click(&toc);
                let toc_delayed = toc.clone();
                timers::timeout(300, move || {
                    setup_toc_scroll_spy(&toc_delayed);
                });
            }
        }
    }
}

fn setup_toc_anchor_click(toc: &Element) {
    let uid = format!("md-toc-click:{}", toc.get_attribute("data-rs-uid").unwrap_or_default());
    listeners::listen(&uid, toc, "click", move |e: web_sys::Event| {
        let e = e.dyn_into::<web_sys::MouseEvent>().unwrap();
        let Some(target) = e.target().and_then(|t| t.dyn_into::<Element>().ok()) else { return };
        let Some(link) = target.closest("[data-rs-toc-link]").ok().flatten() else { return };
        e.prevent_default();
        let href = link.get_attribute("href").unwrap_or_default();
        if href.starts_with('#') {
            // Use scrollspy kernel — history.replace_state delegated to nav runtime
            scrollspy::scroll_to_anchor(&href[1..], 80.0);
        }
    });
}

fn setup_toc_scroll_spy(toc: &Element) {
    let Ok(items) = toc.query_selector_all("[data-rs-toc-item][data-rs-target]") else { return };
    let mut heading_ids: Vec<String> = Vec::new();
    for i in 0..items.length() {
        if let Some(node) = items.item(i) {
            if let Ok(el) = node.dyn_into::<Element>() {
                if let Some(id) = el.get_attribute("data-rs-target") {
                    heading_ids.push(id);
                }
            }
        }
    }
    if heading_ids.is_empty() { return; }

    // Use scrollspy kernel for viewport topology resolution
    let scroll_viewport = scrollspy::resolve_viewport(toc);

    let toc_uid = format!("md-scroll:{}", toc.get_attribute("data-rs-uid").unwrap_or_default());
    let toc_c   = toc.clone();
    let last_active = std::rc::Rc::new(std::cell::RefCell::new(None::<String>));
    let la_cb   = last_active.clone();
    let vp_c    = scroll_viewport.clone();

    // Use scrollspy kernel for element caching
    let cached_headings = std::rc::Rc::new(scrollspy::cache_headings(&heading_ids));

    let on_scroll_fn = move |_: web_sys::Event| {
        // Use scrollspy kernel — no DOM queries
        let active_id = scrollspy::active_heading(cached_headings.as_ref(), vp_c.as_ref(), 80.0);
        let Some(id) = active_id else { return };
        if la_cb.borrow().as_deref() == Some(&id) { return; }
        *la_cb.borrow_mut() = Some(id.clone());
        if let Ok(all) = toc_c.query_selector_all("[data-rs-toc-item]") {
            for j in 0..all.length() {
                if let Some(el) = all.item(j).and_then(|n| n.dyn_into::<Element>().ok()) {
                    state::remove(&el, "active");
                }
            }
        }
        let selector = format!("[data-rs-toc-item][data-rs-target='{}']", id);
        if let Ok(Some(item)) = toc_c.query_selector(&selector) {
            state::add(&item, "active");
        }
    };

    let target: &web_sys::EventTarget = match &scroll_viewport {
        Some(el) => el.as_ref(),
        None => {
            let win = web_sys::window().unwrap();
            listeners::listen_opts(
                &toc_uid,
                win.as_ref(),
                "scroll",
                canonrs_interactions_core::runtime::listeners::ListenOpts { capture: true, passive: false },
                on_scroll_fn,
            );
            return;
        }
    };

    listeners::listen_opts(
        &toc_uid,
        target,
        "scroll",
        canonrs_interactions_core::runtime::listeners::ListenOpts { capture: true, passive: false },
        on_scroll_fn,
    );
}