diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 828492c..36d80b8 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -1758,6 +1758,113 @@ fn find_ancestor_anchor(doc: &Document, node: NodeId) -> Option<(NodeId, &str)> None } +// --------------------------------------------------------------------------- +// Fragment navigation helpers +// --------------------------------------------------------------------------- + +/// Walk the layout tree to find the absolute Y position of the box associated +/// with `target`. Returns `None` if the node is not in the layout tree. +fn find_element_y_in_layout( + layout_box: &we_layout::LayoutBox, + target: NodeId, + parent_y: f32, +) -> Option { + let absolute_y = parent_y + layout_box.rect.y; + + let box_node = match layout_box.box_type { + we_layout::BoxType::Block(n) | we_layout::BoxType::Inline(n) => Some(n), + we_layout::BoxType::TextRun { node, .. } => Some(node), + we_layout::BoxType::Anonymous => None, + }; + + if box_node == Some(target) { + return Some(absolute_y); + } + + for child in &layout_box.children { + if let Some(y) = find_element_y_in_layout(child, target, absolute_y) { + return Some(y); + } + } + + None +} + +/// Perform a layout pass and find the Y position of `target` in the resulting +/// layout tree. Returns `None` if the element has no layout box. +fn compute_element_scroll_y(state: &BrowserState, target: NodeId) -> Option { + let viewport_width = state.bitmap.width() as f32; + let viewport_height = state.bitmap.height() as f32; + + let styled = resolve_styles( + &state.page.doc, + std::slice::from_ref(&state.page.stylesheet), + (viewport_width, viewport_height), + )?; + + let mut sizes = image_sizes(&state.page.images); + let svg_sizes = collect_svg_sizes(&state.page.doc); + sizes.extend(svg_sizes); + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut sizes); + + let tree = layout( + &styled, + &state.page.doc, + viewport_width, + viewport_height, + &state.font, + &sizes, + ); + + find_element_y_in_layout(&tree.root, target, 0.0) +} + +/// Find a fragment target element in the document. First tries `getElementById`, +/// then falls back to `` for legacy compatibility. +fn find_fragment_target(doc: &Document, fragment: &str) -> Option { + if fragment.is_empty() { + return None; + } + doc.get_element_by_id(fragment) + .or_else(|| doc.find_anchor_by_name(fragment)) +} + +/// Perform same-document fragment navigation: update the URL, push a history +/// entry, scroll to the target element, and fire the `hashchange` event. +fn navigate_fragment(state: &mut BrowserState, new_url: Url, fragment: &str) { + let old_url_str = state.page.base_url.serialize(); + let new_url_str = new_url.serialize(); + + eprintln!("[we] Fragment navigation: {old_url_str} → {new_url_str}"); + + // Save current scroll position before changing it. + state.history.save_scroll(0.0, state.page_scroll_y); + + // Push a new history entry for the fragment URL. + state.history.push(new_url.clone()); + + // Update the base URL to include the new fragment. + state.page.base_url = new_url; + + // Scroll to the target element (or top for "#"). + if fragment.is_empty() { + state.page_scroll_y = 0.0; + } else if let Some(target) = find_fragment_target(&state.page.doc, fragment) { + if let Some(y) = compute_element_scroll_y(state, target) { + let viewport_height = state.bitmap.height() as f32; + let max_scroll = (state.content_height - viewport_height).max(0.0); + state.page_scroll_y = y.clamp(0.0, max_scroll); + } + } + // If no matching element, don't scroll (per spec). + + rerender(state); + + // Fire the hashchange event (oldURL, newURL) — logged for now; JS event + // dispatch will be wired once the event loop integrates with the VM. + eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); +} + /// Navigate to a link target. Resolves the href against the page base URL, /// loads the new page, and replaces the current browsing context. fn navigate_to_link(state: &mut BrowserState, href: &str) { @@ -1768,8 +1875,15 @@ fn navigate_to_link(state: &mut BrowserState, href: &str) { return; } - // Ignore fragment-only links for now (same-page scroll — Phase 17 fragment nav). - if href.starts_with('#') { + // Handle fragment-only links (#id) as same-document navigation. + if let Some(fragment) = href.strip_prefix('#') { + let mut new_url = state.page.base_url.clone(); + new_url.set_fragment(if fragment.is_empty() { + None + } else { + Some(fragment.to_string()) + }); + navigate_fragment(state, new_url, fragment); return; } @@ -1787,6 +1901,13 @@ fn navigate_to_link(state: &mut BrowserState, href: &str) { } }; + // If the target URL only differs in the fragment, do same-document navigation. + if target_url.equals_ignoring_fragment(&state.page.base_url) { + let fragment = target_url.fragment().unwrap_or("").to_string(); + navigate_fragment(state, target_url, &fragment); + return; + } + eprintln!("[we] Navigating to: {}", target_url.serialize()); // Save the current scroll position before navigating away. @@ -1832,6 +1953,18 @@ fn navigate_history(state: &mut BrowserState, delta: i32) { entry.url.serialize() ); + // If only the fragment changed, do a same-document traversal (restore + // scroll position from the history entry, no page reload). + if entry.url.equals_ignoring_fragment(&state.page.base_url) { + let old_url_str = state.page.base_url.serialize(); + let new_url_str = entry.url.serialize(); + state.page.base_url = entry.url; + state.page_scroll_y = entry.scroll_y; + rerender(state); + eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); + return; + } + let loaded = load_from_url(&entry.url); let new_page = load_page(loaded); diff --git a/crates/browser/src/navigation_history.rs b/crates/browser/src/navigation_history.rs index 5b0469c..109b94e 100644 --- a/crates/browser/src/navigation_history.rs +++ b/crates/browser/src/navigation_history.rs @@ -439,4 +439,42 @@ mod tests { let e = h.traverse_back().unwrap(); assert!(e.state.is_none()); // Initial entry has no state. } + + #[test] + fn fragment_navigation_creates_history_entry() { + let mut h = NavigationHistory::new(url("https://example.com/page")); + h.save_scroll(0.0, 100.0); + h.push(url("https://example.com/page#section1")); + assert_eq!(h.len(), 2); + assert_eq!( + h.current_entry().url.serialize(), + "https://example.com/page#section1" + ); + + // Traverse back restores original scroll position. + let e = h.traverse_back().unwrap(); + assert_eq!(e.url.serialize(), "https://example.com/page"); + assert_eq!(e.scroll_y, 100.0); + } + + #[test] + fn multiple_fragment_navigations() { + let mut h = NavigationHistory::new(url("https://example.com/page")); + h.push(url("https://example.com/page#a")); + h.push(url("https://example.com/page#b")); + h.push(url("https://example.com/page#c")); + assert_eq!(h.len(), 4); + + // Back through all fragments. + let e = h.traverse_back().unwrap(); + assert_eq!(e.url.fragment(), Some("b")); + let e = h.traverse_back().unwrap(); + assert_eq!(e.url.fragment(), Some("a")); + let e = h.traverse_back().unwrap(); + assert_eq!(e.url.fragment(), None); + + // Forward. + let e = h.traverse_forward().unwrap(); + assert_eq!(e.url.fragment(), Some("a")); + } } diff --git a/crates/dom/src/lib.rs b/crates/dom/src/lib.rs index 6863c6b..1847009 100644 --- a/crates/dom/src/lib.rs +++ b/crates/dom/src/lib.rs @@ -554,6 +554,28 @@ impl Document { None } + /// Find an `` element by its `name` attribute (legacy fragment target). + /// + /// Per the HTML spec, if `getElementById` finds nothing, browsers fall back + /// to the first `` whose name matches the fragment. + pub fn find_anchor_by_name(&self, name: &str) -> Option { + self.find_anchor_by_name_rec(self.root, name) + } + + fn find_anchor_by_name_rec(&self, node: NodeId, name: &str) -> Option { + if self.tag_name(node) == Some("a") && self.get_attribute(node, "name") == Some(name) { + return Some(node); + } + let mut child = self.nodes[node.0].first_child; + while let Some(c) = child { + if let Some(found) = self.find_anchor_by_name_rec(c, name) { + return Some(found); + } + child = self.nodes[c.0].next_sibling; + } + None + } + fn first_form_control_descendant(&self, node: NodeId) -> Option { let mut child = self.nodes[node.0].first_child; while let Some(id) = child { @@ -2143,4 +2165,63 @@ mod tests { let options = doc.select_options(select); assert_eq!(options[0].value, "Hello"); } + + // --- Fragment target lookup --- + + #[test] + fn find_anchor_by_name_finds_matching_anchor() { + let mut doc = Document::new(); + let root = doc.root(); + let body = doc.create_element("body"); + doc.append_child(root, body); + + let anchor = doc.create_element("a"); + doc.set_attribute(anchor, "name", "section1"); + doc.append_child(body, anchor); + + assert_eq!(doc.find_anchor_by_name("section1"), Some(anchor)); + } + + #[test] + fn find_anchor_by_name_ignores_non_anchor() { + let mut doc = Document::new(); + let root = doc.root(); + let body = doc.create_element("body"); + doc.append_child(root, body); + + // A
with name="section1" should NOT be found. + let div = doc.create_element("div"); + doc.set_attribute(div, "name", "section1"); + doc.append_child(body, div); + + assert_eq!(doc.find_anchor_by_name("section1"), None); + } + + #[test] + fn find_anchor_by_name_returns_none_for_missing() { + let doc = Document::new(); + assert_eq!(doc.find_anchor_by_name("nonexistent"), None); + } + + #[test] + fn get_element_by_id_before_anchor_by_name() { + let mut doc = Document::new(); + let root = doc.root(); + let body = doc.create_element("body"); + doc.append_child(root, body); + + // Both an element with id and an anchor with name. + let div = doc.create_element("div"); + doc.set_attribute(div, "id", "target"); + doc.append_child(body, div); + + let anchor = doc.create_element("a"); + doc.set_attribute(anchor, "name", "target"); + doc.append_child(body, anchor); + + // get_element_by_id should find the div first. + assert_eq!(doc.get_element_by_id("target"), Some(div)); + // find_anchor_by_name should find the anchor. + assert_eq!(doc.find_anchor_by_name("target"), Some(anchor)); + } } diff --git a/crates/js/src/history.rs b/crates/js/src/history.rs index 4e0bdb3..a52fdc9 100644 --- a/crates/js/src/history.rs +++ b/crates/js/src/history.rs @@ -424,6 +424,105 @@ pub fn create_popstate_event( gc.alloc(HeapObject::Object(obj)) } +/// Create a HashChangeEvent object with oldURL and newURL properties. +pub fn create_hashchange_event( + gc: &mut Gc, + shapes: &mut ShapeTable, + old_url: &str, + new_url: &str, +) -> GcRef { + let mut obj = ObjectData::new(); + + // Standard Event properties. + obj.insert_property( + "type".to_string(), + Property::data(Value::String("hashchange".to_string())), + shapes, + ); + obj.insert_property( + "bubbles".to_string(), + Property::data(Value::Boolean(true)), + shapes, + ); + obj.insert_property( + "cancelable".to_string(), + Property::data(Value::Boolean(false)), + shapes, + ); + obj.insert_property( + "defaultPrevented".to_string(), + Property::data(Value::Boolean(false)), + shapes, + ); + obj.insert_property( + "eventPhase".to_string(), + Property::data(Value::Number(0.0)), + shapes, + ); + obj.insert_property("target".to_string(), Property::data(Value::Null), shapes); + obj.insert_property( + "currentTarget".to_string(), + Property::data(Value::Null), + shapes, + ); + obj.insert_property( + "timeStamp".to_string(), + Property::data(Value::Number(0.0)), + shapes, + ); + + // Internal event state keys. + obj.insert_property( + "__event_type__".to_string(), + Property::builtin(Value::String("hashchange".to_string())), + shapes, + ); + obj.insert_property( + "__event_bubbles__".to_string(), + Property::builtin(Value::Boolean(true)), + shapes, + ); + obj.insert_property( + "__event_cancelable__".to_string(), + Property::builtin(Value::Boolean(false)), + shapes, + ); + obj.insert_property( + "__event_stop_prop__".to_string(), + Property::builtin(Value::Boolean(false)), + shapes, + ); + obj.insert_property( + "__event_stop_immediate__".to_string(), + Property::builtin(Value::Boolean(false)), + shapes, + ); + obj.insert_property( + "__event_default_prevented__".to_string(), + Property::builtin(Value::Boolean(false)), + shapes, + ); + obj.insert_property( + "__event_phase__".to_string(), + Property::builtin(Value::Number(0.0)), + shapes, + ); + + // HashChangeEvent-specific properties. + obj.insert_property( + "oldURL".to_string(), + Property::data(Value::String(old_url.to_string())), + shapes, + ); + obj.insert_property( + "newURL".to_string(), + Property::data(Value::String(new_url.to_string())), + shapes, + ); + + gc.alloc(HeapObject::Object(obj)) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -554,4 +653,74 @@ mod tests { // Relative URLs are same-origin by definition. assert!(is_same_origin("/relative/path", "https://example.com")); } + + #[test] + fn hashchange_event_has_correct_properties() { + let mut gc = Gc::::new(); + let mut shapes = ShapeTable::new(); + let evt = create_hashchange_event( + &mut gc, + &mut shapes, + "https://example.com/page", + "https://example.com/page#section", + ); + + if let Some(HeapObject::Object(data)) = gc.get(evt) { + // Standard Event properties. + match data + .get_property("type", &shapes) + .as_ref() + .map(|p| &p.value) + { + Some(Value::String(s)) => assert_eq!(s, "hashchange"), + other => panic!("expected 'hashchange', got {other:?}"), + } + match data + .get_property("bubbles", &shapes) + .as_ref() + .map(|p| &p.value) + { + Some(Value::Boolean(true)) => {} + other => panic!("expected true, got {other:?}"), + } + match data + .get_property("cancelable", &shapes) + .as_ref() + .map(|p| &p.value) + { + Some(Value::Boolean(false)) => {} + other => panic!("expected false, got {other:?}"), + } + + // HashChangeEvent-specific properties. + match data + .get_property("oldURL", &shapes) + .as_ref() + .map(|p| &p.value) + { + Some(Value::String(s)) => assert_eq!(s, "https://example.com/page"), + other => panic!("expected oldURL, got {other:?}"), + } + match data + .get_property("newURL", &shapes) + .as_ref() + .map(|p| &p.value) + { + Some(Value::String(s)) => assert_eq!(s, "https://example.com/page#section"), + other => panic!("expected newURL, got {other:?}"), + } + + // Internal event type key. + match data + .get_property("__event_type__", &shapes) + .as_ref() + .map(|p| &p.value) + { + Some(Value::String(s)) => assert_eq!(s, "hashchange"), + other => panic!("expected 'hashchange', got {other:?}"), + } + } else { + panic!("expected Object"); + } + } } diff --git a/crates/url/src/lib.rs b/crates/url/src/lib.rs index debdb54..8ebfcfb 100644 --- a/crates/url/src/lib.rs +++ b/crates/url/src/lib.rs @@ -285,6 +285,26 @@ impl Url { } } + /// Returns `true` if two URLs are identical except for the fragment. + /// + /// Used to detect same-document fragment navigation: if only the fragment + /// differs, the browser should scroll rather than reload. + pub fn equals_ignoring_fragment(&self, other: &Url) -> bool { + self.scheme == other.scheme + && self.username == other.username + && self.password == other.password + && self.host == other.host + && self.port == other.port + && self.path == other.path + && self.opaque_path == other.opaque_path + && self.query == other.query + } + + /// Set the fragment (without leading '#'). Pass `None` to remove it. + pub fn set_fragment(&mut self, fragment: Option) { + self.fragment = fragment; + } + /// Serialize this URL to a string (the href). pub fn serialize(&self) -> String { let mut output = String::new(); @@ -2052,4 +2072,55 @@ mod tests { let encoded = percent_encode("café", is_path_encode); assert_eq!(encoded, "caf%C3%A9"); } + + // ------------------------------------------------------------------- + // equals_ignoring_fragment + // ------------------------------------------------------------------- + + #[test] + fn equals_ignoring_fragment_same_url_different_fragment() { + let a = Url::parse("https://example.com/page#sec1").unwrap(); + let b = Url::parse("https://example.com/page#sec2").unwrap(); + assert!(a.equals_ignoring_fragment(&b)); + } + + #[test] + fn equals_ignoring_fragment_no_fragment_vs_fragment() { + let a = Url::parse("https://example.com/page").unwrap(); + let b = Url::parse("https://example.com/page#sec").unwrap(); + assert!(a.equals_ignoring_fragment(&b)); + } + + #[test] + fn equals_ignoring_fragment_different_path() { + let a = Url::parse("https://example.com/a").unwrap(); + let b = Url::parse("https://example.com/b").unwrap(); + assert!(!a.equals_ignoring_fragment(&b)); + } + + #[test] + fn equals_ignoring_fragment_different_query() { + let a = Url::parse("https://example.com/page?q=1#frag").unwrap(); + let b = Url::parse("https://example.com/page?q=2#frag").unwrap(); + assert!(!a.equals_ignoring_fragment(&b)); + } + + #[test] + fn equals_ignoring_fragment_different_host() { + let a = Url::parse("https://a.com/page#frag").unwrap(); + let b = Url::parse("https://b.com/page#frag").unwrap(); + assert!(!a.equals_ignoring_fragment(&b)); + } + + #[test] + fn set_fragment_updates_url() { + let mut url = Url::parse("https://example.com/page").unwrap(); + assert_eq!(url.fragment(), None); + url.set_fragment(Some("section".to_string())); + assert_eq!(url.fragment(), Some("section")); + assert_eq!(url.serialize(), "https://example.com/page#section"); + url.set_fragment(None); + assert_eq!(url.fragment(), None); + assert_eq!(url.serialize(), "https://example.com/page"); + } }