vevet
Version:
Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.
598 lines (470 loc) • 14 kB
text/typescript
import { Module } from '@/base';
import { Timeline } from '../Timeline';
import {
ISnapCallbacksMap,
ISnapMagnet,
ISnapMutableProps,
ISnapStaticProps,
} from './types';
import { TRequiredProps } from '@/internal/requiredProps';
import { Raf } from '../Raf';
import {
IOnResize,
onResize,
scoped,
loop,
clamp,
EaseOutCubic,
damp,
lerp,
toPixels,
} 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(),
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,
} 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[] = [];
/** Timeline for smooth transitions */
protected _timeline?: Timeline;
/** Resize handler */
protected _resizeHandler: IOnResize;
/** Active slide index */
protected _activeIndex: 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;
}
/** Container size depending on direction (width or height) */
get domSize() {
return this._domSize;
}
/** All slides */
get slides() {
return this._slides;
}
/** 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;
}
// Calculate static values
slides.reduce((prev, slide) => {
slide.setStaticCoord(prev);
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.set(slide.staticCoord);
} else {
this._track.clampTarget();
}
// 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: 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 { _track: track, props } = this;
// Update values
this._updateSlidesCoords();
this._updateSlidesProgress();
// Get magnet after slide coordinates are updated
const { magnet, _swipe: swipe } = this;
if (!magnet) {
return;
}
// Active index change
if (magnet.slide.index !== this._activeIndex) {
this._activeIndex = magnet.slide.index;
this.callbacks.emit('activeSlide', this.activeSlide);
}
// Check if friction is allowed
const canHaveFriction =
(swipe.isSwiping && swipe.allowFriction) || !swipe.isSwiping;
// Apply friction
if (
!track.isSlideScrolling &&
!props.freemode &&
canHaveFriction &&
frameDuration > 0 &&
props.friction >= 0
) {
track.target = damp(
track.target,
track.current + magnet.magnetDiff,
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, domSize, _track: track } = this;
const { centered: isCentered } = this.props;
slides.forEach((slide) => {
const { staticCoord, size } = slide;
const offset = isCentered ? domSize / 2 - slides[0].size / 2 : 0;
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),
);
});
}
/** 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 slide magnets */
protected get magnets(): ISnapMagnet[] {
const { domSize, props, track } = this;
const threshold = props.centered ? domSize / 2 : 0;
const slideMagnets = this.slides.map((slide) => {
const firstPoint = props.centered
? slide.coord + slide.size / 2
: slide.coord;
const points: number[] = [firstPoint];
if (slide.size > domSize) {
if (props.centered) {
points.push(firstPoint + (domSize - slide.size) / 2);
points.push(firstPoint - (domSize - slide.size) / 2);
} else {
points.push(firstPoint + slide.size - domSize);
}
}
return points.map<ISnapMagnet>((point) => ({
slide,
magnetCoord: point,
magnetDiff: point - threshold,
staticMagnetCoord: point + this._track.current,
}));
});
const flatMagnets = slideMagnets.flat();
if (!track.canLoop && !props.centered) {
flatMagnets.forEach((magnet) => {
const oldStaticMagnetCoord = magnet.staticMagnetCoord;
const newStaticMagnetCoord = clamp(
magnet.staticMagnetCoord,
0,
this._track.max,
);
const diff = oldStaticMagnetCoord - newStaticMagnetCoord;
magnet.staticMagnetCoord = newStaticMagnetCoord;
magnet.magnetCoord -= diff;
magnet.magnetDiff -= diff;
});
}
return flatMagnets;
}
/** Get nearest magnet */
protected get magnet(): ISnapMagnet | undefined {
if (this.isEmpty) {
return undefined;
}
const magnets = this.magnets.sort(
(a, b) => Math.abs(a.magnetDiff) - Math.abs(b.magnetDiff),
);
return magnets[0];
}
/** 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.magnetDiff);
}
/** Go to a definite coordinate */
public toCoord(coordinate: number, duration = this.props.duration) {
if (this.isEmpty) {
return;
}
this.cancelTransition();
const start = this._track.current;
const end = coordinate;
const diff = Math.abs(end - start);
const tm = new Timeline({
duration: typeof duration === 'number' ? duration : duration(diff),
easing: this.props.easing,
});
this._timeline = tm;
tm.on('start', () => this.callbacks.emit('timelineStart', undefined));
tm.on('update', (data) => {
this._track.current = lerp(start, end, data.eased);
this._track.target = this._track.current;
this._render();
this.callbacks.emit('timelineUpdate', data);
});
tm.on('end', () => {
tm.destroy();
this.callbacks.emit('timelineEnd', undefined);
this._timeline = undefined;
});
tm.play();
}
/** Go to a slide by index */
public toSlide(index: number, duration = this.props.duration) {
if (index === this.activeIndex) {
this.stick();
return;
}
const magnet = this.magnets.find((data) => data.slide.index === index);
if (!magnet) {
return;
}
this.toCoord(this._track.current + magnet.magnetDiff, duration);
}
/** Go to next slide */
public next(duration = this.props.duration) {
const { props, slides, activeIndex } = this;
const index = props.loop
? loop(activeIndex + 1, 0, slides.length)
: Math.min(activeIndex + 1, slides.length - 1);
this.toSlide(index, duration);
}
/** Go to previous slide */
public prev(duration = this.props.duration) {
const { props, slides, activeIndex } = this;
const index = props.loop
? loop(activeIndex - 1, 0, slides.length)
: Math.max(activeIndex - 1, 0);
this.toSlide(index, duration);
}
/**
* 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());
}
}