| const FLING_VELOCITY_THRESHOLD = 10; |
| const FLING_VELOCITY_WINDOW_SIZE = 20; |
|
|
| window.addEventListener("DOMContentLoaded", initializeCarousel); |
| window.addEventListener("pointerup", () => dragEnd(false)); |
| window.addEventListener("scroll", () => dragEnd(true)); |
| window.addEventListener("pointermove", dragMove); |
|
|
| const carousels = []; |
|
|
| function initializeCarousel() { |
| const carouselContainers = document.querySelectorAll("[data-carousel]"); |
|
|
| carouselContainers.forEach((carouselContainer) => { |
| const slideImages = carouselContainer.querySelectorAll("[data-carousel-slide] [data-carousel-image]"); |
| const tornLeft = carouselContainer.querySelector("[data-carousel-slide-torn-left]"); |
| const tornRight = carouselContainer.querySelector("[data-carousel-slide-torn-right]"); |
| [tornLeft, tornRight].forEach((insertInsideElement) => { |
| slideImages.forEach((image) => { |
| const clonedImage = image.cloneNode(true); |
| if (clonedImage instanceof HTMLImageElement) clonedImage.alt = ""; |
| insertInsideElement.insertAdjacentElement("beforeend", clonedImage); |
| }); |
| }); |
|
|
| const images = carouselContainer.querySelectorAll("[data-carousel-image]"); |
| const directionPrev = carouselContainer.querySelector("[data-carousel-prev]"); |
| const directionNext = carouselContainer.querySelector("[data-carousel-next]"); |
| const dots = carouselContainer.querySelectorAll("[data-carousel-dot]"); |
| const descriptions = carouselContainer.querySelectorAll("[data-carousel-description]"); |
| const performJostleHint = carouselContainer.hasAttribute("data-carousel-jostle-hint"); |
| const dragLastClientX = undefined; |
| const velocityDeltaWindow = Array.from({ length: FLING_VELOCITY_WINDOW_SIZE }, () => ({ time: 0, delta: 0 })); |
| const jostleNoLongerNeeded = false; |
|
|
| const carousel = { |
| carouselContainer, |
| images, |
| directionPrev, |
| directionNext, |
| dots, |
| descriptions, |
| dragLastClientX, |
| velocityDeltaWindow, |
| jostleNoLongerNeeded, |
| requestAnimationFrameActive: false, |
| }; |
| carousels.push(carousel); |
|
|
| images.forEach((image) => { |
| image.addEventListener("pointerdown", dragBegin); |
| }); |
| directionPrev.addEventListener("click", () => slideDirection(carousel, "prev", true, false)); |
| directionNext.addEventListener("click", () => slideDirection(carousel, "next", true, false)); |
| Array.from(dots).forEach((dot) => |
| dot.addEventListener("click", (event) => { |
| const index = Array.from(dots).indexOf(event.target); |
| slideTo(carousel, index, true); |
| }) |
| ); |
|
|
| |
| if (performJostleHint) { |
| window.addEventListener("load", () => { |
| new IntersectionObserver((entries) => { |
| entries.forEach((entry) => { |
| if (entry.intersectionRatio === 1 && currentTransform(carousel) === 0 && !carousel.jostleNoLongerNeeded) { |
| const JOSTLE_TIME = 1000; |
| const MAX_JOSTLE_DISTANCE = -10; |
|
|
| let startTime; |
| const buildUp = (timeStep) => { |
| if (carousel.jostleNoLongerNeeded) { |
| carousel.carouselContainer.classList.remove("jostling"); |
| return; |
| } |
|
|
| if (!startTime) startTime = timeStep; |
| const elapsedTime = timeStep - startTime; |
|
|
| const easeOutCirc = (x) => Math.sqrt(1 - Math.pow(x - 1, 2)); |
| const movementFactor = easeOutCirc(Math.min(1, elapsedTime / JOSTLE_TIME)); |
|
|
| setCurrentTransform(carousel, movementFactor * MAX_JOSTLE_DISTANCE, "%", false, true); |
|
|
| if (elapsedTime < JOSTLE_TIME) { |
| requestAnimationFrame(buildUp); |
| } else { |
| carousel.carouselContainer.classList.remove("jostling"); |
| carousel.jostleNoLongerNeeded = true; |
| slideTo(carousel, 0, true); |
| } |
| }; |
| carousel.carouselContainer.classList.add("jostling") |
| requestAnimationFrame(buildUp); |
| }; |
| }); |
| }, { threshold: 1 }) |
| .observe(directionPrev); |
| }); |
| } |
| }); |
| } |
|
|
| function slideDirection(carousel, direction, smooth, clamped = false) { |
| const directionIndexOffset = { prev: -1, next: 1 }[direction]; |
| const offsetDotIndex = currentClosestImageIndex(carousel) + directionIndexOffset; |
|
|
| const nextDotIndex = (offsetDotIndex + carousel.dots.length) % carousel.dots.length; |
| const unwrappedNextDotIndex = clamp(offsetDotIndex, 0, carousel.dots.length - 1); |
|
|
| if (clamped) slideTo(carousel, unwrappedNextDotIndex, smooth); |
| else slideTo(carousel, nextDotIndex, smooth); |
| } |
|
|
| function slideTo(carousel, index, smooth) { |
| const activeDot = carousel.carouselContainer.querySelector("[data-carousel-dot].active"); |
| activeDot.classList.remove("active"); |
| carousel.dots[index].classList.add("active"); |
|
|
| const activeDescription = carousel.carouselContainer.querySelector("[data-carousel-description].active"); |
| if (activeDescription) { |
| activeDescription.classList.remove("active"); |
| carousel.descriptions[index].classList.add("active"); |
| } |
|
|
| |
| const offsetIndex = index + 1; |
| const slideImages = Array.from(carousel.carouselContainer.querySelectorAll("[data-carousel-slide] [data-carousel-image]")); |
| |
| slideImages[clamp(offsetIndex - 2, 0, slideImages.length - 1)].removeAttribute("loading"); |
| slideImages[clamp(offsetIndex - 1, 0, slideImages.length - 1)].removeAttribute("loading"); |
| slideImages[clamp(offsetIndex, 0, slideImages.length - 1)].removeAttribute("loading"); |
| slideImages[clamp(offsetIndex + 1, 0, slideImages.length - 1)].removeAttribute("loading"); |
| slideImages[clamp(offsetIndex + 2, 0, slideImages.length - 1)].removeAttribute("loading"); |
|
|
| setCurrentTransform(carousel, index * -100, "%", smooth); |
| } |
|
|
| function currentTransform(carousel) { |
| const currentTransformMatrix = window.getComputedStyle(carousel.images[1]).transform; |
| |
| const xValue = Number(currentTransformMatrix.split(",")[4] || "0"); |
|
|
| return xValue + carousel.images[1].getBoundingClientRect().width; |
| } |
|
|
| function setCurrentTransform(carousel, x, unit, smooth, doNotTerminateJostle = false) { |
| const xInitial = currentTransform(carousel); |
| let xValue = x; |
| if (unit === "%") xValue = x - 100; |
| if (unit === "px") xValue = x - carousel.images[1].getBoundingClientRect().width; |
|
|
| Array.from(carousel.images).forEach((image) => { |
| image.style.transitionTimingFunction = smooth ? "ease-in-out" : "cubic-bezier(0, 0, 0.2, 1)"; |
| image.style.transform = `translateX(${xValue}${unit})`; |
| }); |
|
|
| |
| if (!doNotTerminateJostle && x - xInitial < 0.0001) carousel.jostleNoLongerNeeded = true; |
|
|
| const distance = unit === "%" ? x : x / carousel.images[1].getBoundingClientRect().width * 100; |
| const overSlidingLeft = distance > 0; |
| const overSlidingRight = distance < (carousel.dots.length - 1) * -100; |
|
|
| if ((overSlidingLeft || overSlidingRight) && !carousel.requestAnimationFrameActive) updateOverSlide(carousel); |
| } |
|
|
| function updateOverSlide(carousel) { |
| const paddingLeft = parseInt(getComputedStyle(carousel.images[1]).paddingLeft); |
| const paddingRight = parseInt(getComputedStyle(carousel.images[carousel.images.length - 2]).paddingRight); |
| const slidLeftDistance = carousel.images[1].getBoundingClientRect().left + paddingLeft - carousel.images[1].parentElement.getBoundingClientRect().left; |
| const slidRightDistance = -(carousel.images[carousel.images.length - 2].getBoundingClientRect().right - paddingRight - carousel.images[1].parentElement.getBoundingClientRect().right); |
| const imageWidth = carousel.images[1].getBoundingClientRect().width; |
| const overSlideFactor = Math.min(1, Math.max(0, (Math.max(slidLeftDistance, slidRightDistance) / imageWidth))); |
|
|
| const images = carousel.images[0].closest("[data-carousel]").querySelectorAll("[data-carousel-image]:first-child, [data-carousel-image]:last-child"); |
|
|
| |
| if (overSlideFactor > 0) { |
| images.forEach((image) => { |
| image.style.setProperty("--over-slide-factor", overSlideFactor); |
| }); |
|
|
| carousel.requestAnimationFrameActive = true; |
| requestAnimationFrame(() => updateOverSlide(carousel)); |
| } else { |
| images.forEach((image) => { |
| image.style.removeProperty("--over-slide-factor"); |
| }); |
|
|
| carousel.requestAnimationFrameActive = false; |
| } |
| } |
|
|
| function currentClosestImageIndex(carousel) { |
| const currentTransformX = -currentTransform(carousel); |
|
|
| const imageWidth = carousel.images[1].getBoundingClientRect().width; |
| return Math.round(currentTransformX / imageWidth); |
| } |
|
|
| function currentActiveDotIndex(carousel) { |
| const activeDot = carousel.carouselContainer.querySelector("[data-carousel-dot].active"); |
| return Array.from(carousel.dots).indexOf(activeDot); |
| } |
|
|
| function dragBegin(event) { |
| const carouselContainer = event.target.closest("[data-carousel]"); |
| const carousel = carousels.find((carousel) => carousel.carouselContainer === carouselContainer); |
| if (!carousel) return; |
|
|
| event.preventDefault(); |
|
|
| carousel.dragLastClientX = event.clientX; |
|
|
| setCurrentTransform(carousel, currentTransform(carousel), "px", false); |
| carouselContainer.classList.add("dragging"); |
| } |
|
|
| function dragEnd(dropWithoutVelocity) { |
| const carousel = carousels.find((carousel) => carousel.dragLastClientX !== undefined); |
| if (!carousel) return; |
|
|
| if (!carousel.images) return; |
|
|
| carousel.dragLastClientX = undefined; |
|
|
| carousel.carouselContainer.classList.remove("dragging"); |
|
|
| const onlyRecentVelocityDeltaWindow = carousel.velocityDeltaWindow.filter((delta) => delta.time > Date.now() - 1000); |
| const timeRange = Date.now() - (onlyRecentVelocityDeltaWindow[0]?.time ?? NaN); |
| |
| const recentVelocity = onlyRecentVelocityDeltaWindow.reduce((acc, entry) => { |
| const timeSinceNow = Date.now() - entry.time; |
| const recencyFactorScore = 1 - timeSinceNow / timeRange; |
|
|
| return acc + entry.delta * recencyFactorScore; |
| }, 0); |
|
|
| const closestImageIndex = currentClosestImageIndex(carousel); |
| const activeDotIndex = currentActiveDotIndex(carousel); |
|
|
| |
| if (Math.abs(recentVelocity) > FLING_VELOCITY_THRESHOLD && !dropWithoutVelocity) { |
| |
| if (recentVelocity > 0) { |
| |
| if (closestImageIndex >= activeDotIndex) { |
| slideDirection(carousel, "prev", false, true); |
| return; |
| } |
| } |
| |
| else { |
| |
| |
| if (closestImageIndex <= activeDotIndex) { |
| slideDirection(carousel, "next", false, true); |
| return; |
| } |
| } |
| } |
|
|
| |
| |
| slideTo(carousel, clamp(closestImageIndex, 0, carousel.dots.length - 1), true); |
| } |
|
|
| function dragMove(event) { |
| const carouselContainer = event.target.closest("[data-carousel]"); |
| const carousel = carousels.find((carousel) => carousel.carouselContainer === carouselContainer); |
| if (!carousel) return; |
|
|
| if (carousel.dragLastClientX === undefined) return; |
|
|
| event.preventDefault(); |
|
|
| const LEFT_MOUSE_BUTTON = 1; |
| if (!(event.buttons & LEFT_MOUSE_BUTTON)) { |
| dragEnd(false); |
| return; |
| } |
|
|
| const deltaX = event.clientX - carousel.dragLastClientX; |
| carousel.velocityDeltaWindow.shift(); |
| carousel.velocityDeltaWindow.push({ time: Date.now(), delta: deltaX }); |
|
|
| const newTransformX = currentTransform(carousel) + deltaX; |
| setCurrentTransform(carousel, newTransformX, "px", false); |
|
|
| carousel.dragLastClientX = event.clientX; |
| } |
|
|
| function clamp(value, min, max) { |
| const m = Math; |
| return m.min(m.max(value, min), max); |
| } |
|
|