Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"]
exclude.workspace = true

[workspace.package]
version = "0.30.9"
version = "0.30.10"
readme = "README.md"
edition = "2024"
authors = ["root@ltpp.vip"]
Expand All @@ -38,13 +38,13 @@ resolver = "3"
crate-type = ["rlib"]

[workspace.dependencies]
euv = { path = ".", version = "0.30.9" }
euv-ui = { path = "ui", version = "0.30.9" }
euv-cli = { path = "cli", version = "0.30.9" }
euv-core = { path = "core", version = "0.30.9" }
euv-engine = { path = "engine", version = "0.30.9" }
euv-macros = { path = "macros", version = "0.30.9" }
euv-example = { path = "example", version = "0.30.9" }
euv = { path = ".", version = "0.30.10" }
euv-ui = { path = "ui", version = "0.30.10" }
euv-cli = { path = "cli", version = "0.30.10" }
euv-core = { path = "core", version = "0.30.10" }
euv-engine = { path = "engine", version = "0.30.10" }
euv-macros = { path = "macros", version = "0.30.10" }
euv-example = { path = "example", version = "0.30.10" }

log = "0.4.34"
toml = "0.9.12"
Expand Down
16 changes: 14 additions & 2 deletions docs/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,14 @@ fn unique_slug(base: &str, used: &mut HashSet<String>) -> String {
///
/// Returns `(href, external)` where internal hrefs carry the `#` hash-router
/// prefix (plus an optional `#anchor` suffix).
///
/// The anchor is run through [`slugify`] because that is the function the
/// heading ids it points at were built with: authors naturally write
/// `[`run_function`](#run_function)` while the heading id is
/// `run-function` (underscores are not slug-safe), so a verbatim anchor
/// never resolves. Slugifying is idempotent for already-slugged anchors
/// and keeps CJK, so `#相关页面` and `#run-function` pass through
/// unchanged while `#run_function` starts matching its heading.
/// # Arguments
///
/// - `&str` - the raw markdown link target
Expand All @@ -2421,7 +2429,10 @@ fn rewrite_link(dest: &str, route: &str) -> (String, bool) {
return (dest.to_string(), true);
}
if let Some(anchor) = dest.strip_prefix('#') {
return (format!("#{route}#{anchor}"), false);
if anchor.is_empty() {
return (format!("#{route}#"), false);
}
return (format!("#{route}#{}", slugify(anchor)), false);
}
let (path_part, anchor_part) = match dest.split_once('#') {
Some((p, a)) => (p, Some(a)),
Expand All @@ -2436,9 +2447,10 @@ fn rewrite_link(dest: &str, route: &str) -> (String, bool) {
dest.to_string()
};
if let Some(anchor) = anchor_part
&& !anchor.is_empty()
&& (path_part.ends_with(MD_SUFFIX) || path_part.starts_with('/'))
{
href = format!("{href}#{anchor}");
href = format!("{href}#{}", slugify(anchor));
}
(href, false)
}
Expand Down
122 changes: 91 additions & 31 deletions docs/src/component/doc_page/view/fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,13 @@ pub(crate) fn docs_doc_page(node: VirtualNode<DocsPageProps>) -> VirtualNode {
}

/// Keeps the active heading marked in the table of contents as the
/// reader scrolls.
/// reader scrolls, and keeps the URL hash naming that same heading.
///
/// A reactive `Signal<String>` holds the active entry's `href` and the
/// TOC paints its `_active` class straight off it, so the highlight is
/// ordinary declarative state instead of script mutating class lists
/// behind the renderer's back. The spy only reads geometry and writes
/// one signal.
/// behind the renderer's back. The spy only reads geometry, writes one
/// signal, and mirrors the result into `location.hash`.
///
/// Geometry is polled rather than listened to, and the reason is
/// specific: this shell locks the viewport and scrolls an inner
Expand All @@ -139,46 +139,96 @@ pub(crate) fn docs_doc_page(node: VirtualNode<DocsPageProps>) -> VirtualNode {
/// early-outs when it has not moved, so it is effectively free and
/// behaves the same in a background tab.
///
/// An explicit fragment wins over the geometry: arriving at
/// `#route#section` must highlight that section even though the page
/// has not been scrolled yet.
/// The URL hash is the single source of truth for the section intent,
/// and the flows around it are deliberately asymmetric so no feedback
/// loop can exist:
///
/// - **Click / back / paste (hashchange)** — the fragment is an intent:
/// it wins the highlight while the scroll position has not moved since
/// the hash changed (the anchor scroll lands within one tick), and the
/// route signal's own subscriber performs the scroll to the anchor.
/// - **Scroll (reader)** — geometry decides the active entry and the
/// result is written back with `history.replaceState`, which fires no
/// `hashchange`: the URL follows the reader without re-triggering the
/// anchor scroll or flooding the history stack. Because scroll never
/// calls `set_hash`, and the hashchange path never writes the hash,
/// neither direction can re-enter the other.
///
/// Once the reader scrolls away from the position a hash change pointed
/// at, the fragment intent is spent and geometry takes over — so a
/// clicked entry can never pin the highlight forever.
///
/// # Arguments
///
/// - `Signal<String>` - The active TOC entry href, written by the spy.
fn use_toc_scroll_spy(active: Signal<String>) {
// The hash the spy last saw (or wrote itself) and the scroll position
// at that moment. A hash that differs from `seen_hash` arrived from
// outside (click, back/forward, paste); while the scroll position has
// not moved since, its fragment is treated as the reader's intent.
let seen_hash: Signal<String> = App::use_signal(|| {
window()
.and_then(|window: Window| window.location().hash().ok())
.unwrap_or_default()
});
let scroll_at_hash: Signal<i32> = App::use_signal(|| 0);
App::use_interval(TOC_SPY_INTERVAL_MILLIS, move || {
let Some(window) = window() else {
return;
};
let Some(document) = window.document() else {
return;
};
let next: Option<String> = active_toc_entry(&document, &window);
if let Some(next) = next
&& next != active.get()
{
active.set(next);
let Ok(Some(container)) = document.query_selector(TOC_SPY_CONTAINER_SELECTOR) else {
return;
};
let hash: String = window.location().hash().unwrap_or_default();
let scroll_top: i32 = container.scroll_top();
if hash != seen_hash.get() {
seen_hash.set(hash.clone());
scroll_at_hash.set(scroll_top);
}
let Some(entries) = toc_entry_hrefs(&document) else {
return;
};
// The fragment decides only while the scroll has not moved since
// the hash last changed; after that, geometry owns the answer.
let fragment_pick: Option<String> = if scroll_top == scroll_at_hash.get() {
fragment_target(&window, &entries)
} else {
None
};
let Some(next) = fragment_pick.or_else(|| scroll_target(&container, &document, &entries))
else {
return;
};
if next != active.get() {
active.set(next.clone());
}
if hash != next {
// Scroll-driven half of the sync: replaceState fires no
// hashchange, so this write cannot re-enter the anchor scroll.
sync_hash(&window, &next);
seen_hash.set(next);
scroll_at_hash.set(scroll_top);
}
});
}

/// Resolves the `href` of the TOC entry the reader is currently at.
/// Collects every TOC entry's `href` in display order.
///
/// Returns `None` when there is nothing to mark — no TOC on this page,
/// or no heading resolved — which the caller treats as "leave the
/// previous highlight alone". Clearing it during a re-render would
/// flicker the highlight off and back on every time the DOM is replaced.
/// Returns `None` when the page has no TOC at all — the caller treats
/// that as "nothing to mark", leaving the previous highlight alone so a
/// re-render does not flicker it off and back on.
///
/// # Arguments
///
/// - `&Document` - The document to query.
/// - `&Window` - The window, read for the URL fragment.
///
/// # Returns
///
/// - `Option<String>` - The active entry's `href`.
fn active_toc_entry(document: &Document, window: &Window) -> Option<String> {
/// - `Option<Vec<String>>` - The entry hrefs, or `None` when there are none.
fn toc_entry_hrefs(document: &Document) -> Option<Vec<String>> {
let Ok(links) = document.query_selector_all(TOC_SPY_LINK_SELECTOR) else {
return None;
};
Expand All @@ -194,15 +244,27 @@ fn active_toc_entry(document: &Document, window: &Window) -> Option<String> {
entries.push(href);
}
if entries.is_empty() {
return None;
}
if let Some(target) = fragment_target(window, &entries) {
return entries
.iter()
.find(|href: &&String| href_anchor(href).as_ref() == Some(&target))
.cloned();
None
} else {
Some(entries)
}
scroll_target(document, &entries)
}

/// Mirrors the active entry into the URL fragment.
///
/// `history.replaceState` — unlike `location.set_hash` — fires no
/// `hashchange`, which is the structural reason the scroll-driven hash
/// update cannot loop back into the anchor scroll.
///
/// # Arguments
///
/// - `&Window` - The window whose history is rewritten.
/// - `&str` - The entry href (`#<route>#<slug>`) to place in the URL.
fn sync_hash(window: &Window, href: &str) {
let Ok(history) = window.history() else {
return;
};
let _: Result<(), JsValue> = history.replace_state_with_url(&JsValue::NULL, "", Some(href));
}

/// Extracts the heading slug an anchor's href points at.
Expand Down Expand Up @@ -275,16 +337,14 @@ fn fragment_target(window: &Window, entries: &[String]) -> Option<String> {
///
/// # Arguments
///
/// - `&Element` - The scrolling container the spy measures against.
/// - `&Document` - The document to query.
/// - `&[String]` - Every TOC entry href.
///
/// # Returns
///
/// - `Option<String>` - The active entry's `href`.
fn scroll_target(document: &Document, entries: &[String]) -> Option<String> {
let Ok(Some(container)) = document.query_selector(TOC_SPY_CONTAINER_SELECTOR) else {
return None;
};
fn scroll_target(container: &Element, document: &Document, entries: &[String]) -> Option<String> {
let rect: DomRect = container.get_bounding_client_rect();
let line: f64 = rect.top() + TOC_SPY_READING_LINE_PX;
let at_end: bool = container.scroll_top() + container.client_height()
Expand Down
22 changes: 11 additions & 11 deletions ui/src/component/router/view/impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ impl Router {
/// a duplicate history entry, fire `hashchange`, and re-run the route
/// subscriber's re-render path. Skipping the call avoids all of that.
///
/// The comparison is against the **full** route including the `#anchor`
/// suffix: comparing only the path part swallowed same-page anchor
/// navigation outright (`/page` → `/page#section` read as "already
/// there", so no hash was ever written and no scroll happened).
///
/// Multiple rapid `navigate()` calls before the microtask fires are coalesced:
/// only the **last** target route wins, as earlier routes were superseded by
///
Expand All @@ -72,17 +77,12 @@ impl Router {
R: AsRef<str>,
{
let route_string: String = route.as_ref().to_string();
// Cheap early-return: a navigate() to the route the user is already on
// would otherwise queue a microtask, mutate `location.hash`, dispatch
// `hashchange`, and run the route subscriber's patch path — for no
// observable effect. Strips the optional `#anchor` suffix so callers
// can compare against the URL hash form without normalizing themselves.
let current: String = Self::current_route();
let target_path: &str = match route_string.find('#') {
Some(idx) => &route_string[..idx],
None => &route_string,
};
if current == target_path {
// Cheap early-return: a navigate() to the exact route the user is
// already on (path *and* anchor) would otherwise queue a microtask,
// mutate `location.hash`, dispatch `hashchange`, and run the route
// subscriber's patch path — for no observable effect. An anchor-only
// difference is a real navigation and must go through.
if Self::current_route() == route_string {
return;
}
DEFERRED_NAVIGATION.with(|cell: &Cell<Option<String>>| cell.set(Some(route_string)));
Expand Down
Loading