@brandocms/jupiter 3.55.0 → 4.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +509 -54
  2. package/package.json +30 -18
  3. package/src/index.js +15 -10
  4. package/src/modules/Application/index.js +236 -158
  5. package/src/modules/Breakpoints/index.js +116 -36
  6. package/src/modules/Cookies/index.js +95 -64
  7. package/src/modules/CoverOverlay/index.js +21 -14
  8. package/src/modules/Dataloader/index.js +71 -24
  9. package/src/modules/Dataloader/url-sync.js +238 -0
  10. package/src/modules/Dom/index.js +24 -0
  11. package/src/modules/DoubleHeader/index.js +571 -0
  12. package/src/modules/Dropdown/index.js +108 -73
  13. package/src/modules/EqualHeightElements/index.js +8 -8
  14. package/src/modules/EqualHeightImages/index.js +15 -7
  15. package/src/modules/FixedHeader/index.js +116 -30
  16. package/src/modules/FooterReveal/index.js +5 -5
  17. package/src/modules/HeroSlider/index.js +231 -106
  18. package/src/modules/HeroVideo/index.js +72 -44
  19. package/src/modules/Lazyload/index.js +128 -80
  20. package/src/modules/Lightbox/index.js +101 -80
  21. package/src/modules/Links/index.js +77 -51
  22. package/src/modules/Looper/index.js +1737 -0
  23. package/src/modules/Marquee/index.js +106 -37
  24. package/src/modules/MobileMenu/index.js +105 -130
  25. package/src/modules/Moonwalk/index.js +479 -153
  26. package/src/modules/Parallax/index.js +280 -57
  27. package/src/modules/Popover/index.js +187 -17
  28. package/src/modules/Popup/index.js +172 -53
  29. package/src/modules/ScrollSpy/index.js +21 -0
  30. package/src/modules/StackedBoxes/index.js +8 -6
  31. package/src/modules/StickyHeader/index.js +394 -164
  32. package/src/modules/Toggler/index.js +207 -11
  33. package/src/modules/Typography/index.js +33 -20
  34. package/src/utils/motion-helpers.js +330 -0
  35. package/types/README.md +159 -0
  36. package/types/events/index.d.ts +20 -0
  37. package/types/index.d.ts +6 -0
  38. package/types/modules/Application/index.d.ts +168 -0
  39. package/types/modules/Breakpoints/index.d.ts +40 -0
  40. package/types/modules/Cookies/index.d.ts +81 -0
  41. package/types/modules/CoverOverlay/index.d.ts +6 -0
  42. package/types/modules/Dataloader/index.d.ts +38 -0
  43. package/types/modules/Dataloader/url-sync.d.ts +36 -0
  44. package/types/modules/Dom/index.d.ts +47 -0
  45. package/types/modules/DoubleHeader/index.d.ts +63 -0
  46. package/types/modules/Dropdown/index.d.ts +15 -0
  47. package/types/modules/EqualHeightElements/index.d.ts +8 -0
  48. package/types/modules/EqualHeightImages/index.d.ts +11 -0
  49. package/types/modules/FeatureTests/index.d.ts +27 -0
  50. package/types/modules/FixedHeader/index.d.ts +219 -0
  51. package/types/modules/Fontloader/index.d.ts +5 -0
  52. package/types/modules/FooterReveal/index.d.ts +5 -0
  53. package/types/modules/HeroSlider/index.d.ts +28 -0
  54. package/types/modules/HeroVideo/index.d.ts +83 -0
  55. package/types/modules/Lazyload/index.d.ts +80 -0
  56. package/types/modules/Lightbox/index.d.ts +123 -0
  57. package/types/modules/Links/index.d.ts +55 -0
  58. package/types/modules/Looper/index.d.ts +127 -0
  59. package/types/modules/Marquee/index.d.ts +23 -0
  60. package/types/modules/MobileMenu/index.d.ts +63 -0
  61. package/types/modules/Moonwalk/index.d.ts +322 -0
  62. package/types/modules/Parallax/index.d.ts +71 -0
  63. package/types/modules/Popover/index.d.ts +29 -0
  64. package/types/modules/Popup/index.d.ts +76 -0
  65. package/types/modules/ScrollSpy/index.d.ts +29 -0
  66. package/types/modules/StackedBoxes/index.d.ts +9 -0
  67. package/types/modules/StickyHeader/index.d.ts +220 -0
  68. package/types/modules/Toggler/index.d.ts +48 -0
  69. package/types/modules/Typography/index.d.ts +77 -0
  70. package/types/utils/dispatchElementEvent.d.ts +1 -0
  71. package/types/utils/imageIsLoaded.d.ts +1 -0
  72. package/types/utils/imagesAreLoaded.d.ts +1 -0
  73. package/types/utils/loadScript.d.ts +2 -0
  74. package/types/utils/prefersReducedMotion.d.ts +4 -0
  75. package/types/utils/rafCallback.d.ts +2 -0
  76. package/types/utils/zoom.d.ts +4 -0
