@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.
- package/README.md +509 -54
- package/package.json +30 -18
- package/src/index.js +15 -10
- package/src/modules/Application/index.js +236 -158
- package/src/modules/Breakpoints/index.js +116 -36
- package/src/modules/Cookies/index.js +95 -64
- package/src/modules/CoverOverlay/index.js +21 -14
- package/src/modules/Dataloader/index.js +71 -24
- package/src/modules/Dataloader/url-sync.js +238 -0
- package/src/modules/Dom/index.js +24 -0
- package/src/modules/DoubleHeader/index.js +571 -0
- package/src/modules/Dropdown/index.js +108 -73
- package/src/modules/EqualHeightElements/index.js +8 -8
- package/src/modules/EqualHeightImages/index.js +15 -7
- package/src/modules/FixedHeader/index.js +116 -30
- package/src/modules/FooterReveal/index.js +5 -5
- package/src/modules/HeroSlider/index.js +231 -106
- package/src/modules/HeroVideo/index.js +72 -44
- package/src/modules/Lazyload/index.js +128 -80
- package/src/modules/Lightbox/index.js +101 -80
- package/src/modules/Links/index.js +77 -51
- package/src/modules/Looper/index.js +1737 -0
- package/src/modules/Marquee/index.js +106 -37
- package/src/modules/MobileMenu/index.js +105 -130
- package/src/modules/Moonwalk/index.js +479 -153
- package/src/modules/Parallax/index.js +280 -57
- package/src/modules/Popover/index.js +187 -17
- package/src/modules/Popup/index.js +172 -53
- package/src/modules/ScrollSpy/index.js +21 -0
- package/src/modules/StackedBoxes/index.js +8 -6
- package/src/modules/StickyHeader/index.js +394 -164
- package/src/modules/Toggler/index.js +207 -11
- package/src/modules/Typography/index.js +33 -20
- package/src/utils/motion-helpers.js +330 -0
- package/types/README.md +159 -0
- package/types/events/index.d.ts +20 -0
- package/types/index.d.ts +6 -0
- package/types/modules/Application/index.d.ts +168 -0
- package/types/modules/Breakpoints/index.d.ts +40 -0
- package/types/modules/Cookies/index.d.ts +81 -0
- package/types/modules/CoverOverlay/index.d.ts +6 -0
- package/types/modules/Dataloader/index.d.ts +38 -0
- package/types/modules/Dataloader/url-sync.d.ts +36 -0
- package/types/modules/Dom/index.d.ts +47 -0
- package/types/modules/DoubleHeader/index.d.ts +63 -0
- package/types/modules/Dropdown/index.d.ts +15 -0
- package/types/modules/EqualHeightElements/index.d.ts +8 -0
- package/types/modules/EqualHeightImages/index.d.ts +11 -0
- package/types/modules/FeatureTests/index.d.ts +27 -0
- package/types/modules/FixedHeader/index.d.ts +219 -0
- package/types/modules/Fontloader/index.d.ts +5 -0
- package/types/modules/FooterReveal/index.d.ts +5 -0
- package/types/modules/HeroSlider/index.d.ts +28 -0
- package/types/modules/HeroVideo/index.d.ts +83 -0
- package/types/modules/Lazyload/index.d.ts +80 -0
- package/types/modules/Lightbox/index.d.ts +123 -0
- package/types/modules/Links/index.d.ts +55 -0
- package/types/modules/Looper/index.d.ts +127 -0
- package/types/modules/Marquee/index.d.ts +23 -0
- package/types/modules/MobileMenu/index.d.ts +63 -0
- package/types/modules/Moonwalk/index.d.ts +322 -0
- package/types/modules/Parallax/index.d.ts +71 -0
- package/types/modules/Popover/index.d.ts +29 -0
- package/types/modules/Popup/index.d.ts +76 -0
- package/types/modules/ScrollSpy/index.d.ts +29 -0
- package/types/modules/StackedBoxes/index.d.ts +9 -0
- package/types/modules/StickyHeader/index.d.ts +220 -0
- package/types/modules/Toggler/index.d.ts +48 -0
- package/types/modules/Typography/index.d.ts +77 -0
- package/types/utils/dispatchElementEvent.d.ts +1 -0
- package/types/utils/imageIsLoaded.d.ts +1 -0
- package/types/utils/imagesAreLoaded.d.ts +1 -0
- package/types/utils/loadScript.d.ts +2 -0
- package/types/utils/prefersReducedMotion.d.ts +4 -0
- package/types/utils/rafCallback.d.ts +2 -0
- 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
|
+
}
|