diff --git a/Cargo.toml b/Cargo.toml index d61fed6c..3d82d553 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] @@ -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" diff --git a/docs/build.rs b/docs/build.rs index f3ed2873..2a43c151 100644 --- a/docs/build.rs +++ b/docs/build.rs @@ -2405,6 +2405,14 @@ fn unique_slug(base: &str, used: &mut HashSet) -> 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 @@ -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)), @@ -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) } diff --git a/docs/src/component/doc_page/view/fn.rs b/docs/src/component/doc_page/view/fn.rs index 5619cba3..ba28d349 100644 --- a/docs/src/component/doc_page/view/fn.rs +++ b/docs/src/component/doc_page/view/fn.rs @@ -123,13 +123,13 @@ pub(crate) fn docs_doc_page(node: VirtualNode) -> 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` 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 @@ -139,14 +139,39 @@ pub(crate) fn docs_doc_page(node: VirtualNode) -> 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` - The active TOC entry href, written by the spy. fn use_toc_scroll_spy(active: Signal) { + // 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 = App::use_signal(|| { + window() + .and_then(|window: Window| window.location().hash().ok()) + .unwrap_or_default() + }); + let scroll_at_hash: Signal = App::use_signal(|| 0); App::use_interval(TOC_SPY_INTERVAL_MILLIS, move || { let Some(window) = window() else { return; @@ -154,31 +179,56 @@ fn use_toc_scroll_spy(active: Signal) { let Some(document) = window.document() else { return; }; - let next: Option = 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 = 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` - The active entry's `href`. -fn active_toc_entry(document: &Document, window: &Window) -> Option { +/// - `Option>` - The entry hrefs, or `None` when there are none. +fn toc_entry_hrefs(document: &Document) -> Option> { let Ok(links) = document.query_selector_all(TOC_SPY_LINK_SELECTOR) else { return None; }; @@ -194,15 +244,27 @@ fn active_toc_entry(document: &Document, window: &Window) -> Option { 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 (`##`) 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. @@ -275,16 +337,14 @@ fn fragment_target(window: &Window, entries: &[String]) -> Option { /// /// # Arguments /// +/// - `&Element` - The scrolling container the spy measures against. /// - `&Document` - The document to query. /// - `&[String]` - Every TOC entry href. /// /// # Returns /// /// - `Option` - The active entry's `href`. -fn scroll_target(document: &Document, entries: &[String]) -> Option { - let Ok(Some(container)) = document.query_selector(TOC_SPY_CONTAINER_SELECTOR) else { - return None; - }; +fn scroll_target(container: &Element, document: &Document, entries: &[String]) -> Option { 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() diff --git a/ui/src/component/router/view/impl.rs b/ui/src/component/router/view/impl.rs index 97845ef1..d82a5d55 100644 --- a/ui/src/component/router/view/impl.rs +++ b/ui/src/component/router/view/impl.rs @@ -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 /// @@ -72,17 +77,12 @@ impl Router { R: AsRef, { 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>| cell.set(Some(route_string)));