839 lines
31 KiB
Rust
839 lines
31 KiB
Rust
//! Entrypoint WASM de la landing GioSer.
|
||
//!
|
||
//! Capas:
|
||
//! - **Canvas WebGL**: chacana animada (gioser-canvas-web).
|
||
//! - **Tips**: 4 botones DOM en los cardinales, posicionados cada frame.
|
||
//! - **Deck** (vista-web): contenedor único con páginas swipeable estilo
|
||
//! Flutter `PageView`. Cada elemento del logo es una página dinámica.
|
||
//! - **Taskbar**: barra abajo con home + brand "GioSer" + tabs activas +
|
||
//! copyleft/email a la derecha. Sincronizada por WASM.
|
||
//!
|
||
//! Acciones (todas pasan por `AppState`):
|
||
//! - `open_or_switch` — click en tip o abrir nueva pestaña.
|
||
//! - `restore_from_tab` — click en cajita de la taskbar.
|
||
//! - `minimize` — botón ─ de la página o Escape.
|
||
//! - `close` — botón × de la página, remueve del taskbar.
|
||
//! - `home` — botón casa o brand, minimiza todo (mantiene tabs).
|
||
//! - `on_swipe` — callback de vista-web cuando el snap cambia.
|
||
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use barra_web::{Task, TaskList};
|
||
use gioser_canvas_web::{tips, Renderer};
|
||
use gioser_graph_web::GraphWidget;
|
||
use fana_md_reader_web::Reader;
|
||
use revista_web::Deck;
|
||
use wasm_bindgen::prelude::*;
|
||
use wasm_bindgen::JsCast;
|
||
use web_sys::{
|
||
Document, Element, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent,
|
||
PointerEvent, Window,
|
||
};
|
||
|
||
const BUTTON_RADIUS_FACTOR: f32 = 1.32;
|
||
const TASKBAR_HEIGHT_PX: f32 = 52.0;
|
||
const BUTTON_HALF_W_PX: f32 = 90.0;
|
||
const BUTTON_HALF_H_PX: f32 = 64.0;
|
||
const VIEWPORT_MARGIN_PX: f32 = 14.0;
|
||
const ELEMENTS: [&str; 9] = ["aire", "fuego", "tierra", "agua", "cuerpo", "sombra", "cosmos", "practica", "olvido"];
|
||
|
||
#[derive(Default)]
|
||
struct DeckState {
|
||
/// Pestañas abiertas en orden de apertura. Coincide con páginas en el strip.
|
||
pages: Vec<String>,
|
||
/// Cuál es la página visible. `None` = deck minimizado (pestañas siguen
|
||
/// en la taskbar) o sin pestañas.
|
||
active: Option<String>,
|
||
}
|
||
|
||
struct AppState {
|
||
document: Document,
|
||
deck: Deck,
|
||
taskbar: TaskList,
|
||
state: RefCell<DeckState>,
|
||
}
|
||
|
||
impl AppState {
|
||
fn open_or_switch(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) {
|
||
let was_visible = self.state.borrow().active.is_some();
|
||
let was_in_pages = self.state.borrow().pages.iter().any(|e| e == element);
|
||
if !was_in_pages {
|
||
self.ensure_page_dom(element);
|
||
self.state.borrow_mut().pages.push(element.to_string());
|
||
}
|
||
self.state.borrow_mut().active = Some(element.to_string());
|
||
let idx = self
|
||
.state
|
||
.borrow()
|
||
.pages
|
||
.iter()
|
||
.position(|e| e == element)
|
||
.unwrap_or(0);
|
||
if was_visible {
|
||
self.deck.goto(idx, true);
|
||
} else {
|
||
self.deck.goto(idx, false);
|
||
self.show_deck(origin_x, origin_y);
|
||
}
|
||
self.sync_active_class();
|
||
self.sync_taskbar();
|
||
self.load_md_if_empty(element, md_url);
|
||
// Actualizar URL con history.pushState (sin #)
|
||
if let Some(win) = web_sys::window() {
|
||
if let Ok(hist) = win.history() {
|
||
let path = format!("/estudio/{}", element);
|
||
let _ = hist.push_state_with_url(
|
||
&wasm_bindgen::JsValue::NULL,
|
||
"",
|
||
Some(&path),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn restore_from_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
|
||
let idx_opt = self
|
||
.state
|
||
.borrow()
|
||
.pages
|
||
.iter()
|
||
.position(|e| e == element);
|
||
let Some(idx) = idx_opt else { return };
|
||
let was_visible = self.state.borrow().active.is_some();
|
||
self.state.borrow_mut().active = Some(element.to_string());
|
||
if was_visible {
|
||
self.deck.goto(idx, true);
|
||
} else {
|
||
self.deck.goto(idx, false);
|
||
self.show_deck(origin_x, origin_y);
|
||
}
|
||
self.sync_active_class();
|
||
self.sync_taskbar();
|
||
}
|
||
|
||
fn minimize(&self, origin_x: f64, origin_y: f64) {
|
||
self.state.borrow_mut().active = None;
|
||
self.sync_active_class();
|
||
self.sync_taskbar();
|
||
self.hide_deck(origin_x, origin_y);
|
||
// Restaurar URL
|
||
if let Some(win) = web_sys::window() {
|
||
if let Ok(hist) = win.history() {
|
||
let _ = hist.push_state_with_url(
|
||
&wasm_bindgen::JsValue::NULL,
|
||
"",
|
||
Some("/"),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn close(&self, element: &str, origin_x: f64, origin_y: f64) {
|
||
let was_active = self.state.borrow().active.as_deref() == Some(element);
|
||
self.state.borrow_mut().pages.retain(|e| e != element);
|
||
self.remove_page_dom(element);
|
||
if was_active {
|
||
let pages_now: Vec<String> = self.state.borrow().pages.clone();
|
||
let new_active = pages_now.last().cloned();
|
||
self.state.borrow_mut().active = new_active.clone();
|
||
if let Some(new_active) = new_active {
|
||
let idx = pages_now
|
||
.iter()
|
||
.position(|e| e == &new_active)
|
||
.unwrap_or(0);
|
||
self.deck.goto(idx, true);
|
||
} else {
|
||
self.hide_deck(origin_x, origin_y);
|
||
}
|
||
}
|
||
self.sync_active_class();
|
||
self.sync_taskbar();
|
||
}
|
||
|
||
fn home(&self) {
|
||
// Minimiza el deck sin cerrar las pestañas (estilo "show desktop").
|
||
let (ox, oy) = self
|
||
.element_center(".taskbar-home")
|
||
.unwrap_or((24.0, self.viewport_height() - 26.0));
|
||
self.minimize(ox, oy);
|
||
}
|
||
|
||
fn on_swipe(&self, new_index: usize) {
|
||
let element = self.state.borrow().pages.get(new_index).cloned();
|
||
if let Some(element) = element {
|
||
self.state.borrow_mut().active = Some(element);
|
||
self.sync_active_class();
|
||
self.sync_taskbar();
|
||
}
|
||
}
|
||
|
||
fn show_deck(&self, x: f64, y: f64) {
|
||
self.set_deck_origin(x, y);
|
||
if let Some(deck) = self.deck_el() {
|
||
let _ = deck.class_list().add_1("open");
|
||
let _ = deck.set_attribute("aria-hidden", "false");
|
||
}
|
||
if let Some(body) = self.document.body() {
|
||
let _ = body.class_list().add_1("deck-visible");
|
||
}
|
||
self.sync_page_controls();
|
||
}
|
||
|
||
fn hide_deck(&self, x: f64, y: f64) {
|
||
self.set_deck_origin(x, y);
|
||
if let Some(deck) = self.deck_el() {
|
||
let _ = deck.class_list().remove_1("open");
|
||
let _ = deck.set_attribute("aria-hidden", "true");
|
||
}
|
||
if let Some(body) = self.document.body() {
|
||
let _ = body.class_list().remove_1("deck-visible");
|
||
}
|
||
self.sync_page_controls();
|
||
}
|
||
|
||
fn sync_page_controls(&self) {
|
||
if let Some(ctl) = self.document.get_element_by_id("global-page-controls") {
|
||
let is_visible = self.state.borrow().active.is_some();
|
||
ctl.set_attribute("style", if is_visible {
|
||
"opacity:1;pointer-events:auto;"
|
||
} else {
|
||
"opacity:0;pointer-events:none;"
|
||
}).ok();
|
||
}
|
||
}
|
||
|
||
fn deck_el(&self) -> Option<HtmlElement> {
|
||
self.document
|
||
.get_element_by_id("deck")
|
||
.and_then(|e| e.dyn_into::<HtmlElement>().ok())
|
||
}
|
||
|
||
fn set_deck_origin(&self, x: f64, y: f64) {
|
||
if let Some(deck) = self.deck_el() {
|
||
let _ = deck.style().set_property("--origin-x", &format!("{:.1}px", x));
|
||
let _ = deck.style().set_property("--origin-y", &format!("{:.1}px", y));
|
||
}
|
||
}
|
||
|
||
fn sync_active_class(&self) {
|
||
if let Some(body) = self.document.body() {
|
||
let active = self.state.borrow().active.clone();
|
||
for &e in &ELEMENTS {
|
||
let cls = format!("deck-active-{}", e);
|
||
if active.as_deref() == Some(e) {
|
||
let _ = body.class_list().add_1(&cls);
|
||
} else {
|
||
let _ = body.class_list().remove_1(&cls);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn sync_taskbar(&self) {
|
||
let s = self.state.borrow();
|
||
let tasks: Vec<Task> = s
|
||
.pages
|
||
.iter()
|
||
.map(|e| {
|
||
let mut t = Task::new(e.clone(), e.to_uppercase());
|
||
if s.active.as_deref() == Some(e.as_str()) {
|
||
t = t.active();
|
||
}
|
||
t
|
||
})
|
||
.collect();
|
||
self.taskbar.set_tasks(&tasks);
|
||
}
|
||
|
||
fn ensure_page_dom(&self, element: &str) {
|
||
let sel = format!(".deck-page[data-element=\"{}\"]", element);
|
||
if self
|
||
.document
|
||
.query_selector(&sel)
|
||
.ok()
|
||
.flatten()
|
||
.is_some()
|
||
{
|
||
return;
|
||
}
|
||
let Some(strip) = self.document.get_element_by_id("deck-strip") else {
|
||
return;
|
||
};
|
||
let (title, tag) = match element {
|
||
"aire" => ("Software", "Tecnología · Open Source · IA"),
|
||
"fuego" => ("Quién Soy", "Bitácora · Crónica"),
|
||
"tierra" => ("Manifiesto", "Invariantes · Piedra de toque"),
|
||
"agua" => ("Mística", "Espiritualidad aplicada"),
|
||
"cuerpo" => ("El Cuerpo", "Somática · Respiración · Portal"),
|
||
"sombra" => ("La Sombra", "Integración · Patrones · Acecho"),
|
||
"cosmos" => ("Cosmovisión", "4 Elementos · Arquetipos"),
|
||
"practica" => ("Prácticas", "Ejercicios · Transformación"),
|
||
"olvido" => ("El Olvido", "Desaprender · Soltar · Vaciar"),
|
||
_ => return,
|
||
};
|
||
let html = format!(
|
||
"<article class=\"deck-page\" data-element=\"{el}\" id=\"deck-page-{el}\">\
|
||
<div class=\"page-ambience\" aria-hidden=\"true\"></div>\
|
||
<header class=\"page-head\">\
|
||
<span class=\"page-mark\">{el}</span>\
|
||
<h2 class=\"page-title\">{title}</h2>\
|
||
<span class=\"page-tag\">{tag}</span>\
|
||
</header>\
|
||
<section class=\"page-content\" id=\"page-{el}-content\"></section>\
|
||
</article>",
|
||
el = element, title = title, tag = tag
|
||
);
|
||
let _ = strip.insert_adjacent_html("beforeend", &html);
|
||
}
|
||
|
||
fn remove_page_dom(&self, element: &str) {
|
||
let id = format!("deck-page-{}", element);
|
||
if let Some(el) = self.document.get_element_by_id(&id) {
|
||
el.remove();
|
||
}
|
||
}
|
||
|
||
fn load_md_if_empty(&self, element: &str, md_url: &str) {
|
||
let content_id = format!("page-{}-content", element);
|
||
let Some(content_el) = self.document.get_element_by_id(&content_id) else {
|
||
return;
|
||
};
|
||
let Ok(content): Result<HtmlElement, _> = content_el.dyn_into() else {
|
||
return;
|
||
};
|
||
let inner = content.inner_html();
|
||
if inner.contains("pluma-doc") {
|
||
return; // ya hidratado
|
||
}
|
||
let document_clone = self.document.clone();
|
||
let element_owned = element.to_string();
|
||
let url_owned = md_url.to_string();
|
||
let reader = fana_md_reader_web::Reader::new(content.clone());
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
let content_clone = content.clone();
|
||
if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
|
||
web_sys::console::warn_1(&e);
|
||
}
|
||
// Después de cargar el md, montar el grafo debajo
|
||
let graph_container_id = format!("graph-{}-container", element_owned);
|
||
// Si ya existe, no lo duplicamos
|
||
if document_clone.get_element_by_id(&graph_container_id).is_some() {
|
||
return;
|
||
}
|
||
// Crear contenedor debajo del content
|
||
let wrapper: HtmlElement = document_clone
|
||
.create_element("div")
|
||
.ok()
|
||
.and_then(|e| e.dyn_into::<HtmlElement>().ok())
|
||
.unwrap();
|
||
wrapper.set_id(&graph_container_id);
|
||
wrapper.style().set_property("margin-top", "1rem").ok();
|
||
wrapper.style().set_property("padding-top", "1rem").ok();
|
||
wrapper.style().set_property("border-top", "1px solid rgba(255,255,255,0.08)").ok();
|
||
// Label
|
||
let label: HtmlElement = document_clone
|
||
.create_element("div")
|
||
.ok()
|
||
.and_then(|e| e.dyn_into::<HtmlElement>().ok())
|
||
.unwrap();
|
||
label.set_inner_html(
|
||
"<span style=\"font-family: Inter, sans-serif; font-size: 0.75rem; \
|
||
letter-spacing: 0.3em; text-transform: uppercase; color: rgba(232,234,245,0.45);\">
|
||
· grafo semántico ·
|
||
</span>"
|
||
);
|
||
wrapper.append_child(&label).ok();
|
||
content_clone.append_child(&wrapper).ok();
|
||
// Callback: recibe 'camino' del nodo clickeado y navega
|
||
let cb: Box<dyn FnMut(String)> = Box::new(move |target| {
|
||
web_sys::console::log_1(&format!("DEBUG grafo: click target={}", target).into());
|
||
// Mapa: camino → id del tip en HTML
|
||
let el = match target.as_str() {
|
||
"logos" | "aire" => "aire",
|
||
"nomos" | "fuego" => "fuego",
|
||
"kay" | "tierra" => "tierra",
|
||
"uku" | "agua" => "agua",
|
||
"cuerpo" => "cuerpo",
|
||
"sombra" => "sombra",
|
||
"cosmos" => "cosmos",
|
||
"practica" => "practica",
|
||
"olvido" => "olvido",
|
||
_ => "aire",
|
||
};
|
||
web_sys::console::log_1(&format!("DEBUG grafo: el={}", el).into());
|
||
let sel = format!(".tip[data-md][id='tip-{}']", el);
|
||
web_sys::console::log_1(&format!("DEBUG grafo: selector={}", sel).into());
|
||
match document_clone.query_selector(&sel).ok().flatten() {
|
||
Some(tip) => {
|
||
web_sys::console::log_1(&"DEBUG grafo: tip encontrado, llamando click()".into());
|
||
let tip_html: HtmlElement = tip.clone().dyn_into().unwrap();
|
||
tip_html.click();
|
||
web_sys::console::log_1(&"DEBUG grafo: click() ejecutado".into());
|
||
}
|
||
None => {
|
||
web_sys::console::log_1(&"DEBUG grafo: tip NO encontrado".into());
|
||
}
|
||
}
|
||
});
|
||
let mut graph = GraphWidget::new(
|
||
wrapper,
|
||
"https://api.gioser.net",
|
||
Some(cb),
|
||
);
|
||
if let Err(e) = graph.load().await {
|
||
web_sys::console::warn_1(&format!("grafo: error al cargar: {:?}", e).into());
|
||
return;
|
||
}
|
||
});
|
||
}
|
||
|
||
fn element_center(&self, selector: &str) -> Option<(f64, f64)> {
|
||
let el = self.document.query_selector(selector).ok().flatten()?;
|
||
let rect = el.get_bounding_client_rect();
|
||
Some((
|
||
rect.left() + rect.width() / 2.0,
|
||
rect.top() + rect.height() / 2.0,
|
||
))
|
||
}
|
||
|
||
fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
|
||
self.taskbar.task_center(element)
|
||
}
|
||
|
||
fn viewport_height(&self) -> f64 {
|
||
web_sys::window()
|
||
.and_then(|w| w.inner_height().ok())
|
||
.and_then(|v| v.as_f64())
|
||
.unwrap_or(720.0)
|
||
}
|
||
}
|
||
|
||
#[wasm_bindgen(start)]
|
||
pub fn boot() -> Result<(), JsValue> {
|
||
install_panic_hook();
|
||
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
|
||
let document = window
|
||
.document()
|
||
.ok_or_else(|| JsValue::from_str("no document"))?;
|
||
|
||
let canvas: HtmlCanvasElement = document
|
||
.get_element_by_id("gioser-canvas")
|
||
.ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))?
|
||
.dyn_into()?;
|
||
|
||
fit_canvas(&canvas, &window);
|
||
let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
|
||
{
|
||
let mut r = renderer.borrow_mut();
|
||
r.resize(canvas.width(), canvas.height());
|
||
r.set_client_size(
|
||
canvas.client_width() as f32,
|
||
canvas.client_height() as f32,
|
||
);
|
||
}
|
||
|
||
// Mount vista deck
|
||
let strip_el: HtmlElement = document
|
||
.get_element_by_id("deck-strip")
|
||
.ok_or_else(|| JsValue::from_str("no #deck-strip"))?
|
||
.dyn_into()?;
|
||
let deck = Deck::mount(strip_el)?;
|
||
|
||
// Mount barra-web taskbar (manages the dynamic task list).
|
||
let list_el: HtmlElement = document
|
||
.get_element_by_id("taskbar-list")
|
||
.ok_or_else(|| JsValue::from_str("no #taskbar-list"))?
|
||
.dyn_into()?;
|
||
let taskbar = TaskList::mount(list_el)?;
|
||
|
||
let app = Rc::new(AppState {
|
||
document: document.clone(),
|
||
deck: deck.clone(),
|
||
taskbar: taskbar.clone(),
|
||
state: RefCell::default(),
|
||
});
|
||
|
||
// vista on_change → on_swipe del app
|
||
{
|
||
let app2 = app.clone();
|
||
deck.on_change(move |idx| {
|
||
app2.on_swipe(idx);
|
||
});
|
||
}
|
||
|
||
// barra on_click → restore / toggle minimize del app
|
||
{
|
||
let app2 = app.clone();
|
||
taskbar.on_click(move |id, cx, cy| {
|
||
let is_active = app2.state.borrow().active.as_deref() == Some(id);
|
||
if is_active {
|
||
app2.minimize(cx, cy);
|
||
} else {
|
||
app2.restore_from_tab(id, cx, cy);
|
||
}
|
||
});
|
||
}
|
||
|
||
install_resize(&window, &canvas, &renderer)?;
|
||
install_mouse(&document, &canvas, &renderer)?;
|
||
install_canvas_pointer(&canvas, &renderer)?;
|
||
install_canvas_leave(&canvas, &renderer)?;
|
||
install_tip_clicks(&document, &app)?;
|
||
install_controls_delegation(&document, &app)?;
|
||
install_taskbar(&document, &app)?;
|
||
install_keyboard(&document, &app)?;
|
||
install_popstate_listener(&window, &app)?;
|
||
install_raf(&window, &document, &canvas, &renderer);
|
||
|
||
// Leer ruta inicial para abrir página directa
|
||
if let Ok(pathname) = window.location().pathname() {
|
||
let clean = pathname.trim_start_matches('/').trim_start_matches("estudio/");
|
||
if !clean.is_empty() {
|
||
if let Some(el) = document.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() {
|
||
let rect = el.get_bounding_client_rect();
|
||
let cx = rect.left() + rect.width() / 2.0;
|
||
let cy = rect.top() + rect.height() / 2.0;
|
||
if let Some(md_url) = el.get_attribute("data-md") {
|
||
app.open_or_switch(clean, cx, cy, &md_url);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn install_popstate_listener(window: &Window, app: &Rc<AppState>) -> Result<(), JsValue> {
|
||
let app2 = app.clone();
|
||
let doc = app.document.clone();
|
||
let win2 = window.clone();
|
||
let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
|
||
if let Ok(pathname) = win2.location().pathname() {
|
||
let clean = pathname.trim_start_matches('/').trim_start_matches("estudio/");
|
||
if clean.is_empty() || clean == "/" {
|
||
app2.home();
|
||
} else if let Some(el) = doc.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() {
|
||
let rect = el.get_bounding_client_rect();
|
||
let cx = rect.left() + rect.width() / 2.0;
|
||
let cy = rect.top() + rect.height() / 2.0;
|
||
if let Some(md_url) = el.get_attribute("data-md") {
|
||
app2.open_or_switch(clean, cx, cy, &md_url);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
window.add_event_listener_with_callback("popstate", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn install_resize(
|
||
window: &Window,
|
||
canvas: &HtmlCanvasElement,
|
||
renderer: &Rc<RefCell<Renderer>>,
|
||
) -> Result<(), JsValue> {
|
||
let canvas = canvas.clone();
|
||
let win2 = window.clone();
|
||
let r = renderer.clone();
|
||
let cb = Closure::<dyn FnMut()>::new(move || {
|
||
fit_canvas(&canvas, &win2);
|
||
let mut rr = r.borrow_mut();
|
||
rr.resize(canvas.width(), canvas.height());
|
||
rr.set_client_size(
|
||
canvas.client_width() as f32,
|
||
canvas.client_height() as f32,
|
||
);
|
||
});
|
||
window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn install_mouse(
|
||
document: &Document,
|
||
canvas: &HtmlCanvasElement,
|
||
renderer: &Rc<RefCell<Renderer>>,
|
||
) -> Result<(), JsValue> {
|
||
let canvas = canvas.clone();
|
||
let r = renderer.clone();
|
||
let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
|
||
let w = canvas.client_width().max(1) as f32;
|
||
let h = canvas.client_height().max(1) as f32;
|
||
let x = e.client_x() as f32 - w * 0.5;
|
||
let y = h * 0.5 - e.client_y() as f32;
|
||
r.borrow_mut().set_mouse_px(x, y);
|
||
});
|
||
document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn install_canvas_pointer(
|
||
canvas: &HtmlCanvasElement,
|
||
renderer: &Rc<RefCell<Renderer>>,
|
||
) -> Result<(), JsValue> {
|
||
let canvas2 = canvas.clone();
|
||
let r = renderer.clone();
|
||
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
|
||
let rect = canvas2.get_bounding_client_rect();
|
||
let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
|
||
let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
|
||
let dist2 = dx * dx + dy * dy;
|
||
let ring = {
|
||
let rb = r.borrow();
|
||
rb.click_radius_css_px() as f64
|
||
};
|
||
if dist2 <= ring * ring {
|
||
r.borrow_mut().impulse_click();
|
||
}
|
||
});
|
||
canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn install_canvas_leave(
|
||
canvas: &HtmlCanvasElement,
|
||
renderer: &Rc<RefCell<Renderer>>,
|
||
) -> Result<(), JsValue> {
|
||
let r = renderer.clone();
|
||
let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
|
||
r.borrow_mut().release_tilt();
|
||
});
|
||
canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
|
||
let tips_nodes = document.query_selector_all(".tip[data-md]")?;
|
||
for i in 0..tips_nodes.length() {
|
||
let Some(node) = tips_nodes.item(i) else {
|
||
continue;
|
||
};
|
||
let Ok(el) = node.dyn_into::<HtmlElement>() else {
|
||
continue;
|
||
};
|
||
let id = el.id();
|
||
let element = id.strip_prefix("tip-").unwrap_or("").to_string();
|
||
let md_url = el.get_attribute("data-md").unwrap_or_default();
|
||
let app2 = app.clone();
|
||
let el_for_rect = el.clone();
|
||
let el_name = element.clone();
|
||
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
|
||
web_sys::console::log_1(&format!("DEBUG tip: click en {} isTrusted={}", el_name, e.is_trusted()).into());
|
||
e.prevent_default();
|
||
let rect = el_for_rect.get_bounding_client_rect();
|
||
let cx = rect.left() + rect.width() / 2.0;
|
||
let cy = rect.top() + rect.height() / 2.0;
|
||
web_sys::console::log_1(&format!("DEBUG tip: llamando open_or_switch({}, {}, {})", el_name, cx, cy).into());
|
||
app2.open_or_switch(&element, cx, cy, &md_url);
|
||
});
|
||
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Un listener en el deck delega clicks de minimize y close en cada página.
|
||
/// Las páginas se crean dinámicamente, así que no podemos adjuntar listeners
|
||
/// por botón en boot.
|
||
fn install_controls_delegation(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
|
||
let app2 = app.clone();
|
||
let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
|
||
let Some(target) = e.target() else { return };
|
||
let Ok(target_el): Result<Element, _> = target.dyn_into() else {
|
||
return;
|
||
};
|
||
// Minimize
|
||
if let Ok(Some(btn)) = target_el.closest("[data-minimize]") {
|
||
e.stop_propagation();
|
||
let element = btn.get_attribute("data-minimize").unwrap_or_default();
|
||
// Si el data-minimize está vacío, usar el elemento activo
|
||
let el = if element.is_empty() {
|
||
app2.state.borrow().active.clone().unwrap_or_default()
|
||
} else {
|
||
element
|
||
};
|
||
if !el.is_empty() {
|
||
let origin = app2
|
||
.taskbar_item_center(&el)
|
||
.unwrap_or_else(|| center_of_element(&btn));
|
||
app2.minimize(origin.0, origin.1);
|
||
}
|
||
return;
|
||
}
|
||
// Close
|
||
if let Ok(Some(btn)) = target_el.closest("[data-close-page]") {
|
||
e.stop_propagation();
|
||
let element = btn.get_attribute("data-close-page").unwrap_or_default();
|
||
let el = if element.is_empty() {
|
||
app2.state.borrow().active.clone().unwrap_or_default()
|
||
} else {
|
||
element
|
||
};
|
||
if !el.is_empty() {
|
||
let origin = app2
|
||
.taskbar_item_center(&el)
|
||
.unwrap_or_else(|| center_of_element(&btn));
|
||
app2.close(&el, origin.0, origin.1);
|
||
}
|
||
}
|
||
});
|
||
document.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn center_of_element(el: &Element) -> (f64, f64) {
|
||
let rect = el.get_bounding_client_rect();
|
||
(
|
||
rect.left() + rect.width() / 2.0,
|
||
rect.top() + rect.height() / 2.0,
|
||
)
|
||
}
|
||
|
||
/// Home button + brand link (data-home) — toda la lógica de tabs vive en
|
||
/// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home].
|
||
fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
|
||
let homes = document.query_selector_all("[data-home]")?;
|
||
for i in 0..homes.length() {
|
||
let Some(node) = homes.item(i) else { continue };
|
||
let Ok(el) = node.dyn_into::<HtmlElement>() else {
|
||
continue;
|
||
};
|
||
let app2 = app.clone();
|
||
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
|
||
e.prevent_default();
|
||
app2.home();
|
||
});
|
||
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
|
||
let app2 = app.clone();
|
||
let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
|
||
if e.key() == "Escape" {
|
||
if app2.state.borrow().active.is_some() {
|
||
app2.home();
|
||
}
|
||
}
|
||
});
|
||
document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
|
||
cb.forget();
|
||
Ok(())
|
||
}
|
||
|
||
fn install_raf(
|
||
window: &Window,
|
||
document: &Document,
|
||
canvas: &HtmlCanvasElement,
|
||
renderer: &Rc<RefCell<Renderer>>,
|
||
) {
|
||
let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
|
||
let g = f.clone();
|
||
let renderer = renderer.clone();
|
||
let canvas = canvas.clone();
|
||
let document = document.clone();
|
||
let window2 = window.clone();
|
||
*g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
|
||
renderer.borrow_mut().render(time_ms);
|
||
let r = renderer.borrow();
|
||
position_tips(&document, &canvas, &r);
|
||
drop(r);
|
||
if let Some(cb) = f.borrow().as_ref() {
|
||
let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
|
||
}
|
||
}));
|
||
let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref());
|
||
}
|
||
|
||
fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
|
||
let dpr = window.device_pixel_ratio() as f32;
|
||
let w = window
|
||
.inner_width()
|
||
.ok()
|
||
.and_then(|v| v.as_f64())
|
||
.unwrap_or(1280.0) as f32;
|
||
let h = window
|
||
.inner_height()
|
||
.ok()
|
||
.and_then(|v| v.as_f64())
|
||
.unwrap_or(720.0) as f32;
|
||
canvas.set_width((w * dpr) as u32);
|
||
canvas.set_height((h * dpr) as u32);
|
||
let el: &HtmlElement = canvas.unchecked_ref();
|
||
let style = el.style();
|
||
let _ = style.set_property("width", &format!("{}px", w));
|
||
let _ = style.set_property("height", &format!("{}px", h));
|
||
}
|
||
|
||
fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
|
||
let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
|
||
let cw = canvas.client_width().max(1) as f32;
|
||
let ch = canvas.client_height().max(1) as f32;
|
||
let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
|
||
let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
|
||
let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
|
||
let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
|
||
for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
|
||
let (nx, ny) = clips[i];
|
||
let raw_x = (nx + 1.0) * 0.5 * cw;
|
||
let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
|
||
let px = raw_x.clamp(min_x, max_x);
|
||
let py = raw_y.clamp(min_y, max_y);
|
||
let sel = format!("tip-{}", id);
|
||
if let Some(el) = document.get_element_by_id(&sel) {
|
||
if let Ok(el) = el.dyn_into::<HtmlElement>() {
|
||
let _ = el.style().set_property(
|
||
"transform",
|
||
&format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Mapea un doc_id de Qdrant al nombre del elemento (aire/fuego/tierra/agua)
|
||
/// y su ruta md. Los doc_ids se generan con uuid5 en el indexador, pero
|
||
/// podemos inferir por el nombre del camino o del elemento.
|
||
fn map_doc_id_to_element(doc_id: &str) -> (String, String) {
|
||
// Inferir del doc_id: contiene el nombre del elemento
|
||
let el = if doc_id.contains("aire") || doc_id.contains("logos") {
|
||
"aire"
|
||
} else if doc_id.contains("fuego") || doc_id.contains("nomos") {
|
||
"fuego"
|
||
} else if doc_id.contains("tierra") || doc_id.contains("kay") {
|
||
"tierra"
|
||
} else if doc_id.contains("agua") || doc_id.contains("uku") {
|
||
"agua"
|
||
} else if doc_id.contains("cuerpo") {
|
||
"cuerpo"
|
||
} else if doc_id.contains("sombra") {
|
||
"sombra"
|
||
} else if doc_id.contains("cosmos") {
|
||
"cosmos"
|
||
} else if doc_id.contains("practica") {
|
||
"practica"
|
||
} else if doc_id.contains("olvido") {
|
||
"olvido"
|
||
} else {
|
||
"aire"
|
||
};
|
||
(el.to_string(), format!("./md/{}.md", el))
|
||
}
|
||
|
||
fn install_panic_hook() {
|
||
static SET: std::sync::Once = std::sync::Once::new();
|
||
SET.call_once(|| {
|
||
std::panic::set_hook(Box::new(|info| {
|
||
let msg = format!("{}", info);
|
||
web_sys::console::error_1(&JsValue::from_str(&msg));
|
||
}));
|
||
});
|
||
}
|