UNPKG

smooth-scrollbar

Version:

Customize scrollbar in modern browsers with smooth scrolling experience.

517 lines (415 loc) 11.1 kB
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(); } @debounce(100, true) 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, }; } }