blitz-pdf 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +26 -0
  2. package/THIRD_PARTY_NOTICES.md +5 -2
  3. package/docs/layout-regressions.md +65 -0
  4. package/docs/pagination-regressions.md +89 -0
  5. package/package.json +2 -1
  6. package/vendor/README.md +35 -0
  7. package/vendor/blitz-dom/Cargo.toml +113 -0
  8. package/vendor/blitz-dom/LICENSE-APACHE +176 -0
  9. package/vendor/blitz-dom/LICENSE-MIT +23 -0
  10. package/vendor/blitz-dom/assets/default.css +1142 -0
  11. package/vendor/blitz-dom/assets/moz-bullet-font.otf +0 -0
  12. package/vendor/blitz-dom/src/accessibility.rs +255 -0
  13. package/vendor/blitz-dom/src/config.rs +97 -0
  14. package/vendor/blitz-dom/src/cssom.rs +971 -0
  15. package/vendor/blitz-dom/src/debug.rs +206 -0
  16. package/vendor/blitz-dom/src/document.rs +3395 -0
  17. package/vendor/blitz-dom/src/events/driver.rs +427 -0
  18. package/vendor/blitz-dom/src/events/focus.rs +40 -0
  19. package/vendor/blitz-dom/src/events/ime.rs +31 -0
  20. package/vendor/blitz-dom/src/events/keyboard.rs +174 -0
  21. package/vendor/blitz-dom/src/events/mod.rs +315 -0
  22. package/vendor/blitz-dom/src/events/pointer.rs +849 -0
  23. package/vendor/blitz-dom/src/font_metrics.rs +177 -0
  24. package/vendor/blitz-dom/src/form.rs +463 -0
  25. package/vendor/blitz-dom/src/html.rs +38 -0
  26. package/vendor/blitz-dom/src/iframe.rs +186 -0
  27. package/vendor/blitz-dom/src/layout/construct.rs +1328 -0
  28. package/vendor/blitz-dom/src/layout/damage.rs +642 -0
  29. package/vendor/blitz-dom/src/layout/inline.rs +1160 -0
  30. package/vendor/blitz-dom/src/layout/list.rs +237 -0
  31. package/vendor/blitz-dom/src/layout/mod.rs +969 -0
  32. package/vendor/blitz-dom/src/layout/paint_tree.rs +462 -0
  33. package/vendor/blitz-dom/src/layout/replaced.rs +439 -0
  34. package/vendor/blitz-dom/src/layout/table.rs +967 -0
  35. package/vendor/blitz-dom/src/layout/text_transform.rs +979 -0
  36. package/vendor/blitz-dom/src/layout/writing_mode.rs +437 -0
  37. package/vendor/blitz-dom/src/lib.rs +160 -0
  38. package/vendor/blitz-dom/src/mutator.rs +1818 -0
  39. package/vendor/blitz-dom/src/net.rs +649 -0
  40. package/vendor/blitz-dom/src/node/attributes.rs +60 -0
  41. package/vendor/blitz-dom/src/node/custom_widget.rs +178 -0
  42. package/vendor/blitz-dom/src/node/element.rs +1106 -0
  43. package/vendor/blitz-dom/src/node/mod.rs +30 -0
  44. package/vendor/blitz-dom/src/node/node.rs +2036 -0
  45. package/vendor/blitz-dom/src/node/scrollbar.rs +237 -0
  46. package/vendor/blitz-dom/src/node/serialize.rs +225 -0
  47. package/vendor/blitz-dom/src/node/stylo_data.rs +142 -0
  48. package/vendor/blitz-dom/src/node/svg.rs +265 -0
  49. package/vendor/blitz-dom/src/node/text.rs +804 -0
  50. package/vendor/blitz-dom/src/query_selector.rs +275 -0
  51. package/vendor/blitz-dom/src/resolve.rs +474 -0
  52. package/vendor/blitz-dom/src/resolved_style.rs +797 -0
  53. package/vendor/blitz-dom/src/scrolling.rs +780 -0
  54. package/vendor/blitz-dom/src/selection.rs +122 -0
  55. package/vendor/blitz-dom/src/stylo.rs +1518 -0
  56. package/vendor/blitz-dom/src/stylo_device.rs +88 -0
  57. package/vendor/blitz-dom/src/stylo_to_cursor_icon.rs +49 -0
  58. package/vendor/blitz-dom/src/stylo_to_kurbo.rs +129 -0
  59. package/vendor/blitz-dom/src/stylo_to_parley.rs +655 -0
  60. package/vendor/blitz-dom/src/traversal.rs +600 -0
  61. package/vendor/blitz-dom/src/tree.rs +136 -0
  62. package/vendor/blitz-dom/src/url.rs +63 -0
  63. package/vendor/blitz-dom/src/util.rs +256 -0
  64. package/wasm/blitz_pdf_bg.wasm +0 -0
