vevet
Version:
Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.
661 lines (520 loc) • 15.5 kB
text/typescript
import { Module, TModuleOnCallbacksProps } from '@/base';
import { isString } from '@/internal/isString';
import { isUndefined } from '@/internal/isUndefined';
import { noopIfDestroyed } from '@/internal/noopIfDestroyed';
import { TRequiredProps } from '@/internal/requiredProps';
import {
IOnResize,
onResize,
loop,
damp,
toPixels,
closest,
inRange,
} from '@/utils';
import { SnapIdle } from './logic/Idle';
import { SnapInterval } from './logic/Interval';
import { SnapKeyboard } from './logic/Keyboard';
import { SnapSlide } from './logic/Slide';
import { SnapSwipe } from './logic/Swipe';
import { SnapTrack } from './logic/Track';
import { SnapWheel } from './logic/Wheel';
import { LERP_APPROXIMATION, MUTABLE_PROPS, STATIC_PROPS } from './props';
import {
ISnapCallbacksMap,
ISnapMagnet,
ISnapMutableProps,
ISnapNexPrevArg,
ISnapStaticProps,
ISnapToSlideArg,
ISnapTransitionArg,
} from './types';
export * from './types';
export * from './logic/Slide';
type TC = ISnapCallbacksMap;
type TS = ISnapStaticProps;
type TM = ISnapMutableProps;
/**
* Snap/Carousel handler.
* This class manages sliding progress with options like swipe, wheel interactions, and smooth transitions.
*
* Please not that the class does not apply any styles to the slides, it only handles the logic.
*
* [Documentation](https://vevetjs.com/docs/Snap)
*
* @group Components
*/
export class Snap extends Module<TC, TS, TM> {
/**
* Returns the default static properties.
*/
public _getStatic(): TRequiredProps<TS> {
return { ...super._getStatic(), ...STATIC_PROPS };
}
/**
* Returns the default mutable properties.
*/
public _getMutable(): TRequiredProps<TM> {
return { ...super._getMutable(), ...MUTABLE_PROPS };
}
/** Swipe events */
private _swipe: SnapSwipe;
/** Snap Track */
private _track: SnapTrack;
/** Snap Idle Logic */
private _idle: SnapIdle;
/** Container size */
private _containerSize = 0;
/** All slides */
private _slides: SnapSlide[] = [];
/** Scrollable slides (which size is larger than the container) */
private _scrollableSlides: SnapSlide[] = [];
/** Resize handler */
private _resizer: IOnResize;
/** Active slide index */
private _activeIndex: number;
/**
* Target slide index.
* For internal use only
*/
public $_targetIndex?: number;
constructor(
props: TS & TM & TModuleOnCallbacksProps<TC, Snap>,
onCallbacks?: TModuleOnCallbacksProps<TC, Snap>,
) {
super(props, onCallbacks as any);
const { container, activeIndex } = this.props;
// set vars
this._activeIndex = activeIndex;
// add resize event
this._resizer = onResize({
element: container,
viewportTarget: 'width',
callback: () => this._handleResize(),
name: this.name,
});
// initial resize
this._resizer.debounceResize();
// fetch slides
this._fetchSlides();
// add wheel listener
new SnapWheel(this as any);
// add swipe
this._swipe = new SnapSwipe(this as any);
// add track
this._track = new SnapTrack(this as any);
// add keyboard
new SnapKeyboard(this as any);
// add interval
new SnapInterval(this as any);
// add idle logic
this._idle = new SnapIdle(this as any);
}
/** Handles properties change */
protected _handleProps(props: Partial<TM>) {
// attach slides
if ('slides' in props) {
this._fetchSlides();
}
// resize immediately
this._resizer.resize();
// update props
super._handleProps(props);
}
/** Get container */
get container() {
return this.props.container;
}
/** Get events emitter */
get eventsEmitter() {
return this.props.eventsEmitter ?? this.container;
}
/** Container size depending on direction (width or height) */
get containerSize() {
const { containerSize } = this.props;
if (containerSize === 'auto') {
return this._containerSize;
}
return toPixels(containerSize);
}
/**
* Container size depending on direction (width or height)
* @deprecated
*/
get domSize() {
return this.containerSize;
}
/** All slides */
get slides() {
return this._slides;
}
/** Scrollable slides (which size is larger than the container) */
get scrollableSlides() {
return this._scrollableSlides;
}
/** Active slide index */
get activeIndex() {
return this._activeIndex;
}
/** Active slide */
get activeSlide() {
return this.slides[this._activeIndex];
}
get isEmpty() {
return this.slides.length === 0;
}
/** Get axis name depending on direction */
get axis() {
return this.props.direction === 'horizontal' ? 'x' : 'y';
}
/** If transition in progress */
get isTransitioning() {
return this._track.isTransitioning;
}
/** If swipe in progress */
get isSwiping() {
return this._swipe.isSwiping;
}
/** If swipe has inertia */
get hasInteria() {
return this._swipe.hasIntertia;
}
/** If track values are interpolating */
get isInterpolating() {
const track = this._track;
const diff = Math.abs(track.target - track.current);
return diff > LERP_APPROXIMATION;
}
/** Gets the interpolation influence */
get influence() {
return this._track.influence;
}
/** Gets the current track value. */
get current() {
return this._track.current;
}
/** Gets the target track value. */
get target() {
return this._track.target;
}
/** Detect if can loop */
get canLoop() {
return this._track.canLoop;
}
/** Get looped current value */
get loopedCurrent() {
return this._track.loopedCurrent;
}
/** Get loop count */
get loopCount() {
return this._track.loopCount;
}
/** Sets track to current & target value instantly */
public set(value: number) {
this._track.set(value);
}
/** Loop a coordinate if can loop */
public loopCoord(coord: number) {
return this._track.loopCoord(coord);
}
/** Get minimum track value */
get min() {
return this._track.min;
}
/** Get maximum track value */
get max() {
return this._track.max;
}
/** Get track progress. From 0 to 1 if not loop. From -Infinity to Infinity if loop */
get progress() {
return this.current / this.max;
}
/** If the start has been reached */
get isStart() {
return this._track.isStart;
}
/** If the end has been reached */
get isEnd() {
return this._track.isEnd;
}
/** Clamp target value between min and max values */
public clampTarget() {
this._track.clampTarget();
}
/** Iterate track target value */
public iterateTarget(delta: number) {
this._track.iterateTarget(delta);
}
/** Set track target value */
public setTarget(value: number) {
this._track.setTarget(value);
}
/** Cancel slide transition */
public cancelTransition() {
this._track.cancelTransition();
}
/** Check if the active slide is larger than the container and is being scrolled */
get isSlideScrolling() {
const { containerSize } = this;
return this.scrollableSlides.some(({ size, coord }) =>
inRange(coord, containerSize - size, 0),
);
}
/** Get first slide size */
get firstSlideSize() {
return this.slides[0].size;
}
/** If the scene is idle: not swiping, not interpolating, not transitioning */
get isIdle() {
return this._idle.isIdle;
}
/** Update slides list and attach them */
private _fetchSlides() {
const { props } = this;
this._slides.forEach((slide) => slide.$_detach());
const rawChildren = props.slides
? props.slides
: Array.from(props.container.children);
const children = rawChildren.filter((slide) => {
if (
slide instanceof HTMLElement &&
slide.hasAttribute('data-scrollbar')
) {
return false;
}
return true;
});
this._slides = children.map((item) => {
if (item instanceof SnapSlide) {
return item;
}
return new SnapSlide(item as any);
});
this._slides.forEach((slide, index) => slide.$_attach(this as any, index));
}
/** Request resize (handled with debounce timeout) */
public resize(isManual = true) {
if (isManual) {
this._resizer.resize();
} else {
this._resizer.debounceResize();
}
}
/** Resize the scene and reflow */
private _handleResize() {
const { container } = this.props;
// cancel sticky behavior
this._track.cancelTransition();
// update container size
this._containerSize =
this.axis === 'x' ? container.offsetWidth : container.offsetHeight;
// reflow
this._reflow();
// emit callbacks
this.callbacks.emit('resize', undefined);
}
/** Reflow: update static values of slides */
private _reflow() {
const { slides, props } = this;
if (this.isEmpty) {
return;
}
// Reset scrollable slides
this._scrollableSlides = [];
// Calculate static values
slides.reduce((prev, slide) => {
slide.$_setStaticCoord(prev);
if (slide.size > this.containerSize) {
this._scrollableSlides.push(slide);
}
return prev + slide.size + toPixels(props.gap);
}, 0);
// Reset to active slide
const slide = slides.find(({ index }) => index === this.activeIndex);
if (props.stickOnResize && slide) {
this._track.clampTarget();
this._track.set(slide.magnets[0]);
}
// Emit callbacks
this.callbacks.emit('reflow', undefined);
// Render after resize
this.render();
}
/** Render slides */
public render(frameDuration = 0) {
if (this.isEmpty) {
return;
}
const { _swipe: swipe, _track: track, props } = this;
// Update values
this._updateSlidesCoords();
this._updateSlideProgress();
// Get magnet after slide coordinates are updated
const { magnet } = this;
// Active index change
if (
magnet &&
magnet.slide.index !== this._activeIndex &&
(isUndefined(this.$_targetIndex) ||
magnet.slide.index === this.$_targetIndex)
) {
this._activeIndex = magnet.slide.index;
this.$_targetIndex = undefined;
this.callbacks.emit('activeSlide', this.activeSlide);
}
// Check if friction is allowed
const hasFriction =
(swipe.isSwiping && swipe.allowFriction) || !swipe.isSwiping;
// Apply friction
if (
magnet &&
hasFriction &&
frameDuration > 0 &&
props.friction >= 0 &&
!this.isSlideScrolling &&
!props.freemode
) {
track.target = damp(
track.target,
track.current + magnet.diff,
props.friction * props.lerp,
frameDuration,
0.000001,
);
}
// Render slides
this.slides.forEach((slide) => slide.$_render());
// Emit Calbacks
this.callbacks.emit('update', undefined);
}
/** Update slides values */
private _updateSlidesCoords() {
const { slides, props, containerSize, firstSlideSize } = this;
const offset = props.centered ? containerSize / 2 - firstSlideSize / 2 : 0;
slides.forEach((slide) => slide.$_updateCoords(offset));
}
/** Update slides progress */
private _updateSlideProgress() {
this.slides.forEach((slide) => slide.$_updateProgress());
}
/** Get nearest magnet */
private get magnet(): ISnapMagnet | undefined {
const current = this._track.loopedCurrent;
return this.getNearestMagnet(current);
}
/** Get nearest magnet to the current position */
public getNearestMagnet(coord: number): ISnapMagnet | undefined {
const magnets = this.slides.flatMap((slide) =>
slide.magnets.map((magnet) => ({ slide, magnet, index: slide.index })),
);
if (magnets.length === 0) {
return undefined;
}
const closestMagnet = magnets.reduce((p, c) =>
Math.abs(c.magnet - coord) < Math.abs(p.magnet - coord) ? c : p,
);
return { ...closestMagnet, diff: closestMagnet.magnet - coord };
}
/** Stick to the nearest magnet */
public stick() {
const { magnet, isSlideScrolling } = this;
if (isSlideScrolling || !magnet) {
return;
}
this.toCoord(this._track.current + magnet.diff);
}
/** Go to a definite coordinate */
public toCoord(coordinate: number, options?: ISnapTransitionArg) {
return this._track.toCoord(coordinate, options);
}
/** Go to a slide by index */
public toSlide(
targetIndex: number,
{ direction = null, ...options }: ISnapToSlideArg = {},
) {
const { isEmpty, activeIndex, slides, _track: track, props } = this;
const { current, max, loopCount } = track;
if (isEmpty || this.isDestroyed) {
return false;
}
const index = loop(targetIndex, 0, this.slides.length);
// Stick if the same slide
if (index === activeIndex) {
this.stick();
return false;
}
this.$_targetIndex = index;
const slideMagnets = slides[index].magnets;
let targetStaticMagnet = slideMagnets[0];
if (props.centered) {
if (direction === 'prev') {
targetStaticMagnet = slideMagnets[2] ?? slideMagnets[0];
} else if (direction === 'next') {
targetStaticMagnet = slideMagnets[1] ?? slideMagnets[0];
}
} else {
targetStaticMagnet =
direction === 'prev'
? slideMagnets[slideMagnets.length - 1]
: targetStaticMagnet;
}
// Use static magnet when not looping
if (!props.loop) {
return this.toCoord(targetStaticMagnet, options);
}
// Or calculate closest magnet
const targetMagnet = targetStaticMagnet + loopCount * max;
const targetMagnetMin = targetMagnet - max;
const targetMagnetMax = targetMagnet + max;
const allMagnets = [targetMagnetMin, targetMagnet, targetMagnetMax];
if (isString(direction)) {
const magnets = allMagnets.filter((magnet) =>
direction === 'next' ? magnet >= current : magnet <= current,
);
const magnet = closest(current, magnets);
return this.toCoord(magnet, options);
}
const magnet = closest(current, allMagnets);
return this.toCoord(magnet, options);
}
/** Go to next slide */
public next({
skip = this.props.slidesToScroll,
...options
}: ISnapNexPrevArg = {}) {
const { props, slides, activeIndex } = this;
const { length } = slides;
let index = loop(activeIndex + skip, 0, length);
if (!props.loop) {
index = props.rewind
? loop(activeIndex + skip, 0, length)
: Math.min(activeIndex + skip, length - 1);
}
return this.toSlide(index, { ...options, direction: 'next' });
}
/** Go to previous slide */
public prev({
skip = this.props.slidesToScroll,
...options
}: ISnapNexPrevArg = {}) {
const { props, slides, activeIndex } = this;
let index = loop(activeIndex - skip, 0, slides.length);
if (!props.loop) {
index = props.rewind
? loop(activeIndex - skip, 0, slides.length)
: Math.max(activeIndex - skip, 0);
}
return this.toSlide(index, { ...options, direction: 'prev' });
}
/**
* Destroys the component and clears all timeouts and resources.
*/
protected _destroy() {
super._destroy();
this._resizer.remove();
this._slides.forEach((slide) => slide.$_detach());
}
}