Skip to content

Commit 6d00961

Browse files
committed
Implement standard scrollTo, scrollBy, and scrollIntoView
Signed-off-by: Nico Burns <nico@nicoburns.com>
1 parent 4fc4706 commit 6d00961

8 files changed

Lines changed: 141 additions & 124 deletions

File tree

packages/blitz-dom/src/document.rs

Lines changed: 79 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ pub enum DocumentEvent {
178178
ResourceLoad(ResourceLoadResponse),
179179
}
180180

181+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
182+
pub enum ScrollBehavior {
183+
#[default]
184+
Auto,
185+
Instant,
186+
Smooth,
187+
}
188+
181189
pub struct BaseDocument {
182190
/// ID of the document
183191
id: usize,
@@ -1879,7 +1887,7 @@ impl BaseDocument {
18791887
self.viewport_scroll != initial
18801888
}
18811889

1882-
pub fn scroll_by(
1890+
pub(crate) fn scroll_chain_by(
18831891
&mut self,
18841892
anchor_node_id: Option<usize>,
18851893
scroll_x: f64,
@@ -1918,24 +1926,7 @@ impl BaseDocument {
19181926
})
19191927
}
19201928

1921-
/// Scroll the viewport so that the given node is aligned with the top of the viewport.
1922-
pub fn scroll_to_node(&mut self, node_id: usize) {
1923-
let Some(node) = self.nodes.get(node_id) else {
1924-
return;
1925-
};
1926-
1927-
// `absolute_position` gives the node's position in document space (it does not
1928-
// account for the viewport scroll), so it is the scroll offset we want to land on.
1929-
let target = node.absolute_position(0.0, 0.0);
1930-
let current = self.viewport_scroll;
1931-
1932-
// `scroll_viewport_by` subtracts the delta from the current scroll offset, so pass
1933-
// `current - target` in order to land on `target`.
1934-
self.scroll_viewport_by(current.x - target.x as f64, current.y - target.y as f64);
1935-
}
1936-
1937-
/// Duration (in milliseconds) of an animated scroll started via
1938-
/// [`BaseDocument::scroll_to_node_smooth`] or [`BaseDocument::scroll_to_fragment_smooth`].
1929+
/// Duration (in milliseconds) of an animated scroll.
19391930
const SMOOTH_SCROLL_DURATION_MS: f64 = 300.0;
19401931

19411932
/// Returns the current scroll offset and the maximum scroll offset (the minimum is
@@ -1999,52 +1990,64 @@ impl BaseDocument {
19991990
self.shell_provider.request_redraw();
20001991
}
20011992

2002-
/// Like [`BaseDocument::scroll_to_node`], but animates the viewport towards the node
2003-
/// instead of jumping instantly.
2004-
pub fn scroll_to_node_smooth(&mut self, node_id: usize) {
2005-
let Some(node) = self.nodes.get(node_id) else {
1993+
fn should_scroll_smoothly(&self, node_id: usize, behavior: ScrollBehavior) -> bool {
1994+
match behavior {
1995+
ScrollBehavior::Auto => self.nodes.get(node_id).is_some_and(|node| {
1996+
node.primary_styles().is_some_and(|style| {
1997+
style.clone_scroll_behavior()
1998+
== style::computed_values::scroll_behavior::T::Smooth
1999+
})
2000+
}),
2001+
ScrollBehavior::Instant => false,
2002+
ScrollBehavior::Smooth => true,
2003+
}
2004+
}
2005+
2006+
/// Scroll an element to the given absolute scroll offset in CSS pixels.
2007+
pub fn scroll_to(&mut self, node_id: usize, x: f64, y: f64, behavior: ScrollBehavior) {
2008+
if self.nodes.get(node_id).is_none() {
20062009
return;
2010+
}
2011+
2012+
let (current, max) = self.node_scroll_state(node_id);
2013+
let target = crate::Point {
2014+
x: x.clamp(0.0, max.x),
2015+
y: y.clamp(0.0, max.y),
20072016
};
20082017

2009-
let target = node.absolute_position(0.0, 0.0);
2010-
self.start_scroll_animation(
2011-
None,
2012-
crate::Point {
2013-
x: target.x as f64,
2014-
y: target.y as f64,
2015-
},
2016-
);
2018+
if self.should_scroll_smoothly(node_id, behavior) {
2019+
self.start_scroll_animation(Some(node_id), target);
2020+
} else {
2021+
self.scroll_animation = ScrollAnimationState::None;
2022+
self.scroll_node_by(
2023+
node_id,
2024+
current.x - target.x,
2025+
current.y - target.y,
2026+
&mut |_| {},
2027+
);
2028+
self.shell_provider.request_redraw();
2029+
}
20172030
}
20182031

2019-
/// Scroll a node to the given absolute scroll offset (in CSS pixels), clamped to the
2020-
/// node's scrollable range. Scrolls on the root element are applied to the viewport.
2021-
pub fn scroll_node_to(&mut self, node_id: usize, x: f64, y: f64) {
2022-
let (current, max) = self.node_scroll_state(node_id);
2023-
let target_x = x.clamp(0.0, max.x);
2024-
let target_y = y.clamp(0.0, max.y);
2025-
2026-
// `scroll_node_by` works in deltas (subtracted from the current offset), so pass
2027-
// `current - target` to land on `target`. Because the target is pre-clamped to the
2028-
// scrollable range, no scroll is bubbled up to ancestors.
2029-
self.scroll_node_by(
2030-
node_id,
2031-
current.x - target_x,
2032-
current.y - target_y,
2033-
&mut |_| {},
2034-
);
2032+
/// Scroll an element by the given relative offset in CSS pixels.
2033+
pub fn scroll_by(&mut self, node_id: usize, x: f64, y: f64, behavior: ScrollBehavior) {
2034+
let current = self.node_scroll_state(node_id).0;
2035+
self.scroll_to(node_id, current.x + x, current.y + y, behavior);
20352036
}
20362037

2037-
/// Like [`BaseDocument::scroll_node_to`], but animates towards the offset instead of
2038-
/// jumping instantly.
2039-
pub fn scroll_node_to_smooth(&mut self, node_id: usize, x: f64, y: f64) {
2040-
let (_, max) = self.node_scroll_state(node_id);
2041-
self.start_scroll_animation(
2042-
Some(node_id),
2043-
crate::Point {
2044-
x: x.clamp(0.0, max.x),
2045-
y: y.clamp(0.0, max.y),
2046-
},
2047-
);
2038+
/// Scroll the viewport so that the given element is aligned with its top-left edge.
2039+
pub fn scroll_into_view(&mut self, node_id: usize, behavior: ScrollBehavior) {
2040+
let Some(target) = self
2041+
.nodes
2042+
.get(node_id)
2043+
.map(|node| node.absolute_position(0.0, 0.0))
2044+
else {
2045+
return;
2046+
};
2047+
let Some(root_id) = self.try_root_element().map(|root| root.id) else {
2048+
return;
2049+
};
2050+
self.scroll_to(root_id, target.x as f64, target.y as f64, behavior);
20482051
}
20492052

20502053
/// Resolve a URL fragment (the `#...` part of a URL) to a scroll target.
@@ -2074,40 +2077,38 @@ impl BaseDocument {
20742077
None
20752078
}
20762079

2077-
/// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
2078-
///
2079-
/// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
2080-
/// of the document, matching browser behaviour. Returns `true` if a scroll target was
2081-
/// found.
2082-
pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
2080+
fn scroll_to_fragment_with_behavior(
2081+
&mut self,
2082+
fragment: &str,
2083+
behavior: ScrollBehavior,
2084+
) -> bool {
20832085
match self.resolve_fragment_scroll_target(fragment) {
20842086
Some(Some(node_id)) => {
2085-
self.scroll_to_node(node_id);
2087+
self.scroll_into_view(node_id, behavior);
20862088
true
20872089
}
20882090
Some(None) => {
2089-
let current = self.viewport_scroll;
2090-
self.scroll_viewport_by(current.x, current.y);
2091+
let root_id = self.root_element().id;
2092+
self.scroll_to(root_id, 0.0, 0.0, behavior);
20912093
true
20922094
}
20932095
None => false,
20942096
}
20952097
}
20962098

2099+
/// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
2100+
///
2101+
/// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
2102+
/// of the document, matching browser behaviour. Returns `true` if a scroll target was
2103+
/// found.
2104+
pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
2105+
self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Auto)
2106+
}
2107+
20972108
/// Like [`BaseDocument::scroll_to_fragment`], but animates the viewport towards the
20982109
/// target instead of jumping instantly. Returns `true` if a scroll target was found.
20992110
pub fn scroll_to_fragment_smooth(&mut self, fragment: &str) -> bool {
2100-
match self.resolve_fragment_scroll_target(fragment) {
2101-
Some(Some(node_id)) => {
2102-
self.scroll_to_node_smooth(node_id);
2103-
true
2104-
}
2105-
Some(None) => {
2106-
self.start_scroll_animation(None, crate::Point::ZERO);
2107-
true
2108-
}
2109-
None => false,
2110-
}
2111+
self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Smooth)
21112112
}
21122113

21132114
/// Computes the size and position of the `Node` relative to the viewport

packages/blitz-dom/src/events/pointer.rs

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use blitz_traits::{
1111
};
1212
use keyboard_types::Modifiers;
1313
use markup5ever::local_name;
14-
use style::{computed_values::scroll_behavior::T as ScrollBehavior, values::computed::UserSelect};
14+
use style::values::computed::UserSelect;
1515
use taffy::AbsoluteAxis;
1616

1717
use crate::{
@@ -233,7 +233,7 @@ pub(crate) fn handle_pointermove<F: FnMut(DomEvent)>(
233233
let target = state.target;
234234
let (dx, dy) = state.update(time_ms, event.screen_x(), event.screen_y());
235235

236-
let has_changed = doc.scroll_by(Some(target), dx, dy, &mut dispatch_event);
236+
let has_changed = doc.scroll_chain_by(Some(target), dx, dy, &mut dispatch_event);
237237
return has_changed;
238238
}
239239

@@ -253,7 +253,7 @@ pub(crate) fn handle_pointermove<F: FnMut(DomEvent)>(
253253
AbsoluteAxis::Horizontal => (-delta_px * ratio, 0.0),
254254
AbsoluteAxis::Vertical => (0.0, -delta_px * ratio),
255255
};
256-
let has_changed = doc.scroll_by(Some(node_id), dx, dy, &mut dispatch_event);
256+
let has_changed = doc.scroll_chain_by(Some(node_id), dx, dy, &mut dispatch_event);
257257
return has_changed;
258258
}
259259

@@ -685,17 +685,7 @@ pub(crate) fn handle_click(
685685
// perform in-page fragment navigation (scrolling) instead of a
686686
// full navigation.
687687
if url.fragment().is_some() && doc.url.is_same_document(&url) {
688-
let fragment = url.fragment().unwrap_or_default();
689-
let behavior = doc
690-
.try_root_element()
691-
.and_then(|root| root.primary_styles())
692-
.map(|style| style.clone_scroll_behavior())
693-
.unwrap_or(ScrollBehavior::Auto);
694-
if behavior == ScrollBehavior::Smooth {
695-
doc.scroll_to_fragment_smooth(fragment);
696-
} else {
697-
doc.scroll_to_fragment(fragment);
698-
}
688+
doc.scroll_to_fragment(url.fragment().unwrap_or_default());
699689
} else {
700690
doc.navigation_provider.navigate_to(NavigationOptions::new(
701691
url,
@@ -792,7 +782,7 @@ pub(crate) fn handle_wheel<F: FnMut(DomEvent)>(
792782
BlitzWheelDelta::Pixels(x, y) => (x, y),
793783
};
794784

795-
let has_changed = doc.scroll_by(
785+
let has_changed = doc.scroll_chain_by(
796786
doc.get_hover_node_id(),
797787
scroll_x,
798788
scroll_y,

packages/blitz-dom/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ mod accessibility;
7272
pub use crate::node::Widget;
7373

7474
pub use config::{DocumentConfig, StyleThreading};
75-
pub use document::{BaseDocument, DocGuard, DocGuardMut, Document, PlainDocument};
75+
pub use document::{BaseDocument, DocGuard, DocGuardMut, Document, PlainDocument, ScrollBehavior};
7676
pub use markup5ever::{
7777
LocalName, Namespace, NamespaceStaticSet, Prefix, PrefixStaticSet, QualName, local_name,
7878
namespace_prefix, namespace_url, ns,

packages/blitz-dom/src/resolve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ impl BaseDocument {
218218
let dx = fling_state.x_velocity * time_diff_ms;
219219
let dy = fling_state.y_velocity * time_diff_ms;
220220

221-
self.scroll_by(Some(fling_state.target), dx, dy, &mut |_| {});
221+
self.scroll_chain_by(Some(fling_state.target), dx, dy, &mut |_| {});
222222
if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
223223
self.scroll_animation = ScrollAnimationState::None;
224224
}

packages/blitz-html/tests/fragment_navigation.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Fragment navigation: resolving a URL fragment (the `#...` part) to an element
22
//! and scrolling the viewport to it.
33
4-
use blitz_dom::{Document, DocumentConfig, FontContext};
4+
use blitz_dom::{Document, DocumentConfig, FontContext, ScrollBehavior};
55
use blitz_html::{HtmlDocument, HtmlProvider};
66
use blitz_traits::{
77
events::{
@@ -176,13 +176,13 @@ fn scroll_to_fragment_smooth_unknown_returns_false() {
176176
}
177177

178178
#[test]
179-
fn scroll_to_node_smooth_animates() {
179+
fn scroll_into_view_smooth_animates() {
180180
let mut doc = layout_doc(HTML);
181181

182182
let target = doc.query_selector("#target").unwrap().unwrap();
183183
let target_y = doc.get_node(target).unwrap().final_layout.location.y as f64;
184184

185-
doc.scroll_to_node_smooth(target);
185+
doc.scroll_into_view(target, ScrollBehavior::Smooth);
186186
assert!(doc.is_animating());
187187

188188
drive_until_settled(&mut doc);
@@ -222,37 +222,37 @@ fn fragment_link_uses_auto_scroll_behavior_from_root_style() {
222222

223223
/// A page with a nested scrollable container (`#scroller`) whose content overflows.
224224
const SCROLLER_HTML: &str = r#"<html><body style="margin:0">
225-
<div id="scroller" style="height:100px; overflow:scroll">
225+
<div id="scroller" style="height:100px; overflow:scroll; scroll-behavior:smooth">
226226
<div style="height:1000px"></div>
227227
</div>
228228
</body></html>"#;
229229

230230
#[test]
231-
fn scroll_node_to_sets_element_offset_and_clamps() {
231+
fn scroll_to_sets_element_offset_and_clamps() {
232232
let mut doc = layout_doc(SCROLLER_HTML);
233233
let scroller = doc.query_selector("#scroller").unwrap().unwrap();
234234

235235
// Scroll the element's own content down by 200px.
236-
doc.scroll_node_to(scroller, 0.0, 200.0);
236+
doc.scroll_to(scroller, 0.0, 200.0, ScrollBehavior::Instant);
237237
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, 200.0);
238238

239239
// Scrolling far past the end clamps to the element's maximum scroll offset.
240240
let max = doc.get_node(scroller).unwrap().final_layout.scroll_height() as f64;
241241
assert!(max > 0.0);
242-
doc.scroll_node_to(scroller, 0.0, 100_000.0);
242+
doc.scroll_to(scroller, 0.0, 100_000.0, ScrollBehavior::Instant);
243243
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, max);
244244

245245
// Scrolling to a negative offset clamps to 0.
246-
doc.scroll_node_to(scroller, 0.0, -100.0);
246+
doc.scroll_to(scroller, 0.0, -100.0, ScrollBehavior::Instant);
247247
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, 0.0);
248248
}
249249

250250
#[test]
251-
fn scroll_node_to_smooth_animates_element_offset() {
251+
fn scroll_to_smooth_animates_element_offset() {
252252
let mut doc = layout_doc(SCROLLER_HTML);
253253
let scroller = doc.query_selector("#scroller").unwrap().unwrap();
254254

255-
doc.scroll_node_to_smooth(scroller, 0.0, 200.0);
255+
doc.scroll_to(scroller, 0.0, 200.0, ScrollBehavior::Smooth);
256256
assert!(doc.is_animating(), "node scroll should be animating");
257257
assert!(
258258
doc.get_node(scroller).unwrap().scroll_offset.y < 200.0,
@@ -263,3 +263,27 @@ fn scroll_node_to_smooth_animates_element_offset() {
263263
assert!(!doc.is_animating());
264264
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, 200.0);
265265
}
266+
267+
#[test]
268+
fn scroll_by_uses_relative_offsets_and_clamps() {
269+
let mut doc = layout_doc(SCROLLER_HTML);
270+
let scroller = doc.query_selector("#scroller").unwrap().unwrap();
271+
272+
doc.scroll_to(scroller, 0.0, 100.0, ScrollBehavior::Instant);
273+
doc.scroll_by(scroller, 0.0, 75.0, ScrollBehavior::Instant);
274+
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, 175.0);
275+
276+
doc.scroll_by(scroller, 0.0, -300.0, ScrollBehavior::Instant);
277+
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, 0.0);
278+
}
279+
280+
#[test]
281+
fn auto_behavior_uses_scroll_behavior_style() {
282+
let mut doc = layout_doc(SCROLLER_HTML);
283+
let scroller = doc.query_selector("#scroller").unwrap().unwrap();
284+
285+
doc.scroll_to(scroller, 0.0, 200.0, ScrollBehavior::Auto);
286+
assert!(doc.is_animating());
287+
drive_until_settled(&mut doc);
288+
assert_eq!(doc.get_node(scroller).unwrap().scroll_offset.y, 200.0);
289+
}

0 commit comments

Comments
 (0)