@@ -0,0 +1,780 @@
1
+ //! Scrolling: user-initiated (interactive) and programmatic scrolling of nodes and the
2
+ //! viewport, and the scroll animations (smooth scrolls and flings) which drive them.
3
+
4
+ use blitz_traits::events::{BlitzScrollEvent, DomEvent, DomEventData};
5
+ use blitz_traits::node_id::NodeId;
6
+ use style::values::computed::Overflow;
7
+ use web_time::{SystemTime, UNIX_EPOCH};
8
+
9
+ use crate::BaseDocument;
10
+ use crate::util::Point;
11
+
12
+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13
+ pub enum ScrollBehavior {
14
+ #[default]
15
+ Auto,
16
+ Instant,
17
+ Smooth,
18
+ }
19
+
20
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
21
+ pub enum ScrollLogicalPosition {
22
+ Start,
23
+ Center,
24
+ End,
25
+ Nearest,
26
+ }
27
+
28
+ /// What a scroll applies to.
29
+ ///
30
+ /// Per the CSS overflow propagation rules the root element has no scrolling mechanism of its
31
+ /// own (its overflow is applied to the viewport), so [`ScrollTarget::Node`] holding the root
32
+ /// element is equivalent to [`ScrollTarget::Viewport`].
33
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
34
+ pub(crate) enum ScrollTarget {
35
+ Node(NodeId),
36
+ Viewport,
37
+ }
38
+
39
+ /// How far to scroll.
40
+ #[derive(Clone, Copy, Debug, PartialEq)]
41
+ pub(crate) enum ScrollAmount {
42
+ /// An absolute scroll offset.
43
+ To(Point<f64>),
44
+ /// A delta to apply to the current scroll offset.
45
+ By(Point<f64>),
46
+ }
47
+
48
+ /// What to do with scroll which the target cannot consume.
49
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
50
+ pub(crate) enum ScrollOverflow {
51
+ /// Discard it: the scroll affects exactly one scroller.
52
+ Clamp,
53
+ /// Transfer it to the next scroller in the scroll chain (the parent node, and eventually
54
+ /// the viewport).
55
+ Chain,
56
+ }
57
+
58
+ /// Who initiated a scroll. Determines which overflow values count as scrollable:
59
+ /// `overflow: hidden` boxes have a scrolling box which programmatic scrolls apply to,
60
+ /// but no user scrolling mechanism.
61
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
62
+ pub(crate) enum ScrollSource {
63
+ /// A user-initiated scroll (wheel, touch, scrollbar drag, keyboard).
64
+ User,
65
+ /// A script-initiated scroll (`scrollTo` and friends, fragment navigation).
66
+ Programmatic,
67
+ }
68
+
69
+ #[derive(Clone, Copy, Debug, PartialEq)]
70
+ pub(crate) struct ScrollRequest {
71
+ pub(crate) target: ScrollTarget,
72
+ pub(crate) amount: ScrollAmount,
73
+ pub(crate) overflow: ScrollOverflow,
74
+ pub(crate) source: ScrollSource,
75
+ pub(crate) behavior: ScrollBehavior,
76
+ /// Whether to abort a smooth scroll in progress. Set for user-initiated scrolls, so that
77
+ /// an animation does not fight the user's input for the rest of its duration.
78
+ pub(crate) interrupt_animation: bool,
79
+ }
80
+
81
+ #[derive(Debug, Clone, PartialEq)]
82
+ pub(crate) struct FlingState {
83
+ pub(crate) target: NodeId,
84
+ pub(crate) last_seen_time: f64,
85
+ pub(crate) x_velocity: f64,
86
+ pub(crate) y_velocity: f64,
87
+ }
88
+
89
+ /// State driving a smooth (animated) scroll towards a target offset. Used for
90
+ /// fragment navigation (`#anchor` links) and programmatic smooth scrolling.
91
+ #[derive(Debug, Clone, PartialEq)]
92
+ pub(crate) struct ScrollToState {
93
+ /// What is being scrolled.
94
+ pub(crate) target: ScrollTarget,
95
+ /// The scroll offset at the start of the animation.
96
+ pub(crate) start: Point<f64>,
97
+ /// The scroll offset to animate towards.
98
+ pub(crate) end: Point<f64>,
99
+ /// Time (in milliseconds since the Unix epoch) at which the animation started.
100
+ pub(crate) start_time: f64,
101
+ /// Total duration of the animation in milliseconds.
102
+ pub(crate) duration: f64,
103
+ }
104
+
105
+ #[derive(Debug, Clone, PartialEq)]
106
+ pub(crate) enum ScrollAnimationState {
107
+ None,
108
+ Fling(FlingState),
109
+ /// A smooth scroll of the viewport towards a target offset.
110
+ ScrollTo(ScrollToState),
111
+ }
112
+
113
+ /// Cubic ease-in-out easing function, mapping a normalised time `t` in `[0, 1]`
114
+ /// to an eased progress value in `[0, 1]`. Used to give smooth scrolls a natural
115
+ /// acceleration/deceleration curve.
116
+ fn ease_in_out_cubic(t: f64) -> f64 {
117
+ if t < 0.5 {
118
+ 4.0 * t * t * t
119
+ } else {
120
+ let f = 2.0 * t - 2.0;
121
+ 1.0 + (f * f * f) / 2.0
122
+ }
123
+ }
124
+
125
+ impl BaseDocument {
126
+ /// Apply a scroll to the document, returning whether anything moved.
127
+ ///
128
+ /// This is the single scrolling primitive: user-initiated scrolls
129
+ /// ([`BaseDocument::scroll_chain_by`]) and programmatic scrolls
130
+ /// ([`BaseDocument::scroll_to`]) differ only in the [`ScrollRequest`] they build.
131
+ pub(crate) fn scroll(
132
+ &mut self,
133
+ request: ScrollRequest,
134
+ dispatch_event: &mut dyn FnMut(DomEvent),
135
+ ) -> bool {
136
+ if request.interrupt_animation
137
+ && matches!(self.scroll_animation, ScrollAnimationState::ScrollTo(_))
138
+ {
139
+ self.scroll_animation = ScrollAnimationState::None;
140
+ }
141
+
142
+ let target = self.canonical_scroll_target(request.target);
143
+
144
+ // Text inputs and sub-documents scroll their own content rather than an overflow
145
+ // scrollport, so they only take part in the chained (user-initiated) path.
146
+ if let (ScrollTarget::Node(node_id), ScrollAmount::By(delta), ScrollOverflow::Chain) =
147
+ (target, request.amount, request.overflow)
148
+ {
149
+ if let Some(has_changed) =
150
+ self.scroll_inner_content_by(node_id, delta, request, dispatch_event)
151
+ {
152
+ return has_changed;
153
+ }
154
+ }
155
+
156
+ let include_hidden = request.source == ScrollSource::Programmatic;
157
+ let (current, max) = self.scroll_state(target, include_hidden);
158
+ let unclamped = match request.amount {
159
+ ScrollAmount::To(to) => to,
160
+ ScrollAmount::By(by) => Point {
161
+ x: current.x + by.x,
162
+ y: current.y + by.y,
163
+ },
164
+ };
165
+ let end = Point {
166
+ x: unclamped.x.clamp(0.0, max.x),
167
+ y: unclamped.y.clamp(0.0, max.y),
168
+ };
169
+
170
+ if self.should_scroll_smoothly(target, request.behavior) {
171
+ self.start_scroll_animation(target, end);
172
+ return end != current;
173
+ }
174
+
175
+ let has_changed = self.write_scroll_offset(target, end, dispatch_event);
176
+
177
+ // Transfer the delta the target could not consume to the next scroller in the chain.
178
+ if request.overflow == ScrollOverflow::Chain {
179
+ let remainder = Point {
180
+ x: unclamped.x - end.x,
181
+ y: unclamped.y - end.y,
182
+ };
183
+ if remainder != Point::ZERO {
184
+ if let Some(next) = self.next_scroller_in_chain(target) {
185
+ let request = ScrollRequest {
186
+ target: next,
187
+ amount: ScrollAmount::By(remainder),
188
+ ..request
189
+ };
190
+ return has_changed | self.scroll(request, dispatch_event);
191
+ }
192
+ }
193
+ }
194
+
195
+ has_changed
196
+ }
197
+
198
+ /// Scroll a node which scrolls content of its own rather than an overflow scrollport
199
+ /// (a text input or a sub-document), returning `None` if the node is not such a node.
200
+ fn scroll_inner_content_by(
201
+ &mut self,
202
+ node_id: NodeId,
203
+ delta: Point<f64>,
204
+ request: ScrollRequest,
205
+ dispatch_event: &mut dyn FnMut(DomEvent),
206
+ ) -> Option<bool> {
207
+ let node = self.nodes.get_mut(node_id)?;
208
+
209
+ if let Some(mut sub_doc) = node.subdoc_mut().map(|doc| doc.inner_mut()) {
210
+ let target = sub_doc
211
+ .get_hover_node_id()
212
+ .map_or(ScrollTarget::Viewport, ScrollTarget::Node);
213
+ // TODO: propagate the remaining scroll to the outer document
214
+ let request = ScrollRequest {
215
+ target,
216
+ amount: ScrollAmount::By(delta),
217
+ ..request
218
+ };
219
+ return Some(sub_doc.scroll(request, dispatch_event));
220
+ }
221
+
222
+ // Single-line inputs scroll their text horizontally, multi-line inputs vertically.
223
+ node.element_data()?.text_input_data()?;
224
+ let content_box_width = node.final_layout().content_box_width();
225
+ let content_box_height = node.final_layout().content_box_height();
226
+ let input = node
227
+ .element_data_mut()
228
+ .and_then(|el| el.text_input_data_mut())
229
+ .unwrap();
230
+
231
+ // `TextInputData::scroll_by` takes (and returns the unconsumed part of) a delta in
232
+ // the opposite direction to a scroll offset.
233
+ let mut remainder = delta;
234
+ if input.is_multiline {
235
+ remainder.y =
236
+ -(input.scroll_by(-delta.y as f32, content_box_width, content_box_height) as f64);
237
+ } else {
238
+ remainder.x =
239
+ -(input.scroll_by(-delta.x as f32, content_box_width, content_box_height) as f64);
240
+ }
241
+
242
+ let has_changed = remainder != delta;
243
+
244
+ if remainder != Point::ZERO {
245
+ if let Some(next) = self.next_scroller_in_chain(ScrollTarget::Node(node_id)) {
246
+ let request = ScrollRequest {
247
+ target: next,
248
+ amount: ScrollAmount::By(remainder),
249
+ ..request
250
+ };
251
+ return Some(has_changed | self.scroll(request, dispatch_event));
252
+ }
253
+ }
254
+
255
+ Some(has_changed)
256
+ }
257
+
258
+ /// The scroller which unconsumed scroll is transferred to: the node's parent, or the
259
+ /// viewport once the chain runs out of nodes.
260
+ fn next_scroller_in_chain(&self, target: ScrollTarget) -> Option<ScrollTarget> {
261
+ match target {
262
+ ScrollTarget::Viewport => None,
263
+ ScrollTarget::Node(node_id) => Some(
264
+ self.nodes
265
+ .get(node_id)
266
+ .and_then(|node| node.parent)
267
+ .map_or(ScrollTarget::Viewport, ScrollTarget::Node),
268
+ ),
269
+ }
270
+ }
271
+
272
+ /// Resolve a scroll target to the scroller which actually moves: the root element scrolls
273
+ /// the viewport, per the CSS overflow propagation rules.
274
+ fn canonical_scroll_target(&self, target: ScrollTarget) -> ScrollTarget {
275
+ match target {
276
+ ScrollTarget::Node(node_id)
277
+ if self.try_root_element().is_some_and(|el| el.id == node_id) =>
278
+ {
279
+ ScrollTarget::Viewport
280
+ }
281
+ target => target,
282
+ }
283
+ }
284
+
285
+ /// Write a scroll target's offset (which must already be clamped to its scrollable
286
+ /// range), dispatching a `scroll` event and returning whether the offset changed.
287
+ pub(crate) fn write_scroll_offset(
288
+ &mut self,
289
+ target: ScrollTarget,
290
+ offset: Point<f64>,
291
+ dispatch_event: &mut dyn FnMut(DomEvent),
292
+ ) -> bool {
293
+ match self.canonical_scroll_target(target) {
294
+ ScrollTarget::Viewport => {
295
+ let initial = self.viewport_scroll();
296
+ self.set_viewport_scroll(offset);
297
+ if offset == initial {
298
+ return false;
299
+ }
300
+
301
+ if let Some(root) = self.try_root_element() {
302
+ let root_id = root.id;
303
+ let layout = *root.final_layout();
304
+ let scale = self.viewport.scale() as f64;
305
+ let event = BlitzScrollEvent {
306
+ scroll_top: offset.y,
307
+ scroll_left: offset.x,
308
+ scroll_width: layout.size.width.max(layout.scrollable_overflow_rect.right)
309
+ as i32,
310
+ scroll_height: layout
311
+ .size
312
+ .height
313
+ .max(layout.scrollable_overflow_rect.bottom)
314
+ as i32,
315
+ client_width: (self.viewport.window_size.0 as f64 / scale) as i32,
316
+ client_height: (self.viewport.window_size.1 as f64 / scale) as i32,
317
+ };
318
+ dispatch_event(DomEvent::new(root_id, DomEventData::Scroll(event)));
319
+ }
320
+
321
+ self.shell_provider.request_redraw();
322
+ true
323
+ }
324
+ ScrollTarget::Node(node_id) => {
325
+ let Some(node) = self.nodes.get_mut(node_id) else {
326
+ return false;
327
+ };
328
+
329
+ let initial = *node.scroll_offset();
330
+ *node.scroll_offset_mut() = offset;
331
+ if offset == initial {
332
+ return false;
333
+ }
334
+
335
+ let layout = *node.final_layout();
336
+ self.nodes.bump_geometry_generation();
337
+ let event = BlitzScrollEvent {
338
+ scroll_top: offset.y,
339
+ scroll_left: offset.x,
340
+ scroll_width: layout.scroll_width() as i32,
341
+ scroll_height: layout.scroll_height() as i32,
342
+ client_width: layout.size.width as i32,
343
+ client_height: layout.size.height as i32,
344
+ };
345
+ dispatch_event(DomEvent::new(node_id, DomEventData::Scroll(event)));
346
+
347
+ self.show_scrollbars(node_id);
348
+ self.shell_provider.request_redraw();
349
+ true
350
+ }
351
+ }
352
+ }
353
+
354
+ pub fn scroll_node_by<F: FnMut(DomEvent)>(
355
+ &mut self,
356
+ node_id: NodeId,
357
+ x: f64,
358
+ y: f64,
359
+ dispatch_event: F,
360
+ ) {
361
+ self.scroll_node_by_has_changed(node_id, x, y, dispatch_event);
362
+ }
363
+
364
+ /// Scroll a node by given x and y
365
+ /// Will bubble scrolling up to parent node once it can no longer scroll further
366
+ /// If we're already at the root node, bubbles scrolling up to the viewport
367
+ pub fn scroll_node_by_has_changed<F: FnMut(DomEvent)>(
368
+ &mut self,
369
+ node_id: NodeId,
370
+ x: f64,
371
+ y: f64,
372
+ mut dispatch_event: F,
373
+ ) -> bool {
374
+ self.scroll(
375
+ ScrollRequest {
376
+ target: ScrollTarget::Node(node_id),
377
+ // A user-facing scroll delta moves the content, i.e. the opposite direction
378
+ // to the scroll offset.
379
+ amount: ScrollAmount::By(Point { x: -x, y: -y }),
380
+ overflow: ScrollOverflow::Chain,
381
+ source: ScrollSource::User,
382
+ behavior: ScrollBehavior::Instant,
383
+ interrupt_animation: false,
384
+ },
385
+ &mut dispatch_event,
386
+ )
387
+ }
388
+
389
+ pub fn scroll_viewport_by(&mut self, x: f64, y: f64) {
390
+ self.scroll_viewport_by_has_changed(x, y);
391
+ }
392
+
393
+ /// Scroll the viewport by the given values
394
+ pub fn scroll_viewport_by_has_changed(&mut self, x: f64, y: f64) -> bool {
395
+ self.scroll(
396
+ ScrollRequest {
397
+ target: ScrollTarget::Viewport,
398
+ amount: ScrollAmount::By(Point { x: -x, y: -y }),
399
+ overflow: ScrollOverflow::Clamp,
400
+ source: ScrollSource::User,
401
+ behavior: ScrollBehavior::Instant,
402
+ interrupt_animation: false,
403
+ },
404
+ &mut |_| {},
405
+ )
406
+ }
407
+
408
+ pub(crate) fn scroll_chain_by(
409
+ &mut self,
410
+ anchor_node_id: Option<NodeId>,
411
+ scroll_x: f64,
412
+ scroll_y: f64,
413
+ dispatch_event: &mut dyn FnMut(DomEvent),
414
+ ) -> bool {
415
+ self.scroll(
416
+ ScrollRequest {
417
+ target: anchor_node_id.map_or(ScrollTarget::Viewport, ScrollTarget::Node),
418
+ amount: ScrollAmount::By(Point {
419
+ x: -scroll_x,
420
+ y: -scroll_y,
421
+ }),
422
+ overflow: ScrollOverflow::Chain,
423
+ source: ScrollSource::User,
424
+ behavior: ScrollBehavior::Instant,
425
+ // A user-initiated scroll aborts any smooth scroll in progress, so that the
426
+ // two do not fight over the scroll offset for the rest of the animation.
427
+ interrupt_animation: true,
428
+ },
429
+ dispatch_event,
430
+ )
431
+ }
432
+
433
+ /// Duration (in milliseconds) of an animated scroll.
434
+ const SMOOTH_SCROLL_DURATION_MS: f64 = 300.0;
435
+
436
+ /// Returns the current scroll offset and the maximum scroll offset (the minimum is
437
+ /// always `0`) for the given scroll target. `include_hidden` controls whether
438
+ /// `overflow: hidden` axes count as scrollable (they do for programmatic scrolls,
439
+ /// which apply to any scrolling box, but not for user scrolls).
440
+ pub(crate) fn scroll_state(
441
+ &self,
442
+ target: ScrollTarget,
443
+ include_hidden: bool,
444
+ ) -> (Point<f64>, Point<f64>) {
445
+ match self.canonical_scroll_target(target) {
446
+ ScrollTarget::Viewport => {
447
+ // The viewport scrolls the root element's scrollable overflow, which includes
448
+ // both the root element itself and any content which overflows it (e.g. when
449
+ // the root element has a fixed height but its content is taller). A document
450
+ // without a root element has no scrollable content.
451
+ let (content_width, content_height) = match self.try_root_element() {
452
+ Some(root) => {
453
+ let layout = root.final_layout();
454
+ (
455
+ layout.size.width.max(layout.scrollable_overflow_rect.right) as f64,
456
+ layout
457
+ .size
458
+ .height
459
+ .max(layout.scrollable_overflow_rect.bottom)
460
+ as f64,
461
+ )
462
+ }
463
+ None => (0.0, 0.0),
464
+ };
465
+ let scale = self.viewport.scale() as f64;
466
+ let window_width = self.viewport.window_size.0 as f64 / scale;
467
+ let window_height = self.viewport.window_size.1 as f64 / scale;
468
+ let max = Point {
469
+ x: (content_width - window_width).max(0.0),
470
+ y: (content_height - window_height).max(0.0),
471
+ };
472
+ (self.viewport_scroll(), max)
473
+ }
474
+ ScrollTarget::Node(node_id) => {
475
+ let Some(node) = self.nodes.get(node_id) else {
476
+ return (Point::ZERO, Point::ZERO);
477
+ };
478
+
479
+ // An axis with a non-scrolling overflow value has no scrollable range, even
480
+ // when its content overflows.
481
+ let scrollable = |overflow| match overflow {
482
+ Overflow::Scroll | Overflow::Auto => true,
483
+ Overflow::Hidden => include_hidden,
484
+ _ => false,
485
+ };
486
+ let (can_x_scroll, can_y_scroll) = node
487
+ .primary_styles()
488
+ .map(|styles| {
489
+ (
490
+ scrollable(styles.clone_overflow_x()),
491
+ scrollable(styles.clone_overflow_y()),
492
+ )
493
+ })
494
+ .unwrap_or((false, false));
495
+ let max = Point {
496
+ x: match can_x_scroll {
497
+ true => node.final_layout().scroll_width() as f64,
498
+ false => 0.0,
499
+ },
500
+ y: match can_y_scroll {
501
+ true => node.final_layout().scroll_height() as f64,
502
+ false => 0.0,
503
+ },
504
+ };
505
+ (*node.scroll_offset(), max)
506
+ }
507
+ }
508
+ }
509
+
510
+ /// Start a smooth (animated) scroll towards the given absolute scroll offset. The
511
+ /// animation is advanced each frame in [`BaseDocument::resolve_scroll_animation`].
512
+ fn start_scroll_animation(&mut self, target: ScrollTarget, end: Point<f64>) {
513
+ let start = self.scroll_state(target, true).0;
514
+
515
+ let start_time = SystemTime::now()
516
+ .duration_since(UNIX_EPOCH)
517
+ .unwrap()
518
+ .as_millis() as u64 as f64;
519
+
520
+ self.scroll_animation = ScrollAnimationState::ScrollTo(ScrollToState {
521
+ target,
522
+ start,
523
+ end,
524
+ start_time,
525
+ duration: Self::SMOOTH_SCROLL_DURATION_MS,
526
+ });
527
+
528
+ // Ensure the frame loop runs so the animation is driven to completion.
529
+ self.shell_provider.request_redraw();
530
+ }
531
+
532
+ fn should_scroll_smoothly(&self, target: ScrollTarget, behavior: ScrollBehavior) -> bool {
533
+ match behavior {
534
+ ScrollBehavior::Auto => {
535
+ let styled_node = match target {
536
+ ScrollTarget::Node(node_id) => self.nodes.get(node_id),
537
+ ScrollTarget::Viewport => self.try_root_element(),
538
+ };
539
+ styled_node.is_some_and(|node| {
540
+ node.primary_styles().is_some_and(|style| {
541
+ style.clone_scroll_behavior()
542
+ == style::computed_values::scroll_behavior::T::Smooth
543
+ })
544
+ })
545
+ }
546
+ ScrollBehavior::Instant => false,
547
+ ScrollBehavior::Smooth => true,
548
+ }
549
+ }
550
+
551
+ /// Scroll an element to the given absolute scroll offset in CSS pixels.
552
+ ///
553
+ /// Unlike a user-initiated scroll, a programmatic scroll targets exactly one scroller:
554
+ /// scroll the element cannot consume is discarded rather than transferred to an ancestor.
555
+ pub fn scroll_to(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
556
+ self.scroll_programmatically(node_id, ScrollAmount::To(Point { x, y }), behavior);
557
+ }
558
+
559
+ /// Scroll an element by the given relative offset in CSS pixels.
560
+ pub fn scroll_by(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
561
+ self.scroll_programmatically(node_id, ScrollAmount::By(Point { x, y }), behavior);
562
+ }
563
+
564
+ fn scroll_programmatically(
565
+ &mut self,
566
+ node_id: NodeId,
567
+ amount: ScrollAmount,
568
+ behavior: ScrollBehavior,
569
+ ) {
570
+ if self.nodes.get(node_id).is_none() {
571
+ return;
572
+ }
573
+
574
+ // TODO: dispatch `scroll` events for programmatic scrolls.
575
+ self.scroll(
576
+ ScrollRequest {
577
+ target: ScrollTarget::Node(node_id),
578
+ amount,
579
+ overflow: ScrollOverflow::Clamp,
580
+ source: ScrollSource::Programmatic,
581
+ behavior,
582
+ interrupt_animation: true,
583
+ },
584
+ &mut |_| {},
585
+ );
586
+ }
587
+
588
+ fn aligned_scroll_offset(
589
+ current: f64,
590
+ viewport_size: f64,
591
+ target_start: f64,
592
+ target_size: f64,
593
+ position: ScrollLogicalPosition,
594
+ ) -> f64 {
595
+ let target_end = target_start + target_size;
596
+ match position {
597
+ ScrollLogicalPosition::Start => target_start,
598
+ ScrollLogicalPosition::Center => target_start - (viewport_size - target_size) / 2.0,
599
+ ScrollLogicalPosition::End => target_end - viewport_size,
600
+ ScrollLogicalPosition::Nearest => {
601
+ let viewport_end = current + viewport_size;
602
+ if (target_start >= current && target_end <= viewport_end)
603
+ || (target_start <= current && target_end >= viewport_end)
604
+ {
605
+ current
606
+ } else {
607
+ let start_offset = target_start;
608
+ let end_offset = target_end - viewport_size;
609
+ if (start_offset - current).abs() < (end_offset - current).abs() {
610
+ start_offset
611
+ } else {
612
+ end_offset
613
+ }
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ /// Scroll the viewport so that the given element has the requested alignment in each axis.
620
+ pub fn scroll_into_view(
621
+ &mut self,
622
+ node_id: NodeId,
623
+ behavior: ScrollBehavior,
624
+ vertical: ScrollLogicalPosition,
625
+ horizontal: ScrollLogicalPosition,
626
+ ) {
627
+ let Some(node) = self.nodes.get(node_id) else {
628
+ return;
629
+ };
630
+ let target =
631
+ node.absolute_position(node.scroll_offset().x as f32, node.scroll_offset().y as f32);
632
+ let target_size = node.final_layout().size;
633
+ let Some(root_id) = self.try_root_element().map(|root| root.id) else {
634
+ return;
635
+ };
636
+ let scale = self.viewport.scale() as f64;
637
+ let viewport_width = self.viewport.window_size.0 as f64 / scale;
638
+ let viewport_height = self.viewport.window_size.1 as f64 / scale;
639
+ let x = Self::aligned_scroll_offset(
640
+ self.viewport_scroll().x,
641
+ viewport_width,
642
+ target.x as f64,
643
+ target_size.width as f64,
644
+ horizontal,
645
+ );
646
+ let y = Self::aligned_scroll_offset(
647
+ self.viewport_scroll().y,
648
+ viewport_height,
649
+ target.y as f64,
650
+ target_size.height as f64,
651
+ vertical,
652
+ );
653
+ self.scroll_to(root_id, x, y, behavior);
654
+ }
655
+
656
+ /// Resolve a URL fragment (the `#...` part of a URL) to a scroll target.
657
+ ///
658
+ /// Returns `None` if the fragment matches no element and is not a top-of-document
659
+ /// fragment. Otherwise returns `Some(target)`, where `target` is `Some(node_id)` for
660
+ /// the element to scroll to, or `None` to scroll to the top of the document (matching
661
+ /// browser behaviour for empty and `top` fragments).
662
+ fn resolve_fragment_scroll_target(&self, fragment: &str) -> Option<Option<NodeId>> {
663
+ // Fragments are percent-encoded in URLs (e.g. `%20`); decode before matching.
664
+ let decoded = percent_encoding::percent_decode_str(fragment)
665
+ .decode_utf8_lossy()
666
+ .into_owned();
667
+
668
+ if !decoded.is_empty() {
669
+ if let Some(node_id) = self.get_fragment_target(&decoded) {
670
+ return Some(Some(node_id));
671
+ }
672
+ }
673
+
674
+ // An empty fragment, or the special "top" fragment when no matching element exists,
675
+ // scrolls to the top of the document.
676
+ if decoded.is_empty() || decoded.eq_ignore_ascii_case("top") {
677
+ return Some(None);
678
+ }
679
+
680
+ None
681
+ }
682
+
683
+ fn scroll_to_fragment_with_behavior(
684
+ &mut self,
685
+ fragment: &str,
686
+ behavior: ScrollBehavior,
687
+ ) -> bool {
688
+ match self.resolve_fragment_scroll_target(fragment) {
689
+ Some(Some(node_id)) => {
690
+ self.scroll_into_view(
691
+ node_id,
692
+ behavior,
693
+ ScrollLogicalPosition::Start,
694
+ ScrollLogicalPosition::Nearest,
695
+ );
696
+ true
697
+ }
698
+ Some(None) => {
699
+ let root_id = self.root_element().id;
700
+ self.scroll_to(root_id, 0.0, 0.0, behavior);
701
+ true
702
+ }
703
+ None => false,
704
+ }
705
+ }
706
+
707
+ /// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
708
+ ///
709
+ /// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
710
+ /// of the document, matching browser behaviour. Returns `true` if a scroll target was
711
+ /// found.
712
+ pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
713
+ self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Auto)
714
+ }
715
+
716
+ /// Like [`BaseDocument::scroll_to_fragment`], but animates the viewport towards the
717
+ /// target instead of jumping instantly. Returns `true` if a scroll target was found.
718
+ pub fn scroll_to_fragment_smooth(&mut self, fragment: &str) -> bool {
719
+ self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Smooth)
720
+ }
721
+
722
+ pub fn resolve_scroll_animation(&mut self) {
723
+ match &mut self.scroll_animation {
724
+ ScrollAnimationState::Fling(fling_state) => {
725
+ let time_ms = SystemTime::now()
726
+ .duration_since(UNIX_EPOCH)
727
+ .unwrap()
728
+ .as_millis() as u64 as f64;
729
+
730
+ let time_diff_ms = time_ms - fling_state.last_seen_time;
731
+
732
+ // 0.95 @ 60fps normalized to actual frame times
733
+ let deceleration = 1.0 - ((0.05 / 16.66666) * time_diff_ms);
734
+
735
+ fling_state.x_velocity *= deceleration;
736
+ fling_state.y_velocity *= deceleration;
737
+ fling_state.last_seen_time = time_ms;
738
+ let fling_state = fling_state.clone();
739
+
740
+ let dx = fling_state.x_velocity * time_diff_ms;
741
+ let dy = fling_state.y_velocity * time_diff_ms;
742
+
743
+ self.scroll_chain_by(Some(fling_state.target), dx, dy, &mut |_| {});
744
+ if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
745
+ self.scroll_animation = ScrollAnimationState::None;
746
+ }
747
+ }
748
+ ScrollAnimationState::ScrollTo(scroll_to) => {
749
+ let scroll_to = scroll_to.clone();
750
+ let time_ms = SystemTime::now()
751
+ .duration_since(UNIX_EPOCH)
752
+ .unwrap()
753
+ .as_millis() as u64 as f64;
754
+
755
+ // Normalised progress through the animation, clamped to [0, 1].
756
+ let progress = if scroll_to.duration <= 0.0 {
757
+ 1.0
758
+ } else {
759
+ ((time_ms - scroll_to.start_time) / scroll_to.duration).clamp(0.0, 1.0)
760
+ };
761
+ let eased = ease_in_out_cubic(progress);
762
+
763
+ // Interpolate the target offset and move to it.
764
+ let target = Point {
765
+ x: scroll_to.start.x + (scroll_to.end.x - scroll_to.start.x) * eased,
766
+ y: scroll_to.start.y + (scroll_to.end.y - scroll_to.start.y) * eased,
767
+ };
768
+ // TODO: dispatch `scroll` events for programmatic scrolls.
769
+ self.write_scroll_offset(scroll_to.target, target, &mut |_| {});
770
+
771
+ if progress >= 1.0 {
772
+ self.scroll_animation = ScrollAnimationState::None;
773
+ }
774
+ }
775
+ ScrollAnimationState::None => {
776
+ // Do nothing
777
+ }
778
+ }
779
+ }
780
+ }