549 lines
13 KiB
Svelte
549 lines
13 KiB
Svelte
<script>
|
|
import { onDestroy, onMount, tick, createEventDispatcher } from 'svelte'
|
|
import { createStore } from '../../store'
|
|
import Dots from '../Dots/Dots.svelte'
|
|
import Arrow from '../Arrow/Arrow.svelte'
|
|
import Progress from '../Progress/Progress.svelte'
|
|
import { NEXT, PREV } from '../../direction'
|
|
import { swipeable } from '../../actions/swipeable'
|
|
import { hoverable } from '../../actions/hoverable'
|
|
import { tappable } from '../../actions/tappable'
|
|
import {
|
|
applyParticleSizes,
|
|
getCurrentPageIndexByCurrentParticleIndex,
|
|
getPartialPageSize,
|
|
getPagesCountByParticlesCount,
|
|
getParticleIndexByPageIndex,
|
|
createResizeObserver,
|
|
} from '../../utils/page'
|
|
import {
|
|
getClones,
|
|
applyClones,
|
|
getClonesCount,
|
|
} from '../../utils/clones'
|
|
import {
|
|
getAdjacentIndexes,
|
|
} from '../../utils/lazy'
|
|
import {
|
|
getValueInRange,
|
|
} from '../../utils/math'
|
|
import { get } from '../../utils/object'
|
|
import { ProgressManager } from '../../utils/ProgressManager'
|
|
import { wait } from '../../utils/interval'
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
const autoplayDirectionFnDescription = {
|
|
[NEXT]: async () => await progressManager.start(showNextPage),
|
|
[PREV]: async () => await progressManager.start(showPrevPage)
|
|
}
|
|
|
|
const directionFnDescription = {
|
|
[NEXT]: showNextPage,
|
|
[PREV]: showPrevPage
|
|
}
|
|
|
|
/**
|
|
* CSS animation timing function
|
|
* examples: 'linear', 'steps(5, end)', 'cubic-bezier(0.1, -0.6, 0.2, 0)'
|
|
*/
|
|
export let timingFunction = 'ease-in-out';
|
|
|
|
/**
|
|
* Enable Next/Prev arrows
|
|
*/
|
|
export let arrows = true
|
|
|
|
/**
|
|
* Infinite looping
|
|
*/
|
|
export let infinite = true
|
|
|
|
/**
|
|
* Page to start on
|
|
*/
|
|
export let initialPageIndex = 0
|
|
|
|
/**
|
|
* Transition duration (ms)
|
|
*/
|
|
export let duration = 500
|
|
let _duration = duration
|
|
|
|
/**
|
|
* Enables autoplay of pages
|
|
*/
|
|
export let autoplay = false
|
|
$: {
|
|
applyAutoplayIfNeeded(autoplay)
|
|
}
|
|
|
|
/**
|
|
* Autoplay change interval (ms)
|
|
*/
|
|
export let autoplayDuration = 3000
|
|
|
|
/**
|
|
* Autoplay change direction ('next', 'prev')
|
|
*/
|
|
export let autoplayDirection = NEXT
|
|
|
|
/**
|
|
* Pause autoplay on focus
|
|
*/
|
|
export let pauseOnFocus = false
|
|
|
|
/**
|
|
* Show autoplay duration progress indicator
|
|
*/
|
|
export let autoplayProgressVisible = false
|
|
|
|
/**
|
|
* Current page indicator dots
|
|
*/
|
|
export let dots = true
|
|
|
|
/**
|
|
* Enable swiping
|
|
*/
|
|
export let swiping = true
|
|
|
|
/**
|
|
* Number of particles to show
|
|
*/
|
|
export let particlesToShow = 1
|
|
|
|
/**
|
|
* Number of particles to scroll
|
|
*/
|
|
export let particlesToScroll = 1
|
|
|
|
export async function goTo(pageIndex, options) {
|
|
const animated = get(options, 'animated', true)
|
|
if (typeof pageIndex !== 'number') {
|
|
throw new Error('pageIndex should be a number')
|
|
}
|
|
await showPage(getParticleIndexByPageIndex({
|
|
infinite,
|
|
pageIndex,
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTail: clonesCount.tail,
|
|
particlesToScroll,
|
|
particlesCount,
|
|
particlesToShow,
|
|
}), { animated })
|
|
}
|
|
|
|
export async function goToPrev(options) {
|
|
const animated = get(options, 'animated', true)
|
|
await showPrevPage({ animated })
|
|
}
|
|
|
|
export async function goToNext(options) {
|
|
const animated = get(options, 'animated', true)
|
|
await showNextPage({ animated })
|
|
}
|
|
|
|
let store = createStore()
|
|
|
|
$: clonesCount = getClonesCount({
|
|
infinite,
|
|
particlesToShow,
|
|
partialPageSize,
|
|
})
|
|
|
|
let currentParticleIndex = 0
|
|
$: currentPageIndex = getCurrentPageIndexByCurrentParticleIndex({
|
|
currentParticleIndex,
|
|
particlesCount,
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTotal: clonesCount.total,
|
|
infinite,
|
|
particlesToScroll,
|
|
})
|
|
$: dispatch('pageChange', currentPageIndex)
|
|
|
|
let particlesCount = 0
|
|
let particlesCountWithoutClones = 1
|
|
$: pagesCount = getPagesCountByParticlesCount({
|
|
infinite,
|
|
particlesCountWithoutClones,
|
|
particlesToScroll,
|
|
})
|
|
|
|
let partialPageSize = 0
|
|
|
|
let pageWindowWidth = 0
|
|
let particleWidth = 0
|
|
let offset = 0
|
|
let pageWindowElement
|
|
let particlesContainer
|
|
|
|
const pageWindowElementResizeObserver = createResizeObserver(({
|
|
width,
|
|
}) => {
|
|
pageWindowWidth = width
|
|
particleWidth = pageWindowWidth / particlesToShow
|
|
|
|
applyParticleSizes({
|
|
particlesContainerChildren: particlesContainer.children,
|
|
particleWidth,
|
|
})
|
|
offsetPage({
|
|
animated: false,
|
|
})
|
|
})
|
|
|
|
let focused = false
|
|
|
|
let progressValue
|
|
const progressManager = new ProgressManager({
|
|
autoplayDuration,
|
|
onProgressValueChange: (value) => {
|
|
progressValue = 1 - value
|
|
}
|
|
})
|
|
|
|
$: {
|
|
if (pauseOnFocus) {
|
|
if (focused) {
|
|
progressManager.pause()
|
|
} else {
|
|
progressManager.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
// used for lazy loading images, preloaded only current, adjacent and cloanable images
|
|
$: loaded = getAdjacentIndexes({
|
|
infinite,
|
|
pageIndex: currentPageIndex,
|
|
pagesCount,
|
|
particlesCount: particlesCountWithoutClones,
|
|
particlesToShow,
|
|
particlesToScroll,
|
|
}).particleIndexes
|
|
|
|
function addClones() {
|
|
const {
|
|
clonesToAppend,
|
|
clonesToPrepend,
|
|
} = getClones({
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTail: clonesCount.tail,
|
|
particlesContainerChildren: particlesContainer.children,
|
|
})
|
|
applyClones({
|
|
particlesContainer,
|
|
clonesToAppend,
|
|
clonesToPrepend,
|
|
})
|
|
}
|
|
|
|
async function applyAutoplayIfNeeded(autoplay) {
|
|
// prevent progress change if not infinite for first and last page
|
|
if (
|
|
!infinite && (
|
|
(autoplayDirection === NEXT && currentParticleIndex === particlesCount - 1) ||
|
|
(autoplayDirection === PREV && currentParticleIndex === 0)
|
|
)
|
|
) {
|
|
progressManager.reset()
|
|
return
|
|
}
|
|
|
|
if (autoplay) {
|
|
await autoplayDirectionFnDescription[autoplayDirection]()
|
|
}
|
|
}
|
|
|
|
let cleanupFns = []
|
|
|
|
onMount(() => {
|
|
(async () => {
|
|
await tick()
|
|
cleanupFns.push(store.subscribe(value => {
|
|
currentParticleIndex = value.currentParticleIndex
|
|
}))
|
|
cleanupFns.push(() => progressManager.reset())
|
|
if (particlesContainer && pageWindowElement) {
|
|
particlesCountWithoutClones = particlesContainer.children.length
|
|
|
|
particlesToShow = getValueInRange(1, particlesToShow, particlesCountWithoutClones)
|
|
particlesToScroll = getValueInRange(1, particlesToScroll, particlesCountWithoutClones)
|
|
initialPageIndex = getValueInRange(0, initialPageIndex, particlesCountWithoutClones - 1)
|
|
|
|
partialPageSize = getPartialPageSize({
|
|
particlesToScroll,
|
|
particlesToShow,
|
|
particlesCountWithoutClones,
|
|
})
|
|
|
|
await tick()
|
|
infinite && addClones()
|
|
particlesCount = particlesContainer.children.length
|
|
|
|
store.init(getParticleIndexByPageIndex({
|
|
infinite,
|
|
pageIndex: initialPageIndex,
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTail: clonesCount.tail,
|
|
particlesToScroll,
|
|
particlesCount,
|
|
particlesToShow,
|
|
}))
|
|
|
|
pageWindowElementResizeObserver.observe(pageWindowElement);
|
|
}
|
|
})()
|
|
})
|
|
|
|
onDestroy(() => {
|
|
pageWindowElementResizeObserver.disconnect()
|
|
cleanupFns.filter(fn => fn && typeof fn === 'function').forEach(fn => fn())
|
|
})
|
|
|
|
async function handlePageChange(pageIndex) {
|
|
await showPage(getParticleIndexByPageIndex({
|
|
infinite,
|
|
pageIndex,
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTail: clonesCount.tail,
|
|
particlesToScroll,
|
|
particlesCount,
|
|
particlesToShow,
|
|
}))
|
|
}
|
|
|
|
function offsetPage(options) {
|
|
const animated = get(options, 'animated', true)
|
|
return new Promise((resolve) => {
|
|
// _duration is an offset animation time
|
|
_duration = animated ? duration : 0
|
|
offset = -currentParticleIndex * particleWidth
|
|
setTimeout(() => {
|
|
resolve()
|
|
}, _duration)
|
|
})
|
|
}
|
|
|
|
// makes delayed jump to 1st or last element
|
|
async function jumpIfNeeded() {
|
|
let jumped = false
|
|
if (infinite) {
|
|
if (currentParticleIndex === 0) {
|
|
await showPage(particlesCount - clonesCount.total, { animated: false })
|
|
jumped = true
|
|
} else if (currentParticleIndex === particlesCount - clonesCount.tail) {
|
|
await showPage(clonesCount.head, { animated: false })
|
|
jumped = true
|
|
}
|
|
}
|
|
return jumped
|
|
}
|
|
|
|
// Disable page change while animation is in progress
|
|
let disabled = false
|
|
async function changePage(updateStoreFn, options) {
|
|
progressManager.reset()
|
|
if (disabled) return
|
|
disabled = true
|
|
|
|
updateStoreFn()
|
|
await offsetPage({ animated: get(options, 'animated', true) })
|
|
disabled = false
|
|
|
|
const jumped = await jumpIfNeeded()
|
|
!jumped && applyAutoplayIfNeeded(autoplay) // no need to wait it finishes
|
|
}
|
|
|
|
async function showPage(particleIndex, options) {
|
|
await changePage(
|
|
() => store.moveToParticle({
|
|
particleIndex,
|
|
particlesCount,
|
|
}),
|
|
options
|
|
)
|
|
}
|
|
async function showPrevPage(options) {
|
|
// TODO: return if disabled
|
|
await changePage(
|
|
() => store.prev({
|
|
infinite,
|
|
currentPageIndex,
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTail: clonesCount.tail,
|
|
particlesToScroll,
|
|
particlesCount,
|
|
particlesToShow,
|
|
}),
|
|
options,
|
|
)
|
|
}
|
|
async function showNextPage(options) {
|
|
// TODO: return if disabled
|
|
await changePage(
|
|
() => store.next({
|
|
infinite,
|
|
currentPageIndex,
|
|
particlesCount,
|
|
particlesToScroll,
|
|
particlesToShow,
|
|
clonesCountHead: clonesCount.head,
|
|
clonesCountTail: clonesCount.tail,
|
|
}),
|
|
options,
|
|
)
|
|
}
|
|
|
|
// gestures
|
|
function handleSwipeStart() {
|
|
if (!swiping) return
|
|
_duration = 0
|
|
}
|
|
async function handleSwipeThresholdReached(event) {
|
|
if (!swiping) return
|
|
await directionFnDescription[event.detail.direction]()
|
|
}
|
|
function handleSwipeMove(event) {
|
|
if (!swiping) return
|
|
offset += event.detail.dx
|
|
}
|
|
function handleSwipeEnd() {
|
|
if (!swiping) return
|
|
showPage(currentParticleIndex)
|
|
}
|
|
async function handleSwipeFailed() {
|
|
if (!swiping) return
|
|
await offsetPage({ animated: true })
|
|
}
|
|
|
|
function handleHovered(event) {
|
|
focused = event.detail.value
|
|
}
|
|
function handleTapped(event) {
|
|
focused = !focused
|
|
}
|
|
</script>
|
|
|
|
<div class="sc-carousel__carousel-container">
|
|
<div class="sc-carousel__content-container">
|
|
{#if arrows}
|
|
<slot name="prev" {showPrevPage}>
|
|
<div class="sc-carousel__arrow-container">
|
|
<Arrow
|
|
direction="prev"
|
|
disabled={!infinite && currentPageIndex === 0}
|
|
on:click={showPrevPage}
|
|
/>
|
|
</div>
|
|
</slot>
|
|
{/if}
|
|
<div
|
|
class="sc-carousel__pages-window"
|
|
bind:this={pageWindowElement}
|
|
|
|
use:hoverable
|
|
on:hovered={handleHovered}
|
|
|
|
use:tappable
|
|
on:tapped={handleTapped}
|
|
>
|
|
<div
|
|
class="sc-carousel__pages-container"
|
|
use:swipeable="{{ thresholdProvider: () => pageWindowWidth/3 }}"
|
|
on:swipeStart={handleSwipeStart}
|
|
on:swipeMove={handleSwipeMove}
|
|
on:swipeEnd={handleSwipeEnd}
|
|
on:swipeFailed={handleSwipeFailed}
|
|
on:swipeThresholdReached={handleSwipeThresholdReached}
|
|
style="
|
|
transform: translateX({offset}px);
|
|
transition-duration: {_duration}ms;
|
|
transition-timing-function: {timingFunction};
|
|
"
|
|
bind:this={particlesContainer}
|
|
>
|
|
<slot {loaded}></slot>
|
|
</div>
|
|
{#if autoplayProgressVisible}
|
|
<div class="sc-carousel-progress__container">
|
|
<Progress value={progressValue} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if arrows}
|
|
<slot name="next" {showNextPage}>
|
|
<div class="sc-carousel__arrow-container">
|
|
<Arrow
|
|
direction="next"
|
|
disabled={!infinite && currentPageIndex === pagesCount - 1}
|
|
on:click={showNextPage}
|
|
/>
|
|
</div>
|
|
</slot>
|
|
{/if}
|
|
</div>
|
|
{#if dots}
|
|
<slot
|
|
name="dots"
|
|
currentPageIndex={currentPageIndex}
|
|
pagesCount={pagesCount}
|
|
showPage={handlePageChange}
|
|
>
|
|
<Dots
|
|
pagesCount={pagesCount}
|
|
currentPageIndex={currentPageIndex}
|
|
on:pageChange={event => handlePageChange(event.detail)}
|
|
></Dots>
|
|
</slot>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
:root {
|
|
--sc-color-rgb-light-50p: rgba(93, 93, 93, 0.5);
|
|
--sc-color-rgb-light: #5d5d5d;
|
|
--sc-color-hex-dark-50p: rgba(30, 30, 30, 0.5);
|
|
--sc-color-hex-dark: #1e1e1e;
|
|
}
|
|
.sc-carousel__carousel-container {
|
|
display: flex;
|
|
width: 100%;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
.sc-carousel__content-container {
|
|
position: relative;
|
|
display: flex;
|
|
width: 100%;
|
|
}
|
|
.sc-carousel__pages-window {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
box-sizing: border-box;
|
|
position: relative;
|
|
}
|
|
.sc-carousel__pages-container {
|
|
width: 100%;
|
|
display: flex; /* to put child elements in one row */
|
|
transition-property: transform;
|
|
}
|
|
.sc-carousel__arrow-container {
|
|
padding: 5px;
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.sc-carousel-progress__container {
|
|
width: 100%;
|
|
height: 5px;
|
|
background-color: var(--sc-color-rgb-light-50p);
|
|
position: absolute;
|
|
bottom: 0;
|
|
}
|
|
</style>
|