diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-09-04 20:22:52 +0300 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-09-04 20:22:52 +0300 |
| commit | 12b7a3a7910eb371603f6a0754a4e5829f01ee66 (patch) | |
| tree | 150fb8e6238c25be129a39e2cadcb9e8d2fdf5d1 | |
| parent | f645f381b95e458680ca6d64ce0d810fbe579d12 (diff) | |
| parent | a56bf42f66820df36ac2b9c01d4fe2bfb4ad2d86 (diff) | |
| download | mullvadvpn-12b7a3a7910eb371603f6a0754a4e5829f01ee66.tar.xz mullvadvpn-12b7a3a7910eb371603f6a0754a4e5829f01ee66.zip | |
Merge branch 'draggable-scrollbars'
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | gui/packages/desktop/src/renderer/components/CustomScrollbars.css | 35 | ||||
| -rw-r--r-- | gui/packages/desktop/src/renderer/components/CustomScrollbars.js | 223 |
3 files changed, 223 insertions, 36 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c38c6fe367..720dac928a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Line wrap the file at 100 chars. Th - Enter a "blocked" state in case of connection error, relay server unavailability or invalid configuration, which prevents leaking traffic until the user specifically requests to disconnect. - Add support for Ubuntu 14.04 and other distributions that use the Upstart init system. +- Make scrollbar thumb draggable. #### Windows - Extend uninstaller to also remove logs, cache and optionally settings. diff --git a/gui/packages/desktop/src/renderer/components/CustomScrollbars.css b/gui/packages/desktop/src/renderer/components/CustomScrollbars.css index 4604b1e413..8e5e2c9b11 100644 --- a/gui/packages/desktop/src/renderer/components/CustomScrollbars.css +++ b/gui/packages/desktop/src/renderer/components/CustomScrollbars.css @@ -13,18 +13,47 @@ display: none; } +.custom-scrollbars__track { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 16px; + background-color: rgba(0, 0, 0, 0.1); + opacity: 0; + transition: width 0.1s ease-in-out, opacity 0.25s ease-in-out; + z-index: 98; + pointer-events: none; +} + +.custom-scrollbars__track--visible { + opacity: 1; + pointer-events: all; +} + .custom-scrollbars__thumb { background-color: rgba(255, 255, 255, 0.2); border-radius: 4px; width: 8px; - transition: height 0.25s ease-in-out, opacity 0.25s ease-in-out; - pointer-events: none; + transition: width 0.25s ease-in-out, border-radius 0.25s ease-in-out, height 0.25s ease-in-out, + opacity 0.25s ease-in-out; opacity: 0; z-index: 99; + pointer-events: none; +} + +.custom-scrollbars__thumb--wide { + width: 12px; + border-radius: 6px; +} + +.custom-scrollbars__thumb--active { + background-color: rgba(255, 255, 255, 0.4); } .custom-scrollbars__thumb--visible { /* thumb appears without animation */ - transition: height 0.25s ease-in-out; + transition: width 0.25s ease-in-out, border-radius 0.25s ease-in-out, height 0.25s ease-in-out, + background-color 0.1s ease-in-out; opacity: 1; } diff --git a/gui/packages/desktop/src/renderer/components/CustomScrollbars.js b/gui/packages/desktop/src/renderer/components/CustomScrollbars.js index a149faff75..db7613fb6b 100644 --- a/gui/packages/desktop/src/renderer/components/CustomScrollbars.js +++ b/gui/packages/desktop/src/renderer/components/CustomScrollbars.js @@ -11,13 +11,21 @@ const AUTOHIDE_TIMEOUT = 1000; type Props = { autoHide: boolean, - thumbInset: { x: number, y: number }, + trackPadding: { x: number, y: number }, children?: React.Node, }; type State = { canScroll: boolean, showScrollIndicators: boolean, + showTrack: boolean, + isTrackHovered: boolean, + isDragging: boolean, + dragStart: { + x: number, + y: number, + }, + isWide: boolean, }; type ScrollPosition = 'top' | 'bottom' | 'middle'; @@ -25,20 +33,26 @@ type ScrollPosition = 'top' | 'bottom' | 'middle'; export default class CustomScrollbars extends React.Component<Props, State> { static defaultProps = { autoHide: true, - thumbInset: { x: 2, y: 2 }, + trackPadding: { x: 2, y: 2 }, }; state = { canScroll: false, showScrollIndicators: true, + showTrack: false, + isTrackHovered: false, + isDragging: false, + dragStart: { x: 0, y: 0 }, + isWide: false, }; - _scrollableElement: ?HTMLElement; - _thumbElement: ?HTMLElement; + _scrollableRef = React.createRef(); + _trackRef = React.createRef(); + _thumbRef = React.createRef(); _autoHideTimer: ?TimeoutID; scrollTo(x: number, y: number) { - const scrollable = this._scrollableElement; + const scrollable = this._scrollableRef.current; if (scrollable) { scrollable.scrollLeft = x; scrollable.scrollTop = y; @@ -46,7 +60,7 @@ export default class CustomScrollbars extends React.Component<Props, State> { } scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) { - const scrollable = this._scrollableElement; + const scrollable = this._scrollableRef.current; if (scrollable) { // throw if child is not a descendant of scroll view if (!scrollable.contains(child)) { @@ -66,6 +80,10 @@ export default class CustomScrollbars extends React.Component<Props, State> { size: true, }); + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + document.addEventListener('mousedown', this.handleMouseDown); + // show scroll indicators briefly when mounted if (this.props.autoHide) { this._startAutoHide(); @@ -74,6 +92,10 @@ export default class CustomScrollbars extends React.Component<Props, State> { componentWillUnmount() { this._stopAutoHide(); + + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + document.removeEventListener('mousedown', this.handleMouseDown); } componentDidUpdate() { @@ -83,44 +105,165 @@ export default class CustomScrollbars extends React.Component<Props, State> { }); } + handleEnterTrack = () => { + this._stopAutoHide(); + this.setState({ + isTrackHovered: true, + showScrollIndicators: true, + showTrack: true, + isWide: true, + }); + }; + + handleLeaveTrack = () => { + this.setState({ + isTrackHovered: false, + }); + + // do not hide the scrollbar if user is dragging a thumb but left the track area. + if (this.props.autoHide && !this.state.isDragging) { + this._startAutoHide(); + } + }; + + handleMouseDown = (event: MouseEvent) => { + const thumb = this._thumbRef.current; + const cursorPosition = { + x: event.clientX, + y: event.clientY, + }; + + // initiate dragging when user clicked inside of thumb + if (thumb && this._isPointInsideOfElement(thumb, cursorPosition)) { + this.setState({ + isDragging: true, + dragStart: this._getPointRelativeToElement(thumb, cursorPosition), + }); + } + }; + + handleMouseUp = (event: MouseEvent) => { + if (!this.state.isDragging) { + return; + } + + this.setState({ + isDragging: false, + }); + + const track = this._trackRef.current; + if (track) { + // Make sure to auto-hide the scrollbar if cursor ended up outside of scroll track + const cursorPosition = { + x: event.clientX, + y: event.clientY, + }; + + if (this.props.autoHide && !this._isPointInsideOfElement(track, cursorPosition)) { + this._startAutoHide(); + } + } + }; + + handleMouseMove = (event: MouseEvent) => { + const scrollable = this._scrollableRef.current; + const thumb = this._thumbRef.current; + const track = this._trackRef.current; + + const cursorPosition = { + x: event.clientX, + y: event.clientY, + }; + + if (this.state.isDragging && scrollable && thumb) { + // the content height of the scroll view + const scrollHeight = scrollable.scrollHeight; + + // the visible height of the scroll view + const visibleHeight = scrollable.offsetHeight; + + // lowest point of scrollTop + const maxScrollTop = scrollHeight - visibleHeight; + + // Map absolute cursor coordinate to point in scroll container + const pointInScrollContainer = this._getPointRelativeToElement(scrollable, cursorPosition); + + // calculate the thumb boundary to make sure that the visual appearance of + // a thumb at the lowest point matches the bottom of scrollable view + const thumbBoundary = this._computeTrackLength(scrollable) - thumb.clientHeight; + const thumbTop = + pointInScrollContainer.y - this.state.dragStart.y - this.props.trackPadding.y; + const newScrollTop = (thumbTop / thumbBoundary) * maxScrollTop; + + scrollable.scrollTop = newScrollTop; + } + + if (scrollable && track) { + const intersectsTrack = this._isPointInsideOfElement(track, cursorPosition); + + if (!this.state.isTrackHovered && intersectsTrack) { + this.handleEnterTrack(); + } else if (this.state.isTrackHovered && !intersectsTrack) { + this.handleLeaveTrack(); + } + } + }; + render() { - const { autoHide: _autoHide, thumbInset: _thumbInset, children, ...otherProps } = this.props; + const { + autoHide: _autoHide, + trackPadding: _trackPadding, + children, + ...otherProps + } = this.props; const showScrollbars = this.state.canScroll && this.state.showScrollIndicators; const thumbAnimationClass = showScrollbars ? ' custom-scrollbars__thumb--visible' : ''; + const thumbActiveClass = + this.state.isTrackHovered || this.state.isDragging ? ' custom-scrollbars__thumb--active' : ''; + const thumbWideClass = this.state.isWide ? ' custom-scrollbars__thumb--wide' : ''; + const trackClass = + showScrollbars && this.state.showTrack ? ' custom-scrollbars__track--visible' : ''; + return ( <div {...otherProps} className="custom-scrollbars"> + <div className={`custom-scrollbars__track ${trackClass}`} ref={this._trackRef} /> <div - className={`custom-scrollbars__thumb ${thumbAnimationClass}`} + className={`custom-scrollbars__thumb ${thumbWideClass} ${thumbActiveClass} ${thumbAnimationClass}`} style={{ position: 'absolute', top: 0, right: 0 }} - ref={this._onThumbRef} + ref={this._thumbRef} /> <div className="custom-scrollbars__scrollable" style={{ overflow: 'auto' }} onScroll={this._onScroll} - ref={this._onScrollableRef}> + ref={this._scrollableRef}> {children} </div> </div> ); } - _onScrollableRef = (ref) => { - this._scrollableElement = ref; - }; - - _onThumbRef = (ref) => { - this._thumbElement = ref; - }; - _onScroll = () => { this._updateScrollbarsHelper({ position: true }); if (this.props.autoHide) { - this._startAutoHide(); + this._ensureScrollbarsVisible(); + + // only auto-hide when scrolling with mousewheel + if (!this.state.isDragging) { + this._startAutoHide(); + } } }; + _ensureScrollbarsVisible() { + if (!this.state.showScrollIndicators) { + this.setState({ + showScrollIndicators: true, + }); + } + } + _startAutoHide() { if (this._autoHideTimer) { clearTimeout(this._autoHideTimer); @@ -129,14 +272,10 @@ export default class CustomScrollbars extends React.Component<Props, State> { this._autoHideTimer = setTimeout(() => { this.setState({ showScrollIndicators: false, + showTrack: false, + isWide: false, }); }, AUTOHIDE_TIMEOUT); - - if (!this.state.showScrollIndicators) { - this.setState({ - showScrollIndicators: true, - }); - } } _stopAutoHide() { @@ -146,6 +285,25 @@ export default class CustomScrollbars extends React.Component<Props, State> { } } + _isPointInsideOfElement(element: HTMLElement, point: { x: number, y: number }) { + const rect = element.getBoundingClientRect(); + return ( + point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom + ); + } + + _getPointRelativeToElement(element: HTMLElement, point: { x: number, y: number }) { + const rect = element.getBoundingClientRect(); + return { + x: point.x - rect.left, + y: point.y - rect.top, + }; + } + + _computeTrackLength(scrollable: HTMLElement) { + return scrollable.offsetHeight - this.props.trackPadding.y * 2; + } + // Computes the position of child element within scrollable container _computeOffsetTop(scrollable: HTMLElement, child: HTMLElement) { let offsetTop = 0; @@ -196,18 +354,16 @@ export default class CustomScrollbars extends React.Component<Props, State> { // calculate scroll position within 0..1 range const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0; - const thumbHeight = thumb.clientHeight; - // calculate the thumb boundary to make sure that the visual appearance of - // a thumb at lowest point matches the bottom of scrollable view - const thumbBoundary = visibleHeight - thumbHeight - this.props.thumbInset.y * 2; + // a thumb at the lowest point matches the bottom of scrollable view + const thumbBoundary = this._computeTrackLength(scrollable) - thumb.clientHeight; // calculate thumb position based on scroll progress and thumb boundary // adding vertical inset to adjust the thumb's appearance - const thumbPosition = thumbBoundary * scrollPosition + this.props.thumbInset.y; + const thumbPosition = thumbBoundary * scrollPosition + this.props.trackPadding.y; return { - x: -this.props.thumbInset.x, + x: -this.props.trackPadding.x, y: thumbPosition, }; } @@ -223,8 +379,8 @@ export default class CustomScrollbars extends React.Component<Props, State> { } _updateScrollbarsHelper(updateFlags: $Shape<ScrollbarUpdateContext>) { - const scrollable = this._scrollableElement; - const thumb = this._thumbElement; + const scrollable = this._scrollableRef.current; + const thumb = this._thumbRef.current; if (scrollable && thumb) { this._updateScrollbars(scrollable, thumb, updateFlags); } @@ -247,6 +403,7 @@ export default class CustomScrollbars extends React.Component<Props, State> { // flash the scroll indicators when the view becomes scrollable if (this.props.autoHide && canScroll) { this._startAutoHide(); + this._ensureScrollbarsVisible(); } } } |
