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 { /** * Returns the default static properties. */ public _getStatic(): TRequiredProps { return { ...super._getStatic(), ...STATIC_PROPS }; } /** * Returns the default mutable properties. */ public _getMutable(): TRequiredProps { 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, onCallbacks?: TModuleOnCallbacksProps, ) { 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) { // 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) */ @noopIfDestroyed 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 */ @noopIfDestroyed 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 */ @noopIfDestroyed 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()); } }