From a79d6390171a67e65c69a39e90827967e929d6e3 Mon Sep 17 00:00:00 2001 From: Daniel Stock Date: Fri, 2 Oct 2026 15:55:28 +0200 Subject: [PATCH] Add initial project --- LICENSE | 7 + index.html | 175 ++++++++++++++++ package-lock.json | 499 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 12 ++ src/snappy.css | 57 ++++++ src/snappy.js | 334 +++++++++++++++++++++++++++++++ 6 files changed, 1084 insertions(+) create mode 100644 LICENSE create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/snappy.css create mode 100644 src/snappy.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ec7e0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +"NO ASSHOLE-WARE LICENSE" + wrote this file. As long as you retain this notice you +can do whatever you want with this stuff. If we meet some day, and you think +this stuff is worth it, you can buy me a beer. + +Be nice to other people. Respect women. Don't be a dick. Don't support fascists. +If you can't do that, don't use this code. \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..aa84417 --- /dev/null +++ b/index.html @@ -0,0 +1,175 @@ + + + + + + + Snappy + + + + + + +

Snappy

+ + + + +
+ + +
+
+ + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1dff1db --- /dev/null +++ b/package-lock.json @@ -0,0 +1,499 @@ +{ + "name": "snappy-carousel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "snappy-carousel", + "version": "1.0.0", + "devDependencies": { + "esbuild": "^0.25.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa73d4d --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "snappy-carousel", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "esbuild src/snappy.js src/snappy.css --outdir=dist --minify", + "watch": "esbuild src/snappy.js src/snappy.css --outdir=dist --minify --watch" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/src/snappy.css b/src/snappy.css new file mode 100644 index 0000000..e576eed --- /dev/null +++ b/src/snappy.css @@ -0,0 +1,57 @@ +.snappy-carousel { + display: flex; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + user-select: none; + cursor: grab; + scroll-behavior: smooth; + + &[data-direction="y"] { + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + } + + &.is-dragging { + scroll-snap-type: none; + scroll-behavior: unset; + } + + scroll-snap-type: x mandatory; + + &[data-direction="y"] { + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + scroll-snap-type: y mandatory; + } + + gap: var(--carousel-gap, 0); + gap: attr(data-gap type(*), 0); + + .snappy-slide { + flex-shrink: 0; + scroll-snap-align: center; + scroll-snap-stop: normal; + } + + &[data-snap-align="start"] .snappy-slide { + scroll-snap-align: start; + } + + &[data-snap-align="end"] .snappy-slide { + scroll-snap-align: end; + } + + &[data-snap-stop="always"] .snappy-slide { + scroll-snap-stop: always; + } + +} +.snappy-pagination-dot { + display: inline-block; + width: 1rem; + height: 1rem; + background-color: silver; +} \ No newline at end of file diff --git a/src/snappy.js b/src/snappy.js new file mode 100644 index 0000000..8e16742 --- /dev/null +++ b/src/snappy.js @@ -0,0 +1,334 @@ +/* use strict; */ + +document.addEventListener('DOMContentLoaded', () => { + window.snappy = new Snappy(); +}); + +class Snappy { + carousels = []; + + constructor() { + document.querySelectorAll('.snappy-carousel').forEach(element => { + this.carousels.push(new Carousel(element)); + }); + } +} + +class Carousel { + // DOM references + element = null; + prevButton = null; + nextButton = null; + pagination = null; + slides = []; + paginationDots = []; + + // Bahavioral constants + inertia = 0.90; // Decay rate (0.9 = slower stop, 0.99 = longer slide) + + // State variables + isDragging = false; + direction = 'x'; + startPosition = 0; + startScrollPosition = 0; + velocity = 0; + lastPosition; + lastTime; + activeSlideIndex = 0; + + constructor(element) { + this.element = element; + this.direction = this.element.dataset.direction === 'y' ? 'y' : 'x'; + + const gap = this.element.dataset.gap; + if (gap) this.element.style.setProperty('--carousel-gap', gap); + + this.slides = this.element.querySelectorAll('.snappy-slide'); + this.prevButton = document.querySelector(`.snappy-prev[data-carousel="${this.element.id}"]`); + this.nextButton = document.querySelector(`.snappy-next[data-carousel="${this.element.id}"]`); + this.pagination = document.querySelector(`.snappy-pagination[data-carousel="${this.element.id}"]`); + + this.element.addEventListener('mousedown', this.startDrag.bind(this)); + this.element.addEventListener('mousemove', this.drag.bind(this)); + this.element.addEventListener('mouseup', this.endDrag.bind(this)); + this.element.addEventListener('mouseleave', this.endDrag.bind(this)); + this.element.addEventListener('touchstart', this.reset.bind(this)); + this.element.addEventListener('wheel', this.reset.bind(this)); + this.element.addEventListener('scroll', this.updateActiveSlide.bind(this)); + + if (this.prevButton) { + this.prevButton.addEventListener('click', () => this.prev()); + } + + if (this.nextButton) { + this.nextButton.addEventListener('click', () => this.next()); + } + + if (this.pagination) { + this.createPagination(); + } + + this.updateActiveSlide(); + } + + startDrag(e) { + this.isDragging = true; + this.element.classList.add('is-dragging'); // Disable scroll snapping + this.velocity = 0; + this.lastPosition = undefined; + this.lastTime = undefined; + this.startPosition = this.getPointerPosition(e); + this.startScrollPosition = this.getScrollPosition(); + e.preventDefault(); // Prevent text selection during drag + } + + 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; + + const distance = pointerPosition - this.startPosition; + requestAnimationFrame(() => { + this.setScrollPosition(this.startScrollPosition - distance); + }); + } + + endDrag() { + if (!this.isDragging) return; + 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(); + return; + } + + const target = this.calculateDragTarget(); + const element = this.element; + const distance = target - this.getScrollPosition(); + + this.velocity = -distance * (1 - this.inertia) / 10; + + const animateInertia = () => { + if (this.isDragging) return; // Stop the animation if the user starts dragging again + + const current = this.getScrollPosition(); + const remaining = target - current; + + if (Math.abs(remaining) <= 0.1) { + this.finishDrag(target); + return; + } + + const delta = -this.velocity * 10; + const next = Math.abs(delta) >= Math.abs(remaining) + ? target + : current + delta; + + this.setScrollPosition(next); + + // Stop if the browser cannot represent a smaller scroll-position change. + if (this.getScrollPosition() === current || this.getScrollPosition() === target) { + this.finishDrag(target); + return; + } + + this.velocity *= this.inertia; + requestAnimationFrame(animateInertia); + }; + + animateInertia(); + } + + finishDrag(target) { + this.setScrollPosition(target); + this.reset(); + } + + getPointerPosition(event) { + return this.direction === 'y' ? event.clientY : event.clientX; + } + + getScrollPosition() { + return this.direction === 'y' ? this.element.scrollTop : this.element.scrollLeft; + } + + setScrollPosition(position) { + if (this.direction === 'y') { + this.element.scrollTop = position; + } else { + this.element.scrollLeft = position; + } + } + + calculateDragTarget() { + const element = this.element; + 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'; + + // Project the final scroll position based on current velocity and inertia + const projectedStop = clamp( + this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia) + ); + + if (!this.slides.length) return clamp(projectedStop); + + const elementRect = element.getBoundingClientRect(); + const elementStyle = getComputedStyle(element); + 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); + }); + + return targets.reduce((nearest, target) => + Math.abs(target - projectedStop) < Math.abs(nearest - projectedStop) + ? target + : nearest + ); + } + + reset() { + this.isDragging = false; + this.element.classList.remove('is-dragging'); + this.velocity = 0 + } + + createPagination() { + this.slides.forEach((slide, index) => { + // Populate the pagination container with dots for each slide + let div = document.createElement('div'); + div.classList.add('snappy-pagination-dot'); + div.addEventListener('click', () => this.goToSlide(index)); + this.pagination.appendChild(div); + + // Store a reference to the pagination dots in the carousel object for later use + if (!this.paginationDots) this.paginationDots = []; + this.paginationDots.push(div); + }); + + } + + 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.element.getBoundingClientRect(); + const containerStyle = getComputedStyle(this.element); + const snapportStartEdge = containerRect[startEdge] + + (isVertical ? this.element.clientTop : this.element.clientLeft); + const clientExtent = isVertical ? this.element.clientHeight : this.element.clientWidth; + const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]); + const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]); + const snapportCenter = (snapportStart + snapportEnd) / 2; + + 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 distance; + + if (align === 'start') { + distance = Math.abs(snapAreaStart - snapportStart); + } else if (align === 'end') { + distance = Math.abs(snapAreaEnd - snapportEnd); + } else { + const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; + distance = Math.abs(snapAreaCenter - snapportCenter); + } + + if (distance < closestDistance) { + activeIndex = index; + closestDistance = distance; + } + }); + + return activeIndex; + } + + updateActiveSlide() { + this.activeSlideIndex = this.getActiveSlideIndex(); + + this.slides.forEach((slide, index) => { + slide.classList.toggle('is-active', index === this.activeSlideIndex); + }); + + this.paginationDots.forEach((dot, index) => { + dot.classList.toggle('is-active', index === this.activeSlideIndex); + }); + } + + + goToSlide(index) { + this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1)); + this.slides[this.activeSlideIndex].scrollIntoView(); + } + + next() { + this.goToSlide(this.activeSlideIndex + 1); + } + prev() { + this.goToSlide(this.activeSlideIndex - 1); + } +} \ No newline at end of file