smooth-scrollbar
Version:
Customize scrollbar in modern browsers with smooth scrolling experience.
517 lines (415 loc) • 11.1 kB
text/typescript
import { clamp } from './utils';
import { Options } from './options';
import {
setStyle,
clearEventsOn,
} from './utils/';
import {
debounce,
} from './decorators/';
import {
TrackController,
} from './track/';
import {
getSize,
update,
isVisible,
} from './geometry/';
import {
scrollTo,
setPosition,
scrollIntoView,
} from './scrolling/';
import {
initPlugins,
} from './plugin';
import * as eventHandlers from './events/';
import * as I from './interfaces/';
// DO NOT use WeakMap here
// .getAll() methods requires `scrollbarMap.values()`
export const scrollbarMap = new Map<HTMLElement, Scrollbar>();
export class Scrollbar implements I.Scrollbar {
/**
* Options for current scrollbar instancs
*/
readonly options: Options;
readonly track: TrackController;
/**
* The element that you initialized scrollbar to
*/
readonly containerEl: HTMLElement;
/**
* The wrapper element that contains your contents
*/
readonly contentEl: HTMLElement;
/**
* Geometry infomation for current scrollbar instance
*/
size: I.ScrollbarSize;
/**
* Current scrolling offsets
*/
offset = {
x: 0,
y: 0,
};
/**
* Max-allowed scrolling offsets
*/
limit = {
x: Infinity,
y: Infinity,
};
/**
* Container bounding rect
*/
bounding = {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
/**
* Parent scrollbar
*/
get parent() {
let elem = this.containerEl.parentElement;
while (elem) {
const parentScrollbar = scrollbarMap.get(elem);
if (parentScrollbar) {
return parentScrollbar;
}
elem = elem.parentElement;
}
return null;
}
/**
* Gets or sets `scrollbar.offset.y`
*/
get scrollTop() {
return this.offset.y;
}
set scrollTop(y: number) {
this.setPosition(this.scrollLeft, y);
}
/**
* Gets or sets `scrollbar.offset.x`
*/
get scrollLeft() {
return this.offset.x;
}
set scrollLeft(x: number) {
this.setPosition(x, this.scrollTop);
}
private _renderID: number;
private _observer: any; // FIXME: we need to update typescript version to support `ResizeObserver`
// private _observer: ResizeObserver;
private _plugins: I.ScrollbarPlugin[] = [];
private _momentum = { x: 0, y: 0 };
private _listeners = new Set<I.ScrollListener>();
constructor(
containerEl: HTMLElement,
options?: Partial<I.ScrollbarOptions>,
) {
this.containerEl = containerEl;
const contentEl = this.contentEl = document.createElement('div');
this.options = new Options(options);
// mark as a scroll element
containerEl.setAttribute('data-scrollbar', 'true');
// make container focusable
containerEl.setAttribute('tabindex', '-1');
setStyle(containerEl, {
overflow: 'hidden',
outline: 'none',
});
// enable touch event capturing in IE, see:
// https://github.com/idiotWu/smooth-scrollbar/issues/39
if (window.navigator.msPointerEnabled) {
containerEl.style.msTouchAction = 'none';
}
// mount content
contentEl.className = 'scroll-content';
Array.from(containerEl.childNodes).forEach((node) => {
contentEl.appendChild(node);
});
containerEl.appendChild(contentEl);
// attach track
this.track = new TrackController(this);
// initial measuring
this.size = this.getSize();
// init plugins
this._plugins = initPlugins(this, this.options.plugins);
// preserve scroll offset
const { scrollLeft, scrollTop } = containerEl;
containerEl.scrollLeft = containerEl.scrollTop = 0;
this.setPosition(scrollLeft, scrollTop, {
withoutCallbacks: true,
});
// FIXME: update typescript
const ResizeObserver = (window as any).ResizeObserver;
// observe
if (typeof ResizeObserver === 'function') {
this._observer = new ResizeObserver(() => {
this.update();
});
this._observer.observe(contentEl);
}
scrollbarMap.set(containerEl, this);
// wait for DOM ready
requestAnimationFrame(() => {
this._init();
});
}
/**
* Returns the size of the scrollbar container element
* and the content wrapper element
*/
getSize(): I.ScrollbarSize {
return getSize(this);
}
/**
* Forces scrollbar to update geometry infomation.
*
* By default, scrollbars are automatically updated with `100ms` debounce (or `MutationObserver` fires).
* You can call this method to force an update when you modified contents
*/
update() {
update(this);
this._plugins.forEach((plugin) => {
plugin.onUpdate();
});
}
/**
* Checks if an element is visible in the current view area
*/
isVisible(elem: HTMLElement): boolean {
return isVisible(this, elem);
}
/**
* Sets the scrollbar to the given offset without easing
*/
setPosition(
x = this.offset.x,
y = this.offset.y,
options: Partial<I.SetPositionOptions> = {},
) {
const status = setPosition(this, x, y);
if (!status || options.withoutCallbacks) {
return;
}
this._listeners.forEach((fn) => {
fn.call(this, status);
});
}
/**
* Scrolls to given position with easing function
*/
scrollTo(
x = this.offset.x,
y = this.offset.y,
duration = 0,
options: Partial<I.ScrollToOptions> = {},
) {
scrollTo(this, x, y, duration, options);
}
/**
* Scrolls the target element into visible area of scrollbar,
* likes the DOM method `element.scrollIntoView().
*/
scrollIntoView(
elem: HTMLElement,
options: Partial<I.ScrollIntoViewOptions> = {},
) {
scrollIntoView(this, elem, options);
}
/**
* Adds scrolling listener
*/
addListener(fn: I.ScrollListener) {
if (typeof fn !== 'function') {
throw new TypeError('[smooth-scrollbar] scrolling listener should be a function');
}
this._listeners.add(fn);
}
/**
* Removes listener previously registered with `scrollbar.addListener()`
*/
removeListener(fn: I.ScrollListener) {
this._listeners.delete(fn);
}
/**
* Adds momentum and applys delta transformers.
*/
addTransformableMomentum(
x: number,
y: number,
fromEvent: Event,
callback?: I.AddTransformableMomentumCallback,
) {
this._updateDebounced();
const finalDelta = this._plugins.reduce((delta, plugin) => {
return plugin.transformDelta(delta, fromEvent) || delta;
}, { x, y });
const willScroll = !this._shouldPropagateMomentum(finalDelta.x, finalDelta.y);
if (willScroll) {
this.addMomentum(finalDelta.x, finalDelta.y);
}
if (callback) {
callback.call(this, willScroll);
}
}
/**
* Increases scrollbar's momentum
*/
addMomentum(x: number, y: number) {
this.setMomentum(
this._momentum.x + x,
this._momentum.y + y,
);
}
/**
* Sets scrollbar's momentum to given value
*/
setMomentum(x: number, y: number) {
if (this.limit.x === 0) {
x = 0;
}
if (this.limit.y === 0) {
y = 0;
}
if (this.options.renderByPixels) {
x = Math.round(x);
y = Math.round(y);
}
this._momentum.x = x;
this._momentum.y = y;
}
/**
* Update options for specific plugin
*
* @param pluginName Name of the plugin
* @param [options] An object includes the properties that you want to update
*/
updatePluginOptions(pluginName: string, options?: any) {
this._plugins.forEach((plugin) => {
if (plugin.name === pluginName) {
Object.assign(plugin.options, options);
}
});
}
destroy() {
const {
containerEl,
contentEl,
} = this;
clearEventsOn(this);
this._listeners.clear();
this.setMomentum(0, 0);
cancelAnimationFrame(this._renderID);
if (this._observer) {
this._observer.disconnect();
}
scrollbarMap.delete(this.containerEl);
// restore contents
const childNodes = Array.from(contentEl.childNodes);
while (containerEl.firstChild) {
containerEl.removeChild(containerEl.firstChild);
}
childNodes.forEach((el) => {
containerEl.appendChild(el);
});
// reset scroll position
setStyle(containerEl, {
overflow: '',
});
containerEl.scrollTop = this.scrollTop;
containerEl.scrollLeft = this.scrollLeft;
// invoke plugin.onDestroy
this._plugins.forEach((plugin) => {
plugin.onDestroy();
});
this._plugins.length = 0;
}
private _init() {
this.update();
// init evet handlers
Object.keys(eventHandlers).forEach((prop) => {
eventHandlers[prop](this);
});
// invoke `plugin.onInit`
this._plugins.forEach((plugin) => {
plugin.onInit();
});
this._render();
}
private _updateDebounced() {
this.update();
}
// check whether to propagate monmentum to parent scrollbar
// the following situations are considered as `true`:
// 1. continuous scrolling is enabled (automatically disabled when overscroll is enabled)
// 2. scrollbar reaches one side and is not about to scroll on the other direction
private _shouldPropagateMomentum(deltaX = 0, deltaY = 0): boolean {
const {
options,
offset,
limit,
} = this;
if (!options.continuousScrolling) return false;
// force an update when scrollbar is "unscrollable", see #106
if (limit.x === 0 && limit.y === 0) {
this._updateDebounced();
}
const destX = clamp(deltaX + offset.x, 0, limit.x);
const destY = clamp(deltaY + offset.y, 0, limit.y);
let res = true;
// offsets are not about to change
// `&=` operator is not allowed for boolean types
res = res && (destX === offset.x);
res = res && (destY === offset.y);
// current offsets are on the edge
res = res && (offset.x === limit.x || offset.x === 0 || offset.y === limit.y || offset.y === 0);
return res;
}
private _render() {
const {
_momentum,
} = this;
if (_momentum.x || _momentum.y) {
const nextX = this._nextTick('x');
const nextY = this._nextTick('y');
_momentum.x = nextX.momentum;
_momentum.y = nextY.momentum;
this.setPosition(nextX.position, nextY.position);
}
const remain = { ...this._momentum };
this._plugins.forEach((plugin) => {
plugin.onRender(remain);
});
this._renderID = requestAnimationFrame(this._render.bind(this));
}
private _nextTick(direction: 'x' | 'y'): { momentum: number, position: number } {
const {
options,
offset,
_momentum,
} = this;
const current = offset[direction];
const remain = _momentum[direction];
if (Math.abs(remain) <= 0.1) {
return {
momentum: 0,
position: current + remain,
};
}
let nextMomentum = remain * (1 - options.damping);
if (options.renderByPixels) {
nextMomentum |= 0;
}
return {
momentum: nextMomentum,
position: current + remain - nextMomentum,
};
}
}