Optimization pass
This commit is contained in:
1 parent
fa97f2c369
commit
60a33e922e
2 files changed
+185
-42
No files matched your search
@@ -32,12 +32,6 @@
|
||||
|
||||
/* Options */
|
||||
|
||||
&[data-direction="y"] .scroll-container {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&[data-direction="y"] .scroll-container {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
@@ -45,14 +39,6 @@
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
&[data-gap] {
|
||||
--carousel-gap: attr(data-gap type(*), 0);
|
||||
|
||||
.scroll-container {
|
||||
gap: var(--carousel-gap, 0);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-snap-align="start"] .slide {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
+185
-28
@@ -1,12 +1,15 @@
|
||||
/* use strict; */
|
||||
|
||||
/** Initialize the carousel controller after the document has been parsed. */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.snappy = new Snappy();
|
||||
});
|
||||
|
||||
/** Discovers carousel elements and creates a controller for each one. */
|
||||
class Snappy {
|
||||
carousels = [];
|
||||
|
||||
/** Create controllers for all carousel elements in the document. */
|
||||
constructor() {
|
||||
document.querySelectorAll('.snappy-carousel').forEach(element => {
|
||||
this.carousels.push(new Carousel(element));
|
||||
@@ -14,6 +17,7 @@ class Snappy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles input, scrolling, navigation, and pagination for one carousel. */
|
||||
class Carousel {
|
||||
// DOM references
|
||||
scrollContainer = null;
|
||||
@@ -23,24 +27,34 @@ class Carousel {
|
||||
slides = [];
|
||||
paginationDots = [];
|
||||
|
||||
// Bahavioral constants
|
||||
// Behavioral constants
|
||||
inertia = 0.90; // Decay rate (0.9 = slower stop, 0.99 = longer slide)
|
||||
velocityFrameCount = 5;
|
||||
|
||||
// State variables
|
||||
isDragging = false;
|
||||
dragFrame = null;
|
||||
pendingDragPosition = null;
|
||||
scrollUpdateFrame = null;
|
||||
autoScrolling = false;
|
||||
direction = 'x';
|
||||
startPosition = 0;
|
||||
startScrollPosition = 0;
|
||||
velocity = 0;
|
||||
velocitySamples = [];
|
||||
pendingPointerPosition = null;
|
||||
lastPosition;
|
||||
lastTime;
|
||||
activeSlideIndex = 0;
|
||||
renderedActiveSlideIndex = null;
|
||||
|
||||
/**
|
||||
* Bind a carousel element to its controls and initialize its active slide.
|
||||
* @param {HTMLElement} element - The element containing the carousel markup.
|
||||
*/
|
||||
constructor(element) {
|
||||
this.scrollContainer = element.querySelector('.scroll-container');
|
||||
this.direction = element.dataset.direction === 'y' ? 'y' : 'x';
|
||||
const gap = element.dataset.gap;
|
||||
if (gap) this.scrollContainer.style.setProperty('--carousel-gap', gap);
|
||||
this.slides = this.scrollContainer.querySelectorAll('.slide');
|
||||
|
||||
this.prevButton = document.querySelector(`.prev`);
|
||||
@@ -51,9 +65,19 @@ class Carousel {
|
||||
this.scrollContainer.addEventListener('mousemove', this.drag.bind(this));
|
||||
this.scrollContainer.addEventListener('mouseup', this.endDrag.bind(this));
|
||||
this.scrollContainer.addEventListener('mouseleave', this.endDrag.bind(this));
|
||||
this.scrollContainer.addEventListener('touchstart', this.reset.bind(this));
|
||||
this.scrollContainer.addEventListener('wheel', this.reset.bind(this));
|
||||
this.scrollContainer.addEventListener('scroll', this.updateActiveSlide.bind(this));
|
||||
|
||||
this.scrollContainer.addEventListener('touchstart', this.resetDragStates.bind(this));
|
||||
this.scrollContainer.addEventListener('wheel', this.resetDragStates.bind(this));
|
||||
|
||||
this.scrollContainer.addEventListener('scroll', () => {
|
||||
if (this.scrollUpdateFrame !== null) return;
|
||||
|
||||
// Coalesce bursts of scroll events into one layout read per frame.
|
||||
this.scrollUpdateFrame = requestAnimationFrame(() => {
|
||||
this.scrollUpdateFrame = null;
|
||||
this.updateActiveSlide();
|
||||
});
|
||||
});
|
||||
|
||||
if (this.prevButton) {
|
||||
this.prevButton.addEventListener('click', () => this.prev());
|
||||
@@ -70,52 +94,74 @@ class Carousel {
|
||||
this.updateActiveSlide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin mouse dragging and record the starting pointer and scroll positions.
|
||||
* @param {MouseEvent} e - The mouse-down event.
|
||||
* @returns {void}
|
||||
*/
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
this.autoScrolling = false;
|
||||
this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping
|
||||
this.velocity = 0;
|
||||
this.lastPosition = undefined;
|
||||
this.lastTime = undefined;
|
||||
this.startPosition = this.getPointerPosition(e);
|
||||
this.lastPosition = this.startPosition;
|
||||
this.lastTime = performance.now();
|
||||
this.velocitySamples = [];
|
||||
this.pendingPointerPosition = null;
|
||||
this.startScrollPosition = this.getScrollPosition();
|
||||
e.preventDefault(); // Prevent text selection during drag
|
||||
}
|
||||
|
||||
/**
|
||||
* Track pointer velocity and schedule the latest drag position for painting.
|
||||
* @param {MouseEvent} e - The mouse-move event.
|
||||
* @returns {void}
|
||||
*/
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
const pointerPosition = this.getPointerPosition(e);
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Calculate velocity (pixels per millisecond)
|
||||
if (this.lastTime !== undefined && currentTime > this.lastTime) {
|
||||
this.velocity = (pointerPosition - this.lastPosition) / (currentTime - this.lastTime);
|
||||
}
|
||||
|
||||
this.lastPosition = pointerPosition;
|
||||
this.lastTime = currentTime;
|
||||
this.pendingPointerPosition = pointerPosition;
|
||||
|
||||
const distance = pointerPosition - this.startPosition;
|
||||
requestAnimationFrame(() => {
|
||||
this.setScrollPosition(this.startScrollPosition - distance);
|
||||
this.pendingDragPosition = this.startScrollPosition - distance;
|
||||
|
||||
// Keep at most one pending write; the callback applies the newest position.
|
||||
if (this.dragFrame === null) {
|
||||
this.dragFrame = requestAnimationFrame(() => {
|
||||
this.dragFrame = null;
|
||||
this.flushPendingDragPosition();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish dragging and apply inertial motion when the release velocity warrants it.
|
||||
* @returns {void}
|
||||
*/
|
||||
endDrag() {
|
||||
if (!this.isDragging) return;
|
||||
this.flushPendingDragPosition();
|
||||
this.isDragging = false;
|
||||
|
||||
// If the drag velocity is low at release, let the native scroll snap take over
|
||||
if (Math.abs(this.velocity) < 0.05) {
|
||||
this.reset();
|
||||
this.resetDragStates();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.calculateDragTarget();
|
||||
const distance = target - this.getScrollPosition();
|
||||
|
||||
// Scale the initial velocity so its geometric decay converges on the chosen target.
|
||||
this.velocity = -distance * (1 - this.inertia) / 10;
|
||||
|
||||
/**
|
||||
* Advance the inertial scroll until it reaches the selected snap target.
|
||||
* @returns {void}
|
||||
*/
|
||||
const animateInertia = () => {
|
||||
if (this.isDragging) return; // Stop the animation if the user starts dragging again
|
||||
|
||||
@@ -147,19 +193,38 @@ class Carousel {
|
||||
animateInertia();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the final drag position and restore native snapping behavior.
|
||||
* @param {number} target - The final scroll position.
|
||||
* @returns {void}
|
||||
*/
|
||||
finishDrag(target) {
|
||||
this.setScrollPosition(target);
|
||||
this.reset();
|
||||
this.resetDragStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pointer coordinate on the carousel's scrolling axis.
|
||||
* @param {MouseEvent} event - The current pointer event.
|
||||
* @returns {number} The pointer coordinate in viewport pixels.
|
||||
*/
|
||||
getPointerPosition(event) {
|
||||
return this.direction === 'y' ? event.clientY : event.clientX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the scroll offset on the carousel's scrolling axis.
|
||||
* @returns {number} The current scroll offset in pixels.
|
||||
*/
|
||||
getScrollPosition() {
|
||||
return this.direction === 'y' ? this.scrollContainer.scrollTop : this.scrollContainer.scrollLeft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the scroll offset on the carousel's scrolling axis.
|
||||
* @param {number} position - The target scroll offset in pixels.
|
||||
* @returns {void}
|
||||
*/
|
||||
setScrollPosition(position) {
|
||||
if (this.direction === 'y') {
|
||||
this.scrollContainer.scrollTop = position;
|
||||
@@ -168,6 +233,10 @@ class Carousel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the reachable snap target closest to the inertia-projected stopping point.
|
||||
* @returns {number} The target scroll offset in pixels.
|
||||
*/
|
||||
calculateDragTarget() {
|
||||
const element = this.scrollContainer;
|
||||
const isVertical = this.direction === 'y';
|
||||
@@ -183,7 +252,7 @@ class Carousel {
|
||||
const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft';
|
||||
const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight';
|
||||
|
||||
// Project the final scroll position based on current velocity and inertia
|
||||
// Project the stopping point from velocity, then constrain it to the scroll range.
|
||||
const projectedStop = clamp(
|
||||
this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia)
|
||||
);
|
||||
@@ -192,6 +261,7 @@ class Carousel {
|
||||
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const elementStyle = getComputedStyle(element);
|
||||
// Scroll padding defines the snapport; scroll margins define each slide's snap area.
|
||||
const snapportStartEdge =
|
||||
elementRect[startEdge] + (isVertical ? element.clientTop : element.clientLeft);
|
||||
const clientExtent = isVertical ? element.clientHeight : element.clientWidth;
|
||||
@@ -226,6 +296,7 @@ class Carousel {
|
||||
return clamp(target);
|
||||
});
|
||||
|
||||
// Select the snap target nearest the projected stopping point.
|
||||
return targets.reduce((nearest, target) =>
|
||||
Math.abs(target - projectedStop) < Math.abs(nearest - projectedStop)
|
||||
? target
|
||||
@@ -233,12 +304,59 @@ class Carousel {
|
||||
);
|
||||
}
|
||||
|
||||
reset() {
|
||||
/**
|
||||
* Flush pending drag movement and restore non-dragging state.
|
||||
* @returns {void}
|
||||
*/
|
||||
resetDragStates() {
|
||||
this.flushPendingDragPosition();
|
||||
this.isDragging = false;
|
||||
this.autoScrolling = false;
|
||||
this.scrollContainer.classList.remove('is-dragging');
|
||||
this.velocity = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a queued drag frame and apply its latest position immediately.
|
||||
* @returns {void}
|
||||
*/
|
||||
flushPendingDragPosition() {
|
||||
if (this.dragFrame !== null) {
|
||||
cancelAnimationFrame(this.dragFrame);
|
||||
this.dragFrame = null;
|
||||
}
|
||||
|
||||
if (this.pendingPointerPosition !== null) {
|
||||
const currentTime = performance.now();
|
||||
|
||||
if (currentTime > this.lastTime) {
|
||||
const sample =
|
||||
(this.pendingPointerPosition - this.lastPosition) / (currentTime - this.lastTime);
|
||||
this.velocitySamples.push(sample);
|
||||
|
||||
if (this.velocitySamples.length > this.velocityFrameCount) {
|
||||
this.velocitySamples.shift();
|
||||
}
|
||||
|
||||
this.velocity = this.velocitySamples.reduce((total, value) => total + value, 0) /
|
||||
this.velocitySamples.length;
|
||||
}
|
||||
|
||||
this.lastPosition = this.pendingPointerPosition;
|
||||
this.lastTime = currentTime;
|
||||
this.pendingPointerPosition = null;
|
||||
}
|
||||
|
||||
if (this.pendingDragPosition !== null) {
|
||||
this.setScrollPosition(this.pendingDragPosition);
|
||||
this.pendingDragPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one clickable pagination dot for each slide.
|
||||
* @returns {void}
|
||||
*/
|
||||
createPagination() {
|
||||
this.slides.forEach((slide, index) => {
|
||||
// Populate the pagination container with dots for each slide
|
||||
@@ -254,6 +372,10 @@ class Carousel {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the slide whose reachable snap position is nearest the current offset.
|
||||
* @returns {number} The active slide index, or -1 when there are no slides.
|
||||
*/
|
||||
getActiveSlideIndex() {
|
||||
if (!this.slides.length) return -1;
|
||||
|
||||
@@ -267,12 +389,19 @@ class Carousel {
|
||||
const cssPixels = value => Number.parseFloat(value) || 0;
|
||||
const containerRect = this.scrollContainer.getBoundingClientRect();
|
||||
const containerStyle = getComputedStyle(this.scrollContainer);
|
||||
// Compare reachable snap positions so end-of-scroll clamping matches browser snapping.
|
||||
const snapportStartEdge = containerRect[startEdge] +
|
||||
(isVertical ? this.scrollContainer.clientTop : this.scrollContainer.clientLeft);
|
||||
const clientExtent = isVertical ? this.scrollContainer.clientHeight : this.scrollContainer.clientWidth;
|
||||
const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]);
|
||||
const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]);
|
||||
const snapportCenter = (snapportStart + snapportEnd) / 2;
|
||||
const scrollPosition = this.getScrollPosition();
|
||||
const maxScrollPosition = isVertical
|
||||
? this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight
|
||||
: this.scrollContainer.scrollWidth - this.scrollContainer.clientWidth;
|
||||
const clamp = value => Math.max(0, Math.min(value, maxScrollPosition));
|
||||
const isAtEnd = maxScrollPosition > 0 && scrollPosition >= maxScrollPosition - 1;
|
||||
|
||||
let activeIndex = 0;
|
||||
let closestDistance = Infinity;
|
||||
@@ -285,18 +414,21 @@ class Carousel {
|
||||
const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/);
|
||||
const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0];
|
||||
|
||||
let distance;
|
||||
let snapPosition;
|
||||
|
||||
if (align === 'start') {
|
||||
distance = Math.abs(snapAreaStart - snapportStart);
|
||||
snapPosition = scrollPosition + snapAreaStart - snapportStart;
|
||||
} else if (align === 'end') {
|
||||
distance = Math.abs(snapAreaEnd - snapportEnd);
|
||||
snapPosition = scrollPosition + snapAreaEnd - snapportEnd;
|
||||
} else {
|
||||
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2;
|
||||
distance = Math.abs(snapAreaCenter - snapportCenter);
|
||||
snapPosition = scrollPosition + snapAreaCenter - snapportCenter;
|
||||
}
|
||||
|
||||
if (distance < closestDistance) {
|
||||
const distance = Math.abs(clamp(snapPosition) - scrollPosition);
|
||||
|
||||
// At the scroll limit, prefer the later slide when clamping makes targets tie.
|
||||
if (distance < closestDistance || (isAtEnd && distance === closestDistance)) {
|
||||
activeIndex = index;
|
||||
closestDistance = distance;
|
||||
}
|
||||
@@ -305,8 +437,17 @@ class Carousel {
|
||||
return activeIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the active slide and pagination classes when the index changes.
|
||||
* @returns {void}
|
||||
*/
|
||||
updateActiveSlide() {
|
||||
if (!this.autoScrolling) {
|
||||
this.activeSlideIndex = this.getActiveSlideIndex();
|
||||
}
|
||||
|
||||
if (this.activeSlideIndex === this.renderedActiveSlideIndex) return;
|
||||
this.renderedActiveSlideIndex = this.activeSlideIndex;
|
||||
|
||||
this.slides.forEach((slide, index) => {
|
||||
slide.classList.toggle('is-active', index === this.activeSlideIndex);
|
||||
@@ -318,14 +459,30 @@ class Carousel {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Navigate to a slide by index and mark it active during programmatic scrolling.
|
||||
* @param {number} index - The zero-based slide index.
|
||||
* @returns {void}
|
||||
*/
|
||||
goToSlide(index) {
|
||||
this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1));
|
||||
this.autoScrolling = true;
|
||||
this.updateActiveSlide();
|
||||
this.slides[this.activeSlideIndex].scrollIntoView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next slide.
|
||||
* @returns {void}
|
||||
*/
|
||||
next() {
|
||||
this.goToSlide(this.activeSlideIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the previous slide.
|
||||
* @returns {void}
|
||||
*/
|
||||
prev() {
|
||||
this.goToSlide(this.activeSlideIndex - 1);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user