summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-09-04 20:22:52 +0300
committerAndrej Mihajlov <and@mullvad.net>2018-09-04 20:22:52 +0300
commit12b7a3a7910eb371603f6a0754a4e5829f01ee66 (patch)
tree150fb8e6238c25be129a39e2cadcb9e8d2fdf5d1
parentf645f381b95e458680ca6d64ce0d810fbe579d12 (diff)
parenta56bf42f66820df36ac2b9c01d4fe2bfb4ad2d86 (diff)
downloadmullvadvpn-12b7a3a7910eb371603f6a0754a4e5829f01ee66.tar.xz
mullvadvpn-12b7a3a7910eb371603f6a0754a4e5829f01ee66.zip
Merge branch 'draggable-scrollbars'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/packages/desktop/src/renderer/components/CustomScrollbars.css35
-rw-r--r--gui/packages/desktop/src/renderer/components/CustomScrollbars.js223
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();
}
}
}