From 60a33e922e26e3262d74a4061940ba93b0ee8c4d Mon Sep 17 00:00:00 2001 From: Daniel Stock Date: Fri, 2 Oct 2026 21:46:27 +0200 Subject: [PATCH] Optimization pass --- src/snappy.css | 14 ---- src/snappy.js | 217 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 187 insertions(+), 44 deletions(-) diff --git a/src/snappy.css b/src/snappy.css index 8eed536..42e9726 100644 --- a/src/snappy.css +++ b/src/snappy.css @@ -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; } diff --git a/src/snappy.js b/src/snappy.js index fdfeea7..8a8e3ed 100644 --- a/src/snappy.js +++ b/src/snappy.js @@ -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() { - this.activeSlideIndex = this.getActiveSlideIndex(); + 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); }