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