UNPKG

scrollbooster

Version:

Enjoyable content drag-to-scroll library

635 lines (548 loc) 21.8 kB
const getFullWidth = (elem) => Math.max(elem.offsetWidth, elem.scrollWidth); const getFullHeight = (elem) => Math.max(elem.offsetHeight, elem.scrollHeight); const textNodeFromPoint = (element, x, y) => { const nodes = element.childNodes; const range = document.createRange(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.nodeType !== 3) { continue; } range.selectNodeContents(node); const rect = range.getBoundingClientRect(); if (x >= rect.left && y >= rect.top && x <= rect.right && y <= rect.bottom) { return node; } } return false; }; const clearTextSelection = () => { const selection = window.getSelection ? window.getSelection() : document.selection; if (!selection) { return; } if (selection.removeAllRanges) { selection.removeAllRanges(); } else if (selection.empty) { selection.empty(); } }; const CLICK_EVENT_THRESHOLD_PX = 5; export default class ScrollBooster { /** * Create ScrollBooster instance * @param {Object} options - options object * @param {Element} options.viewport - container element * @param {Element} options.content - scrollable content element * @param {String} options.direction - scroll direction * @param {String} options.pointerMode - mouse or touch support * @param {String} options.scrollMode - predefined scrolling technique * @param {Boolean} options.bounce - bounce effect * @param {Number} options.bounceForce - bounce effect factor * @param {Number} options.friction - scroll friction factor * @param {Boolean} options.textSelection - enables text selection * @param {Boolean} options.inputsFocus - enables focus on input elements * @param {Boolean} options.emulateScroll - enables mousewheel emulation * @param {Function} options.onClick - click handler * @param {Function} options.onUpdate - state update handler * @param {Function} options.onWheel - wheel handler * @param {Function} options.shouldScroll - predicate to allow or disable scroll */ constructor(options = {}) { const defaults = { content: options.viewport.children[0], direction: 'all', // 'vertical', 'horizontal' pointerMode: 'all', // 'touch', 'mouse' scrollMode: undefined, // 'transform', 'native' bounce: true, bounceForce: 0.1, friction: 0.05, textSelection: false, inputsFocus: true, emulateScroll: false, preventDefaultOnEmulateScroll: false, // 'vertical', 'horizontal' preventPointerMoveDefault: true, lockScrollOnDragDirection: false, // 'vertical', 'horizontal', 'all' pointerDownPreventDefault: true, dragDirectionTolerance: 40, onPointerDown() {}, onPointerUp() {}, onPointerMove() {}, onClick() {}, onUpdate() {}, onWheel() {}, shouldScroll() { return true; }, }; this.props = { ...defaults, ...options }; if (!this.props.viewport || !(this.props.viewport instanceof Element)) { console.error(`ScrollBooster init error: "viewport" config property must be present and must be Element`); return; } if (!this.props.content) { console.error(`ScrollBooster init error: Viewport does not have any content`); return; } this.isDragging = false; this.isTargetScroll = false; this.isScrolling = false; this.isRunning = false; const START_COORDINATES = { x: 0, y: 0 }; this.position = { ...START_COORDINATES }; this.velocity = { ...START_COORDINATES }; this.dragStartPosition = { ...START_COORDINATES }; this.dragOffset = { ...START_COORDINATES }; this.clientOffset = { ...START_COORDINATES }; this.dragPosition = { ...START_COORDINATES }; this.targetPosition = { ...START_COORDINATES }; this.scrollOffset = { ...START_COORDINATES }; this.rafID = null; this.events = {}; this.updateMetrics(); this.handleEvents(); } /** * Update options object with new given values */ updateOptions(options = {}) { this.props = { ...this.props, ...options }; this.props.onUpdate(this.getState()); this.startAnimationLoop(); } /** * Update DOM container elements metrics (width and height) */ updateMetrics() { this.viewport = { width: this.props.viewport.clientWidth, height: this.props.viewport.clientHeight, }; this.content = { width: getFullWidth(this.props.content), height: getFullHeight(this.props.content), }; this.edgeX = { from: Math.min(-this.content.width + this.viewport.width, 0), to: 0, }; this.edgeY = { from: Math.min(-this.content.height + this.viewport.height, 0), to: 0, }; this.props.onUpdate(this.getState()); this.startAnimationLoop(); } /** * Run animation loop */ startAnimationLoop() { this.isRunning = true; cancelAnimationFrame(this.rafID); this.rafID = requestAnimationFrame(() => this.animate()); } /** * Main animation loop */ animate() { if (!this.isRunning) { return; } this.updateScrollPosition(); // stop animation loop if nothing moves if (!this.isMoving()) { this.isRunning = false; this.isTargetScroll = false; } const state = this.getState(); this.setContentPosition(state); this.props.onUpdate(state); this.rafID = requestAnimationFrame(() => this.animate()); } /** * Calculate and set new scroll position */ updateScrollPosition() { this.applyEdgeForce(); this.applyDragForce(); this.applyScrollForce(); this.applyTargetForce(); const inverseFriction = 1 - this.props.friction; this.velocity.x *= inverseFriction; this.velocity.y *= inverseFriction; if (this.props.direction !== 'vertical') { this.position.x += this.velocity.x; } if (this.props.direction !== 'horizontal') { this.position.y += this.velocity.y; } // disable bounce effect if ((!this.props.bounce || this.isScrolling) && !this.isTargetScroll) { this.position.x = Math.max(Math.min(this.position.x, this.edgeX.to), this.edgeX.from); this.position.y = Math.max(Math.min(this.position.y, this.edgeY.to), this.edgeY.from); } } /** * Increase general scroll velocity by given force amount */ applyForce(force) { this.velocity.x += force.x; this.velocity.y += force.y; } /** * Apply force for bounce effect */ applyEdgeForce() { if (!this.props.bounce || this.isDragging) { return; } // scrolled past viewport edges const beyondXFrom = this.position.x < this.edgeX.from; const beyondXTo = this.position.x > this.edgeX.to; const beyondYFrom = this.position.y < this.edgeY.from; const beyondYTo = this.position.y > this.edgeY.to; const beyondX = beyondXFrom || beyondXTo; const beyondY = beyondYFrom || beyondYTo; if (!beyondX && !beyondY) { return; } const edge = { x: beyondXFrom ? this.edgeX.from : this.edgeX.to, y: beyondYFrom ? this.edgeY.from : this.edgeY.to, }; const distanceToEdge = { x: edge.x - this.position.x, y: edge.y - this.position.y, }; const force = { x: distanceToEdge.x * this.props.bounceForce, y: distanceToEdge.y * this.props.bounceForce, }; const restPosition = { x: this.position.x + (this.velocity.x + force.x) / this.props.friction, y: this.position.y + (this.velocity.y + force.y) / this.props.friction, }; if ((beyondXFrom && restPosition.x >= this.edgeX.from) || (beyondXTo && restPosition.x <= this.edgeX.to)) { force.x = distanceToEdge.x * this.props.bounceForce - this.velocity.x; } if ((beyondYFrom && restPosition.y >= this.edgeY.from) || (beyondYTo && restPosition.y <= this.edgeY.to)) { force.y = distanceToEdge.y * this.props.bounceForce - this.velocity.y; } this.applyForce({ x: beyondX ? force.x : 0, y: beyondY ? force.y : 0, }); } /** * Apply force to move content while dragging with mouse/touch */ applyDragForce() { if (!this.isDragging) { return; } const dragVelocity = { x: this.dragPosition.x - this.position.x, y: this.dragPosition.y - this.position.y, }; this.applyForce({ x: dragVelocity.x - this.velocity.x, y: dragVelocity.y - this.velocity.y, }); } /** * Apply force to emulate mouse wheel or trackpad */ applyScrollForce() { if (!this.isScrolling) { return; } this.applyForce({ x: this.scrollOffset.x - this.velocity.x, y: this.scrollOffset.y - this.velocity.y, }); this.scrollOffset.x = 0; this.scrollOffset.y = 0; } /** * Apply force to scroll to given target coordinate */ applyTargetForce() { if (!this.isTargetScroll) { return; } this.applyForce({ x: (this.targetPosition.x - this.position.x) * 0.08 - this.velocity.x, y: (this.targetPosition.y - this.position.y) * 0.08 - this.velocity.y, }); } /** * Check if scrolling happening */ isMoving() { return ( this.isDragging || this.isScrolling || Math.abs(this.velocity.x) >= 0.01 || Math.abs(this.velocity.y) >= 0.01 ); } /** * Set scroll target coordinate for smooth scroll */ scrollTo(position = {}) { this.isTargetScroll = true; this.targetPosition.x = -position.x || 0; this.targetPosition.y = -position.y || 0; this.startAnimationLoop(); } /** * Manual position setting */ setPosition(position = {}) { this.velocity.x = 0; this.velocity.y = 0; this.position.x = -position.x || 0; this.position.y = -position.y || 0; this.startAnimationLoop(); } /** * Get latest metrics and coordinates */ getState() { return { isMoving: this.isMoving(), isDragging: !!(this.dragOffset.x || this.dragOffset.y), position: { x: -this.position.x, y: -this.position.y }, dragOffset: this.dragOffset, dragAngle: this.getDragAngle(this.clientOffset.x, this.clientOffset.y), borderCollision: { left: this.position.x >= this.edgeX.to, right: this.position.x <= this.edgeX.from, top: this.position.y >= this.edgeY.to, bottom: this.position.y <= this.edgeY.from, }, }; } /** * Get drag angle (up: 180, left: -90, right: 90, down: 0) */ getDragAngle(x, y) { return Math.round(Math.atan2(x, y) * (180 / Math.PI)); } /** * Get drag direction (horizontal or vertical) */ getDragDirection(angle, tolerance) { const absAngle = Math.abs(90 - Math.abs(angle)); if (absAngle <= 90 - tolerance) { return 'horizontal'; } else { return 'vertical'; } } /** * Update DOM container elements metrics (width and height) */ setContentPosition(state) { if (this.props.scrollMode === 'transform') { this.props.content.style.transform = `translate(${-state.position.x}px, ${-state.position.y}px)`; } if (this.props.scrollMode === 'native') { this.props.viewport.scrollTop = state.position.y; this.props.viewport.scrollLeft = state.position.x; } } /** * Register all DOM events */ handleEvents() { const dragOrigin = { x: 0, y: 0 }; const clientOrigin = { x: 0, y: 0 }; let dragDirection = null; let wheelTimer = null; let isTouch = false; const setDragPosition = (event) => { if (!this.isDragging) { return; } const eventData = isTouch ? event.touches[0] : event; const { pageX, pageY, clientX, clientY } = eventData; this.dragOffset.x = pageX - dragOrigin.x; this.dragOffset.y = pageY - dragOrigin.y; this.clientOffset.x = clientX - clientOrigin.x; this.clientOffset.y = clientY - clientOrigin.y; // get dragDirection if offset threshold is reached if ( (Math.abs(this.clientOffset.x) > 5 && !dragDirection) || (Math.abs(this.clientOffset.y) > 5 && !dragDirection) ) { dragDirection = this.getDragDirection( this.getDragAngle(this.clientOffset.x, this.clientOffset.y), this.props.dragDirectionTolerance ); } // prevent scroll if not expected scroll direction if (this.props.lockScrollOnDragDirection && this.props.lockScrollOnDragDirection !== 'all') { if (dragDirection === this.props.lockScrollOnDragDirection && isTouch) { this.dragPosition.x = this.dragStartPosition.x + this.dragOffset.x; this.dragPosition.y = this.dragStartPosition.y + this.dragOffset.y; } else if (!isTouch) { this.dragPosition.x = this.dragStartPosition.x + this.dragOffset.x; this.dragPosition.y = this.dragStartPosition.y + this.dragOffset.y; } else { this.dragPosition.x = this.dragStartPosition.x; this.dragPosition.y = this.dragStartPosition.y; } } else { this.dragPosition.x = this.dragStartPosition.x + this.dragOffset.x; this.dragPosition.y = this.dragStartPosition.y + this.dragOffset.y; } }; this.events.pointerdown = (event) => { isTouch = !!(event.touches && event.touches[0]); this.props.onPointerDown(this.getState(), event, isTouch); const eventData = isTouch ? event.touches[0] : event; const { pageX, pageY, clientX, clientY } = eventData; const { viewport } = this.props; const rect = viewport.getBoundingClientRect(); // click on vertical scrollbar if (clientX - rect.left >= viewport.clientLeft + viewport.clientWidth) { return; } // click on horizontal scrollbar if (clientY - rect.top >= viewport.clientTop + viewport.clientHeight) { return; } // interaction disabled by user if (!this.props.shouldScroll(this.getState(), event)) { return; } // disable right mouse button scroll if (event.button === 2) { return; } // disable on mobile if (this.props.pointerMode === 'mouse' && isTouch) { return; } // disable on desktop if (this.props.pointerMode === 'touch' && !isTouch) { return; } // focus on form input elements const formNodes = ['input', 'textarea', 'button', 'select', 'label']; if (this.props.inputsFocus && formNodes.indexOf(event.target.nodeName.toLowerCase()) > -1) { return; } // handle text selection if (this.props.textSelection) { const textNode = textNodeFromPoint(event.target, clientX, clientY); if (textNode) { return; } clearTextSelection(); } this.isDragging = true; dragOrigin.x = pageX; dragOrigin.y = pageY; clientOrigin.x = clientX; clientOrigin.y = clientY; this.dragStartPosition.x = this.position.x; this.dragStartPosition.y = this.position.y; setDragPosition(event); this.startAnimationLoop(); if (!isTouch && this.props.pointerDownPreventDefault) { event.preventDefault(); } }; this.events.pointermove = (event) => { // prevent default scroll if scroll direction is locked if (event.cancelable && (this.props.lockScrollOnDragDirection === 'all' || this.props.lockScrollOnDragDirection === dragDirection)) { event.preventDefault(); } setDragPosition(event); this.props.onPointerMove(this.getState(), event, isTouch); }; this.events.pointerup = (event) => { this.isDragging = false; dragDirection = null; this.props.onPointerUp(this.getState(), event, isTouch); }; this.events.wheel = (event) => { const state = this.getState(); if (!this.props.emulateScroll) { return; } this.velocity.x = 0; this.velocity.y = 0; this.isScrolling = true; this.scrollOffset.x = -event.deltaX; this.scrollOffset.y = -event.deltaY; this.props.onWheel(state, event); this.startAnimationLoop(); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => (this.isScrolling = false), 80); // get (trackpad) scrollDirection and prevent default events if ( this.props.preventDefaultOnEmulateScroll && this.getDragDirection( this.getDragAngle(-event.deltaX, -event.deltaY), this.props.dragDirectionTolerance ) === this.props.preventDefaultOnEmulateScroll ) { event.preventDefault(); } }; this.events.scroll = () => { const { scrollLeft, scrollTop } = this.props.viewport; if (Math.abs(this.position.x + scrollLeft) > 3) { this.position.x = -scrollLeft; this.velocity.x = 0; } if (Math.abs(this.position.y + scrollTop) > 3) { this.position.y = -scrollTop; this.velocity.y = 0; } }; this.events.click = (event) => { const state = this.getState(); const dragOffsetX = this.props.direction !== 'vertical' ? state.dragOffset.x : 0; const dragOffsetY = this.props.direction !== 'horizontal' ? state.dragOffset.y : 0; if (Math.max(Math.abs(dragOffsetX), Math.abs(dragOffsetY)) > CLICK_EVENT_THRESHOLD_PX) { event.preventDefault(); event.stopPropagation(); } this.props.onClick(state, event, isTouch); }; this.events.contentLoad = () => this.updateMetrics(); this.events.resize = () => this.updateMetrics(); this.props.viewport.addEventListener('mousedown', this.events.pointerdown); this.props.viewport.addEventListener('touchstart', this.events.pointerdown, { passive: false }); this.props.viewport.addEventListener('click', this.events.click); this.props.viewport.addEventListener('wheel', this.events.wheel, { passive: false }); this.props.viewport.addEventListener('scroll', this.events.scroll); this.props.content.addEventListener('load', this.events.contentLoad, true); window.addEventListener('mousemove', this.events.pointermove); window.addEventListener('touchmove', this.events.pointermove, { passive: false }); window.addEventListener('mouseup', this.events.pointerup); window.addEventListener('touchend', this.events.pointerup); window.addEventListener('resize', this.events.resize); } /** * Unregister all DOM events */ destroy() { this.props.viewport.removeEventListener('mousedown', this.events.pointerdown); this.props.viewport.removeEventListener('touchstart', this.events.pointerdown); this.props.viewport.removeEventListener('click', this.events.click); this.props.viewport.removeEventListener('wheel', this.events.wheel); this.props.viewport.removeEventListener('scroll', this.events.scroll); this.props.content.removeEventListener('load', this.events.contentLoad); window.removeEventListener('mousemove', this.events.pointermove); window.removeEventListener('touchmove', this.events.pointermove); window.removeEventListener('mouseup', this.events.pointerup); window.removeEventListener('touchend', this.events.pointerup); window.removeEventListener('resize', this.events.resize); } }