Skip to main content

perspective_viewer/components/containers/
scroll_panel.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13// Forked from https://github.com/AircastDev/yew-virtual-scroller (Apache 2.0)
14// Adds support for Yew 0.19, auto-width and a simplified message structure.
15
16use std::ops::Range;
17use std::rc::Rc;
18
19use itertools::Itertools;
20use web_sys::Element;
21use yew::prelude::*;
22use yew::virtual_dom::VChild;
23
24use super::scroll_panel_item::ScrollPanelItem;
25use crate::utils::*;
26
27#[derive(Properties)]
28pub struct ScrollPanelProps {
29    #[prop_or_default]
30    pub children: Vec<VChild<ScrollPanelItem>>,
31
32    #[prop_or_default]
33    pub viewport_ref: Option<NodeRef>,
34
35    #[prop_or_default]
36    pub initial_width: Option<f64>,
37
38    #[prop_or_default]
39    pub class: Classes,
40
41    #[prop_or_default]
42    pub id: &'static str,
43
44    #[prop_or_default]
45    pub dragenter: Callback<DragEvent>,
46
47    #[prop_or_default]
48    pub dragover: Callback<DragEvent>,
49
50    #[prop_or_default]
51    pub dragleave: Callback<DragEvent>,
52
53    #[prop_or_default]
54    pub on_resize: Option<Rc<PubSub<()>>>,
55
56    #[prop_or_default]
57    pub on_auto_width: Callback<f64>,
58
59    #[prop_or_default]
60    pub on_dimensions_reset: Option<Rc<PubSub<()>>>,
61
62    #[prop_or_default]
63    pub drop: Callback<DragEvent>,
64
65    #[prop_or_default]
66    pub omit_autosize_div: bool,
67}
68
69impl ScrollPanelProps {
70    /// Calculate the total virtual height of this scroll panel from the `size`
71    /// prop of its children.
72    fn total_height(&self) -> f64 {
73        self.children
74            .iter()
75            .map(|x| x.props.get_size())
76            .reduce(|x, y| x + y)
77            .unwrap_or_default()
78    }
79}
80
81impl PartialEq for ScrollPanelProps {
82    fn eq(&self, _rhs: &Self) -> bool {
83        false
84    }
85}
86
87#[doc(hidden)]
88pub enum ScrollPanelMsg {
89    CalculateWindowContent,
90    UpdateViewportDimensions,
91    ResetAutoWidth,
92    ChildrenChanged,
93}
94
95pub struct ScrollPanel {
96    viewport_ref: NodeRef,
97    viewport_height: f64,
98    viewport_width: f64,
99    content_window: Option<ContentWindow>,
100    needs_rerender: bool,
101    total_height: f64,
102    _dimensions_reset_sub: Option<Subscription>,
103    _resize_sub: Option<Subscription>,
104}
105
106impl Component for ScrollPanel {
107    type Message = ScrollPanelMsg;
108    type Properties = ScrollPanelProps;
109
110    fn create(ctx: &Context<Self>) -> Self {
111        let _dimensions_reset_sub = ctx.props().on_dimensions_reset.as_ref().map(|pubsub| {
112            let link = ctx.link().clone();
113            pubsub.add_listener(move |_| {
114                link.send_message_batch(vec![
115                    ScrollPanelMsg::ResetAutoWidth,
116                    ScrollPanelMsg::CalculateWindowContent,
117                ])
118            })
119        });
120
121        let _resize_sub = ctx.props().on_resize.as_ref().map(|pubsub| {
122            let link = ctx.link().clone();
123            pubsub.add_listener(move |_| {
124                link.send_message_batch(vec![
125                    ScrollPanelMsg::UpdateViewportDimensions,
126                    ScrollPanelMsg::CalculateWindowContent,
127                ])
128            })
129        });
130
131        let total_height = ctx.props().total_height();
132        Self {
133            viewport_ref: Default::default(),
134            viewport_height: 0f64,
135            viewport_width: ctx.props().initial_width.unwrap_or_default(),
136            content_window: None,
137            needs_rerender: true,
138            total_height,
139            _dimensions_reset_sub,
140            _resize_sub,
141        }
142    }
143
144    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
145        match msg {
146            ScrollPanelMsg::ResetAutoWidth => {
147                self.viewport_width = 0.0;
148                self.calculate_window_content(ctx)
149            },
150
151            // TODO don't hardcode pixel offsets - fix the CSS container to not
152            // require pixel offsets to work.
153            ScrollPanelMsg::UpdateViewportDimensions => {
154                let viewport = self.viewport_elem(ctx);
155                let rect = viewport.get_bounding_client_rect();
156                let viewport_height = rect.height() - 8.0;
157                let scroll_offset = if ctx.props().omit_autosize_div {
158                    0.0
159                } else {
160                    6.0
161                };
162
163                let viewport_width = self.viewport_width.max(rect.width() - scroll_offset);
164                let re_render = self.viewport_height != viewport_height
165                    || self.viewport_width != viewport_width;
166
167                self.viewport_height = rect.height() - 8.0;
168                self.viewport_width = self.viewport_width.max(rect.width() - scroll_offset);
169                ctx.props().on_auto_width.emit(self.viewport_width);
170                re_render
171            },
172            ScrollPanelMsg::CalculateWindowContent => self.calculate_window_content(ctx),
173            ScrollPanelMsg::ChildrenChanged => true,
174        }
175    }
176
177    /// If the new total row height is different than last time this component
178    /// was rendered, we need to double-render to read the container's
179    /// potentially updated height.
180    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
181        let total_height = ctx.props().total_height();
182        self.needs_rerender =
183            self.needs_rerender || (self.total_height - total_height).abs() > 0.1f64;
184        self.total_height = total_height;
185        ctx.link().send_message_batch(vec![
186            ScrollPanelMsg::UpdateViewportDimensions,
187            ScrollPanelMsg::CalculateWindowContent,
188            ScrollPanelMsg::ChildrenChanged,
189        ]);
190
191        false
192    }
193
194    fn view(&self, ctx: &Context<Self>) -> Html {
195        let content_style = format!("height:{}px", self.total_height);
196        let (window_style, windowed_items) = match &self.content_window {
197            None => ("".to_string(), &[][..]),
198            Some(cw) => (
199                format!(
200                    "position:sticky;top:0;transform:translateY({}px);",
201                    cw.start_y - cw.scroll_top
202                ),
203                (&ctx.props().children[cw.visible_range.clone()]),
204            ),
205        };
206
207        let width_style = format!("width:{}px", self.viewport_width.max(0.0));
208        let items = if !windowed_items.is_empty() {
209            let onscroll = ctx.link().batch_callback(|_| {
210                vec![
211                    ScrollPanelMsg::UpdateViewportDimensions,
212                    ScrollPanelMsg::CalculateWindowContent,
213                ]
214            });
215
216            // TODO This glitches - we should use the `sticky` positioning strategy that
217            // `regular-table` uses.
218            html! {
219                <div
220                    ref={self.viewport(ctx)}
221                    id={ctx.props().id}
222                    {onscroll}
223                    ondragover={&ctx.props().dragover}
224                    ondragenter={&ctx.props().dragenter}
225                    ondragleave={&ctx.props().dragleave}
226                    ondrop={&ctx.props().drop}
227                    class={ctx.props().class.clone()}
228                >
229                    <div class="scroll-panel-container" style={window_style}>
230                        { for windowed_items.iter().cloned().map(Html::from) }
231                        if !ctx.props().omit_autosize_div {
232                            <div
233                                key="__scroll-panel-auto-width__"
234                                class="scroll-panel-auto-width"
235                                style={width_style}
236                            />
237                        }
238                    </div>
239                    <div class="scroll-panel-content" style={content_style} />
240                </div>
241            }
242        } else {
243            html! {
244                <div
245                    ref={self.viewport(ctx)}
246                    id={ctx.props().id}
247                    class={ctx.props().class.clone()}
248                >
249                    <div style={content_style} />
250                </div>
251            }
252        };
253
254        html! { <>{ items }</> }
255    }
256
257    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
258        ctx.link().send_message_batch(vec![
259            ScrollPanelMsg::UpdateViewportDimensions,
260            ScrollPanelMsg::CalculateWindowContent,
261        ]);
262    }
263}
264
265impl ScrollPanel {
266    fn viewport<'a, 'b: 'a, 'c: 'a>(&'b self, ctx: &'c Context<Self>) -> &'a NodeRef {
267        ctx.props()
268            .viewport_ref
269            .as_ref()
270            .unwrap_or(&self.viewport_ref)
271    }
272
273    fn viewport_elem(&self, ctx: &Context<Self>) -> Element {
274        self.viewport(ctx).cast::<Element>().unwrap()
275    }
276}
277
278#[derive(PartialEq)]
279struct ContentWindow {
280    scroll_top: f64,
281    start_y: f64,
282    visible_range: Range<usize>,
283}
284
285impl ScrollPanel {
286    fn calculate_window_content(&mut self, ctx: &Context<Self>) -> bool {
287        let viewport = self.viewport_elem(ctx);
288        let scroll_top = viewport.scroll_top() as f64;
289        let mut start_node = 0;
290        let mut start_y = 0_f64;
291        let mut offset = 0_f64;
292        let end_node = ctx
293            .props()
294            .children
295            .iter()
296            .enumerate()
297            .find_or_last(|(i, x)| {
298                if offset + x.props.get_size() < scroll_top {
299                    start_node = *i + 1;
300                    start_y = offset + x.props.get_size();
301                }
302
303                offset += x.props.get_size();
304                offset > scroll_top + self.viewport_height
305            })
306            .map(|x| x.0)
307            .unwrap_or_default();
308
309        // Why is this `end_node + 2`, I can see you asking yourself? `end_node` is the
310        // index of the last visible child, but [`Range`] is an open interval so we must
311        // increment by 1. The next rendered element is always occluded by the parent
312        // container, it may seem unnecessary to render it, however not doing so causing
313        // scroll glitching in Chrome:
314        // * When the first pixel of the `end_node + 1` child is scrolled into view, the
315        //   container element it is embedded in will expand past the end of the scroll
316        //   container.
317        // * Chrome detects this and helpfully scrolls this new element into view,
318        //   re-triggering the on scroll callback.
319        let visible_range = start_node..ctx.props().children.len().min(end_node + 2);
320        let content_window = Some(ContentWindow {
321            scroll_top,
322            start_y,
323            visible_range,
324        });
325
326        let re_render = self.content_window != content_window;
327        self.content_window = content_window;
328        re_render
329    }
330}