UNPKG

@wareme/smooth-scrolling

Version:

High performance smooth scrolling for Dark applications

514 lines (430 loc) 13.9 kB
import { EventEmitter } from '@wareme/event-emitter' import { nisha } from '@wareme/utils' import { Animate } from './animate' import { Dimensions } from './dimensions' import { clamp, modulo } from './utils' import { VirtualScroll } from './virtualScroll' import { detectIsEmpty, detectIsFunction, detectIsString } from '@dark-engine/core' // Odayaka does the following: // - listens to 'wheel' events // - prevents 'wheel' event to prevent scroll // - normalizes wheel delta // - adds delta to targetScroll // - animates scroll to targetScroll (smooth context) // - if animation is not running, listens to 'scroll' events (native context) export class Odayaka { __isScrolling = false __isStopped = false __isLocked = false __preventNextNativeScrollEvent __resetVelocityTimeout time userData lastVelocity velocity direction options targetScroll animatedScroll constructor ({ wrapper = window, // Element to use as the scroll container content = document.documentElement, // Element that contains the content that will be scrolled eventsTarget = wrapper, // Element that listens to events smoothWheel = true, // Enable smooth scrolling for mouse wheel events syncTouch = false, // Mimic touch device scroll while allowing scroll sync syncTouchLerp = 0.075, // Lerp applied during syncTouch inertia touchInertiaMultiplier = 35, // Strength of syncTouch inertia duration, // The duration of scroll animation (in seconds) easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // Function to use for the scroll animation lerp = 0.1, // Linear interpolation intensity, overrides duration infinite = false, // Infinite scrolling, requires syncTouch on touch devices orientation = 'vertical', // Accepts 'vertical', 'horizontal' or 'both' gestureOrientation = 'vertical', // Accepts either 'vertical' or 'horizontal' touchMultiplier = 1, // Used for touch events wheelMultiplier = 1, // Used for mouse wheel events autoResize = true, // resize instance automatically based on `ResizeObserver` prevent = false // Prevent scroll to be smoothed according to elements traversed by events (node) => node.classList.contains('no-smooth-scrolling') } = {}) { // if wrapper is html or body, fallback to window if (!wrapper || wrapper === document.documentElement || wrapper === document.body) { wrapper = window } this.options = { wrapper, content, eventsTarget, smoothWheel, syncTouch, syncTouchLerp, touchInertiaMultiplier, duration, easing, lerp, infinite, gestureOrientation, orientation, touchMultiplier, wheelMultiplier, autoResize, prevent } this.animate = new Animate() this.eventEmitter = new EventEmitter() this.dimensions = new Dimensions({ wrapper, content, autoResize }) this.updateClassName() this.userData = {} this.time = 0 this.velocity = this.lastVelocity = 0 this.isLocked = false this.isStopped = false this.isScrolling = false this.targetScroll = this.animatedScroll = this.actualScroll this.options.wrapper.addEventListener('scroll', this.onNativeScroll, false) this.options.wrapper.addEventListener('pointerdown', this.onPointerDown, false) this.virtualScroll = new VirtualScroll(eventsTarget, { touchMultiplier, wheelMultiplier }) this.virtualScroll.on(this.onVirtualScroll) } destroy () { this.options.wrapper.removeEventListener('scroll', this.onNativeScroll, false) this.options.wrapper.removeEventListener('pointerdown', this.onPointerDown, false) this.virtualScroll.destroy() this.dimensions.destroy() this.cleanUpClassName() } on (callback) { return this.eventEmitter.on(callback) } off (callback) { return this.eventEmitter.off(callback) } setScroll (scroll) { // apply scroll value immediately if (this.isHorizontal) { this.rootElement.scrollLeft = scroll } else { this.rootElement.scrollTop = scroll } } onPointerDown = (event) => { // reset on mouse wheel down if (event.button === 1) { this.reset() } } onVirtualScroll = ({ deltaX, deltaY, event }) => { // keep zoom feature if (event.ctrlKey) { return } const isTouch = event.type.includes('touch') const isWheel = event.type.includes('wheel') this.isTouching = event.type === 'touchstart' || event.type === 'touchmove' const isTapToStop = this.options.syncTouch && isTouch && event.type === 'touchstart' && !this.isStopped && !this.isLocked if (isTapToStop) { this.reset() return } const isClick = deltaX === 0 && deltaY === 0 // click event const isUnknownGesture = (this.options.gestureOrientation === 'vertical' && deltaY === 0) || (this.options.gestureOrientation === 'horizontal' && deltaX === 0) if (isClick || isUnknownGesture) { return } // catch if scrolling on nested scroll elements let composedPath = event.composedPath() composedPath = composedPath.slice(0, composedPath.indexOf(this.rootElement)) // remove parent elements const prevent = this.options.prevent if ( // node instanceof Element && Boolean(composedPath.find( (node) => nisha(detectIsFunction(prevent), () => prevent(node), prevent) || node.hasAttribute?.('data-odayaka-prevent') || (isTouch && node.hasAttribute?.('data-odayaka-prevent-touch')) || (isWheel && node.hasAttribute?.('data-odayaka-prevent-wheel')) || (node.classList?.contains('odayaka') && !node.classList?.contains('odayaka-stopped')) // nested instance )) ) { return } if (this.isStopped || this.isLocked) { event.preventDefault() // stop forwarding the event to the parent return } const isSmooth = (this.options.syncTouch && isTouch) || (this.options.smoothWheel && isWheel) if (!isSmooth) { this.isScrolling = 'native' this.animate.stop() return } event.preventDefault() let delta = deltaY if (this.options.gestureOrientation === 'both') { if (Math.abs(deltaY) > Math.abs(deltaX)) { delta = deltaY } else { delta = deltaX } } else if (this.options.gestureOrientation === 'horizontal') { delta = deltaX } const syncTouch = isTouch && this.options.syncTouch const isTouchEnd = isTouch && event.type === 'touchend' const hasTouchInertia = isTouchEnd && Math.abs(delta) > 5 if (hasTouchInertia) { delta = this.velocity * this.options.touchInertiaMultiplier } this.scrollTo(this.targetScroll + delta, { programmatic: false, ...nisha(syncTouch, { lerp: nisha(hasTouchInertia, this.options.syncTouchLerp, 1) }, { lerp: this.options.lerp, duration: this.options.duration, easing: this.options.easing }) }) } resize () { this.dimensions.resize() } emit () { this.eventEmitter.emit(this) } onNativeScroll = () => { clearTimeout(this.__resetVelocityTimeout) delete this.__resetVelocityTimeout if (this.__preventNextNativeScrollEvent) { delete this.__preventNextNativeScrollEvent return } if (this.isScrolling === false || this.isScrolling === 'native') { const lastScroll = this.animatedScroll this.animatedScroll = this.targetScroll = this.actualScroll this.lastVelocity = this.velocity this.velocity = this.animatedScroll - lastScroll this.direction = Math.sign(this.animatedScroll - lastScroll) this.isScrolling = 'native' this.emit() if (this.velocity !== 0) { this.__resetVelocityTimeout = setTimeout(() => { this.lastVelocity = this.velocity this.velocity = 0 this.isScrolling = false this.emit() }, 400) } } } reset () { this.isLocked = false this.isScrolling = false this.animatedScroll = this.targetScroll = this.actualScroll this.lastVelocity = this.velocity = 0 this.animate.stop() } start () { if (!this.isStopped) return this.isStopped = false this.reset() } stop () { if (this.isStopped) return this.isStopped = true this.animate.stop() this.reset() } raf (time) { const deltaTime = time - (this.time || time) this.time = time this.animate.advance(deltaTime * 0.001) } scrollTo ( target, { offset = 0, immediate = false, lock = false, duration = this.options.duration, easing = this.options.easing, lerp = this.options.lerp, onStart, onComplete, force = false, // scroll even if stopped programmatic = true, // called from outside of the class userData = {} } = {} ) { if ((this.isStopped || this.isLocked) && !force) return // keywords if (detectIsString(target) && ['top', 'left', 'start'].includes(target)) { target = 0 } else if (detectIsString(target) && ['bottom', 'right', 'end'].includes(target)) { target = this.limit } else { let node if (typeof target === 'string') { // CSS selector node = document.querySelector(target) } else if (target instanceof HTMLElement && target?.nodeType) { // Node element node = target } if (node) { if (this.options.wrapper !== window) { // nested scroll offset correction const wrapperRect = this.rootElement.getBoundingClientRect() offset -= this.isHorizontal ? wrapperRect.left : wrapperRect.top } const rect = node.getBoundingClientRect() target = (this.isHorizontal ? rect.left : rect.top) + this.animatedScroll } } if (typeof target !== 'number') return target += offset target = Math.round(target) if (this.options.infinite) { if (programmatic) { this.targetScroll = this.animatedScroll = this.scroll } } else { target = clamp(0, target, this.limit) } if (target === this.targetScroll) { return } this.userData = userData if (immediate) { this.animatedScroll = this.targetScroll = target this.setScroll(this.scroll) this.reset() this.preventNextNativeScrollEvent() this.emit() if (!detectIsEmpty(onComplete) && detectIsFunction(onComplete)) { onComplete(this) } this.userData = {} return } if (!programmatic) { this.targetScroll = target } this.animate.fromTo(this.animatedScroll, target, { duration, easing, lerp, onStart: () => { // started if (lock) this.isLocked = true this.isScrolling = 'smooth' onStart?.(this) }, onUpdate: (value, completed) => { this.isScrolling = 'smooth' // updated this.lastVelocity = this.velocity this.velocity = value - this.animatedScroll this.direction = Math.sign(this.velocity) this.animatedScroll = value this.setScroll(this.scroll) if (programmatic) { // wheel during programmatic should stop it this.targetScroll = value } if (!completed) this.emit() if (completed) { this.reset() this.emit() if (!detectIsEmpty(onComplete) && detectIsFunction(onComplete)) { onComplete(this) } this.userData = {} // avoid emitting event twice this.preventNextNativeScrollEvent() } } }) } preventNextNativeScrollEvent () { this.__preventNextNativeScrollEvent = true requestAnimationFrame(() => { delete this.__preventNextNativeScrollEvent }) } get rootElement () { if (this.options.wrapper === window) { return document.documentElement } return this.options.wrapper } get limit () { return this.dimensions.limit[nisha(this.isHorizontal, 'x', 'y')] } get isHorizontal () { return this.options.orientation === 'horizontal' } get actualScroll () { // value browser takes into account return nisha(this.isHorizontal, this.rootElement.scrollLeft, this.rootElement.scrollTop) } get scroll () { return nisha(this.options.infinite, () => modulo(this.animatedScroll, this.limit), this.animatedScroll) } get isScrolling () { return this.__isScrolling } set isScrolling (value) { if (this.__isScrolling !== value) { this.__isScrolling = value this.updateClassName() } } get isStopped () { return this.__isStopped } set isStopped (value) { if (this.__isStopped !== value) { this.__isStopped = value this.updateClassName() } } get isLocked () { return this.__isLocked } set isLocked (value) { if (this.__isLocked !== value) { this.__isLocked = value this.updateClassName() } } get isSmooth () { return this.isScrolling === 'smooth' } get className () { let className = 'odayaka' if (this.isStopped) className += ' odayaka-stopped' if (this.isLocked) className += ' odayaka-locked' if (this.isScrolling) className += ' odayaka-scrolling' if (this.isScrolling === 'smooth') className += ' odayaka-smooth' return className } updateClassName () { this.cleanUpClassName() this.rootElement.className = `${this.rootElement.className} ${this.className}`.trim() } cleanUpClassName () { this.rootElement.className = this.rootElement.className .replace(/odayaka(-\w+)?/g, '') .trim() } }