/**
* CustomScrollbar.ts
*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT license.
*
* Custom scrollbar implementation for web.
*/
import * as React from 'react';
import assert from '../common/assert';
import Timers from '../common/utils/Timers';
const UNIT = 'px';
const SCROLLER_MIN_SIZE = 15;
const SCROLLER_NEGATIVE_MARGIN = 30;
const NEUTRAL_OVERRIDE_CLASS = 'neutraloverride';
interface ScrollbarInfo {
size?: number;
scrollSize?: number;
scroll2Slider?: number;
slider2Scroll?: number;
sliderSize?: number;
dragOffset?: number;
rail?: HTMLElement;
slider?: HTMLElement;
}
export interface ScrollbarOptions {
horizontal?: boolean;
vertical?: boolean;
hiddenScrollbar?: boolean;
}
let _nativeSrollBarWidth = -1;
let _isStyleSheetInstalled = false;
const _customScrollbarCss = `
.rxCustomScroll .scrollViewport > * {
box-sizing: border-box;
display: block;
}
.rxCustomScroll .rail {
position: absolute;
border-radius: 4px;
opacity: 0;
background-color: transparent;
transition-delay: 0, 0;
transition-duration: .2s, .2s;
transition-property: background-color, opacity;
transition-timing-function: linear, linear;
display: none;
box-sizing: border-box;
}
.rxCustomScroll .rail:hover {
background-color: #EEE;
border-color: #EEE;
opacity: .9;
border-radius: 6px;
}
.rxCustomScroll .rail:hover .slider {
border-radius: 6px;
}
.rxCustomScroll .rail .slider {
position: absolute;
border-radius: 4px;
background: #555;
box-sizing: border-box;
border: 1px solid #555;
}
.rxCustomScroll:not(.neutraloverride) > .scrollViewportV > * {
margin-right: em(-17px) !important;
}
.rxCustomScroll .railV {
top: 0;
bottom: 0;
right: 3px;
width: 8px;
}
.rxCustomScroll .railV .slider {
top: 10px;
width: 8px;
min-height: 15px;
}
.rxCustomScroll .railV.railBoth {
bottom: 15px;
}
.rxCustomScroll .railH {
left: 0;
right: 0;
bottom: 3px;
height: 8px;
}
.rxCustomScroll .railH .slider {
left: 10px;
top: -1px;
height: 8px;
min-width: 15px;
}
.rxCustomScroll .railH.railBoth {
right: 15px;
}
.rxCustomScroll.active .rail {
display: block;
}
.rxCustomScroll:hover .rail {
opacity: .6;
}
.rxCustomScroll:hover .rail .slider {
background: #AAA;
border-color: #AAA;
}
.rxCustomScroll.rxCustomScrollH {
width: auto;
}
.rxCustomScroll.rxCustomScrollV {
width: 100%;
}
.rxCustomScroll.scrolling .rail {
background-color: #EEE;
border-color: #EEE;
opacity: .9;
border-radius: 6px;
}
.rxCustomScroll.scrolling .rail .slider {
border-radius: 6px;
background: #AAA;
border-color: #AAA;
}
.rxCustomScroll.scrolling .scrollViewport > * {
pointer-events: none !important;
}
.rxCustomScroll.scrolling .railV {
width: 12px;
}
.rxCustomScroll.scrolling .railV .slider {
width: 12px;
}
.rxCustomScroll.scrolling .railH {
height: 12px;
}
.rxCustomScroll.scrolling .railH .slider {
height: 12px;
}
.rxCustomScroll .railV:hover {
width: 12px;
}
.rxCustomScroll .railV:hover .slider {
width: 12px;
}
.rxCustomScroll .railH:hover {
height: 12px;
}
.rxCustomScroll .railH:hover .slider {
height: 12px;
}
`;
export class Scrollbar {
private _container: HTMLElement;
private _verticalBar: ScrollbarInfo = {};
private _horizontalBar: ScrollbarInfo = {};
// Viewport will always be initialized before it's used
private _viewport!: HTMLElement;
private _dragging = false;
private _dragIsVertical = false;
private _scrollingVisible = false;
private _hasHorizontal = false;
private _hasVertical = true;
private _hasHiddenScrollbar = false;
private _stopDragCallback = this._stopDrag.bind(this);
private _startDragVCallback = this._startDrag.bind(this, true);
private _startDragHCallback = this._startDrag.bind(this, false);
private _handleDragCallback = this._handleDrag.bind(this);
private _handleWheelCallback = this._handleWheel.bind(this);
private _handleMouseDownCallback = this._handleMouseDown.bind(this);
private _updateCallback = this.update.bind(this);
private _asyncInitTimer: number | undefined;
static getNativeScrollbarWidth() {
// Have we cached the value alread?
if (_nativeSrollBarWidth >= 0) {
return _nativeSrollBarWidth;
}
const inner = document.createElement('p');
inner.style.width = '100%';
inner.style.height = '100%';
const outer = document.createElement('div');
outer.style.position = 'absolute';
outer.style.top = '0';
outer.style.left = '0';
outer.style.visibility = 'hidden';
outer.style.width = '100px';
outer.style.height = '100px';
outer.style.overflow = 'hidden';
outer.appendChild(inner);
document.body.appendChild(outer);
const w1 = inner.offsetWidth;
outer.style.overflow = 'scroll';
let w2 = inner.offsetWidth;
if (w1 === w2) {
w2 = outer.clientWidth;
}
document.body.removeChild(outer);
_nativeSrollBarWidth = w1 - w2;
return _nativeSrollBarWidth;
}
private static _installStyleSheet() {
// Have we installed the style sheet already?
if (_isStyleSheetInstalled) {
return;
}
// We set the CSS style sheet here to avoid the need
// for users of this class to carry along another CSS
// file.
const head = document.head || document.getElementsByTagName('head')[0];
const style = document.createElement('style') as any;
style.type = 'text/css';
if (style.styleSheet) {
style.styleSheet.cssText = _customScrollbarCss;
} else {
style.appendChild(document.createTextNode(_customScrollbarCss));
}
head.appendChild(style);
_isStyleSheetInstalled = true;
}
constructor(container: HTMLElement) {
assert(container, 'Container must not be null');
this._container = container;
}
private _tryLtrOverride() {
const rtlbox = document.createElement('div');
rtlbox.style.cssText = 'position: absolute; overflow-y: scroll; width: 30px; visibility: hidden;';
// tslint:disable-next-line
rtlbox.innerHTML = '
';
this._container.appendChild(rtlbox);
const probe = rtlbox.querySelector('.probe')!;
const rtlboxRect = rtlbox.getBoundingClientRect();
const probeRect = probe.getBoundingClientRect();
const isLeftBound = rtlboxRect.left === probeRect.left;
const isRightBound = rtlboxRect.right === probeRect.right;
const isNeutral = isLeftBound && isRightBound;
this._container.classList.remove(NEUTRAL_OVERRIDE_CLASS);
if (isNeutral) {
this._container.classList.add(NEUTRAL_OVERRIDE_CLASS);
}
// tslint:disable-next-line
rtlbox.innerHTML = '';
this._container.removeChild(rtlbox);
}
private _prevent(e: React.SyntheticEvent) {
e.preventDefault();
}
private _updateSliders() {
if (this._hasHorizontal) {
// Read from DOM before we write back
const newSliderWidth = this._horizontalBar.sliderSize + UNIT;
const newSliderLeft = this._viewport.scrollLeft * this._horizontalBar.scroll2Slider! + UNIT;
this._horizontalBar.slider!.style.width = newSliderWidth;
this._horizontalBar.slider!.style.left = newSliderLeft;
}
if (this._hasVertical) {
// Read from DOM before we write back
const newSliderHeight = this._verticalBar.sliderSize + UNIT;
const newSliderTop = this._viewport.scrollTop * this._verticalBar.scroll2Slider! + UNIT;
this._verticalBar.slider!.style.height = newSliderHeight;
this._verticalBar.slider!.style.top = newSliderTop;
}
}
private _handleDrag(e: React.MouseEvent) {
if (this._dragIsVertical) {
this._viewport.scrollTop = (e.pageY - this._verticalBar.dragOffset!) * this._verticalBar.slider2Scroll!;
} else {
this._viewport.scrollLeft = (e.pageX - this._horizontalBar.dragOffset!) * this._horizontalBar.slider2Scroll!;
}
}
private _startDrag(dragIsVertical: boolean, e: React.MouseEvent) {
if (!this._dragging) {
window.addEventListener('mouseup', this._stopDragCallback);
window.addEventListener('mousemove', this._handleDragCallback);
this._container.classList.add('scrolling');
if (this._hasHorizontal) {
this._horizontalBar.dragOffset = e.pageX - this._horizontalBar.slider!.offsetLeft;
}
if (this._hasVertical) {
this._verticalBar.dragOffset = e.pageY - this._verticalBar.slider!.offsetTop;
}
this._dragging = true;
this._dragIsVertical = dragIsVertical;
}
this._prevent(e);
}
private _stopDrag() {
this._container.classList.remove('scrolling');
window.removeEventListener('mouseup', this._stopDragCallback);
window.removeEventListener('mousemove', this._handleDragCallback);
this._dragging = false;
}
private _handleWheel(e: React.WheelEvent) {
// Always prefer the vertical axis if present. User can override with the control key.
if (this._hasVertical) {
this._viewport.scrollTop = this._normalizeDelta(e) + this._viewport.scrollTop;
} else if (this._hasHorizontal) {
this._viewport.scrollLeft = this._normalizeDelta(e) + this._viewport.scrollLeft;
}
}
private _handleMouseDown(e: React.MouseEvent) {
const target = e.currentTarget;
if (this._dragging || !target) {
this._prevent(e);
return;
}
if (this._hasVertical) {
const eventOffsetY = e.pageY - target.getBoundingClientRect().top;
const halfHeight = this._verticalBar.slider!.offsetHeight / 2;
const offsetY = (eventOffsetY - this._verticalBar.slider!.offsetTop - halfHeight) * this._verticalBar.slider2Scroll!;
this._viewport.scrollTop = offsetY + this._viewport.scrollTop;
}
if (this._hasHorizontal) {
const eventOffsetX = e.pageX - target.getBoundingClientRect().left;
const halfWidth = this._horizontalBar.slider!.offsetWidth / 2;
const offsetX = (eventOffsetX - this._horizontalBar.slider!.offsetLeft - halfWidth) * this._horizontalBar.slider2Scroll!;
this._viewport.scrollLeft = offsetX + this._viewport.scrollLeft;
}
}
private _normalizeDelta(e: React.WheelEvent) {
if (e.deltaY) {
return e.deltaY > 0 ? 100 : -100;
}
const originalEvent = (e as any).originalEvent;
if (originalEvent && originalEvent.wheelDelta) {
return originalEvent.wheelDelta;
}
return 0;
}
private _addListeners() {
if (this._hasVertical) {
this._verticalBar.slider!.addEventListener('mousedown', this._startDragVCallback);
this._verticalBar.rail!.addEventListener('wheel', this._handleWheelCallback, { passive: true });
this._verticalBar.rail!.addEventListener('mousedown', this._handleMouseDownCallback);
}
if (this._hasHorizontal) {
this._horizontalBar.slider!.addEventListener('mousedown', this._startDragHCallback);
this._horizontalBar.rail!.addEventListener('wheel', this._handleWheelCallback, { passive: true });
this._horizontalBar.rail!.addEventListener('mousedown', this._handleMouseDownCallback);
}
}
private _removeListeners() {
if (this._hasVertical) {
this._verticalBar.slider!.removeEventListener('mousedown', this._startDragVCallback);
this._verticalBar.rail!.removeEventListener('wheel', this._handleWheelCallback);
this._verticalBar.rail!.removeEventListener('mousedown', this._handleMouseDownCallback);
}
if (this._hasHorizontal) {
this._horizontalBar.slider!.removeEventListener('mousedown', this._startDragHCallback);
this._horizontalBar.rail!.removeEventListener('wheel', this._handleWheelCallback);
this._horizontalBar.rail!.removeEventListener('mousedown', this._handleMouseDownCallback);
}
}
private _createDivWithClass(className: string): HTMLElement {
const div = document.createElement('div');
div.setAttribute('role', 'none');
div.className = className;
return div;
}
private _addScrollBar(scrollbarInfo: ScrollbarInfo, railClass: string, hasBoth: boolean) {
const slider = this._createDivWithClass('slider');
scrollbarInfo.rail = this._createDivWithClass('rail ' + railClass + (hasBoth ? ' railBoth' : ''));
scrollbarInfo.slider = slider;
scrollbarInfo.rail.appendChild(slider);
this._container.appendChild(scrollbarInfo.rail);
}
private _addScrollbars() {
const containerClass = this._hasVertical ? 'rxCustomScrollV' : 'rxCustomScrollH';
if (this._hasVertical) {
this._addScrollBar(this._verticalBar, 'railV', this._hasHorizontal);
}
if (this._hasHorizontal) {
this._addScrollBar(this._horizontalBar, 'railH', this._hasVertical);
}
this._container.classList.add(containerClass);
this._container.classList.add('rxCustomScroll');
this._viewport = this._container.querySelector('.scrollViewport') as HTMLElement;
}
private _removeScrollbars() {
if (this._hasVertical) {
// tslint:disable-next-line
this._verticalBar.rail!.innerHTML = '';
this._container.removeChild(this._verticalBar.rail!);
}
if (this._hasHorizontal) {
// tslint:disable-next-line
this._horizontalBar.rail!.innerHTML = '';
this._container.removeChild(this._horizontalBar.rail!);
}
}
private _calcNewBarSize(bar: ScrollbarInfo, newSize: number, newScrollSize: number, hasBoth: boolean) {
if (hasBoth || this._hasHiddenScrollbar) {
newSize -= SCROLLER_NEGATIVE_MARGIN;
newScrollSize -= SCROLLER_NEGATIVE_MARGIN - Scrollbar.getNativeScrollbarWidth();
}
if (newScrollSize !== bar.scrollSize || newSize !== bar.size) {
bar.size = newSize;
bar.scrollSize = newScrollSize;
bar.scroll2Slider = newSize / newScrollSize;
bar.sliderSize = newSize * bar.scroll2Slider;
// Don't allow the sliders to overlap.
if (hasBoth) {
bar.sliderSize = Math.max(bar.sliderSize - SCROLLER_NEGATIVE_MARGIN + Scrollbar.getNativeScrollbarWidth(), 0);
}
if (bar.sliderSize < SCROLLER_MIN_SIZE) {
const railRange = newSize - SCROLLER_MIN_SIZE + bar.sliderSize;
bar.scroll2Slider = railRange / newScrollSize;
bar.slider2Scroll = newScrollSize / railRange;
} else {
bar.slider2Scroll = newScrollSize / newSize;
}
}
}
private _resize() {
if (this._hasHorizontal) {
this._calcNewBarSize(this._horizontalBar, this._viewport.offsetWidth, this._viewport.scrollWidth, this._hasVertical);
}
if (this._hasVertical) {
this._calcNewBarSize(this._verticalBar, this._viewport.offsetHeight, this._viewport.scrollHeight, this._hasHorizontal);
}
}
update() {
this._resize();
// We add one below to provide a small fudge factor because browsers round their scroll and offset values to the
// nearest integer, and IE sometimes ends up returning a scroll and offset value that are off by one.
if ((this._verticalBar && this._verticalBar.scrollSize! > this._verticalBar.size! + 1) ||
(this._horizontalBar && this._horizontalBar.scrollSize! > this._horizontalBar.size! + 1)) {
this.show();
this._updateSliders();
} else {
this.hide();
}
}
show() {
if (!this._scrollingVisible) {
this._container.classList.add('active');
this._addListeners();
this._scrollingVisible = true;
}
}
hide() {
if (this._scrollingVisible) {
this._container.classList.remove('active');
this._removeListeners();
this._scrollingVisible = false;
}
}
init(options?: ScrollbarOptions) {
if (options) {
this._hasHorizontal = !!options.horizontal;
// Only if vertical is explicitly false as opposed to null, set it to false (default is true)
if (options.vertical === false) {
this._hasVertical = options.vertical;
}
// Our container may be scrollable even if the corresponding scrollbar is hidden (i.e. vertical
// or horizontal is false). We have to take it into account when calculating scroll bar sizes.
this._hasHiddenScrollbar = !!options.hiddenScrollbar;
}
Scrollbar._installStyleSheet();
this._addScrollbars();
this.show();
this._container.addEventListener('mouseenter', this._updateCallback);
// Defer remaining init work to avoid triggering sync layout
this._asyncInitTimer = Timers.setTimeout(() => {
this._asyncInitTimer = undefined;
this._tryLtrOverride();
this.update();
}, 0);
}
dispose() {
if (this._asyncInitTimer) {
Timers.clearInterval(this._asyncInitTimer);
this._asyncInitTimer = undefined;
}
this._stopDrag();
this._container.removeEventListener('mouseenter', this._updateCallback);
this.hide();
this._removeScrollbars();
// release DOM nodes
this._container = null!;
this._viewport = null!;
this._verticalBar = null!;
this._horizontalBar = null!;
}
}
export default Scrollbar;