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