@@ -0,0 +1,1737 @@
1
+ import { animate, motionValue, frame, cancelFrame } from 'motion'
2
+ import _defaultsDeep from 'lodash.defaultsdeep'
3
+ import Dom from '../Dom'
4
+
5
+ /**
6
+ * Looper Module
7
+ *
8
+ * Creates seamless horizontal infinite scrolling carousels with:
9
+ * - Draggable interaction with momentum/inertia
10
+ * - Auto-crawl (continuous scrolling)
11
+ * - Snap-to-item behavior
12
+ * - Next/Previous navigation
13
+ * - Responsive resize handling
14
+ * - Moonwalk integration (play/pause on viewport entry/exit)
15
+ *
16
+ * Optimized Motion.js implementation replacing GSAP Draggable + InertiaPlugin
17
+ */
18
+
19
+ const DEFAULT_OPTIONS = {
20
+ center: false,
21
+ snap: false, // Set to true to enable snap-to-item behavior
22
+ crawl: true, // Continuous auto-scrolling
23
+ loop: true, // Infinite looping (false for linear scrolling)
24
+ draggable: true, // Enable drag interaction
25
+ endAlignment: 'right', // For non-looping: 'right' = last item at viewport right edge, 'start' = last item at viewport left edge
26
+ minimumMovement: 3, // Pixels - movement below this is treated as click, above as drag
27
+
28
+ // Inertia/throw configuration (when dragging and releasing)
29
+ throwResistance: 325, // Time constant for deceleration (lower = more resistance/faster stop, higher = less resistance/longer glide)
30
+ throwPower: 0.8, // Deceleration curve (0-1, higher = more gradual slowdown)
31
+ throwVelocityMultiplier: 1.0, // Scale velocity for all throws (0.5 = half speed, 2.0 = double)
32
+ snapVelocityMultiplier: 0.8, // Additional scaling for snapped loopers (stacks with throwVelocityMultiplier)
33
+
34
+ // Snap animation configuration (when snap: true)
35
+ snapDuration: 0.5, // Duration of snap animation in seconds (0.3-1.0, lower = faster/snappier)
36
+ snapBounce: 0.15, // Spring bounce amount (0-1, 0 = no bounce, higher = more bouncy)
37
+
38
+ speed: {
39
+ sm: 0.1, // Speed for mobile (multiplier)
40
+ lg: 0.35, // Speed for desktop (multiplier)
41
+ },
42
+
43
+ ease: {
44
+ mouseOver: { speed: 0.3, duration: 0.75 },
45
+ mouseOut: { speed: 1, duration: 0.75 },
46
+ },
47
+
48
+ selector: '[data-moonwalk-run="loop"]',
49
+ }
50
+
51
+ /**
52
+ * Create a horizontal looping carousel
53
+ * @param {Object} app - Jupiter application instance
54
+ * @param {Array|NodeList} items - Items to loop
55
+ * @param {Object} config - Configuration options
56
+ * @returns {Object} Loop controller with methods
57
+ */
58
+ function horizontalLoop(app, items, config) {
59
+ // Convert to array
60
+ items = Array.from(items)
61
+ config = config || {}
62
+
63
+ const shouldLoop = config.loop !== false
64
+ const shouldDrag = config.draggable !== false
65
+
66
+ // Container setup
67
+ const center = config.center
68
+ const container =
69
+ center === true
70
+ ? items[0].parentNode
71
+ : (typeof center === 'string' ? document.querySelector(center) : center) ||
72
+ items[0].parentNode
73
+
74
+ // State
75
+ let curIndex = 0
76
+ let totalWidth = 0
77
+ let originalItemsWidth = 0 // Width of ONLY original items (for wrapping)
78
+ let pixelsPerSecond = (config.speed || 1) * 100
79
+ let animation = null
80
+ let position = motionValue(0) // Source of truth for position
81
+ let boundedPos = motionValue(0) // Bounded position (0 to originalItemsWidth)
82
+ let lastBoundedValue = 0 // Track last value to detect wraps
83
+ let originalItemCount = 0 // Track count of ORIGINAL items (before clones)
84
+ let maxScrollPosition = 0 // For non-looping: max scroll where last item is at right edge
85
+
86
+ // Cached measurements
87
+ let widths = []
88
+ let xPercents = []
89
+ let times = []
90
+ let startX = 0
91
+ let gap = 0 // CSS gap between items
92
+ let offsetLefts = [] // Cache offsetLeft values to avoid layout thrashing
93
+ let containerWidth = 0 // Cache container width to avoid layout reads on every frame
94
+ let itemWrapOffsets = [] // Cache current wrap offset for each item
95
+ let isCloneCache = [] // Cache which items are clones (avoid hasAttribute checks)
96
+
97
+ // Drag state and cleanup handlers
98
+ let dragState = {}
99
+ let speedRampAnimation = null // Track speed ramp animation
100
+ let inertiaAnimation = null // Track inertia animation
101
+ let snapAnimation = null // Track snap animation
102
+ let navAnimation = null // Track navigation animation (next/previous/toIndex)
103
+ let positionUnsubscribe = null // Track position listener for cleanup
104
+ let renderUnsubscribe = null // Track frame.render loop for cleanup
105
+
106
+ // Display elements for index/count
107
+ let indexElements = []
108
+ let countElements = []
109
+
110
+ /**
111
+ * Measure total width of all items as currently laid out
112
+ * @returns {number} Total width in pixels
113
+ */
114
+ function getTotalWidthOfItems() {
115
+ if (!items.length) return 0
116
+
117
+ const first = items[0]
118
+ const last = items[items.length - 1]
119
+
120
+ // Get measurements
121
+ const startX = first.offsetLeft
122
+ const lastRect = last.getBoundingClientRect()
123
+ const lastWidth = lastRect.width
124
+
125
+ // Use cached gap, or calculate if not yet cached
126
+ if (gap === 0) {
127
+ gap = parseFloat(getComputedStyle(container).gap) || 0
128
+ }
129
+
130
+ // Calculate total including gaps and padding
131
+ // Total = sum of item widths + gaps between items + paddingRight + trailing gap
132
+ const totalWidth =
133
+ last.offsetLeft + lastWidth - startX + (parseFloat(config.paddingRight) || 0) + gap
134
+
135
+ return totalWidth
136
+ }
137
+
138
+ /**
139
+ * Replicate items until total width >= container width + buffer
140
+ * Fixes exponential duplication bug from original
141
+ */
142
+ function replicateItemsIfNeeded() {
143
+ if (!shouldLoop) {
144
+ return
145
+ }
146
+
147
+ const containerWidth = container.offsetWidth
148
+ let totalWidth = getTotalWidthOfItems()
149
+
150
+ // Safety: bail if no layout yet
151
+ if (containerWidth === 0 || totalWidth === 0) {
152
+ return
153
+ }
154
+
155
+ // Calculate minimum width needed for seamless looping
156
+ // We need enough width so that when an item exits one side, there are always
157
+ // enough items on the other side to fill the viewport without visible gaps
158
+ const firstItemWidth = items[0].offsetWidth
159
+ const lastItemWidth = items[items.length - 1].offsetWidth
160
+ const maxItemWidth = Math.max(firstItemWidth, lastItemWidth)
161
+
162
+ // Use 2.5x container width to ensure plenty of buffer for wrapping
163
+ // This prevents items from visibly moving to the back before they're off-screen
164
+ const minRequiredWidth = containerWidth * 2.5 + maxItemWidth
165
+
166
+ // Only replicate if needed
167
+ if (totalWidth >= minRequiredWidth) {
168
+ return
169
+ }
170
+
171
+ // Store original count to prevent exponential growth
172
+ const originalItemCount = items.length
173
+ const maxReplications = 10
174
+ let count = 0
175
+ let previousTotalWidth = totalWidth
176
+
177
+ while (totalWidth < minRequiredWidth && count < maxReplications) {
178
+ // Clone ONLY original items
179
+ for (let i = 0; i < originalItemCount; i++) {
180
+ const clone = items[i].cloneNode(true)
181
+ clone.setAttribute('data-looper-clone', 'true')
182
+ container.appendChild(clone)
183
+ items.push(clone)
184
+ }
185
+
186
+ // Force layout recalculation
187
+ container.getBoundingClientRect()
188
+ totalWidth = getTotalWidthOfItems()
189
+ count++
190
+
191
+ // Safety: detect if width isn't increasing
192
+ if (totalWidth <= previousTotalWidth && count > 1) {
193
+ break
194
+ }
195
+
196
+ previousTotalWidth = totalWidth
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Measure and cache all item widths and positions
202
+ * Batched to avoid layout thrashing
203
+ */
204
+ function populateWidths() {
205
+ // Cache CSS gap (only changes on resize)
206
+ gap = parseFloat(getComputedStyle(container).gap) || 0
207
+
208
+ items.forEach((el, i) => {
209
+ // Cache offsetLeft for all items (needed for positioning calculations)
210
+ offsetLefts[i] = el.offsetLeft
211
+
212
+ // For clones, copy width from corresponding original item (avoid getBoundingClientRect)
213
+ // Clones are exact copies so they have the same dimensions
214
+ if (isCloneCache[i]) {
215
+ const originalIndex = i % originalItemCount
216
+ widths[i] = widths[originalIndex]
217
+ xPercents[i] = 0 // Clones don't have any initial transform
218
+ } else {
219
+ // For original items, measure actual dimensions
220
+ const rect = el.getBoundingClientRect()
221
+ widths[i] = rect.width
222
+
223
+ // Calculate xPercent based on current transform (expensive, only for originals)
224
+ const computedStyle = window.getComputedStyle(el)
225
+ const transform = computedStyle.transform
226
+ let currentX = 0
227
+
228
+ if (transform && transform !== 'none') {
229
+ const matrix = new DOMMatrix(transform)
230
+ currentX = matrix.m41
231
+ }
232
+
233
+ xPercents[i] = (currentX / widths[i]) * 100
234
+ }
235
+ })
236
+
237
+ // Update startX and cache container width
238
+ startX = items[0].offsetLeft
239
+ containerWidth = container.offsetWidth // Cache once here instead of reading every frame
240
+ totalWidth = getTotalWidthOfItems()
241
+
242
+ // Calculate width of ONLY original items (for wrapping distance)
243
+ // This is the distance from first item to first clone
244
+ if (originalItemCount > 0 && items.length > originalItemCount) {
245
+ originalItemsWidth = items[originalItemCount].offsetLeft - items[0].offsetLeft // + gap
246
+ } else {
247
+ // No clones yet, use totalWidth
248
+ originalItemsWidth = totalWidth
249
+ }
250
+
251
+ // Calculate max scroll for non-looping based on endAlignment
252
+ if (!shouldLoop && originalItemCount > 0) {
253
+ if (config.centerSlide) {
254
+ // Center mode: max scroll is where last item's center is at viewport center
255
+ // But clamped so we don't show empty space
256
+ const lastItemIndex = originalItemCount - 1
257
+ const lastItemCenter = offsetLefts[lastItemIndex] + widths[lastItemIndex] / 2 - startX
258
+ const viewportCenter = containerWidth / 2
259
+ const idealMaxScroll = lastItemCenter - viewportCenter
260
+ // Clamp to ensure last item doesn't go past right edge
261
+ const lastItemRightEdge = offsetLefts[lastItemIndex] + widths[lastItemIndex] - startX
262
+ const absoluteMax = lastItemRightEdge - containerWidth
263
+ maxScrollPosition = Math.max(0, Math.min(idealMaxScroll, absoluteMax))
264
+ } else if (config.endAlignment === 'start') {
265
+ // 'start' alignment: last item can scroll to left edge (traditional behavior)
266
+ const lastItemIndex = originalItemCount - 1
267
+ const lastItemLeftEdge = offsetLefts[lastItemIndex] - startX
268
+ maxScrollPosition = Math.max(0, lastItemLeftEdge)
269
+ } else {
270
+ // 'right' alignment (default): last item's right edge at viewport's right edge
271
+ const lastItemIndex = originalItemCount - 1
272
+ const lastItemRightEdge = offsetLefts[lastItemIndex] + widths[lastItemIndex] - startX
273
+ maxScrollPosition = Math.max(0, lastItemRightEdge - containerWidth)
274
+ }
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Calculate time positions for snapping
280
+ * These represent when each item hits the "start" position
281
+ */
282
+ function populateSnapTimes() {
283
+ if (!shouldLoop) {
284
+ // For non-looping, calculate based on actual item positions (pixels)
285
+ // This ensures snap and navigation work correctly
286
+ items.forEach((item, i) => {
287
+ const curX = (xPercents[i] / 100) * widths[i]
288
+ let snapPos
289
+
290
+ if (config.centerSlide) {
291
+ // Center mode: item's center at viewport's center
292
+ const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
293
+ const viewportCenter = containerWidth / 2
294
+ snapPos = itemCenter - viewportCenter
295
+ } else {
296
+ // Normal mode: item's left edge at viewport's left edge
297
+ snapPos = item.offsetLeft + curX - startX
298
+ }
299
+
300
+ times[i] = snapPos / pixelsPerSecond
301
+ })
302
+ return
303
+ }
304
+
305
+ // For looping, calculate based on item positions including gaps
306
+ items.forEach((item, i) => {
307
+ const curX = (xPercents[i] / 100) * widths[i]
308
+ let snapPos
309
+
310
+ if (config.centerSlide) {
311
+ // Center mode: item's center at viewport's center
312
+ const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
313
+ const viewportCenter = containerWidth / 2
314
+ snapPos = itemCenter - viewportCenter
315
+ } else {
316
+ // Normal mode: item's left edge at viewport's left edge
317
+ snapPos = item.offsetLeft + curX - startX
318
+ }
319
+
320
+ times[i] = snapPos / pixelsPerSecond
321
+ })
322
+
323
+ // Adjust for container padding if present
324
+ const itemsContainer = items[0].parentNode
325
+ const containerPaddingLeft = parseFloat(getComputedStyle(itemsContainer).paddingLeft) || 0
326
+
327
+ if (containerPaddingLeft > 0) {
328
+ const paddingTime = containerPaddingLeft / pixelsPerSecond
329
+ times = times.map(time => time - paddingTime)
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Check item positions and wrap when needed
335
+ * Container is animated DIRECTLY (not updated here!)
336
+ * This function only reads position to determine wrapping
337
+ * @param {number} pos - Current position value (can grow infinitely)
338
+ */
339
+ function updateItemPositions(pos) {
340
+ const containerElement = items[0].parentElement
341
+
342
+ if (!shouldLoop) {
343
+ // Non-looping: we'll handle this with direct animation
344
+ return
345
+ }
346
+
347
+ // Calculate bounded position for checking item wrap points
348
+ // Use same wrapping formula as frame.render to handle negative positions (reversed mode)
349
+ const boundedPos = ((pos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
350
+
351
+ // Initialize wrap offsets cache if needed
352
+ if (itemWrapOffsets.length === 0) {
353
+ itemWrapOffsets = new Array(items.length).fill(0)
354
+ }
355
+
356
+ // TICKER PATTERN: ONLY move ORIGINAL items to the back, NEVER touch clones!
357
+ // This massively reduces DOM manipulation and style recalculation
358
+ items.forEach((item, i) => {
359
+ // Skip clones - they stay in natural flow! (use cached value for performance)
360
+ if (isCloneCache[i]) {
361
+ return
362
+ }
363
+
364
+ // Calculate where this ORIGINAL item is on screen (relative to bounded container)
365
+ const itemLeft = offsetLefts[i] - boundedPos
366
+
367
+ // Original items only ever have -totalWidth, 0, or +totalWidth offset
368
+ // This positions them AFTER all clones (not just after wrapping area)
369
+ let newOffset = 0
370
+
371
+ // Ticker boundary pattern: Check if item should be at the END or at the START
372
+ // When container cycles (boundedPos wraps from ~originalItemsWidth to ~0),
373
+ // items with large offsets get reset back to 0
374
+
375
+ // Wrap distance includes the trailing gap for seamless cycling
376
+ const wrapOffset = totalWidth + gap
377
+
378
+ // Check if we're in the "reset zone" near wrap boundaries
379
+ const nearForwardWrap = boundedPos > originalItemsWidth - gap
380
+ const nearReverseWrap = boundedPos < gap
381
+
382
+ // RESET: When in reset zone, reset items and SKIP wrap checks to avoid fighting
383
+ if (nearForwardWrap && itemWrapOffsets[i] === wrapOffset) {
384
+ // Container about to wrap (forward), reset items at END back to START
385
+ newOffset = 0
386
+ } else if (nearReverseWrap && itemWrapOffsets[i] === -wrapOffset) {
387
+ // Container about to wrap (reverse), reset items at START back to END
388
+ newOffset = 0
389
+ } else if (nearForwardWrap || nearReverseWrap) {
390
+ // In reset zone but item doesn't need reset → keep current offset
391
+ newOffset = itemWrapOffsets[i]
392
+ } else if (itemLeft < -(widths[i] + containerWidth * 0.5)) {
393
+ // Item exited LEFT edge
394
+ // Forward drag (low boundedPos): wrap to END
395
+ // Backward drag (high boundedPos): don't wrap, clones fill in from right
396
+ newOffset = boundedPos < originalItemsWidth / 2 ? wrapOffset : 0
397
+ } else if (itemLeft > containerWidth + containerWidth * 0.5) {
398
+ // Item exited RIGHT edge
399
+ // This shouldn't happen much, but handle it
400
+ newOffset = 0
401
+ } else {
402
+ // Keep current offset
403
+ newOffset = itemWrapOffsets[i]
404
+ }
405
+
406
+ // ONLY update transform if the offset has changed!
407
+ if (newOffset !== itemWrapOffsets[i]) {
408
+ item.style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
409
+ itemWrapOffsets[i] = newOffset
410
+ }
411
+ })
412
+ }
413
+
414
+ /**
415
+ * Refresh measurements and recalculate animation
416
+ * @param {boolean} deep - Whether to rebuild animation (on resize)
417
+ */
418
+ function refresh(deep = false) {
419
+ // Save progress to preserve position
420
+ const progress = animation ? animation.time / animation.duration : 0
421
+ const currentPos = position.get()
422
+
423
+ // Pause animation if running
424
+ const wasPlaying = animation && animation.speed !== 0
425
+ if (animation) {
426
+ animation.pause()
427
+ }
428
+
429
+ // Remeasure everything
430
+ populateWidths()
431
+
432
+ if (deep) {
433
+ // Check if we need to replicate more items
434
+ const containerWidth = container.offsetWidth
435
+ const currentTotalWidth = getTotalWidthOfItems()
436
+
437
+ // Use same 2.5x buffer as replication logic
438
+ if (shouldLoop && currentTotalWidth < containerWidth * 2.5) {
439
+ replicateItemsIfNeeded()
440
+ populateWidths()
441
+ }
442
+
443
+ populateSnapTimes()
444
+
445
+ // Recreate animation with new measurements
446
+ if (shouldLoop && config.crawl) {
447
+ // Stop old animation
448
+ if (animation) {
449
+ animation.stop()
450
+ }
451
+
452
+ // Use startLoopAnimation for bounded position (no repeat: Infinity!)
453
+ animation = startLoopAnimation()
454
+
455
+ // Restore playback state
456
+ if (wasPlaying) {
457
+ animation.play()
458
+ } else {
459
+ animation.pause()
460
+ }
461
+ } else if (config.crawl) {
462
+ // Non-looping: recreate animation
463
+ const duration = (totalWidth - container.offsetWidth) / pixelsPerSecond
464
+
465
+ if (animation) {
466
+ animation.stop()
467
+ }
468
+
469
+ animation = animate(position, totalWidth - container.offsetWidth, {
470
+ duration,
471
+ ease: 'linear',
472
+ })
473
+
474
+ if (wasPlaying) {
475
+ animation.time = progress * animation.duration
476
+ animation.play()
477
+ } else {
478
+ animation.pause()
479
+ }
480
+ }
481
+ } else {
482
+ // Light refresh - just update measurements
483
+ populateSnapTimes()
484
+ }
485
+
486
+ // Update positions based on current scroll
487
+ updateItemPositions(currentPos)
488
+ }
489
+
490
+ /**
491
+ * Update the slide index display elements
492
+ */
493
+ function updateIndexDisplay() {
494
+ if (indexElements.length === 0) return
495
+ const displayIndex =
496
+ (((curIndex % originalItemCount) + originalItemCount) % originalItemCount) + 1 // 1-based, handle negative
497
+ indexElements.forEach(el => {
498
+ el.textContent = displayIndex
499
+ })
500
+ }
501
+
502
+ /**
503
+ * Initialize the loop animation
504
+ */
505
+ function init() {
506
+ // Store original item count BEFORE replication
507
+ originalItemCount = items.length
508
+
509
+ // Replicate items if needed
510
+ replicateItemsIfNeeded()
511
+
512
+ // Cache which items are clones (avoid hasAttribute checks in hot paths)
513
+ isCloneCache = items.map((item, i) => i >= originalItemCount)
514
+
515
+ // Measure everything
516
+ populateWidths()
517
+ populateSnapTimes()
518
+
519
+ // Set initial container position
520
+ const containerElement = items[0].parentElement
521
+ containerElement.style.willChange = 'transform'
522
+ containerElement.style.transform = 'translateX(0px)'
523
+
524
+ // Set up RAF loop to check item positions for wrapping
525
+ // Frame.render loop to apply bounded position to DOM
526
+ // This is Motion's optimized render loop - prevents layout thrashing
527
+ function startRenderLoop() {
528
+ if (renderUnsubscribe) return // Already running
529
+
530
+ const containerElement = items[0].parentElement
531
+
532
+ // Set up boundedPos motionValue to automatically sync with position
533
+ // This calculates the bounded position (0 to originalItemsWidth)
534
+ const positionUnsubscribe = position.on('change', latest => {
535
+ const bounded = ((latest % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
536
+ boundedPos.set(bounded)
537
+ })
538
+
539
+ // Detect when boundedPos wraps (makes large jump) and reset all items
540
+ // This prevents stuck items during fast drags in either direction
541
+ const boundedPosUnsubscribe = boundedPos.on('change', latest => {
542
+ const delta = Math.abs(latest - lastBoundedValue)
543
+
544
+ // If boundedPos jumped by more than 40% of the width, it wrapped
545
+ // Using 40% instead of 50% to catch edge cases
546
+ const didWrap = delta > originalItemsWidth * 0.4
547
+
548
+ if (didWrap) {
549
+ const direction =
550
+ latest > lastBoundedValue ? 'backward (drag right)' : 'forward (drag left)'
551
+
552
+ // Count how many items have non-zero offset before reset
553
+ const itemsWithOffset = items.filter(
554
+ (item, i) => !isCloneCache[i] && itemWrapOffsets[i] !== 0
555
+ ).length
556
+
557
+ // Reset ALL original items to 0 (both positive and negative offsets)
558
+ items.forEach((item, i) => {
559
+ if (!isCloneCache[i] && itemWrapOffsets[i] !== 0) {
560
+ item.style.transform = 'none'
561
+ itemWrapOffsets[i] = 0
562
+ }
563
+ })
564
+
565
+ // CRITICAL: Sync unbounded position with bounded position to prevent
566
+ // inertia calculation bugs when dragging RIGHT across boundaries
567
+ // BUT only do this when NOT animating snap or nav, otherwise it interferes
568
+ if (!snapAnimation && !navAnimation) {
569
+ position.set(latest)
570
+ } else {
571
+ }
572
+ }
573
+
574
+ lastBoundedValue = latest
575
+ })
576
+
577
+ renderUnsubscribe = frame.render(() => {
578
+ // Read bounded position from motionValue
579
+ const currentBoundedPos = boundedPos.get()
580
+
581
+ // Apply bounded transform to container
582
+ containerElement.style.transform = `translateX(${-currentBoundedPos}px)`
583
+
584
+ // Wrap items based on bounded position
585
+ updateItemPositions(currentBoundedPos)
586
+ }, true) // true = keep alive
587
+
588
+ // Store unsubscribe functions for cleanup
589
+ renderUnsubscribe.positionUnsubscribe = positionUnsubscribe
590
+ renderUnsubscribe.boundedPosUnsubscribe = boundedPosUnsubscribe
591
+ }
592
+
593
+ function stopRenderLoop() {
594
+ if (renderUnsubscribe) {
595
+ // Unsubscribe from motionValue listeners
596
+ if (renderUnsubscribe.positionUnsubscribe) {
597
+ renderUnsubscribe.positionUnsubscribe()
598
+ }
599
+ if (renderUnsubscribe.boundedPosUnsubscribe) {
600
+ renderUnsubscribe.boundedPosUnsubscribe()
601
+ }
602
+ cancelFrame(renderUnsubscribe)
603
+ renderUnsubscribe = null
604
+ }
605
+ }
606
+
607
+ // Start the frame.render loop
608
+ if (shouldLoop) {
609
+ startRenderLoop()
610
+ } else {
611
+ // Non-looping: simple position listener to update container transform
612
+ const containerElement = items[0].parentElement
613
+ positionUnsubscribe = position.on('change', latest => {
614
+ containerElement.style.transform = `translateX(${-latest}px)`
615
+ })
616
+ }
617
+
618
+ // Function to create and start the animation loop
619
+ // Animates the position motionValue (frame.render loop applies to DOM)
620
+ function startLoopAnimation() {
621
+ if (!shouldLoop || !config.crawl) return null
622
+
623
+ const duration = originalItemsWidth / pixelsPerSecond
624
+ const currentPos = position.get()
625
+ // Reversed: crawl backwards (right-to-left), Normal: crawl forward (left-to-right)
626
+ const target = config.reversed
627
+ ? currentPos - originalItemsWidth
628
+ : currentPos + originalItemsWidth
629
+
630
+ // Animate the position motionValue
631
+ // frame.render loop will apply bounded position to DOM
632
+ animation = animate(position, target, {
633
+ duration,
634
+ repeat: Infinity,
635
+ ease: 'linear',
636
+ })
637
+
638
+ return animation
639
+ }
640
+
641
+ // Create animation by animating the position motionValue
642
+ if (shouldLoop && config.crawl) {
643
+ const duration = totalWidth / pixelsPerSecond
644
+
645
+ // Create initial animation (paused)
646
+ animation = startLoopAnimation()
647
+ animation.pause()
648
+ } else if (!shouldLoop && config.crawl) {
649
+ // Non-looping: ping-pong animation (crawl to end, reverse to start)
650
+ // Use maxScrollPosition (last item at right edge) instead of totalWidth
651
+ const duration = maxScrollPosition / pixelsPerSecond
652
+
653
+ // Create a ping-pong crawl animation
654
+ function startPingPongCrawl(fromStart = true) {
655
+ const currentPos = position.get()
656
+ const target = fromStart ? maxScrollPosition : 0
657
+ const remainingDist = Math.abs(target - currentPos)
658
+ const remainingDuration = remainingDist / pixelsPerSecond
659
+
660
+ // Use easeInOut for smooth acceleration and deceleration at boundaries
661
+ // This creates a natural "bounce back" feel at the edges
662
+ animation = animate(position, target, {
663
+ duration: remainingDuration,
664
+ ease: [0.4, 0.0, 0.2, 1], // Custom cubic-bezier for smooth ease-in-out
665
+ })
666
+
667
+ // When reaching the end, reverse direction
668
+ animation.then(() => {
669
+ // Brief pause at boundary for visual clarity
670
+ setTimeout(() => {
671
+ if (animation && animation.speed !== 0) {
672
+ startPingPongCrawl(!fromStart)
673
+ }
674
+ }, 200) // Slightly longer pause for smooth reversal
675
+ })
676
+ }
677
+
678
+ // Store the ping-pong starter for later use
679
+ config.startPingPongCrawl = startPingPongCrawl
680
+
681
+ // Create initial animation (starts paused)
682
+ animation = animate(position, maxScrollPosition, {
683
+ duration,
684
+ ease: 'linear',
685
+ })
686
+ animation.pause()
687
+ }
688
+
689
+ // Setup drag if enabled
690
+ if (shouldDrag) {
691
+ setupDrag()
692
+ }
693
+
694
+ // Setup hover effects
695
+ setupHoverEffects()
696
+
697
+ // Setup slide index/count display elements
698
+ if (config.wrapper) {
699
+ indexElements = Array.from(config.wrapper.querySelectorAll('[data-looper-slide-index]'))
700
+ countElements = Array.from(config.wrapper.querySelectorAll('[data-looper-slide-count]'))
701
+
702
+ if (countElements.length > 0) {
703
+ countElements.forEach(el => {
704
+ el.textContent = originalItemCount
705
+ })
706
+ }
707
+
708
+ // Only setup real-time index tracking if display elements exist
709
+ if (indexElements.length > 0) {
710
+ updateIndexDisplay()
711
+
712
+ // Update display in real-time as position changes
713
+ let lastDisplayedIndex = -1
714
+ const updateIndexOnChange = () => {
715
+ // Find closest slide to current bounded position
716
+ const closest = closestIndex(false)
717
+
718
+ // Only update DOM if index changed (avoid thrashing)
719
+ if (closest !== lastDisplayedIndex) {
720
+ lastDisplayedIndex = closest
721
+ const displayIndex = closest + 1
722
+ indexElements.forEach(el => {
723
+ el.textContent = displayIndex
724
+ })
725
+ }
726
+ }
727
+
728
+ // For looping, use boundedPos; for non-looping, use position directly
729
+ if (shouldLoop) {
730
+ boundedPos.on('change', updateIndexOnChange)
731
+ } else {
732
+ position.on('change', updateIndexOnChange)
733
+ }
734
+ }
735
+ }
736
+
737
+ // Set initial position
738
+ updateItemPositions(position.get())
739
+
740
+ // For center mode, start at middle slide
741
+ if (config.centerSlide && originalItemCount > 0) {
742
+ const middleIndex = Math.floor(originalItemCount / 2)
743
+ const targetTime = times[middleIndex]
744
+ let initialPos = targetTime * pixelsPerSecond
745
+
746
+ // Clamp for non-looping
747
+ if (!shouldLoop) {
748
+ initialPos = Math.max(0, Math.min(initialPos, maxScrollPosition))
749
+ }
750
+
751
+ position.set(initialPos)
752
+ curIndex = middleIndex
753
+ updateIndexDisplay()
754
+ }
755
+
756
+ // Listen for resize events
757
+ window.addEventListener('APPLICATION:RESIZE', handleResize)
758
+ }
759
+
760
+ /**
761
+ * Handle window resize
762
+ */
763
+ function handleResize(e) {
764
+ // Only refresh if width actually changed
765
+ if (e.detail && !e.detail.widthChanged) {
766
+ return
767
+ }
768
+
769
+ refresh(true)
770
+ }
771
+
772
+ /**
773
+ * Setup pointer-based drag interaction
774
+ * Replaces GSAP Draggable with optimized pointer events
775
+ */
776
+ function setupDrag() {
777
+ let isDragging = false
778
+ let startX = 0
779
+ let startPosition = 0
780
+ let velocityTracker = [] // Track recent movements for velocity calculation
781
+ let hasDragged = false // Did movement exceed minimumMovement threshold?
782
+ let totalMovement = 0 // Total pixels moved (for click vs drag detection)
783
+
784
+ /**
785
+ * Calculate velocity from recent pointer movements
786
+ * Uses weighted average of last few movements
787
+ * @returns {number} Velocity in pixels per second
788
+ */
789
+ function getVelocity() {
790
+ if (velocityTracker.length < 2) return 0
791
+
792
+ // Use last 5 movements for smoothing
793
+ const recent = velocityTracker.slice(-5)
794
+ let totalVelocity = 0
795
+ let totalWeight = 0
796
+
797
+ for (let i = 1; i < recent.length; i++) {
798
+ const prev = recent[i - 1]
799
+ const curr = recent[i]
800
+ const deltaX = curr.x - prev.x
801
+ const deltaTime = curr.time - prev.time
802
+
803
+ if (deltaTime > 0) {
804
+ // Weight more recent movements higher
805
+ const weight = i / recent.length
806
+ const velocity = (deltaX / deltaTime) * 1000 // Convert to px/second
807
+ totalVelocity += velocity * weight
808
+ totalWeight += weight
809
+ }
810
+ }
811
+
812
+ return totalWeight > 0 ? totalVelocity / totalWeight : 0
813
+ }
814
+
815
+ /**
816
+ * Handle pointer down - start drag
817
+ */
818
+ function onPointerDown(e) {
819
+ // Only handle primary pointer (left click, first touch)
820
+ if (e.button !== undefined && e.button !== 0) return
821
+
822
+ isDragging = true
823
+ startX = e.clientX
824
+ startPosition = position.get()
825
+ velocityTracker = [{ x: e.clientX, time: Date.now() }]
826
+ hasDragged = false // Reset - will be set true if movement exceeds threshold
827
+ totalMovement = 0
828
+
829
+ // Stop autoplay on user interaction
830
+ if (loopController && loopController.stopAutoplay) {
831
+ loopController.stopAutoplay()
832
+ }
833
+
834
+ // Stop any ongoing animations
835
+ if (inertiaAnimation) {
836
+ inertiaAnimation.stop()
837
+ inertiaAnimation = null
838
+ }
839
+ if (snapAnimation) {
840
+ snapAnimation.stop()
841
+ snapAnimation = null
842
+ }
843
+ if (animation) {
844
+ animation.stop()
845
+ animation = null
846
+ }
847
+ if (speedRampAnimation) {
848
+ speedRampAnimation.stop()
849
+ speedRampAnimation = null
850
+ }
851
+
852
+ // Prevent default to stop native drag behavior on links/images
853
+ // We'll manually trigger click in onPointerUp if it wasn't a real drag
854
+ e.preventDefault()
855
+
856
+ // Add move/up listeners to window for better tracking
857
+ window.addEventListener('pointermove', onPointerMove, { passive: false })
858
+ window.addEventListener('pointerup', onPointerUp)
859
+ window.addEventListener('pointercancel', onPointerUp)
860
+ }
861
+
862
+ /**
863
+ * Handle pointer move - update position
864
+ */
865
+ function onPointerMove(e) {
866
+ if (!isDragging) return
867
+
868
+ const currentX = e.clientX
869
+ const currentTime = Date.now()
870
+
871
+ // Track total movement for click vs drag detection
872
+ const movementDelta = Math.abs(currentX - startX)
873
+
874
+ // Check if this is now a real drag (exceeded minimum movement threshold)
875
+ if (!hasDragged && movementDelta > config.minimumMovement) {
876
+ hasDragged = true
877
+ // Now that we know it's a drag, change cursor and disable pointer events on items
878
+ container.style.cursor = 'grabbing'
879
+ items.forEach(item => {
880
+ item.style.pointerEvents = 'none'
881
+ })
882
+ }
883
+
884
+ // Only update position if we've confirmed this is a drag
885
+ if (!hasDragged) return
886
+
887
+ // Prevent default only for actual drags (not clicks)
888
+ e.preventDefault()
889
+
890
+ // Track for velocity calculation
891
+ velocityTracker.push({ x: currentX, time: currentTime })
892
+
893
+ // Keep only recent movements (last 100ms)
894
+ while (velocityTracker.length > 0 && currentTime - velocityTracker[0].time > 100) {
895
+ velocityTracker.shift()
896
+ }
897
+
898
+ // Calculate drag delta and new position
899
+ const deltaX = startX - currentX
900
+ const newPosition = startPosition + deltaX
901
+
902
+ // Update position motionValue
903
+ // frame.render loop will apply bounded transform to DOM
904
+ if (shouldLoop) {
905
+ // For looping, allow unbounded position (frame.render will bound it)
906
+ position.set(newPosition)
907
+ } else {
908
+ // For non-looping, clamp position to maxScrollPosition (last item at right edge)
909
+ const clampedPos = Math.max(0, Math.min(maxScrollPosition, newPosition))
910
+ position.set(clampedPos)
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Calculate where inertia would land based on velocity
916
+ * Uses same physics as startInertia to predict landing position
917
+ * Motion.js inertia formula: distance = velocity * timeConstant * (power / (1 - power))
918
+ * @param {number} velocity - Cursor velocity in pixels per second
919
+ * @returns {number} Predicted landing position
920
+ */
921
+ function calculateInertiaTarget(velocity) {
922
+ const currentPos = position.get()
923
+ const motionVelocity = -velocity
924
+ const power = config.throwPower
925
+ const timeConstant = config.throwResistance / 1000
926
+ // Motion.js inertia distance formula
927
+ const estimatedDistance = motionVelocity * timeConstant * (power / (1 - power))
928
+ return currentPos + estimatedDistance
929
+ }
930
+
931
+ /**
932
+ * Handle pointer up - end drag and start inertia
933
+ */
934
+ function onPointerUp(e) {
935
+ if (!isDragging) return
936
+
937
+ isDragging = false
938
+
939
+ // Clean up listeners
940
+ window.removeEventListener('pointermove', onPointerMove)
941
+ window.removeEventListener('pointerup', onPointerUp)
942
+ window.removeEventListener('pointercancel', onPointerUp)
943
+
944
+ // Reset cursor and re-enable hover effects (only if we actually dragged)
945
+ if (hasDragged) {
946
+ container.style.cursor = 'grab'
947
+ items.forEach(item => {
948
+ item.style.pointerEvents = ''
949
+ })
950
+ }
951
+
952
+ // If this was a click (not a drag), trigger click on the element
953
+ if (!hasDragged) {
954
+ // Find the element under the pointer and trigger a click
955
+ const clickedElement = document.elementFromPoint(e.clientX, e.clientY)
956
+ if (clickedElement) {
957
+ clickedElement.click()
958
+ }
959
+ return
960
+ }
961
+
962
+ // Calculate final velocity
963
+ const velocity = getVelocity()
964
+
965
+ // If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
966
+ // Otherwise use old logic: inertia if velocity, or resume crawl
967
+ if (config.snap) {
968
+ snapToNearest(velocity)
969
+ } else if (Math.abs(velocity) > 1) {
970
+ startInertia(velocity)
971
+ } else if (config.crawl) {
972
+ resumeCrawl()
973
+ }
974
+ }
975
+
976
+ /**
977
+ * Start inertia animation with momentum
978
+ * Uses Motion's inertia type for physics-based deceleration
979
+ * @param {number} velocity - Initial velocity in pixels per second
980
+ */
981
+ function startInertia(velocity) {
982
+ // Read current position from motionValue
983
+ const currentPos = position.get()
984
+
985
+ // Calculate inertia velocity accounting for direction
986
+ // Cursor velocity and position velocity are OPPOSITE:
987
+ // - Drag left (cursor decreases) = scroll right (position increases)
988
+ // - Drag right (cursor increases) = scroll left (position decreases)
989
+ // Note: This is ALWAYS opposite, regardless of reversed setting
990
+ // (reversed only affects auto-crawl, not drag)
991
+ // Apply velocity multiplier for tuning throw feel
992
+ const motionVelocity = -velocity * config.throwVelocityMultiplier
993
+
994
+ // Calculate estimated target based on inertia physics
995
+ const power = config.throwPower
996
+ const timeConstant = config.throwResistance / 1000 // Convert to seconds
997
+ const estimatedDistance = motionVelocity * timeConstant * 0.5
998
+ const targetPos = currentPos + estimatedDistance
999
+
1000
+ // Animate position motionValue with inertia
1001
+ inertiaAnimation = animate(position, targetPos, {
1002
+ type: 'inertia',
1003
+ velocity: motionVelocity,
1004
+ power,
1005
+ timeConstant: config.throwResistance,
1006
+ restSpeed: 10,
1007
+ restDelta: 0.5,
1008
+ // For non-looping, add boundaries (last item at right edge)
1009
+ ...(shouldLoop
1010
+ ? {}
1011
+ : {
1012
+ min: 0,
1013
+ max: maxScrollPosition,
1014
+ bounceStiffness: 300,
1015
+ bounceDamping: 30,
1016
+ }),
1017
+ })
1018
+
1019
+ // When inertia completes
1020
+ inertiaAnimation
1021
+ .then(() => {
1022
+ inertiaAnimation = null
1023
+ if (config.crawl) {
1024
+ resumeCrawl()
1025
+ }
1026
+ })
1027
+ .catch(() => {
1028
+ inertiaAnimation = null
1029
+ })
1030
+ }
1031
+
1032
+ /**
1033
+ * Find the nearest snap point to a given position
1034
+ * @param {number} targetPos - Position to find nearest snap point for
1035
+ * @returns {number} The snap position
1036
+ */
1037
+ function findNearestSnapPoint(targetPos) {
1038
+ if (!times || times.length === 0 || originalItemCount === 0) {
1039
+ return targetPos
1040
+ }
1041
+
1042
+ // Find closest snap point by checking each original item at different cycle offsets
1043
+ let closestIndex = 0
1044
+ let closestSnapPos = 0
1045
+ let closestDist = Infinity
1046
+
1047
+ // Calculate which cycle the target is in to determine which cycles to check
1048
+ const targetCycle = Math.floor(targetPos / originalItemsWidth)
1049
+
1050
+ // Only iterate over original items
1051
+ for (let i = 0; i < originalItemCount; i++) {
1052
+ const snapTime = times[i]
1053
+ const baseSnapPos = snapTime * pixelsPerSecond
1054
+
1055
+ // For looping, check this snap point at multiple cycle offsets relative to target
1056
+ if (shouldLoop) {
1057
+ // Check previous cycle, target cycle, and next cycle relative to where target is
1058
+ for (let cycleOffset = -1; cycleOffset <= 1; cycleOffset++) {
1059
+ const candidatePos = baseSnapPos + (targetCycle + cycleOffset) * originalItemsWidth
1060
+ const dist = Math.abs(candidatePos - targetPos)
1061
+
1062
+ if (dist < closestDist) {
1063
+ closestDist = dist
1064
+ closestIndex = i
1065
+ closestSnapPos = candidatePos
1066
+ }
1067
+ }
1068
+ } else {
1069
+ // Non-looping: just use base position, clamped to maxScrollPosition
1070
+ const clampedSnapPos = Math.min(baseSnapPos, maxScrollPosition)
1071
+ const dist = Math.abs(clampedSnapPos - targetPos)
1072
+ if (dist < closestDist) {
1073
+ closestDist = dist
1074
+ closestIndex = i
1075
+ closestSnapPos = clampedSnapPos
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ // Update current index
1081
+ curIndex = closestIndex
1082
+
1083
+ // Return the snap position closest to the inertia target
1084
+ // DO NOT normalize to current position - we want to preserve momentum
1085
+ // and allow the carousel to spin through multiple cycles for high-velocity throws
1086
+ return closestSnapPos
1087
+ }
1088
+
1089
+ /**
1090
+ * Snap to nearest item with animation
1091
+ * Uses Motion's native inertia with modifyTarget for natural physics + snap
1092
+ * @param {number} velocity - Optional cursor velocity for inertia-based snapping
1093
+ */
1094
+ function snapToNearest(velocity = 0) {
1095
+ const currentPos = position.get()
1096
+ // Apply both velocity multipliers for snapped loopers
1097
+ const motionVelocity =
1098
+ -velocity * config.throwVelocityMultiplier * config.snapVelocityMultiplier
1099
+
1100
+ // Calculate ideal inertia target (Motion will recalculate, but we need a non-zero animation)
1101
+ // This ensures Motion starts the inertia physics
1102
+ const idealTarget =
1103
+ currentPos + motionVelocity * config.throwPower * (config.throwResistance / 1000)
1104
+
1105
+ // Use Motion's native inertia animation with modifyTarget
1106
+ // This gives us identical physics to non-snapped, but snaps to nearest item
1107
+ snapAnimation = animate(position, idealTarget, {
1108
+ type: 'inertia',
1109
+ velocity: motionVelocity,
1110
+ power: config.throwPower,
1111
+ timeConstant: config.throwResistance,
1112
+ modifyTarget: target => {
1113
+ const snapPos = findNearestSnapPoint(target)
1114
+ // For non-looping, clamp snap position to valid range
1115
+ if (!shouldLoop) {
1116
+ return Math.max(0, Math.min(snapPos, maxScrollPosition))
1117
+ }
1118
+ return snapPos
1119
+ },
1120
+ restSpeed: 10,
1121
+ restDelta: 0.5,
1122
+ // For non-looping, add boundaries to prevent overshooting
1123
+ ...(shouldLoop
1124
+ ? {}
1125
+ : {
1126
+ min: 0,
1127
+ max: maxScrollPosition,
1128
+ bounceStiffness: 300,
1129
+ bounceDamping: 30,
1130
+ }),
1131
+ })
1132
+
1133
+ // Resume crawl after snap
1134
+ snapAnimation
1135
+ .then(() => {
1136
+ snapAnimation = null
1137
+ // Update display to reflect landed position
1138
+ updateIndexDisplay()
1139
+ if (config.crawl && animation) {
1140
+ resumeCrawl()
1141
+ }
1142
+ })
1143
+ .catch(err => {
1144
+ snapAnimation = null
1145
+ })
1146
+ }
1147
+
1148
+ /**
1149
+ * Resume crawl animation after drag/inertia
1150
+ * Reads current position and resumes infinite loop
1151
+ */
1152
+ function resumeCrawl() {
1153
+ if (!config.crawl) return
1154
+
1155
+ // Stop any existing animations
1156
+ if (animation) {
1157
+ animation.stop()
1158
+ }
1159
+ if (speedRampAnimation) {
1160
+ speedRampAnimation.stop()
1161
+ speedRampAnimation = null
1162
+ }
1163
+
1164
+ // Read current position from motionValue
1165
+ const currentPos = position.get()
1166
+
1167
+ // Calculate position within current cycle (using originalItemsWidth)
1168
+ const cyclePos = currentPos % originalItemsWidth
1169
+ const remainingDist = originalItemsWidth - cyclePos
1170
+ const remainingDuration = remainingDist / pixelsPerSecond
1171
+
1172
+ // Animate position to complete this cycle
1173
+ // Reversed: move backwards, Normal: move forward
1174
+ const targetPos = config.reversed ? currentPos - remainingDist : currentPos + remainingDist
1175
+ animation = animate(position, targetPos, {
1176
+ duration: remainingDuration,
1177
+ ease: 'linear',
1178
+ })
1179
+
1180
+ // When cycle completes, restart infinite loop
1181
+ animation.then(() => {
1182
+ // Capture current speed before replacing animation
1183
+ const currentSpeed = animation.speed
1184
+
1185
+ // Create new infinite loop animation
1186
+ const currentPos = position.get()
1187
+ // Reversed: crawl backwards, Normal: crawl forward
1188
+ const target = config.reversed
1189
+ ? currentPos - originalItemsWidth
1190
+ : currentPos + originalItemsWidth
1191
+ animation = animate(position, target, {
1192
+ duration: originalItemsWidth / pixelsPerSecond,
1193
+ repeat: Infinity,
1194
+ ease: 'linear',
1195
+ })
1196
+
1197
+ // Inherit the current speed from the ramp
1198
+ animation.speed = currentSpeed
1199
+
1200
+ // If speed ramp is still running, re-target it to continue ramping the new animation
1201
+ if (speedRampAnimation) {
1202
+ speedRampAnimation.stop()
1203
+ // Calculate remaining ramp duration based on current speed
1204
+ // speed goes from 0.001 to 1.0, so progress = (currentSpeed - 0.001) / (1.0 - 0.001)
1205
+ const rampProgress = (currentSpeed - 0.001) / 0.999
1206
+ const remainingRampDuration = 2 * (1 - rampProgress)
1207
+
1208
+ speedRampAnimation = animate(
1209
+ animation,
1210
+ { speed: 1 },
1211
+ { duration: remainingRampDuration, ease: 'easeIn' }
1212
+ )
1213
+ }
1214
+ })
1215
+
1216
+ // Start at nearly-stopped speed and ramp up to full speed
1217
+ // Use 0.001 instead of 0 to keep animation running (speed = 0 completely pauses)
1218
+ animation.speed = 0.001
1219
+ speedRampAnimation = animate(animation, { speed: 1 }, { duration: 2, ease: 'easeIn' })
1220
+ }
1221
+
1222
+ // Set up touch-action CSS for proper touch handling
1223
+ container.style.touchAction = 'pan-y' // Allow vertical scroll, prevent horizontal
1224
+
1225
+ // Add pointer down listener
1226
+ container.style.cursor = 'grab'
1227
+ container.addEventListener('pointerdown', onPointerDown)
1228
+
1229
+ // Store cleanup function
1230
+ dragState = {
1231
+ cleanup: () => {
1232
+ container.removeEventListener('pointerdown', onPointerDown)
1233
+ window.removeEventListener('pointermove', onPointerMove)
1234
+ window.removeEventListener('pointerup', onPointerUp)
1235
+ window.removeEventListener('pointercancel', onPointerUp)
1236
+ },
1237
+ }
1238
+ }
1239
+
1240
+ /**
1241
+ * Setup hover slow-down effects
1242
+ */
1243
+ function setupHoverEffects() {
1244
+ if (!config.crawl || !animation) return
1245
+
1246
+ // Speed is always positive - direction is baked into animation
1247
+ const targetSpeed = 1
1248
+ const hoverSpeed = config.ease.mouseOver.speed
1249
+
1250
+ // Track hover animations to prevent accumulation
1251
+ let hoverAnimation = null
1252
+
1253
+ items.forEach(item => {
1254
+ item.addEventListener('mouseenter', () => {
1255
+ if (!animation) return
1256
+
1257
+ // Stop previous hover animation before creating new one
1258
+ if (hoverAnimation) {
1259
+ hoverAnimation.stop()
1260
+ }
1261
+
1262
+ hoverAnimation = animate(
1263
+ animation,
1264
+ { speed: hoverSpeed },
1265
+ { duration: config.ease.mouseOver.duration, ease: 'easeOut' }
1266
+ )
1267
+ })
1268
+
1269
+ item.addEventListener('mouseleave', () => {
1270
+ if (!animation) return
1271
+
1272
+ // Stop previous hover animation before creating new one
1273
+ if (hoverAnimation) {
1274
+ hoverAnimation.stop()
1275
+ }
1276
+
1277
+ hoverAnimation = animate(
1278
+ animation,
1279
+ { speed: targetSpeed },
1280
+ { duration: config.ease.mouseOut.duration, ease: 'easeOut' }
1281
+ )
1282
+ })
1283
+ })
1284
+ }
1285
+
1286
+ /**
1287
+ * Find closest index based on current position
1288
+ * @param {boolean} setCurrent - Whether to update curIndex
1289
+ * @returns {number} Closest item index
1290
+ */
1291
+ function closestIndex(setCurrent = false) {
1292
+ if (!times || times.length === 0) return 0
1293
+
1294
+ // Use bounded position to find what's actually visible
1295
+ const currentPos = position.get()
1296
+
1297
+ // For looping, use bounded position; for non-looping, use direct position
1298
+ let currentTime
1299
+ if (shouldLoop) {
1300
+ const boundedCurrentPos =
1301
+ ((currentPos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
1302
+ currentTime = boundedCurrentPos / pixelsPerSecond
1303
+ } else {
1304
+ currentTime = currentPos / pixelsPerSecond
1305
+ }
1306
+
1307
+ let closest = 0
1308
+ let closestDist = Infinity
1309
+
1310
+ // Only check original items, not clones
1311
+ for (let i = 0; i < originalItemCount; i++) {
1312
+ const time = times[i]
1313
+ let dist = Math.abs(time - currentTime)
1314
+
1315
+ // For looping, check wrapped distance
1316
+ if (shouldLoop) {
1317
+ const duration = originalItemsWidth / pixelsPerSecond
1318
+ const wrappedDist = Math.min(
1319
+ Math.abs(time + duration - currentTime),
1320
+ Math.abs(time - duration - currentTime)
1321
+ )
1322
+ dist = Math.min(dist, wrappedDist)
1323
+ }
1324
+
1325
+ if (dist < closestDist) {
1326
+ closestDist = dist
1327
+ closest = i
1328
+ }
1329
+ }
1330
+
1331
+ if (setCurrent) {
1332
+ curIndex = closest
1333
+ }
1334
+
1335
+ return closest
1336
+ }
1337
+
1338
+ /**
1339
+ * Animate to a specific index
1340
+ * @param {number} index - Target index
1341
+ * @param {Object} vars - Animation options (duration, easing, etc.)
1342
+ */
1343
+ function toIndex(index, vars = {}) {
1344
+ vars = vars || {}
1345
+
1346
+ if (!times || times.length === 0) return
1347
+
1348
+ // Calculate target index with shortest path for looping
1349
+ let targetIndex = index
1350
+
1351
+ if (shouldLoop) {
1352
+ // Always go in shortest direction
1353
+ // IMPORTANT: Use originalItemCount, not items.length (which includes clones)
1354
+ const length = originalItemCount
1355
+ if (Math.abs(index - curIndex) > length / 2) {
1356
+ targetIndex = index + (index > curIndex ? -length : length)
1357
+ }
1358
+ targetIndex = ((targetIndex % length) + length) % length
1359
+ } else {
1360
+ // Clamp to valid indices for non-looping (use originalItemCount, not items.length)
1361
+ targetIndex = Math.max(0, Math.min(index, originalItemCount - 1))
1362
+ }
1363
+
1364
+ // Get target position
1365
+ const targetTime = times[targetIndex]
1366
+ let targetPos = targetTime * pixelsPerSecond
1367
+
1368
+ // For looping, normalize target to be close to current position
1369
+ // This ensures we take the shortest path and don't cross boundaries unnecessarily
1370
+ if (shouldLoop) {
1371
+ const currentPos = position.get()
1372
+
1373
+ // Find which cycle the current position is in
1374
+ // This handles cases where position has drifted to -4800 or +9600 etc.
1375
+ const currentCycle = Math.floor(currentPos / originalItemsWidth)
1376
+
1377
+ let minDist = Infinity
1378
+ let bestCandidate = targetPos
1379
+
1380
+ // Check cycles around the current cycle (not around cycle 0)
1381
+ for (let offset = -1; offset <= 1; offset++) {
1382
+ const candidate = targetPos + (currentCycle + offset) * originalItemsWidth
1383
+ const dist = Math.abs(candidate - currentPos)
1384
+ if (dist < minDist) {
1385
+ minDist = dist
1386
+ bestCandidate = candidate
1387
+ }
1388
+ }
1389
+
1390
+ targetPos = bestCandidate
1391
+ } else {
1392
+ // For non-looping, clamp target position to valid range [0, maxScrollPosition]
1393
+ // This ensures first item stays at left edge and last item at right edge
1394
+ targetPos = Math.max(0, Math.min(targetPos, maxScrollPosition))
1395
+ }
1396
+
1397
+ // Update current index
1398
+ curIndex = targetIndex
1399
+
1400
+ // Update display immediately
1401
+ updateIndexDisplay()
1402
+
1403
+ // Animate to target
1404
+ const duration = vars.duration !== undefined ? vars.duration : 0.85
1405
+ const ease = vars.ease || 'easeInOut'
1406
+
1407
+ // Track navigation animation to prevent sync interference
1408
+ navAnimation = animate(position, targetPos, {
1409
+ duration,
1410
+ ease,
1411
+ })
1412
+
1413
+ // Clear navAnimation when done
1414
+ navAnimation
1415
+ .then(() => {
1416
+ navAnimation = null
1417
+ })
1418
+ .catch(() => {
1419
+ navAnimation = null
1420
+ })
1421
+
1422
+ return navAnimation
1423
+ }
1424
+
1425
+ /**
1426
+ * Public API
1427
+ */
1428
+ let autoplayTimer = null
1429
+
1430
+ const loopController = {
1431
+ position,
1432
+ animation,
1433
+ items,
1434
+ times,
1435
+ isReversed: config.reversed,
1436
+ isLooping: shouldLoop,
1437
+
1438
+ play() {
1439
+ if (!shouldLoop && config.crawl && config.startPingPongCrawl) {
1440
+ // Non-looping: start ping-pong crawl
1441
+ const currentPos = position.get()
1442
+ // Determine direction based on current position
1443
+ const goForward = currentPos < maxScrollPosition / 2
1444
+ config.startPingPongCrawl(goForward)
1445
+ } else if (animation) {
1446
+ animation.play()
1447
+ }
1448
+ },
1449
+
1450
+ pause() {
1451
+ if (animation) {
1452
+ animation.pause()
1453
+ }
1454
+ },
1455
+
1456
+ current() {
1457
+ return curIndex
1458
+ },
1459
+
1460
+ closestIndex(setCurrent) {
1461
+ return closestIndex(setCurrent)
1462
+ },
1463
+
1464
+ stopAutoplay() {
1465
+ if (autoplayTimer) {
1466
+ clearInterval(autoplayTimer)
1467
+ autoplayTimer = null
1468
+ }
1469
+ },
1470
+
1471
+ startAutoplay(seconds) {
1472
+ this.stopAutoplay()
1473
+ const interval = Math.abs(seconds) * 1000
1474
+ const direction = seconds > 0 ? 'next' : 'previous'
1475
+ autoplayTimer = setInterval(() => {
1476
+ if (direction === 'next') {
1477
+ this.next(null, { autoplay: true })
1478
+ } else {
1479
+ this.previous(null, { autoplay: true })
1480
+ }
1481
+ }, interval)
1482
+ },
1483
+
1484
+ next(vars, options = {}) {
1485
+ // Stop autoplay on user interaction (unless this IS autoplay)
1486
+ if (!options.autoplay) {
1487
+ this.stopAutoplay()
1488
+ }
1489
+ // Sync curIndex with current scroll position before navigating
1490
+ closestIndex(true)
1491
+ let nextIndex = curIndex + 1
1492
+
1493
+ // Non-looping: reset to start when reaching the end
1494
+ if (!shouldLoop) {
1495
+ const currentPos = position.get()
1496
+ const atEnd = currentPos >= maxScrollPosition - 1
1497
+
1498
+ if (nextIndex >= originalItemCount || atEnd) {
1499
+ nextIndex = 0
1500
+ }
1501
+ }
1502
+
1503
+ return toIndex(nextIndex, vars)
1504
+ },
1505
+
1506
+ previous(vars, options = {}) {
1507
+ // Stop autoplay on user interaction (unless this IS autoplay)
1508
+ if (!options.autoplay) {
1509
+ this.stopAutoplay()
1510
+ }
1511
+ // Sync curIndex with current scroll position before navigating
1512
+ closestIndex(true)
1513
+ let prevIndex = curIndex - 1
1514
+
1515
+ // Non-looping: reset to end when at the start
1516
+ if (!shouldLoop && prevIndex < 0) {
1517
+ prevIndex = originalItemCount - 1
1518
+ }
1519
+
1520
+ return toIndex(prevIndex, vars)
1521
+ },
1522
+
1523
+ toIndex(index, vars) {
1524
+ return toIndex(index, vars)
1525
+ },
1526
+
1527
+ refresh(deep) {
1528
+ return refresh(deep)
1529
+ },
1530
+
1531
+ destroy() {
1532
+ // Stop autoplay
1533
+ this.stopAutoplay()
1534
+
1535
+ // Stop animation
1536
+ if (animation) {
1537
+ animation.stop()
1538
+ }
1539
+
1540
+ // Stop frame.render loop
1541
+ stopRenderLoop()
1542
+
1543
+ // Cleanup position listener
1544
+ if (positionUnsubscribe) {
1545
+ positionUnsubscribe()
1546
+ }
1547
+
1548
+ // Cleanup drag
1549
+ if (dragState && dragState.cleanup) {
1550
+ dragState.cleanup()
1551
+ }
1552
+
1553
+ // Cleanup resize listener
1554
+ window.removeEventListener('APPLICATION:RESIZE', handleResize)
1555
+
1556
+ // Destroy position value
1557
+ position.destroy()
1558
+ },
1559
+ }
1560
+
1561
+ // Initialize
1562
+ init()
1563
+
1564
+ return loopController
1565
+ }
1566
+
1567
+ /**
1568
+ * Looper Module Class
1569
+ */
1570
+ export default class Looper {
1571
+ constructor(app, opts = {}) {
1572
+ this.app = app
1573
+ this.opts = _defaultsDeep(opts, DEFAULT_OPTIONS)
1574
+ this.loopers = []
1575
+ this.pendingLoopers = []
1576
+ this.init()
1577
+ }
1578
+
1579
+ init() {
1580
+ this.looperElements = Dom.all(this.opts.selector)
1581
+ this.looperElements.forEach((element, idx) => {
1582
+ const items = Dom.all(element, '[data-panner-item], [data-looper-item]')
1583
+
1584
+ if (!items.length) {
1585
+ return
1586
+ }
1587
+
1588
+ // Find the wrapper element (with opacity: 0)
1589
+ const wrapper =
1590
+ Dom.find(element, '[data-looper-container]') || Dom.find(element, '.looper-wrapper')
1591
+
1592
+ if (!wrapper) {
1593
+ console.error(
1594
+ '[Looper] ⚠️ No wrapper element found (expected [data-looper-container] or .looper-wrapper)'
1595
+ )
1596
+ }
1597
+
1598
+ const speed = ['mobile', 'iphone'].includes(this.app.breakpoint)
1599
+ ? this.opts.speed.sm
1600
+ : this.opts.speed.lg
1601
+
1602
+ // Parse data attributes with support for explicit "false" values
1603
+ const looperEl = element.querySelector('[data-looper]')
1604
+
1605
+ // Snap: data-looper-snap or data-looper-snap="false"
1606
+ const hasSnapAttr = looperEl?.hasAttribute('data-looper-snap')
1607
+ const snapValue = looperEl?.getAttribute('data-looper-snap')
1608
+ const shouldSnap = snapValue === 'false' ? false : this.opts.snap || hasSnapAttr
1609
+
1610
+ // Crawl: data-looper-crawl or data-looper-crawl="false"
1611
+ const hasCrawlAttr = looperEl?.hasAttribute('data-looper-crawl')
1612
+ const crawlValue = looperEl?.getAttribute('data-looper-crawl')
1613
+ const shouldCrawl = crawlValue === 'false' ? false : hasCrawlAttr || this.opts.crawl
1614
+
1615
+ // Reverse: data-looper-reverse or data-looper-reverse="false"
1616
+ const hasReverseAttr = looperEl?.hasAttribute('data-looper-reverse')
1617
+ const reverseValue = looperEl?.getAttribute('data-looper-reverse')
1618
+ const isReverse = reverseValue === 'false' ? false : hasReverseAttr
1619
+
1620
+ // Autoplay: data-looper-autoplay="5" (seconds, negative for previous)
1621
+ const autoplayValue = looperEl?.getAttribute('data-looper-autoplay')
1622
+ const autoplayInterval = autoplayValue ? parseFloat(autoplayValue) : null
1623
+
1624
+ // Loop: data-looper-loop or data-looper-loop="false"
1625
+ const hasLoopAttr = looperEl?.hasAttribute('data-looper-loop')
1626
+ const loopValue = looperEl?.getAttribute('data-looper-loop')
1627
+ const shouldLoop = loopValue === 'false' ? false : this.opts.loop
1628
+
1629
+ // End alignment: data-looper-end-alignment="right" or "start"
1630
+ const endAlignmentValue = looperEl?.getAttribute('data-looper-end-alignment')
1631
+ const endAlignment = endAlignmentValue || this.opts.endAlignment
1632
+
1633
+ // Center: data-looper-center or data-looper-center="false"
1634
+ const hasCenterAttr = looperEl?.hasAttribute('data-looper-center')
1635
+ const centerValue = looperEl?.getAttribute('data-looper-center')
1636
+ const shouldCenterSlide = centerValue === 'false' ? false : hasCenterAttr
1637
+
1638
+ // Create stub for Moonwalk compatibility
1639
+ const stubLoop = {
1640
+ play: () => {},
1641
+ pause: () => {},
1642
+ isReversed: isReverse,
1643
+ }
1644
+
1645
+ element.$loop = stubLoop
1646
+
1647
+ // Store for later initialization
1648
+ this.pendingLoopers.push({
1649
+ element,
1650
+ wrapper,
1651
+ items,
1652
+ config: {
1653
+ paused: true,
1654
+ repeat: -1,
1655
+ draggable: this.opts.draggable,
1656
+ center: this.opts.center,
1657
+ centerSlide: shouldCenterSlide,
1658
+ snap: shouldSnap,
1659
+ speed,
1660
+ reversed: isReverse,
1661
+ loop: shouldLoop,
1662
+ crawl: shouldCrawl,
1663
+ autoplayInterval,
1664
+ endAlignment,
1665
+ ease: this.opts.ease,
1666
+ throwResistance: this.opts.throwResistance,
1667
+ throwPower: this.opts.throwPower,
1668
+ throwVelocityMultiplier: this.opts.throwVelocityMultiplier,
1669
+ snapVelocityMultiplier: this.opts.snapVelocityMultiplier,
1670
+ snapDuration: this.opts.snapDuration,
1671
+ snapBounce: this.opts.snapBounce,
1672
+ minimumMovement: this.opts.minimumMovement,
1673
+ },
1674
+ })
1675
+ })
1676
+
1677
+ // Register callback for when layout is ready
1678
+ this.app.registerCallback('APPLICATION:REVEALED', () => {
1679
+ this.finalizeLoopers()
1680
+ })
1681
+ }
1682
+
1683
+ finalizeLoopers() {
1684
+ this.pendingLoopers.forEach(({ element, wrapper, items, config }, idx) => {
1685
+ // Pass wrapper to config for display element lookup
1686
+ config.wrapper = wrapper
1687
+
1688
+ // Create the real loop
1689
+ const loop = horizontalLoop(this.app, items, config)
1690
+
1691
+ // Start playing if crawl enabled
1692
+ if (config.crawl) {
1693
+ loop.play()
1694
+ }
1695
+
1696
+ // Start autoplay if configured
1697
+ if (config.autoplayInterval && !isNaN(config.autoplayInterval)) {
1698
+ loop.startAutoplay(config.autoplayInterval)
1699
+ }
1700
+
1701
+ // Replace stub with real loop
1702
+ element.$loop = loop
1703
+
1704
+ // Setup navigation buttons if present
1705
+ const next = Dom.find(element, '[data-panner-next]')
1706
+ const previous = Dom.find(element, '[data-panner-previous]')
1707
+
1708
+ if (next) {
1709
+ next.addEventListener('click', () => {
1710
+ loop.next({ duration: 0.85, ease: 'easeInOut' })
1711
+ })
1712
+ }
1713
+
1714
+ if (previous) {
1715
+ previous.addEventListener('click', () => {
1716
+ loop.previous({ duration: 0.85, ease: 'easeInOut' })
1717
+ })
1718
+ }
1719
+
1720
+ // Fade in the WRAPPER (not the outer element!)
1721
+ if (wrapper) {
1722
+ animate(wrapper, { opacity: 1 }, { duration: 0.5, delay: 0.5, ease: 'easeOut' })
1723
+ }
1724
+
1725
+ this.loopers.push(loop)
1726
+ })
1727
+
1728
+ // Clear pending
1729
+ this.pendingLoopers = []
1730
+ }
1731
+
1732
+ destroy() {
1733
+ this.loopers.forEach(loop => loop.destroy())
1734
+ this.loopers = []
1735
+ this.pendingLoopers = []
1736
+ }
1737
+ }