mantra/crates/mantra-ui/src/pages/artifact.rs

246 lines
8.6 KiB
Rust

//! `/artifact/:slug` — closing document of a cycle.
use leptos::prelude::*;
use leptos_router::hooks::{use_params, use_query_map};
use leptos_router::params::Params;
use crate::api::{fetch_artifact, ArtifactPageData};
use crate::corpus::Lang;
use crate::pages::inspector::{Anchor, Inspector, PopoverPos, SelectionPopover, Tab};
use crate::pages::shared::LangToggle;
#[derive(Params, PartialEq, Clone, Debug)]
struct SlugParam {
slug: Option<String>,
}
#[component]
pub fn ArtifactPage() -> impl IntoView {
let params = use_params::<SlugParam>();
let slug = Memo::new(move |_| {
params.get().ok().and_then(|p| p.slug).unwrap_or_default()
});
let query = use_query_map();
let lang = Memo::new(move |_| {
Lang::from_query(query.read().get("lang").as_deref())
});
let (anchor, set_anchor) = signal::<Option<Anchor>>(None);
let (tab, set_tab) = signal(Tab::Note);
let (popover_pos, set_popover_pos) = signal::<Option<PopoverPos>>(None);
let notes_tick = RwSignal::new(0u32);
let slug_sig: Signal<String> = Signal::derive(move || slug.get());
let lang_sig: Signal<Lang> = Signal::derive(move || lang.get());
Effect::new(move |_| {
let _ = slug.get();
set_anchor.set(None);
set_popover_pos.set(None);
});
let data = Resource::new(
move || (slug.get(), lang.get().as_str().to_string()),
|(s, l)| fetch_artifact(s, l),
);
#[cfg(feature = "hydrate")]
{
let slug_for_fx = slug_sig;
Effect::new(move |_| {
let _ = notes_tick.get();
let current_slug = slug_for_fx.get();
if current_slug.is_empty() { return; }
leptos::task::spawn_local(async move {
if let Ok(entries) = crate::api::fetch_notes(current_slug).await {
let ids: std::collections::HashSet<String> =
entries.into_iter().map(|e| e.para_id).collect();
annotate_notes(&ids);
}
});
});
}
view! {
<LangToggle current=lang/>
<div class="reader-frame">
<Suspense fallback=move || view! { <p class="source-loading">""</p> }>
{move || {
data.get().map(|res| {
let lang_val = lang.get();
let home_href = format!("/?lang={}", lang_val.as_str());
let label_back = match lang_val {
Lang::Ru => "все источники",
Lang::En => "all sources",
};
match res {
Ok(Some(ArtifactPageData { artifact })) => view! {
<main class="source artifact">
<nav class="source-breadcrumb">
<a href=home_href.clone()>"design dna"</a>
<span class="sep">" · "</span>
<span class="source-author">{artifact.subtitle}</span>
</nav>
<article
class="source-body"
inner_html=artifact.body_html
on:mouseup=move |ev| {
handle_mouseup(&ev, &set_popover_pos);
}
on:click=move |ev| {
if popover_pos.get_untracked().is_some() {
return;
}
if let Some(a) = paragraph_from_event(&ev) {
set_anchor.set(Some(a));
}
}
></article>
<footer class="source-foot">
<a class="nav-home" href=home_href>{label_back}</a>
</footer>
</main>
}.into_any(),
Ok(None) => view! {
<main class="source not-found">
<p>"artifact not found"</p>
<a class="back-link" href=home_href>"← back"</a>
</main>
}.into_any(),
Err(e) => view! {
<main class="source not-found">
<p class="err">{format!("{e}")}</p>
</main>
}.into_any(),
}
})
}}
</Suspense>
<Inspector
slug=slug_sig
lang=lang_sig
anchor=anchor
set_anchor=set_anchor
tab=tab
set_tab=set_tab
notes_tick=notes_tick
/>
</div>
<SelectionPopover
pos=popover_pos
set_pos=set_popover_pos
set_anchor=set_anchor
set_tab=set_tab
lang=lang_sig
/>
}
}
#[cfg(feature = "hydrate")]
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
use wasm_bindgen::JsCast;
let target = ev.target()?;
let mut el = target.dyn_into::<web_sys::Element>().ok()?;
loop {
if el.has_attribute("data-para-id") {
let id = el.get_attribute("data-para-id")?;
let text = el.text_content().unwrap_or_default();
let excerpt = text.trim().to_string();
return Some(Anchor {
para_id: id,
excerpt,
is_selection: false,
});
}
el = el.parent_element()?;
}
}
#[cfg(not(feature = "hydrate"))]
fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
None
}
#[cfg(feature = "hydrate")]
fn handle_mouseup(
_ev: &leptos::ev::MouseEvent,
set_pos: &WriteSignal<Option<PopoverPos>>,
) {
let Some(win) = web_sys::window() else { return };
let Ok(Some(sel)) = win.get_selection() else {
set_pos.set(None);
return;
};
if sel.is_collapsed() {
set_pos.set(None);
return;
}
let text = match sel.to_string().as_string() {
Some(s) => s.trim().to_string(),
None => { set_pos.set(None); return; }
};
if text.len() < 2 { set_pos.set(None); return; }
let Ok(range) = sel.get_range_at(0) else {
set_pos.set(None);
return;
};
let rect = range.get_bounding_client_rect();
let start_node = range.start_container().ok();
let para_id = start_node.as_ref().and_then(find_para_id_from_node);
let Some(para_id) = para_id else {
set_pos.set(None);
return;
};
set_pos.set(Some(PopoverPos {
x: rect.left() + rect.width() / 2.0,
y: rect.top() - 8.0,
anchor: Anchor { para_id, excerpt: text, is_selection: true },
}));
}
#[cfg(not(feature = "hydrate"))]
fn handle_mouseup(
_ev: &leptos::ev::MouseEvent,
_set_pos: &WriteSignal<Option<PopoverPos>>,
) {}
#[cfg(feature = "hydrate")]
fn find_para_id_from_node(node: &web_sys::Node) -> Option<String> {
use wasm_bindgen::JsCast;
let mut current: Option<web_sys::Element> = node
.dyn_ref::<web_sys::Element>()
.cloned()
.or_else(|| node.parent_element());
while let Some(el) = current {
if el.has_attribute("data-para-id") {
return el.get_attribute("data-para-id");
}
current = el.parent_element();
}
None
}
#[cfg(feature = "hydrate")]
fn annotate_notes(ids: &std::collections::HashSet<String>) {
use wasm_bindgen::JsCast;
let Some(win) = web_sys::window() else { return };
let Some(doc) = win.document() else { return };
let Ok(nodes) = doc.query_selector_all("[data-para-id]") else { return };
for i in 0..nodes.length() {
let Some(node) = nodes.item(i) else { continue };
let Ok(el) = node.dyn_into::<web_sys::Element>() else { continue };
let id = el.get_attribute("data-para-id").unwrap_or_default();
let classes = el.class_list();
if ids.contains(&id) {
let _ = classes.add_1("has-notes");
} else {
let _ = classes.remove_1("has-notes");
}
}
}