src/Draggable/Plugins/Scrollable/Scrollable.js
import AbstractPlugin from 'shared/AbstractPlugin';
import {closest} from 'shared/utils';
export const onDragStart = Symbol('onDragStart');
export const onDragMove = Symbol('onDragMove');
export const onDragStop = Symbol('onDragStop');
export const scroll = Symbol('scroll');
/**
* Scrollable default options
* @property {Object} defaultOptions
* @property {Number} defaultOptions.speed
* @property {Number} defaultOptions.sensitivity
* @property {HTMLElement[]} defaultOptions.scrollableElements
* @type {Object}
*/
export const defaultOptions = {
speed: 6,
sensitivity: 50,
scrollableElements: [],
};
/**
* Scrollable plugin which scrolls the closest scrollable parent
* @class Scrollable
* @module Scrollable
* @extends AbstractPlugin
*/
export default class Scrollable extends AbstractPlugin {
/**
* Scrollable constructor.
* @constructs Scrollable
* @param {Draggable} draggable - Draggable instance
*/
constructor(draggable) {
super(draggable);
/**
* Scrollable options
* @property {Object} options
* @property {Number} options.speed
* @property {Number} options.sensitivity
* @property {HTMLElement[]} options.scrollableElements
* @type {Object}
*/
this.options = {
...defaultOptions,
...this.getOptions(),
};
/**
* Keeps current mouse position
* @property {Object} currentMousePosition
* @property {Number} currentMousePosition.clientX
* @property {Number} currentMousePosition.clientY
* @type {Object|null}
*/
this.currentMousePosition = null;
/**
* Scroll animation frame
* @property scrollAnimationFrame
* @type {Number|null}
*/
this.scrollAnimationFrame = null;
/**
* Closest scrollable element
* @property scrollableElement
* @type {HTMLElement|null}
*/
this.scrollableElement = null;
/**
* Animation frame looking for the closest scrollable element
* @property findScrollableElementFrame
* @type {Number|null}
*/
this.findScrollableElementFrame = null;
this[onDragStart] = this[onDragStart].bind(this);
this[onDragMove] = this[onDragMove].bind(this);
this[onDragStop] = this[onDragStop].bind(this);
this[scroll] = this[scroll].bind(this);
}
/**
* Attaches plugins event listeners
*/
attach() {
this.draggable
.on('drag:start', this[onDragStart])
.on('drag:move', this[onDragMove])
.on('drag:stop', this[onDragStop]);
}
/**
* Detaches plugins event listeners
*/
detach() {
this.draggable
.off('drag:start', this[onDragStart])
.off('drag:move', this[onDragMove])
.off('drag:stop', this[onDragStop]);
}
/**
* Returns options passed through draggable
* @return {Object}
*/
getOptions() {
return this.draggable.options.scrollable || {};
}
/**
* Returns closest scrollable elements by element
* @param {HTMLElement} target
* @return {HTMLElement}
*/
getScrollableElement(target) {
if (this.hasDefinedScrollableElements()) {
return closest(target, this.options.scrollableElements) || document.documentElement;
} else {
return closestScrollableElement(target);
}
}
/**
* Returns true if at least one scrollable element have been defined via options
* @param {HTMLElement} target
* @return {Boolean}
*/
hasDefinedScrollableElements() {
return Boolean(this.options.scrollableElements.length !== 0);
}
/**
* Drag start handler. Finds closest scrollable parent in separate frame
* @param {DragStartEvent} dragEvent
* @private
*/
[onDragStart](dragEvent) {
this.findScrollableElementFrame = requestAnimationFrame(() => {
this.scrollableElement = this.getScrollableElement(dragEvent.source);
});
}
/**
* Drag move handler. Remembers mouse position and initiates scrolling
* @param {DragMoveEvent} dragEvent
* @private
*/
[onDragMove](dragEvent) {
this.findScrollableElementFrame = requestAnimationFrame(() => {
this.scrollableElement = this.getScrollableElement(dragEvent.sensorEvent.target);
});
if (!this.scrollableElement) {
return;
}
const sensorEvent = dragEvent.sensorEvent;
const scrollOffset = {x: 0, y: 0};
if ('ontouchstart' in window) {
scrollOffset.y = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
scrollOffset.x = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft || 0;
}
this.currentMousePosition = {
clientX: sensorEvent.clientX - scrollOffset.x,
clientY: sensorEvent.clientY - scrollOffset.y,
};
this.scrollAnimationFrame = requestAnimationFrame(this[scroll]);
}
/**
* Drag stop handler. Cancels scroll animations and resets state
* @private
*/
[onDragStop]() {
cancelAnimationFrame(this.scrollAnimationFrame);
cancelAnimationFrame(this.findScrollableElementFrame);
this.scrollableElement = null;
this.scrollAnimationFrame = null;
this.findScrollableElementFrame = null;
this.currentMousePosition = null;
}
/**
* Scroll function that does the heavylifting
* @private
*/
[scroll]() {
if (!this.scrollableElement || !this.currentMousePosition) {
return;
}
cancelAnimationFrame(this.scrollAnimationFrame);
const {speed, sensitivity} = this.options;
const rect = this.scrollableElement.getBoundingClientRect();
const bottomCutOff = rect.bottom > window.innerHeight;
const topCutOff = rect.top < 0;
const cutOff = topCutOff || bottomCutOff;
const documentScrollingElement = getDocumentScrollingElement();
const scrollableElement = this.scrollableElement;
const clientX = this.currentMousePosition.clientX;
const clientY = this.currentMousePosition.clientY;
if (scrollableElement !== document.body && scrollableElement !== document.documentElement && !cutOff) {
const {offsetHeight, offsetWidth} = scrollableElement;
if (rect.top + offsetHeight - clientY < sensitivity) {
scrollableElement.scrollTop += speed;
} else if (clientY - rect.top < sensitivity) {
scrollableElement.scrollTop -= speed;
}
if (rect.left + offsetWidth - clientX < sensitivity) {
scrollableElement.scrollLeft += speed;
} else if (clientX - rect.left < sensitivity) {
scrollableElement.scrollLeft -= speed;
}
} else {
const {innerHeight, innerWidth} = window;
if (clientY < sensitivity) {
documentScrollingElement.scrollTop -= speed;
} else if (innerHeight - clientY < sensitivity) {
documentScrollingElement.scrollTop += speed;
}
if (clientX < sensitivity) {
documentScrollingElement.scrollLeft -= speed;
} else if (innerWidth - clientX < sensitivity) {
documentScrollingElement.scrollLeft += speed;
}
}
this.scrollAnimationFrame = requestAnimationFrame(this[scroll]);
}
}
/**
* Returns true if the passed element has overflow
* @param {HTMLElement} element
* @return {Boolean}
* @private
*/
function hasOverflow(element) {
const overflowRegex = /(auto|scroll)/;
const computedStyles = getComputedStyle(element, null);
const overflow =
computedStyles.getPropertyValue('overflow') +
computedStyles.getPropertyValue('overflow-y') +
computedStyles.getPropertyValue('overflow-x');
return overflowRegex.test(overflow);
}
/**
* Returns true if the passed element is statically positioned
* @param {HTMLElement} element
* @return {Boolean}
* @private
*/
function isStaticallyPositioned(element) {
const position = getComputedStyle(element).getPropertyValue('position');
return position === 'static';
}
/**
* Finds closest scrollable element
* @param {HTMLElement} element
* @return {HTMLElement}
* @private
*/
function closestScrollableElement(element) {
if (!element) {
return getDocumentScrollingElement();
}
const position = getComputedStyle(element).getPropertyValue('position');
const excludeStaticParents = position === 'absolute';
const scrollableElement = closest(element, (parent) => {
if (excludeStaticParents && isStaticallyPositioned(parent)) {
return false;
}
return hasOverflow(parent);
});
if (position === 'fixed' || !scrollableElement) {
return getDocumentScrollingElement();
} else {
return scrollableElement;
}
}
/**
* Returns element that scrolls document
* @return {HTMLElement}
* @private
*/
function getDocumentScrollingElement() {
return document.scrollingElement || document.documentElement;
}