|
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); |
|
} |
|
|