246 lines
8.6 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|