Optimization pass

This commit is contained in:
vollstock committed 2026-10-02 21:46:27 +02:00
1 parent fa97f2c369
commit 60a33e922e
2 files changed
+187 -44

No files matched your search

-14
View File
@@ -32,12 +32,6 @@
/* Options */ /* Options */
&[data-direction="y"] .scroll-container {
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
}
&[data-direction="y"] .scroll-container { &[data-direction="y"] .scroll-container {
flex-direction: column; flex-direction: column;
overflow-x: hidden; overflow-x: hidden;
@@ -45,14 +39,6 @@
scroll-snap-type: y mandatory; 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 { &[data-snap-align="start"] .slide {
scroll-snap-align: start; scroll-snap-align: start;
} }
+187 -30
View File
@@ -1,12 +1,15 @@
/* use strict; */ /* use strict; */
/** Initialize the carousel controller after the document has been parsed. */
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.snappy = new Snappy(); window.snappy = new Snappy();
}); });
/** Discovers carousel elements and creates a controller for each one. */
class Snappy { class Snappy {
carousels = []; carousels = [];
/** Create controllers for all carousel elements in the document. */
constructor() { constructor() {
document.querySelectorAll('.snappy-carousel').forEach(element => { document.querySelectorAll('.snappy-carousel').forEach(element => {
this.carousels.push(new Carousel(element)); this.carousels.push(new Carousel(element));
@@ -14,6 +17,7 @@ class Snappy {
} }
} }
/** Handles input, scrolling, navigation, and pagination for one carousel. */
class Carousel { class Carousel {
// DOM references // DOM references
scrollContainer = null; scrollContainer = null;
@@ -23,24 +27,34 @@ class Carousel {
slides = []; slides = [];
paginationDots = []; paginationDots = [];
// Bahavioral constants // Behavioral constants
inertia = 0.90; // Decay rate (0.9 = slower stop, 0.99 = longer slide) inertia = 0.90; // Decay rate (0.9 = slower stop, 0.99 = longer slide)
velocityFrameCount = 5;
// State variables // State variables
isDragging = false; isDragging = false;
dragFrame = null;
pendingDragPosition = null;
scrollUpdateFrame = null;
autoScrolling = false;
direction = 'x'; direction = 'x';
startPosition = 0; startPosition = 0;
startScrollPosition = 0; startScrollPosition = 0;
velocity = 0; velocity = 0;
velocitySamples = [];
pendingPointerPosition = null;
lastPosition; lastPosition;
lastTime; lastTime;
activeSlideIndex = 0; 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) { constructor(element) {
this.scrollContainer = element.querySelector('.scroll-container'); this.scrollContainer = element.querySelector('.scroll-container');
this.direction = element.dataset.direction === 'y' ? 'y' : 'x'; 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.slides = this.scrollContainer.querySelectorAll('.slide');
this.prevButton = document.querySelector(`.prev`); this.prevButton = document.querySelector(`.prev`);
@@ -51,9 +65,19 @@ class Carousel {
this.scrollContainer.addEventListener('mousemove', this.drag.bind(this)); this.scrollContainer.addEventListener('mousemove', this.drag.bind(this));
this.scrollContainer.addEventListener('mouseup', this.endDrag.bind(this)); this.scrollContainer.addEventListener('mouseup', this.endDrag.bind(this));
this.scrollContainer.addEventListener('mouseleave', 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('touchstart', this.resetDragStates.bind(this));
this.scrollContainer.addEventListener('scroll', this.updateActiveSlide.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) { if (this.prevButton) {
this.prevButton.addEventListener('click', () => this.prev()); this.prevButton.addEventListener('click', () => this.prev());
@@ -70,52 +94,74 @@ class Carousel {
this.updateActiveSlide(); this.updateActiveSlide();
} }
/**
* Begin mouse dragging and record the starting pointer and scroll positions.
* @param {MouseEvent} e - The mouse-down event.
* @returns {void}
*/
startDrag(e) { startDrag(e) {
this.isDragging = true; this.isDragging = true;
this.autoScrolling = false;
this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping
this.velocity = 0; this.velocity = 0;
this.lastPosition = undefined;
this.lastTime = undefined;
this.startPosition = this.getPointerPosition(e); this.startPosition = this.getPointerPosition(e);
this.lastPosition = this.startPosition;
this.lastTime = performance.now();
this.velocitySamples = [];
this.pendingPointerPosition = null;
this.startScrollPosition = this.getScrollPosition(); this.startScrollPosition = this.getScrollPosition();
e.preventDefault(); // Prevent text selection during drag 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) { drag(e) {
if (!this.isDragging) return; if (!this.isDragging) return;
e.preventDefault(); e.preventDefault();
const pointerPosition = this.getPointerPosition(e); const pointerPosition = this.getPointerPosition(e);
const currentTime = Date.now(); this.pendingPointerPosition = pointerPosition;
// 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;
const distance = pointerPosition - this.startPosition; const distance = pointerPosition - this.startPosition;
requestAnimationFrame(() => { this.pendingDragPosition = this.startScrollPosition - distance;
this.setScrollPosition(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() { endDrag() {
if (!this.isDragging) return; if (!this.isDragging) return;
this.flushPendingDragPosition();
this.isDragging = false; this.isDragging = false;
// If the drag velocity is low at release, let the native scroll snap take over // If the drag velocity is low at release, let the native scroll snap take over
if (Math.abs(this.velocity) < 0.05) { if (Math.abs(this.velocity) < 0.05) {
this.reset(); this.resetDragStates();
return; return;
} }
const target = this.calculateDragTarget(); const target = this.calculateDragTarget();
const distance = target - this.getScrollPosition(); 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; this.velocity = -distance * (1 - this.inertia) / 10;
/**
* Advance the inertial scroll until it reaches the selected snap target.
* @returns {void}
*/
const animateInertia = () => { const animateInertia = () => {
if (this.isDragging) return; // Stop the animation if the user starts dragging again if (this.isDragging) return; // Stop the animation if the user starts dragging again
@@ -147,19 +193,38 @@ class Carousel {
animateInertia(); animateInertia();
} }
/**
* Set the final drag position and restore native snapping behavior.
* @param {number} target - The final scroll position.
* @returns {void}
*/
finishDrag(target) { finishDrag(target) {
this.setScrollPosition(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) { getPointerPosition(event) {
return this.direction === 'y' ? event.clientY : event.clientX; 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() { getScrollPosition() {
return this.direction === 'y' ? this.scrollContainer.scrollTop : this.scrollContainer.scrollLeft; 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) { setScrollPosition(position) {
if (this.direction === 'y') { if (this.direction === 'y') {
this.scrollContainer.scrollTop = position; 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() { calculateDragTarget() {
const element = this.scrollContainer; const element = this.scrollContainer;
const isVertical = this.direction === 'y'; const isVertical = this.direction === 'y';
@@ -183,7 +252,7 @@ class Carousel {
const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft'; const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft';
const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight'; 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( const projectedStop = clamp(
this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia) this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia)
); );
@@ -192,6 +261,7 @@ class Carousel {
const elementRect = element.getBoundingClientRect(); const elementRect = element.getBoundingClientRect();
const elementStyle = getComputedStyle(element); const elementStyle = getComputedStyle(element);
// Scroll padding defines the snapport; scroll margins define each slide's snap area.
const snapportStartEdge = const snapportStartEdge =
elementRect[startEdge] + (isVertical ? element.clientTop : element.clientLeft); elementRect[startEdge] + (isVertical ? element.clientTop : element.clientLeft);
const clientExtent = isVertical ? element.clientHeight : element.clientWidth; const clientExtent = isVertical ? element.clientHeight : element.clientWidth;
@@ -226,6 +296,7 @@ class Carousel {
return clamp(target); return clamp(target);
}); });
// Select the snap target nearest the projected stopping point.
return targets.reduce((nearest, target) => return targets.reduce((nearest, target) =>
Math.abs(target - projectedStop) < Math.abs(nearest - projectedStop) Math.abs(target - projectedStop) < Math.abs(nearest - projectedStop)
? target ? 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.isDragging = false;
this.autoScrolling = false;
this.scrollContainer.classList.remove('is-dragging'); this.scrollContainer.classList.remove('is-dragging');
this.velocity = 0 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() { createPagination() {
this.slides.forEach((slide, index) => { this.slides.forEach((slide, index) => {
// Populate the pagination container with dots for each slide // 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() { getActiveSlideIndex() {
if (!this.slides.length) return -1; if (!this.slides.length) return -1;
@@ -267,12 +389,19 @@ class Carousel {
const cssPixels = value => Number.parseFloat(value) || 0; const cssPixels = value => Number.parseFloat(value) || 0;
const containerRect = this.scrollContainer.getBoundingClientRect(); const containerRect = this.scrollContainer.getBoundingClientRect();
const containerStyle = getComputedStyle(this.scrollContainer); const containerStyle = getComputedStyle(this.scrollContainer);
// Compare reachable snap positions so end-of-scroll clamping matches browser snapping.
const snapportStartEdge = containerRect[startEdge] + const snapportStartEdge = containerRect[startEdge] +
(isVertical ? this.scrollContainer.clientTop : this.scrollContainer.clientLeft); (isVertical ? this.scrollContainer.clientTop : this.scrollContainer.clientLeft);
const clientExtent = isVertical ? this.scrollContainer.clientHeight : this.scrollContainer.clientWidth; const clientExtent = isVertical ? this.scrollContainer.clientHeight : this.scrollContainer.clientWidth;
const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]); const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]);
const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]); const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]);
const snapportCenter = (snapportStart + snapportEnd) / 2; 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 activeIndex = 0;
let closestDistance = Infinity; let closestDistance = Infinity;
@@ -285,18 +414,21 @@ class Carousel {
const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/); const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/);
const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0]; const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0];
let distance; let snapPosition;
if (align === 'start') { if (align === 'start') {
distance = Math.abs(snapAreaStart - snapportStart); snapPosition = scrollPosition + snapAreaStart - snapportStart;
} else if (align === 'end') { } else if (align === 'end') {
distance = Math.abs(snapAreaEnd - snapportEnd); snapPosition = scrollPosition + snapAreaEnd - snapportEnd;
} else { } else {
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; 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; activeIndex = index;
closestDistance = distance; closestDistance = distance;
} }
@@ -305,8 +437,17 @@ class Carousel {
return activeIndex; return activeIndex;
} }
/**
* Synchronize the active slide and pagination classes when the index changes.
* @returns {void}
*/
updateActiveSlide() { 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) => { this.slides.forEach((slide, index) => {
slide.classList.toggle('is-active', index === this.activeSlideIndex); 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) { goToSlide(index) {
this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1)); this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1));
this.autoScrolling = true;
this.updateActiveSlide();
this.slides[this.activeSlideIndex].scrollIntoView(); this.slides[this.activeSlideIndex].scrollIntoView();
} }
/**
* Navigate to the next slide.
* @returns {void}
*/
next() { next() {
this.goToSlide(this.activeSlideIndex + 1); this.goToSlide(this.activeSlideIndex + 1);
} }
/**
* Navigate to the previous slide.
* @returns {void}
*/
prev() { prev() {
this.goToSlide(this.activeSlideIndex - 1); this.goToSlide(this.activeSlideIndex - 1);
} }