1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
use crate::*;
/// Encapsulated access to the global pending measurement set.
impl PendingMeasureCell {
/// Returns a mutable reference to the set of pending container ids.
///
/// Lazily initializes the set on first access.
#[allow(static_mut_refs)]
fn get_mut() -> &'static mut HashSet<String> {
unsafe {
let slot: &mut Option<HashSet<String>> = &mut *PENDING_MEASURE_BY_ID.get_mut_0().get();
slot.get_or_insert_with(HashSet::new)
}
}
}
/// Implementation of virtual list functionality.
impl UseVirtualList {
/// Creates virtual list state signals for tracking scroll offset and viewport height.
///
/// # Returns
///
/// - `UseVirtualList`: The virtual list state containing scroll offset and viewport height signals.
pub fn use_scroll_state() -> UseVirtualList {
UseVirtualList::new(App::use_signal(|| 0), App::use_signal(|| 0))
}
/// Creates a scroll event handler that tracks the container scroll position and viewport height.
///
/// Reads `scrollTop` and `clientHeight` from the scroll container element
/// referenced by `VIRTUAL_LIST_CONTAINER_ID` and updates the corresponding signals.
///
/// # Returns
///
/// - `Option<Rc<dyn Fn(Event)>>`: A scroll handler for the virtual list container.
pub fn on_scroll(self) -> Option<Rc<dyn Fn(Event)>> {
Some(Rc::new(move |_: Event| {
if let Some(container) = Self::try_get_container() {
let html_element: HtmlElement = container.unchecked_into();
self.get_scroll_offset().set(html_element.scroll_top());
self.get_viewport_height().set(html_element.client_height());
}
}))
}
/// Reads the container `clientHeight` and writes it to the viewport height signal.
///
/// Should only be called when the DOM is already present (e.g. inside a resize
/// callback or after the first paint). For initial mount use
/// `schedule_measure` instead.
pub fn update_viewport_height(self) {
if let Some(container) = Self::try_get_container() {
let html_element: HtmlElement = container.unchecked_into();
self.get_viewport_height().set(html_element.client_height());
}
}
/// Schedules a viewport height measurement on the next animation frame.
///
/// Uses an atomic guard to ensure only one measurement is pending at a time —
/// if a previous callback hasn't fired yet, subsequent calls are silently
/// ignored. This prevents accumulating redundant animation-frame callbacks
/// when the component re-renders frequently.
pub fn schedule_measure(self) {
if PENDING_MEASURE.swap(true, Ordering::Relaxed) {
return;
}
let callback: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
PENDING_MEASURE.store(false, Ordering::Relaxed);
self.update_viewport_height();
}));
window()
.expect("no global window exists")
.request_animation_frame(callback.as_ref().unchecked_ref())
.expect("failed to request animation frame");
callback.forget();
}
/// Schedules a viewport height measurement on the next animation frame for a specific container.
///
/// Uses an atomic set guard keyed by `container_id` to ensure only one pending
/// measurement exists per container — if a callback for the same id hasn't
/// fired yet, subsequent calls are silently ignored. This prevents accumulating
/// redundant animation-frame callbacks when the component re-renders frequently.
///
/// # Arguments
///
/// - `&str`: The container element id.
pub(crate) fn schedule_measure_by_id(self, container_id: &str) {
let set: &mut HashSet<String> = PendingMeasureCell::get_mut();
if !set.insert(container_id.to_string()) {
return;
}
let id: String = container_id.to_string();
let callback: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
PendingMeasureCell::get_mut().remove(&id);
if let Some(element) = Self::try_get_container_by_id(&id) {
let html_element: HtmlElement = element.unchecked_into();
self.get_viewport_height().set(html_element.client_height());
}
}));
window()
.expect("no global window exists")
.request_animation_frame(callback.as_ref().unchecked_ref())
.expect("failed to request animation frame");
callback.forget();
}
/// Returns the virtual list container element by its default id.
///
/// # Returns
///
/// - `Option<Element>`: The container element, if found in the document.
pub fn try_get_container() -> Option<Element> {
window()
.expect("no global window exists")
.document()
.expect("should have a document")
.get_element_by_id(VIRTUAL_LIST_CONTAINER_ID)
}
/// Returns the virtual list container element by its id.
///
/// # Arguments
///
/// - `&str`: The container element id.
///
/// # Returns
///
/// - `Option<Element>`: The container element, if found in the document.
pub fn try_get_container_by_id(container_id: &str) -> Option<Element> {
window()
.expect("no global window exists")
.document()
.expect("should have a document")
.get_element_by_id(container_id)
}
/// Computes the range of visible item indices for the virtual list.
///
/// Calculates the start and end indices based on the current scroll offset,
/// viewport height, fixed item height, and total item count. Includes an
/// overscan buffer to reduce blank areas during fast scrolling.
///
/// # Arguments
///
/// - `i32`: The current scroll offset in pixels.
/// - `i32`: The current viewport height in pixels.
/// - `usize`: The total number of items in the list.
/// - `i32`: The fixed height of each item in pixels.
/// - `usize`: The number of overscan items to render beyond the viewport.
///
/// # Returns
///
/// - `(usize, usize, usize, usize)`: A tuple of (visible_start, visible_end, render_start, render_end).
/// visible_start/visible_end represent the actual visible range without overscan.
/// render_start/render_end represent the rendering range including overscan.
pub(crate) fn compute_visible_range(
scroll_offset: i32,
viewport_height: i32,
total_count: usize,
item_height: i32,
overscan_count: usize,
) -> (usize, usize, usize, usize) {
let visible_start: usize = (scroll_offset / item_height).max(0) as usize;
let visible_count: usize = if viewport_height > 0 {
let viewport_bottom: i32 = scroll_offset + viewport_height;
let visible_end: usize =
((viewport_bottom + item_height - 1) / item_height).max(0) as usize;
visible_end - visible_start
} else {
VIRTUAL_LIST_DEFAULT_VISIBLE_COUNT
};
let visible_end: usize = (visible_start + visible_count).min(total_count);
let render_start: usize = visible_start.saturating_sub(overscan_count);
let render_end: usize = (visible_end + overscan_count).min(total_count);
(visible_start, visible_end, render_start, render_end)
}
}