vevet
Version:
Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.
647 lines (506 loc) • 15.1 kB
text/typescript
import { Module } from '@/base';
import { Timeline } from '../Timeline';
import {
ISnapCallbacksMap,
ISnapMagnet,
ISnapMutableProps,
ISnapNexPrevArg,
ISnapStaticProps,
ISnapToSlideArg,
} from './types';
import { TRequiredProps } from '@/internal/requiredProps';
import { Raf } from '../Raf';
import {
IOnResize,
onResize,
scoped,
loop,
EaseOutCubic,
damp,
lerp,
toPixels,
closest,
} from '@/utils';
import { SnapSlide } from './Slide';
import { SnapWheel } from './Wheel';
import { SnapSwipe } from './Swipe';
import { SnapTrack } from './Track';
import { SnapKeyboard } from './Keyboard';
import { initVevet } from '@/global/initVevet';
export * from './types';
export * from './Slide';
// todo: jsdoc
/**
* 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://antonbobrov.github.io/vevet/docs/components/Snap)
*
* @group Components
*/
export class Snap<
CallbacksMap extends ISnapCallbacksMap = ISnapCallbacksMap,
StaticProps extends ISnapStaticProps = ISnapStaticProps,
MutableProps extends ISnapMutableProps = ISnapMutableProps,
> extends Module<CallbacksMap, StaticProps, MutableProps> {
/** Retrieves the default static properties. */
public _getStatic(): TRequiredProps<StaticProps> {
return {
...super._getStatic(),
eventsEmitter: null,
activeIndex: 0,
} as TRequiredProps<StaticProps>;
}
/** Retrieves the default mutable properties. */
public _getMutable(): TRequiredProps<MutableProps> {
return {
...super._getMutable(),
slides: false,
direction: 'horizontal',
centered: false,
loop: false,
gap: 0,
lerp: 0.2,
freemode: false,
stickOnResize: true,
friction: 0.15,
edgeFriction: 0.85,
duration: 500,
easing: EaseOutCubic,
swipe: true,
grabCursor: false,
swipeSpeed: 1,
swipeAxis: 'auto',
followSwipe: true,
shortSwipes: true,
shortSwipesDuration: 300,
shortSwipesThreshold: 30,
swipeFriction: false,
swipeLerp: initVevet().mobile ? 1 : 0.6,
swipeThreshold: 5,
swipeMinTime: 0,
wheel: false,
wheelSpeed: 1,
wheelAxis: 'auto',
followWheel: true,
wheelThrottle: 'auto',
stickOnWheelEnd: true,
slideSize: 'auto',
} as TRequiredProps<MutableProps>;
}
/** Animation frame for smooth animations */
protected _raf: Raf;
/** Wheel events */
protected _wheel: SnapWheel;
/** Swipe events */
protected _swipe: SnapSwipe;
/** Snap Track */
protected _track: SnapTrack;
/** Snap keyboard */
protected _keyboard: SnapKeyboard;
/** Container size */
protected _domSize = 0;
/** All slides */
protected _slides: SnapSlide[] = [];
/** Scrollable slides (which size is larger than the container) */
protected _scrollableSlides: SnapSlide[] = [];
/** Timeline for smooth transitions */
protected _timeline?: Timeline;
/** Resize handler */
protected _resizeHandler: IOnResize;
/** Active slide index */
protected _activeIndex: number;
/** Target slide index */
protected _targetIndex?: number;
constructor(props?: StaticProps & MutableProps) {
super(props);
const { container, activeIndex } = this.props;
// set vars
this._activeIndex = activeIndex;
// add resize event
this._resizeHandler = onResize({
element: container,
callback: () => this._handleResize(),
name: this.name,
});
// initial resize
this._resizeHandler.debounceResize();
// Create the animation frame
this._raf = new Raf();
this._raf.on('frame', () => this._handleRaf());
this._raf.on('play', () => this._callbacks.emit('rafPlay', undefined));
this._raf.on('pause', () => this._callbacks.emit('rafPause', undefined));
// fetch slides
this._fetchSlides();
// add wheel listener
this._wheel = 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
this._keyboard = new SnapKeyboard(this as any);
}
/** Handles properties change */
protected _handleProps() {
// attach slides
this._fetchSlides();
// resize instantly
this._resizeHandler.resize();
// update props
super._handleProps();
}
/** Update slides list and attach them */
protected _fetchSlides() {
this._slides.forEach((slide) => slide.detach());
const children = this.props.slides
? this.props.slides
: Array.from(this.props.container.children);
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 = false) {
if (isManual) {
this._resizeHandler.resize();
} else {
this._resizeHandler.debounceResize();
}
}
/** Resize the scene and reflow */
protected _handleResize() {
const { direction, container } = this.props;
// cancel sticky behavior
this.cancelTransition();
// update container size
this._domSize =
direction === 'horizontal'
? container.offsetWidth
: container.offsetHeight;
// reflow
this._reflow();
// emit callbacks
this.callbacks.emit('resize', undefined);
}
/** 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 domSize() {
return this._domSize;
}
/** 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';
}
/** Snap track */
get track() {
return this._track;
}
/** If transition in progress */
get isTransitioning() {
return !!this._timeline;
}
/** Reflow: update static values of slides */
protected _reflow() {
const { slides, props } = this;
if (slides.length === 0) {
return;
}
// Reset scrollable slides
this._scrollableSlides = [];
// Calculate static values
slides.reduce((prev, slide) => {
slide.setStaticCoord(prev);
if (slide.size > this.domSize) {
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();
}
/** Handle RAF update, interpolate track values */
protected _handleRaf() {
if (this.isTransitioning) {
return;
}
const { track, props, _swipe: swipe } = this;
// Get lerp factor
const lerpFactor =
swipe.isSwiping && props.swipeLerp ? props.swipeLerp : props.lerp;
// Interpolate track value
track.lerp(this._raf.lerpFactor(lerpFactor));
// Stop raf if target reached
if (track.isInterpolated) {
this._raf.pause();
}
// Render the scene
this._render(this._raf.duration);
}
/** Render slides logic */
protected _render(frameDuration = 0) {
if (this.isEmpty) {
return;
}
const { _swipe: swipe, track, props } = this;
// Update values
this._updateSlidesCoords();
this._updateSlidesProgress();
// Get magnet after slide coordinates are updated
const { magnet } = this;
// Active index change
if (
magnet &&
magnet.slide.index !== this._activeIndex &&
(typeof this._targetIndex === 'undefined' ||
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 canHaveFriction =
(swipe.isSwiping && swipe.allowFriction) || !swipe.isSwiping;
// Apply friction
if (
magnet &&
canHaveFriction &&
frameDuration > 0 &&
props.friction >= 0 &&
!track.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 */
protected _updateSlidesCoords() {
const { slides, track } = this;
const { centered: isCentered } = this.props;
const offset = isCentered ? this._domSize / 2 - this.firstSlideSize / 2 : 0;
slides.forEach((slide) => {
const { staticCoord, size } = slide;
if (!track.canLoop) {
slide.setCoord(staticCoord + offset - track.current);
return;
}
if (isCentered) {
slide.setCoord(
loop(
staticCoord + offset - track.current,
-track.max / 2 + offset,
track.max / 2 + offset,
),
);
return;
}
slide.setCoord(
loop(staticCoord - track.current, -size, track.max - size),
);
});
}
/** Get first slide size */
get firstSlideSize() {
return this.slides[0].size;
}
/** Update slides progress */
protected _updateSlidesProgress() {
const { slides, domSize } = this;
slides.forEach((slide) => {
const { coord, size } = slide;
if (this.props.centered) {
const center = domSize / 2 - size / 2;
slide.setProgress(scoped(coord, center, center - size));
return;
}
slide.setProgress(scoped(coord, 0, -size));
});
}
/** Get nearest magnet */
protected get magnet(): ISnapMagnet | undefined {
// todo: search only in nearby slides
const current = this.track.loopedCurrent;
const magnets = this.slides.flatMap((slide) =>
slide.magnets.map((magnet) => ({ slide, magnet, index: slide.index })),
);
if (magnets.length === 0) {
return undefined;
}
const magnet = magnets.reduce((p, c) =>
Math.abs(c.magnet - current) < Math.abs(p.magnet - current) ? c : p,
);
return {
...magnet,
diff: magnet.magnet - current,
};
}
/** Cancel sticky behavior */
public cancelTransition() {
this._timeline?.destroy();
this._timeline = undefined;
}
/** Stick to the nearest magnet */
public stick() {
const { magnet } = this;
if (this.track.isSlideScrolling || !magnet) {
return;
}
this.toCoord(this.track.current + magnet.diff);
}
/** Go to a definite coordinate */
public toCoord(coordinate: number, duration = this.props.duration) {
if (this.isEmpty) {
return false;
}
this.cancelTransition();
const { track, props, callbacks } = this;
const start = track.current;
const end = coordinate;
const diff = Math.abs(end - start);
const tm = new Timeline({
duration: typeof duration === 'number' ? duration : duration(diff),
easing: props.easing,
});
this._timeline = tm;
tm.on('start', () => callbacks.emit('timelineStart', undefined));
tm.on('update', (data) => {
track.current = lerp(start, end, data.eased);
track.target = track.current;
if (data.progress === 1) {
this._targetIndex = undefined;
}
this._render();
callbacks.emit('timelineUpdate', data);
});
tm.on('end', () => {
tm.destroy();
callbacks.emit('timelineEnd', undefined);
this._timeline = undefined;
});
tm.on('destroy', () => {
this._targetIndex = undefined;
});
tm.play();
return true;
}
/** Go to a slide by index */
public toSlide(
targetIndex: number,
{ direction = null, duration = this.props.duration }: ISnapToSlideArg = {},
) {
const { isEmpty, activeIndex, slides, track, props } = this;
const { current, max, loopCount } = track;
if (isEmpty) {
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;
// Use static magnet when not looping
if (!props.loop) {
return this.toCoord(slideMagnets[0], duration);
}
// Or calculate closest magnet
const targetMagnet = slideMagnets[0] + loopCount * max;
const targetMagnetMin = targetMagnet - max;
const targetMagnetMax = targetMagnet + max;
const allMagnets = [targetMagnetMin, targetMagnet, targetMagnetMax];
if (typeof direction === 'string') {
const magnets = allMagnets.filter((magnet) =>
direction === 'next' ? magnet >= current : magnet <= current,
);
const magnet = closest(current, magnets);
return this.toCoord(magnet, duration);
}
const magnet = closest(current, allMagnets);
return this.toCoord(magnet, duration);
}
/** Go to next slide */
public next({
duration = this.props.duration,
skip = 1,
}: ISnapNexPrevArg = {}) {
const { props, slides, activeIndex } = this;
const index = props.loop
? loop(activeIndex + skip, 0, slides.length)
: Math.min(activeIndex + skip, slides.length - 1);
return this.toSlide(index, { duration, direction: 'next' });
}
/** Go to previous slide */
public prev({
duration = this.props.duration,
skip = 1,
}: ISnapNexPrevArg = {}) {
const { props, slides, activeIndex } = this;
const index = props.loop
? loop(activeIndex - skip, 0, slides.length)
: Math.max(activeIndex - skip, 0);
return this.toSlide(index, { duration, direction: 'prev' });
}
/**
* Destroys the component and clears all timeouts and resources.
*/
protected _destroy() {
super._destroy();
this._resizeHandler.remove();
this.cancelTransition();
this._raf.destroy();
this._slides.forEach((slide) => slide.detach());
}
}