UNPKG

vevet

Version:

Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.

619 lines (482 loc) 14.1 kB
import { Module, TModuleOnCallbacksProps } from '@/base'; import { initVevet } from '@/global/initVevet'; import { TRequiredProps } from '@/internal/requiredProps'; import { addEventListener, EaseOutCubic } from '@/utils'; import { Pointers } from '../Pointers'; import { Timeline } from '../Timeline'; import { SwipeCoords } from './Coords'; import { ISwipeState, ISwipeVec2, ISwipeVec3 } from './global'; import { SwipeInertia } from './Inertia'; import { MUTABLE_PROPS, STATIC_PROPS } from './props'; import { SwipeStyles } from './Styles'; import { ISwipeCallbacksMap, ISwipeMutableProps, ISwipeStaticProps, } from './types'; export * from './types'; export * from './global'; type TC = ISwipeCallbacksMap; type TS = ISwipeStaticProps; type TM = ISwipeMutableProps; /** * Manages swipe interactions: * - Tracks movement and detects direction * - Emits events on start, move, and end * - Supports exponential inertia * - Optional bounds with rubber-band overflow and bounce-back * * Notes: * - Does not transform elements, only computes coordinates. * * [Documentation](https://vevetjs.com/docs/Swipe) * * @group Components */ export class Swipe extends Module<TC, TS, TM> { /** * Returns default static properties. */ public _getStatic(): TRequiredProps<TS> { return { ...super._getStatic(), ...STATIC_PROPS }; } /** * Returns default mutable properties. */ public _getMutable(): TRequiredProps<TM> { return { ...super._getMutable(), ...MUTABLE_PROPS }; } /** Swipe coords */ private _coords: SwipeCoords; /** Inertia animation */ private _inertia: SwipeInertia; /** Bounce animation */ private _bounceTm?: Timeline; /** Styles manager */ private _styles: SwipeStyles; /** Pointer event manager */ private _pointers: Pointers; /** If swiping has started */ private _isSwiping = false; /** If swiping has been aborted */ private _isAborted = false; /** Swipe start time */ private _startTime: number | undefined; /** Initial swipe coordinates (internal use) */ private _startCoord: ISwipeVec2 | undefined; constructor( props?: TS & TM & TModuleOnCallbacksProps<TC, Swipe>, onCallbacks?: TModuleOnCallbacksProps<TC, Swipe>, ) { super(props, onCallbacks as any); const { container, thumb, buttons, pointers } = this.props; this._coords = new SwipeCoords({ container, props: this.props, hasInertia: () => this.hasInertia, recalculateBoundsOnInertia: () => this.props.recalculateBoundsOnInertia, }); this._styles = new SwipeStyles(this as any); this._inertia = new SwipeInertia({ props: this.props, coords: this._coords, onStart: () => { this._coords.syncTempAngle(); this.callbacks.emit('inertiaStart', undefined); }, onFail: () => this.callbacks.emit('inertiaFail', undefined), onCancel: () => this.callbacks.emit('inertiaCancel', undefined), onEnd: () => this.callbacks.emit('inertiaEnd', undefined), }); // create pointers this._pointers = new Pointers({ container: thumb || container, buttons, minPointers: pointers, maxPointers: pointers, relative: false, enabled: this.props.enabled, disableUserSelect: this.props.disableUserSelect, }); // Set Events this._setEvents(); } /** Full coordinate snapshot (pointer space + `movement`). */ get coords() { return this._coords.coords; } /** Coordinate reference element. */ get container() { return this.props.container; } /** Whether release inertia is running. */ get hasInertia() { return this._inertia.has; } /** Whether overflow bounce-back timeline is running. */ get hasBounce() { return !!this._bounceTm; } /** Whether a swipe gesture is in progress. */ get isSwiping() { return this._isSwiping; } /** Handles property updates */ protected _handleProps(props: Partial<TM>) { super._handleProps(props); this._pointers.updateProps({ enabled: this.props.enabled }); this._styles.setInline(); if (!this.props.inertia || !this.props.enabled) { this.cancelInertia(); } if (!this.props.enabled) { this.cancelBounce(); } } /** Sets event listeners */ private _setEvents() { const { callbacks } = this; const { container } = this.props; this._pointers.on('start', () => this._handlePointersStart()); this._pointers.on('pointerdown', (data) => callbacks.emit('pointerdown', data), ); this._pointers.on('pointermove', (data) => callbacks.emit('pointermove', data), ); this._pointers.on('pointerup', (data) => callbacks.emit('pointerup', data)); this._pointers.on('end', () => this._handlePointersEnd()); const touchstart = addEventListener( container, 'touchstart', (event) => this._handleTouchStart(event), { passive: false }, ); this.onDestroy(() => touchstart()); } /** Handles `touchstart` events */ private _handleTouchStart(event: TouchEvent) { if (!this.props.enabled) { return; } this._preventEdgeSwipe(event); this.callbacks.emit('touchstart', event); } /** Prevents edge swipes if enabled */ private _preventEdgeSwipe(event: TouchEvent) { const { props } = this; if (!props.preventEdgeSwipe) { return; } const threshold = props.edgeSwipeThreshold; const x = event.targetTouches[0].pageX; const shouldPrevent = x <= threshold || x >= initVevet().width - threshold; if (event.cancelable && shouldPrevent) { event.preventDefault(); this.callbacks.emit('preventEdgeSwipe', undefined); } } /** Handles pointers start */ private _handlePointersStart() { this.cancelBounce(); this.cancelInertia(); const touchmove = addEventListener( window, 'touchmove', this._handleTouchMove.bind(this), { passive: false }, ); const mousemove = addEventListener( window, 'mousemove', this._handleMouseMove.bind(this), ); const end = this._pointers.on('end', () => { this._handleEnd(); end(); touchmove(); mousemove(); }); this.onDestroy(() => { end(); touchmove(); mousemove(); }); } /** Handles pointers end */ private _handlePointersEnd() { if (!this._isSwiping) { this.releaseBounce(); } } /** Handles `touchmove` event */ private _handleTouchMove(event: TouchEvent) { this.callbacks.emit('touchmove', event); if (this._isSwiping && this.props.preventTouchMove && event.cancelable) { event.preventDefault(); } this._handleMove('touch'); } /** Handles `mousemove` event */ private _handleMouseMove(event: MouseEvent) { if (this.props.requireCtrlKey && !event.ctrlKey) { return; } this.callbacks.emit('mousemove', event); this._handleMove('mouse'); } /** Handles move events */ private _handleMove(type: 'touch' | 'mouse') { if (!this._pointers.move || !this.props.enabled) { return; } const data = this._coords; const state = data.decode(this._pointers.move.center); if (this._isAborted) { return; } // Save start coordinates if (!this._startCoord) { this._startCoord = { ...state }; } // Update start time if (!this._startTime) { this._startTime = +Date.now(); } // check if can start if (!this._isSwiping && !this._canStart(state, type)) { return; } // start if (!this._isSwiping) { this.cancelInertia(); this.cancelBounce(); this._isSwiping = true; this._startCoord = { ...state }; data.setStart(state); this.callbacks.emit('start', this.coords); this._styles.append(); } // move this._move(state); } /** Checks if swipe can start */ private _canStart(state: ISwipeState, type: 'touch' | 'mouse') { const { _startCoord: startCoord, _startTime: startTime } = this; if (!startCoord || !startTime) { return false; } const { threshold, minTime, axis, willAbort } = this.props; const diff = { x: state.x - startCoord.x, y: state.y - startCoord.y, }; // check threshold const distX = diff.x; const distY = diff.y; const dist = Math.sqrt(distX ** 2 + distY ** 2); if (dist < threshold) { return false; } // check time if (+new Date() - startTime < minTime) { return false; } // check axis if (axis) { const rawAngle = (Math.atan2(Math.abs(diff.y), Math.abs(diff.x)) * 180) / Math.PI; const normalizedAngle = axis === 'x' ? rawAngle : 90 - rawAngle; if (normalizedAngle > 45) { this._reset(); this._isAborted = true; this.callbacks.emit('abort', undefined); return false; } } // Check if should abort const shouldAbort = willAbort({ type, state, start: startCoord, diff, }); if (shouldAbort) { this._reset(); this._isAborted = true; this.callbacks.emit('abort', undefined); return false; } return true; } /** Handles move events */ private _move(state: ISwipeState, applyRatio = true) { const coords = this._coords; // Update coords coords.update(state, applyRatio); // trigger callbacks this.callbacks.emit('move', this.coords); } /** Handles swipe end */ private _handleEnd() { // reset this._startTime = undefined; this._isAborted = false; // check swiping if (!this.isSwiping) { return; } // reset this._reset(); // reset styles this._styles.remove(); // calculate direction const { x: diffX, y: diffY } = this._coords.diff; const absDiffX = Math.abs(diffX); const absDiffY = Math.abs(diffY); const { directionThreshold } = this.props; const endAxis = absDiffX > absDiffY ? 'x' : 'y'; if (endAxis === 'x' && absDiffX > directionThreshold) { if (diffX > 0) { this.callbacks.emit('toRight', undefined); } else if (diffX < 0) { this.callbacks.emit('toLeft', undefined); } } if (endAxis === 'y' && absDiffY > directionThreshold) { if (diffY > 0) { this.callbacks.emit('toBottom', undefined); } else if (diffY < 0) { this.callbacks.emit('toTop', undefined); } } // end callback this.callbacks.emit('end', this.coords); // end with inertia or bounce let hasInertia = false; if (this.props.inertia) { hasInertia = this._releaseInertia(); } if (!hasInertia) { this.releaseBounce(); } } /** Reset swipe states */ private _reset() { this._startCoord = undefined; this._isSwiping = false; } /** Apply inertia-based movement */ private _releaseInertia() { return this._inertia.release(({ x, y, angle }) => { this.callbacks.emit('inertia', undefined); this._move({ x, y, angle, time: performance.now() }, false); }); } /** Apply bounce overflow animation */ public releaseBounce(targetDuration?: number) { this.cancelBounce(); const { exceeds } = this._coords; const canBounce = this.props.canBounce(); if ( !exceeds || (!exceeds.x && !exceeds.y && !exceeds.angle) || !canBounce ) { return; } const start = { ...this.current }; const duration = targetDuration ?? this.props.bounceDuration; const tm = new Timeline({ duration, easing: EaseOutCubic }); this._bounceTm = tm; this._coords.syncTempAngle(); tm.on('update', ({ eased }) => { this._move( { x: start.x - exceeds.x * eased, y: start.y - exceeds.y * eased, angle: start.angle - exceeds.angle * eased, time: performance.now(), }, false, ); }); tm.on('end', this.cancelBounce.bind(this)); tm.play(); } /** Cancel inertia */ public cancelInertia() { this._inertia.cancel(); } /** Cancel bounce */ public cancelBounce() { this._bounceTm?.destroy(); this._bounceTm = undefined; } /** Calculate swipe bounds */ public calculateBounds() { return this._coords.calculateBounds(); } /** Pointer position at swipe start. */ get start() { return this._coords.start; } /** Previous pointer position. */ get prev() { return this._coords.prev; } /** Current pointer position. */ get current() { return this._coords.current; } /** Offset from swipe start to current pointer position. */ get diff() { return this._coords.diff; } /** Offset from previous to current pointer position. */ get step() { return this._coords.step; } /** Absolute path length since swipe start. */ get accum() { return this._coords.accum; } /** Total displacement in movement space (use for element transforms). */ get movement() { return this._coords.movement; } /** Current scale modifier. */ get scale() { return this._coords.scale; } /** * Sets programmatic scale in movement space. * Optionally zooms toward an origin point and emits `move`. */ public setScale(value: number, origin: MouseEvent | TouchEvent | ISwipeVec2) { this._coords.applyScale(value, origin); this._move({ ...this.current, time: performance.now() }); if (!this._inertia.has) { this.releaseBounce(0); } } /** * Sets programmatic displacement in movement space. * Reapplies rubber, snap, emits `move`, and cancels overflow bounce. */ public setMovement(value: Partial<ISwipeVec3>) { this._coords.movement = value; this._move({ ...this.current, time: performance.now() }); this.releaseBounce(0); } /** * Destroys the component */ protected _destroy() { super._destroy(); this.cancelBounce(); this._pointers.destroy(); this._inertia.destroy(); this._styles.remove(); } }