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