| 1 | //#region packages/snap/src/debounce.ts
|
|---|
| 2 | function debounce(callback, delay) {
|
|---|
| 3 | let timer;
|
|---|
| 4 | return function(...args) {
|
|---|
| 5 | clearTimeout(timer);
|
|---|
| 6 | timer = setTimeout(() => {
|
|---|
| 7 | timer = void 0;
|
|---|
| 8 | callback.apply(this, args);
|
|---|
| 9 | }, delay);
|
|---|
| 10 | };
|
|---|
| 11 | }
|
|---|
| 12 | //#endregion
|
|---|
| 13 | //#region packages/snap/src/element.ts
|
|---|
| 14 | function removeParentSticky(element) {
|
|---|
| 15 | if (getComputedStyle(element).position === "sticky") {
|
|---|
| 16 | element.style.setProperty("position", "static");
|
|---|
| 17 | element.dataset.sticky = "true";
|
|---|
| 18 | }
|
|---|
| 19 | if (element.offsetParent) removeParentSticky(element.offsetParent);
|
|---|
| 20 | }
|
|---|
| 21 | function addParentSticky(element) {
|
|---|
| 22 | if (element?.dataset?.sticky === "true") {
|
|---|
| 23 | element.style.removeProperty("position");
|
|---|
| 24 | delete element.dataset.sticky;
|
|---|
| 25 | }
|
|---|
| 26 | if (element.offsetParent) addParentSticky(element.offsetParent);
|
|---|
| 27 | }
|
|---|
| 28 | function offsetTop(element, accumulator = 0) {
|
|---|
| 29 | const top = accumulator + element.offsetTop;
|
|---|
| 30 | if (element.offsetParent) return offsetTop(element.offsetParent, top);
|
|---|
| 31 | return top;
|
|---|
| 32 | }
|
|---|
| 33 | function offsetLeft(element, accumulator = 0) {
|
|---|
| 34 | const left = accumulator + element.offsetLeft;
|
|---|
| 35 | if (element.offsetParent) return offsetLeft(element.offsetParent, left);
|
|---|
| 36 | return left;
|
|---|
| 37 | }
|
|---|
| 38 | function scrollTop(element, accumulator = 0) {
|
|---|
| 39 | const top = accumulator + element.scrollTop;
|
|---|
| 40 | if (element.offsetParent) return scrollTop(element.offsetParent, top);
|
|---|
| 41 | return top + window.scrollY;
|
|---|
| 42 | }
|
|---|
| 43 | function scrollLeft(element, accumulator = 0) {
|
|---|
| 44 | const left = accumulator + element.scrollLeft;
|
|---|
| 45 | if (element.offsetParent) return scrollLeft(element.offsetParent, left);
|
|---|
| 46 | return left + window.scrollX;
|
|---|
| 47 | }
|
|---|
| 48 | var SnapElement = class {
|
|---|
| 49 | element;
|
|---|
| 50 | options;
|
|---|
| 51 | align;
|
|---|
| 52 | rect = {};
|
|---|
| 53 | wrapperResizeObserver;
|
|---|
| 54 | resizeObserver;
|
|---|
| 55 | debouncedWrapperResize;
|
|---|
| 56 | constructor(element, { align = ["start"], ignoreSticky = true, ignoreTransform = false } = {}) {
|
|---|
| 57 | this.element = element;
|
|---|
| 58 | this.options = {
|
|---|
| 59 | align,
|
|---|
| 60 | ignoreSticky,
|
|---|
| 61 | ignoreTransform
|
|---|
| 62 | };
|
|---|
| 63 | this.align = [align].flat();
|
|---|
| 64 | this.debouncedWrapperResize = debounce(this.onWrapperResize, 500);
|
|---|
| 65 | this.wrapperResizeObserver = new ResizeObserver(this.debouncedWrapperResize);
|
|---|
| 66 | this.wrapperResizeObserver.observe(document.body);
|
|---|
| 67 | this.onWrapperResize();
|
|---|
| 68 | this.resizeObserver = new ResizeObserver(this.onResize);
|
|---|
| 69 | this.resizeObserver.observe(this.element);
|
|---|
| 70 | this.setRect({
|
|---|
| 71 | width: this.element.offsetWidth,
|
|---|
| 72 | height: this.element.offsetHeight
|
|---|
| 73 | });
|
|---|
| 74 | }
|
|---|
| 75 | destroy() {
|
|---|
| 76 | this.wrapperResizeObserver.disconnect();
|
|---|
| 77 | this.resizeObserver.disconnect();
|
|---|
| 78 | }
|
|---|
| 79 | setRect({ top, left, width, height, element } = {}) {
|
|---|
| 80 | top = top ?? this.rect.top;
|
|---|
| 81 | left = left ?? this.rect.left;
|
|---|
| 82 | width = width ?? this.rect.width;
|
|---|
| 83 | height = height ?? this.rect.height;
|
|---|
| 84 | element = element ?? this.rect.element;
|
|---|
| 85 | if (top === this.rect.top && left === this.rect.left && width === this.rect.width && height === this.rect.height && element === this.rect.element) return;
|
|---|
| 86 | this.rect.top = top;
|
|---|
| 87 | this.rect.y = top;
|
|---|
| 88 | this.rect.width = width;
|
|---|
| 89 | this.rect.height = height;
|
|---|
| 90 | this.rect.left = left;
|
|---|
| 91 | this.rect.x = left;
|
|---|
| 92 | this.rect.bottom = top + height;
|
|---|
| 93 | this.rect.right = left + width;
|
|---|
| 94 | }
|
|---|
| 95 | onWrapperResize = () => {
|
|---|
| 96 | let top;
|
|---|
| 97 | let left;
|
|---|
| 98 | if (this.options.ignoreSticky) removeParentSticky(this.element);
|
|---|
| 99 | if (this.options.ignoreTransform) {
|
|---|
| 100 | top = offsetTop(this.element);
|
|---|
| 101 | left = offsetLeft(this.element);
|
|---|
| 102 | } else {
|
|---|
| 103 | const rect = this.element.getBoundingClientRect();
|
|---|
| 104 | top = rect.top + scrollTop(this.element);
|
|---|
| 105 | left = rect.left + scrollLeft(this.element);
|
|---|
| 106 | }
|
|---|
| 107 | if (this.options.ignoreSticky) addParentSticky(this.element);
|
|---|
| 108 | this.setRect({
|
|---|
| 109 | top,
|
|---|
| 110 | left
|
|---|
| 111 | });
|
|---|
| 112 | };
|
|---|
| 113 | onResize = ([entry]) => {
|
|---|
| 114 | if (!entry?.borderBoxSize[0]) return;
|
|---|
| 115 | const width = entry.borderBoxSize[0].inlineSize;
|
|---|
| 116 | const height = entry.borderBoxSize[0].blockSize;
|
|---|
| 117 | this.setRect({
|
|---|
| 118 | width,
|
|---|
| 119 | height
|
|---|
| 120 | });
|
|---|
| 121 | };
|
|---|
| 122 | };
|
|---|
| 123 | //#endregion
|
|---|
| 124 | //#region packages/snap/src/uid.ts
|
|---|
| 125 | let index = 0;
|
|---|
| 126 | function uid() {
|
|---|
| 127 | return index++;
|
|---|
| 128 | }
|
|---|
| 129 | //#endregion
|
|---|
| 130 | //#region packages/snap/src/snap.ts
|
|---|
| 131 | /**
|
|---|
| 132 | * Snap class to handle the snap functionality
|
|---|
| 133 | *
|
|---|
| 134 | * @example
|
|---|
| 135 | * const snap = new Snap(lenis, {
|
|---|
| 136 | * type: 'mandatory', // 'mandatory', 'proximity' or 'lock'
|
|---|
| 137 | * onSnapStart: (snap) => {
|
|---|
| 138 | * console.log('onSnapStart', snap)
|
|---|
| 139 | * },
|
|---|
| 140 | * onSnapComplete: (snap) => {
|
|---|
| 141 | * console.log('onSnapComplete', snap)
|
|---|
| 142 | * },
|
|---|
| 143 | * })
|
|---|
| 144 | *
|
|---|
| 145 | * snap.add(500) // snap at 500px
|
|---|
| 146 | *
|
|---|
| 147 | * const removeSnap = snap.add(500)
|
|---|
| 148 | *
|
|---|
| 149 | * if (someCondition) {
|
|---|
| 150 | * removeSnap()
|
|---|
| 151 | * }
|
|---|
| 152 | */
|
|---|
| 153 | var Snap = class {
|
|---|
| 154 | options;
|
|---|
| 155 | elements = /* @__PURE__ */ new Map();
|
|---|
| 156 | snaps = /* @__PURE__ */ new Map();
|
|---|
| 157 | viewport = {
|
|---|
| 158 | width: window.innerWidth,
|
|---|
| 159 | height: window.innerHeight
|
|---|
| 160 | };
|
|---|
| 161 | isStopped = false;
|
|---|
| 162 | onSnapDebounced;
|
|---|
| 163 | currentSnapIndex;
|
|---|
| 164 | constructor(lenis, { type = "proximity", lerp, easing, duration, distanceThreshold = "50%", debounce: debounceDelay = 500, onSnapStart, onSnapComplete } = {}) {
|
|---|
| 165 | this.lenis = lenis;
|
|---|
| 166 | if (!window.lenis) window.lenis = {};
|
|---|
| 167 | window.lenis.snap = true;
|
|---|
| 168 | this.options = {
|
|---|
| 169 | type,
|
|---|
| 170 | lerp,
|
|---|
| 171 | easing,
|
|---|
| 172 | duration,
|
|---|
| 173 | distanceThreshold,
|
|---|
| 174 | debounce: debounceDelay,
|
|---|
| 175 | onSnapStart,
|
|---|
| 176 | onSnapComplete
|
|---|
| 177 | };
|
|---|
| 178 | this.onWindowResize();
|
|---|
| 179 | window.addEventListener("resize", this.onWindowResize);
|
|---|
| 180 | this.onSnapDebounced = debounce(this.onSnap, this.options.debounce);
|
|---|
| 181 | this.lenis.on("virtual-scroll", this.onSnapDebounced);
|
|---|
| 182 | }
|
|---|
| 183 | /**
|
|---|
| 184 | * Destroy the snap instance
|
|---|
| 185 | */
|
|---|
| 186 | destroy() {
|
|---|
| 187 | this.lenis.off("virtual-scroll", this.onSnapDebounced);
|
|---|
| 188 | window.removeEventListener("resize", this.onWindowResize);
|
|---|
| 189 | this.elements.forEach((element) => {
|
|---|
| 190 | element.destroy();
|
|---|
| 191 | });
|
|---|
| 192 | }
|
|---|
| 193 | /**
|
|---|
| 194 | * Start the snap after it has been stopped
|
|---|
| 195 | */
|
|---|
| 196 | start() {
|
|---|
| 197 | this.isStopped = false;
|
|---|
| 198 | }
|
|---|
| 199 | /**
|
|---|
| 200 | * Stop the snap
|
|---|
| 201 | */
|
|---|
| 202 | stop() {
|
|---|
| 203 | this.isStopped = true;
|
|---|
| 204 | }
|
|---|
| 205 | /**
|
|---|
| 206 | * Add a snap to the snap instance
|
|---|
| 207 | *
|
|---|
| 208 | * @param value The value to snap to
|
|---|
| 209 | * @param userData User data that will be forwarded through the snap event
|
|---|
| 210 | * @returns Unsubscribe function
|
|---|
| 211 | */
|
|---|
| 212 | add(value) {
|
|---|
| 213 | const id = uid();
|
|---|
| 214 | this.snaps.set(id, { value });
|
|---|
| 215 | return () => this.snaps.delete(id);
|
|---|
| 216 | }
|
|---|
| 217 | /**
|
|---|
| 218 | * Add an element to the snap instance
|
|---|
| 219 | *
|
|---|
| 220 | * @param element The element to add
|
|---|
| 221 | * @param options The options for the element
|
|---|
| 222 | * @returns Unsubscribe function
|
|---|
| 223 | */
|
|---|
| 224 | addElement(element, options = {}) {
|
|---|
| 225 | const id = uid();
|
|---|
| 226 | this.elements.set(id, new SnapElement(element, options));
|
|---|
| 227 | return () => this.elements.delete(id);
|
|---|
| 228 | }
|
|---|
| 229 | addElements(elements, options = {}) {
|
|---|
| 230 | const map = [...elements].map((element) => this.addElement(element, options));
|
|---|
| 231 | return () => {
|
|---|
| 232 | map.forEach((remove) => {
|
|---|
| 233 | remove();
|
|---|
| 234 | });
|
|---|
| 235 | };
|
|---|
| 236 | }
|
|---|
| 237 | onWindowResize = () => {
|
|---|
| 238 | this.viewport.width = window.innerWidth;
|
|---|
| 239 | this.viewport.height = window.innerHeight;
|
|---|
| 240 | };
|
|---|
| 241 | computeSnaps = () => {
|
|---|
| 242 | const { isHorizontal } = this.lenis;
|
|---|
| 243 | let snaps = [...this.snaps.values()];
|
|---|
| 244 | this.elements.forEach(({ rect, align }) => {
|
|---|
| 245 | let value;
|
|---|
| 246 | align.forEach((align) => {
|
|---|
| 247 | if (align === "start") value = rect.top;
|
|---|
| 248 | else if (align === "center") value = isHorizontal ? rect.left + rect.width / 2 - this.viewport.width / 2 : rect.top + rect.height / 2 - this.viewport.height / 2;
|
|---|
| 249 | else if (align === "end") value = isHorizontal ? rect.left + rect.width - this.viewport.width : rect.top + rect.height - this.viewport.height;
|
|---|
| 250 | if (typeof value === "number") snaps.push({ value: Math.ceil(value) });
|
|---|
| 251 | });
|
|---|
| 252 | });
|
|---|
| 253 | snaps = snaps.sort((a, b) => Math.abs(a.value) - Math.abs(b.value));
|
|---|
| 254 | return snaps;
|
|---|
| 255 | };
|
|---|
| 256 | previous() {
|
|---|
| 257 | this.goTo((this.currentSnapIndex ?? 0) - 1);
|
|---|
| 258 | }
|
|---|
| 259 | next() {
|
|---|
| 260 | this.goTo((this.currentSnapIndex ?? 0) + 1);
|
|---|
| 261 | }
|
|---|
| 262 | goTo(index) {
|
|---|
| 263 | const snaps = this.computeSnaps();
|
|---|
| 264 | if (snaps.length === 0) return;
|
|---|
| 265 | this.currentSnapIndex = Math.max(0, Math.min(index, snaps.length - 1));
|
|---|
| 266 | const currentSnap = snaps[this.currentSnapIndex];
|
|---|
| 267 | if (currentSnap === void 0) return;
|
|---|
| 268 | this.lenis.scrollTo(currentSnap.value, {
|
|---|
| 269 | duration: this.options.duration,
|
|---|
| 270 | easing: this.options.easing,
|
|---|
| 271 | lerp: this.options.lerp,
|
|---|
| 272 | lock: this.options.type === "lock",
|
|---|
| 273 | userData: { initiator: "snap" },
|
|---|
| 274 | onStart: () => {
|
|---|
| 275 | this.options.onSnapStart?.({
|
|---|
| 276 | index: this.currentSnapIndex,
|
|---|
| 277 | ...currentSnap
|
|---|
| 278 | });
|
|---|
| 279 | },
|
|---|
| 280 | onComplete: () => {
|
|---|
| 281 | this.options.onSnapComplete?.({
|
|---|
| 282 | index: this.currentSnapIndex,
|
|---|
| 283 | ...currentSnap
|
|---|
| 284 | });
|
|---|
| 285 | }
|
|---|
| 286 | });
|
|---|
| 287 | }
|
|---|
| 288 | get distanceThreshold() {
|
|---|
| 289 | let distanceThreshold = Number.POSITIVE_INFINITY;
|
|---|
| 290 | if (this.options.type === "mandatory") return Number.POSITIVE_INFINITY;
|
|---|
| 291 | const { isHorizontal } = this.lenis;
|
|---|
| 292 | const axis = isHorizontal ? "width" : "height";
|
|---|
| 293 | if (typeof this.options.distanceThreshold === "string" && this.options.distanceThreshold.endsWith("%")) distanceThreshold = Number(this.options.distanceThreshold.replace("%", "")) / 100 * this.viewport[axis];
|
|---|
| 294 | else if (typeof this.options.distanceThreshold === "number") distanceThreshold = this.options.distanceThreshold;
|
|---|
| 295 | else distanceThreshold = this.viewport[axis];
|
|---|
| 296 | return distanceThreshold;
|
|---|
| 297 | }
|
|---|
| 298 | onSnap = (e) => {
|
|---|
| 299 | if (this.isStopped) return;
|
|---|
| 300 | if (e.event.type === "touchmove") return;
|
|---|
| 301 | if (this.options.type === "lock" && this.lenis.userData?.initiator === "snap") return;
|
|---|
| 302 | let { scroll, isHorizontal } = this.lenis;
|
|---|
| 303 | const delta = isHorizontal ? e.deltaX : e.deltaY;
|
|---|
| 304 | scroll = Math.ceil(this.lenis.scroll + delta);
|
|---|
| 305 | const snaps = this.computeSnaps();
|
|---|
| 306 | if (snaps.length === 0) return;
|
|---|
| 307 | let snapIndex;
|
|---|
| 308 | const prevSnapIndex = snaps.findLastIndex(({ value }) => value < scroll);
|
|---|
| 309 | const nextSnapIndex = snaps.findIndex(({ value }) => value > scroll);
|
|---|
| 310 | if (this.options.type === "lock") {
|
|---|
| 311 | if (delta > 0) snapIndex = nextSnapIndex;
|
|---|
| 312 | else if (delta < 0) snapIndex = prevSnapIndex;
|
|---|
| 313 | } else {
|
|---|
| 314 | const prevSnap = snaps[prevSnapIndex];
|
|---|
| 315 | const distanceToPrevSnap = prevSnap ? Math.abs(scroll - prevSnap.value) : Number.POSITIVE_INFINITY;
|
|---|
| 316 | const nextSnap = snaps[nextSnapIndex];
|
|---|
| 317 | snapIndex = distanceToPrevSnap < (nextSnap ? Math.abs(scroll - nextSnap.value) : Number.POSITIVE_INFINITY) ? prevSnapIndex : nextSnapIndex;
|
|---|
| 318 | }
|
|---|
| 319 | if (snapIndex === void 0) return;
|
|---|
| 320 | if (snapIndex === -1) return;
|
|---|
| 321 | snapIndex = Math.max(0, Math.min(snapIndex, snaps.length - 1));
|
|---|
| 322 | const snap = snaps[snapIndex];
|
|---|
| 323 | if (Math.abs(scroll - snap.value) <= this.distanceThreshold) this.goTo(snapIndex);
|
|---|
| 324 | };
|
|---|
| 325 | resize() {
|
|---|
| 326 | this.elements.forEach((element) => {
|
|---|
| 327 | element.onWrapperResize();
|
|---|
| 328 | });
|
|---|
| 329 | }
|
|---|
| 330 | };
|
|---|
| 331 | //#endregion
|
|---|
| 332 | export { Snap as default };
|
|---|
| 333 |
|
|---|
| 334 | //# sourceMappingURL=lenis-snap.mjs.map |
|---|