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