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
text/typescript
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();
}
}