diff --git a/README.md b/README.md index da5190f..5718f80 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ It does not implement every feature found in other carousel libraries. Instead, Mouse-drag behavior closely follows native touch scrolling in the browser, including inertia and snapping to a slide. Thus dragging with the mouse feels pretty identical to native scrolling on a touch device or using a trackpad. -| File | Size | gzip | -| ---------- | ------: | -----: | -| snappy.js | 7.5k kB | 2.2 KB | -| snappy.css | 0.9 B | 0.4 KB | +| File | Size | Ggzip | Brotli | +| ---------- | ------: | ------: | ------: | +| `snappy.css` | 0.91 kB | 0.43 kB | 0.36 kB | +| `snappy.js` | 7.65 kB | 2.20 kB | 1.95 kB | ## Usage diff --git a/src/snappy.js b/src/snappy.js index 7a4008a..03948ed 100644 --- a/src/snappy.js +++ b/src/snappy.js @@ -30,9 +30,12 @@ class Carousel { // Behavioral constants inertia = 0.92; // Decay rate (0.9 = slower stop, 0.99 = longer slide) velocityFrameCount = 2; + dragThreshold = 5; // State variables isDragging = false; + hasDragged = false; + suppressNextClick = false; dragFrame = null; pendingDragPosition = null; scrollUpdateFrame = null; @@ -65,8 +68,20 @@ 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('click', event => { + if (!this.suppressNextClick) return; - this.scrollContainer.addEventListener('touchstart', this.resetDragStates.bind(this)); + this.suppressNextClick = false; + if (event.detail === 0) return; + + event.preventDefault(); + event.stopPropagation(); + }, true); + + this.scrollContainer.addEventListener('touchstart', () => { + this.suppressNextClick = false; + this.resetDragStates(); + }); this.scrollContainer.addEventListener('wheel', this.resetDragStates.bind(this)); this.scrollContainer.addEventListener('scroll', () => { @@ -100,6 +115,8 @@ class Carousel { * @returns {void} */ startDrag(e) { + this.suppressNextClick = false; + this.hasDragged = false; this.isDragging = true; this.autoScrolling = false; this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping @@ -126,6 +143,7 @@ class Carousel { this.pendingPointerPosition = pointerPosition; const distance = pointerPosition - this.startPosition; + if (Math.abs(distance) >= this.dragThreshold) this.hasDragged = true; this.pendingDragPosition = this.startScrollPosition - distance; // Keep at most one pending write; the callback applies the newest position. @@ -145,6 +163,7 @@ class Carousel { if (!this.isDragging) return; this.flushPendingDragPosition(); this.isDragging = false; + this.suppressNextClick = this.hasDragged; // If the drag velocity is low at release, let the native scroll snap take over if (Math.abs(this.velocity) < 0.05) { @@ -233,68 +252,88 @@ class Carousel { } } + /** + * Read the current snapport geometry and reachable scroll range. + * @returns {object} The snapport metrics for the scrolling axis. + */ + getSnapport() { + const container = this.scrollContainer; + const isVertical = this.direction === 'y'; + const startEdge = isVertical ? 'top' : 'left'; + const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft'; + const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight'; + const cssPixels = value => Number.parseFloat(value) || 0; + const containerRect = container.getBoundingClientRect(); + const containerStyle = getComputedStyle(container); + const snapportStartEdge = containerRect[startEdge] + + (isVertical ? container.clientTop : container.clientLeft); + const clientExtent = isVertical ? container.clientHeight : container.clientWidth; + const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]); + const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]); + const maxScrollPosition = isVertical + ? container.scrollHeight - container.clientHeight + : container.scrollWidth - container.clientWidth; + + return { + isVertical, + snapportStart, + snapportEnd, + scrollPosition: this.getScrollPosition(), + maxScrollPosition, + clamp: value => Math.max(0, Math.min(value, maxScrollPosition)) + }; + } + + /** + * Calculate a slide's snap position within the current snapport. + * @param {HTMLElement} slide - The slide to measure. + * @param {object} snapport - The current snapport metrics. + * @returns {number} The target scroll offset before range clamping. + */ + getSlideSnapPosition(slide, snapport) { + const { isVertical, snapportStart, snapportEnd, scrollPosition } = snapport; + const startEdge = isVertical ? 'top' : 'left'; + const endEdge = isVertical ? 'bottom' : 'right'; + const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft'; + const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight'; + const cssPixels = value => Number.parseFloat(value) || 0; + const slideRect = slide.getBoundingClientRect(); + const slideStyle = getComputedStyle(slide); + const snapAreaStart = slideRect[startEdge] - cssPixels(slideStyle[marginStart]); + const snapAreaEnd = slideRect[endEdge] + cssPixels(slideStyle[marginEnd]); + const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/); + const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0]; + + if (align === 'start') { + return scrollPosition + snapAreaStart - snapportStart; + } + + if (align === 'end') { + return scrollPosition + snapAreaEnd - snapportEnd; + } + + const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; + const snapportCenter = (snapportStart + snapportEnd) / 2; + return scrollPosition + snapAreaCenter - snapportCenter; + } + /** * 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'; - const maxScrollPosition = isVertical - ? element.scrollHeight - element.clientHeight - : element.scrollWidth - element.clientWidth; - const clamp = value => Math.max(0, Math.min(value, maxScrollPosition)); - const cssPixels = value => Number.parseFloat(value) || 0; - const startEdge = isVertical ? 'top' : 'left'; - const endEdge = isVertical ? 'bottom' : 'right'; - const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft'; - const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight'; - const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft'; - const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight'; + const snapport = this.getSnapport(); // Project the stopping point from velocity, then constrain it to the scroll range. - const projectedStop = clamp( + const projectedStop = snapport.clamp( this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia) ); - if (!this.slides.length) return clamp(projectedStop); + if (!this.slides.length) return projectedStop; - 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; - const snapportStart = - snapportStartEdge + cssPixels(elementStyle[paddingStart]); - const snapportEnd = - snapportStartEdge + clientExtent - cssPixels(elementStyle[paddingEnd]); - - const targets = [...this.slides].map(slide => { - const slideRect = slide.getBoundingClientRect(); - const slideStyle = getComputedStyle(slide); - const snapAreaStart = - slideRect[startEdge] - cssPixels(slideStyle[marginStart]); - const snapAreaEnd = - slideRect[endEdge] + cssPixels(slideStyle[marginEnd]); - const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/); - const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0]; - const scrollPosition = this.getScrollPosition(); - - let target; - - if (align === 'start') { - target = scrollPosition + snapAreaStart - snapportStart; - } else if (align === 'end') { - target = scrollPosition + snapAreaEnd - snapportEnd; - } else { - const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; - const snapportCenter = (snapportStart + snapportEnd) / 2; - target = scrollPosition + snapAreaCenter - snapportCenter; - } - - return clamp(target); - }); + const targets = [...this.slides].map(slide => + snapport.clamp(this.getSlideSnapPosition(slide, snapport)) + ); // Select the snap target nearest the projected stopping point. return targets.reduce((nearest, target) => @@ -386,53 +425,17 @@ class Carousel { getActiveSlideIndex() { if (!this.slides.length) return -1; - const isVertical = this.direction === 'y'; - const startEdge = isVertical ? 'top' : 'left'; - const endEdge = isVertical ? 'bottom' : 'right'; - const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft'; - const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight'; - const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft'; - const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight'; - 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; + const snapport = this.getSnapport(); + // At the scroll limit, prefer the later slide when clamping makes targets tie. + const isAtEnd = snapport.maxScrollPosition > 0 && + snapport.scrollPosition >= snapport.maxScrollPosition - 1; let activeIndex = 0; let closestDistance = Infinity; this.slides.forEach((slide, index) => { - const rect = slide.getBoundingClientRect(); - const slideStyle = getComputedStyle(slide); - const snapAreaStart = rect[startEdge] - cssPixels(slideStyle[marginStart]); - const snapAreaEnd = rect[endEdge] + cssPixels(slideStyle[marginEnd]); - const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/); - const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0]; - - let snapPosition; - - if (align === 'start') { - snapPosition = scrollPosition + snapAreaStart - snapportStart; - } else if (align === 'end') { - snapPosition = scrollPosition + snapAreaEnd - snapportEnd; - } else { - const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; - snapPosition = scrollPosition + snapAreaCenter - snapportCenter; - } - - const distance = Math.abs(clamp(snapPosition) - scrollPosition); + const snapPosition = this.getSlideSnapPosition(slide, snapport); + const distance = Math.abs(snapport.clamp(snapPosition) - snapport.scrollPosition); // At the scroll limit, prefer the later slide when clamping makes targets tie. if (distance < closestDistance || (isAtEnd && distance === closestDistance)) { @@ -475,10 +478,21 @@ class Carousel { * @returns {void} */ goToSlide(index) { + if (!this.slides.length) return; + this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1)); this.autoScrolling = true; this.updateActiveSlide(); - this.slides[this.activeSlideIndex].scrollIntoView(); + + const container = this.scrollContainer; + const slide = this.slides[this.activeSlideIndex]; + const snapport = this.getSnapport(); + const targetPosition = snapport.clamp(this.getSlideSnapPosition(slide, snapport)); + + container.scrollTo({ + [snapport.isVertical ? 'top' : 'left']: targetPosition, + behavior: 'smooth' + }); } /**