@brandocms/jupiter 5.0.0-beta.12 → 5.0.0-beta.14
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 +7 -0
- package/package.json +1 -1
- package/src/modules/Application/index.js +52 -40
- package/src/modules/Dataloader/index.js +136 -59
- package/src/modules/DoubleHeader/index.js +14 -0
- package/src/modules/FeatureTests/index.js +2 -1
- package/src/modules/FixedHeader/index.js +13 -0
- package/src/modules/Lazyload/index.js +115 -49
- package/src/modules/Links/index.js +1 -1
- package/src/modules/Looper/index.js +453 -247
- package/src/modules/Moonwalk/index.js +195 -165
- package/src/modules/StickyHeader/index.js +13 -0
- package/src/utils/rafCallback.js +4 -5
- package/types/modules/Application/index.d.ts +7 -8
- package/types/modules/Dataloader/index.d.ts +48 -2
- package/types/modules/FeatureTests/index.d.ts +1 -1
- package/types/modules/FixedHeader/index.d.ts +58 -0
- package/types/modules/Lazyload/index.d.ts +32 -13
- package/types/modules/Moonwalk/index.d.ts +93 -18
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { animate, motionValue, frame, cancelFrame } from 'motion'
|
|
2
2
|
import _defaultsDeep from 'lodash.defaultsdeep'
|
|
3
3
|
import Dom from '../Dom'
|
|
4
|
+
import prefersReducedMotion from '../../utils/prefersReducedMotion'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Modulo that centers the result around zero: (-base/2, base/2]
|
|
8
|
+
* Useful for finding shortest distance on a cycle.
|
|
9
|
+
*/
|
|
10
|
+
function symmetricMod(value, base) {
|
|
11
|
+
let m = value % base
|
|
12
|
+
if (Math.abs(m) > base / 2) {
|
|
13
|
+
m = m > 0 ? m - base : m + base
|
|
14
|
+
}
|
|
15
|
+
return m
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Named constants (M5)
|
|
19
|
+
const CLONE_BUFFER_MULTIPLIER = 2.5
|
|
20
|
+
const MIN_CRAWL_SPEED = 0.001
|
|
21
|
+
const PING_PONG_PAUSE_MS = 200
|
|
22
|
+
const VELOCITY_WINDOW_MS = 150
|
|
23
|
+
const SPEED_RAMP_DURATION = 2
|
|
4
24
|
|
|
5
25
|
/**
|
|
6
26
|
* Looper Module
|
|
@@ -24,12 +44,13 @@ const DEFAULT_OPTIONS = {
|
|
|
24
44
|
loop: true, // Infinite looping (false for linear scrolling)
|
|
25
45
|
draggable: true, // Enable drag interaction
|
|
26
46
|
endAlignment: 'right', // For non-looping: 'right' = last item at viewport right edge, 'start' = last item at viewport left edge
|
|
27
|
-
minimumMovement: 3, // Pixels - movement below this is treated as click, above as drag
|
|
47
|
+
minimumMovement: 3, // Pixels for mouse - movement below this is treated as click, above as drag
|
|
48
|
+
touchMinimumMovement: 10, // Pixels for touch - higher threshold for finger imprecision
|
|
28
49
|
|
|
29
50
|
// Inertia/throw configuration (when dragging and releasing)
|
|
30
51
|
throwResistance: 325, // Time constant for deceleration (lower = more resistance/faster stop, higher = less resistance/longer glide)
|
|
31
52
|
throwPower: 0.8, // Deceleration curve (0-1, higher = more gradual slowdown)
|
|
32
|
-
throwVelocityMultiplier:
|
|
53
|
+
throwVelocityMultiplier: 0.8, // Scale velocity for all throws (0.5 = half speed, 2.0 = double)
|
|
33
54
|
snapVelocityMultiplier: 0.8, // Additional scaling for snapped loopers (stacks with throwVelocityMultiplier)
|
|
34
55
|
|
|
35
56
|
// Snap animation configuration (when snap: true)
|
|
@@ -63,6 +84,7 @@ function horizontalLoop(app, items, config) {
|
|
|
63
84
|
|
|
64
85
|
const shouldLoop = config.loop !== false
|
|
65
86
|
const shouldDrag = config.draggable !== false
|
|
87
|
+
const reducedMotion = prefersReducedMotion() && app?.opts?.respectReducedMotion
|
|
66
88
|
|
|
67
89
|
// Container setup
|
|
68
90
|
const center = config.center
|
|
@@ -92,6 +114,7 @@ function horizontalLoop(app, items, config) {
|
|
|
92
114
|
let containerWidth = 0 // Cache container width to avoid layout reads on every frame
|
|
93
115
|
let itemWrapOffsets = [] // Cache current wrap offset for each item
|
|
94
116
|
let isCloneCache = [] // Cache which items are clones (avoid hasAttribute checks)
|
|
117
|
+
let itemsHaveTransforms = null // Cache whether any original items have CSS transforms (H5)
|
|
95
118
|
|
|
96
119
|
// Drag state and cleanup handlers
|
|
97
120
|
let dragState = {}
|
|
@@ -102,6 +125,12 @@ function horizontalLoop(app, items, config) {
|
|
|
102
125
|
let navAnimation = null // Track navigation animation (next/previous/toIndex)
|
|
103
126
|
let positionUnsubscribe = null // Track position listener for cleanup
|
|
104
127
|
let renderUnsubscribe = null // Track frame.render loop for cleanup
|
|
128
|
+
let indexUnsubscribe = null // Track index display position listener (C3)
|
|
129
|
+
let pingPongTimeout = null // Track ping-pong pause timeout (C4)
|
|
130
|
+
let resumeCrawlGeneration = 0 // Generation counter for resumeCrawl race condition (C7)
|
|
131
|
+
let hoverCleanup = null // Track hover effects cleanup function (C1/C9)
|
|
132
|
+
let startPingPongCrawl = null // Closure-scoped ping-pong starter (M2)
|
|
133
|
+
let indexSetByNav = false // True when curIndex was set by toIndex, cleared on drag
|
|
105
134
|
|
|
106
135
|
// Scroll direction tracking for wrap logic
|
|
107
136
|
let scrollDirection = 0 // -1 = backward, 0 = neutral, 1 = forward
|
|
@@ -111,6 +140,9 @@ function horizontalLoop(app, items, config) {
|
|
|
111
140
|
let indexElements = []
|
|
112
141
|
let countElements = []
|
|
113
142
|
|
|
143
|
+
// Cache track element reference (M3)
|
|
144
|
+
const trackElement = items[0].parentElement
|
|
145
|
+
|
|
114
146
|
/**
|
|
115
147
|
* Measure total width of all items as currently laid out
|
|
116
148
|
* @returns {number} Total width in pixels
|
|
@@ -165,7 +197,7 @@ function horizontalLoop(app, items, config) {
|
|
|
165
197
|
|
|
166
198
|
// Use 2.5x container width to ensure plenty of buffer for wrapping
|
|
167
199
|
// This prevents items from visibly moving to the back before they're off-screen
|
|
168
|
-
const minRequiredWidth = containerWidth *
|
|
200
|
+
const minRequiredWidth = containerWidth * CLONE_BUFFER_MULTIPLIER + maxItemWidth
|
|
169
201
|
|
|
170
202
|
// Store original count to prevent exponential growth
|
|
171
203
|
const originalItemCount = items.length
|
|
@@ -228,17 +260,29 @@ function horizontalLoop(app, items, config) {
|
|
|
228
260
|
const rect = el.getBoundingClientRect()
|
|
229
261
|
widths[i] = rect.width
|
|
230
262
|
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
currentX = matrix.m41
|
|
263
|
+
// Check transforms only if items have them (H5)
|
|
264
|
+
// Check once at first call, cache for subsequent calls
|
|
265
|
+
if (itemsHaveTransforms === null) {
|
|
266
|
+
itemsHaveTransforms = items.slice(0, originalItemCount || items.length).some(item => {
|
|
267
|
+
const t = window.getComputedStyle(item).transform
|
|
268
|
+
return t && t !== 'none'
|
|
269
|
+
})
|
|
239
270
|
}
|
|
240
271
|
|
|
241
|
-
|
|
272
|
+
if (itemsHaveTransforms) {
|
|
273
|
+
const computedStyle = window.getComputedStyle(el)
|
|
274
|
+
const transform = computedStyle.transform
|
|
275
|
+
let currentX = 0
|
|
276
|
+
|
|
277
|
+
if (transform && transform !== 'none') {
|
|
278
|
+
const matrix = new DOMMatrix(transform)
|
|
279
|
+
currentX = matrix.m41
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
xPercents[i] = (currentX / widths[i]) * 100
|
|
283
|
+
} else {
|
|
284
|
+
xPercents[i] = 0
|
|
285
|
+
}
|
|
242
286
|
}
|
|
243
287
|
})
|
|
244
288
|
|
|
@@ -291,65 +335,38 @@ function horizontalLoop(app, items, config) {
|
|
|
291
335
|
}
|
|
292
336
|
}
|
|
293
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Calculate snap position for an item (M4 - shared between loop and non-loop paths)
|
|
340
|
+
*/
|
|
341
|
+
function calculateSnapPos(item, i) {
|
|
342
|
+
const curX = (xPercents[i] / 100) * widths[i]
|
|
343
|
+
|
|
344
|
+
if (config.peek) {
|
|
345
|
+
const itemRightEdge = item.offsetLeft + curX + widths[i] - startX
|
|
346
|
+
const viewportCenter = containerWidth / 2
|
|
347
|
+
return itemRightEdge + gap / 2 - viewportCenter
|
|
348
|
+
} else if (config.centerSlide) {
|
|
349
|
+
const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
|
|
350
|
+
const viewportCenter = containerWidth / 2
|
|
351
|
+
return itemCenter - viewportCenter
|
|
352
|
+
} else {
|
|
353
|
+
return item.offsetLeft + curX - startX
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
294
357
|
/**
|
|
295
358
|
* Calculate time positions for snapping
|
|
296
359
|
* These represent when each item hits the "start" position
|
|
297
360
|
*/
|
|
298
361
|
function populateSnapTimes() {
|
|
299
|
-
if (!shouldLoop) {
|
|
300
|
-
// For non-looping, calculate based on actual item positions (pixels)
|
|
301
|
-
// This ensures snap and navigation work correctly
|
|
302
|
-
items.forEach((item, i) => {
|
|
303
|
-
const curX = (xPercents[i] / 100) * widths[i]
|
|
304
|
-
let snapPos
|
|
305
|
-
|
|
306
|
-
if (config.peek) {
|
|
307
|
-
// Peek mode: viewport center at the gap AFTER this item
|
|
308
|
-
// This shows: half(i) | gap | full(i+1) | gap | full(i+2) | gap | half(i+3)
|
|
309
|
-
const itemRightEdge = item.offsetLeft + curX + widths[i] - startX
|
|
310
|
-
const viewportCenter = containerWidth / 2
|
|
311
|
-
snapPos = itemRightEdge + gap / 2 - viewportCenter
|
|
312
|
-
} else if (config.centerSlide) {
|
|
313
|
-
// Center mode: item's center at viewport's center
|
|
314
|
-
const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
|
|
315
|
-
const viewportCenter = containerWidth / 2
|
|
316
|
-
snapPos = itemCenter - viewportCenter
|
|
317
|
-
} else {
|
|
318
|
-
// Normal mode: item's left edge at viewport's left edge
|
|
319
|
-
snapPos = item.offsetLeft + curX - startX
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
times[i] = snapPos / pixelsPerSecond
|
|
323
|
-
})
|
|
324
|
-
return
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// For looping, calculate based on item positions including gaps
|
|
328
362
|
items.forEach((item, i) => {
|
|
329
|
-
|
|
330
|
-
let snapPos
|
|
331
|
-
|
|
332
|
-
if (config.peek) {
|
|
333
|
-
// Peek mode: viewport center at the gap AFTER this item
|
|
334
|
-
const itemRightEdge = item.offsetLeft + curX + widths[i] - startX
|
|
335
|
-
const viewportCenter = containerWidth / 2
|
|
336
|
-
snapPos = itemRightEdge + gap / 2 - viewportCenter
|
|
337
|
-
} else if (config.centerSlide) {
|
|
338
|
-
// Center mode: item's center at viewport's center
|
|
339
|
-
const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
|
|
340
|
-
const viewportCenter = containerWidth / 2
|
|
341
|
-
snapPos = itemCenter - viewportCenter
|
|
342
|
-
} else {
|
|
343
|
-
// Normal mode: item's left edge at viewport's left edge
|
|
344
|
-
snapPos = item.offsetLeft + curX - startX
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
times[i] = snapPos / pixelsPerSecond
|
|
363
|
+
times[i] = calculateSnapPos(item, i) / pixelsPerSecond
|
|
348
364
|
})
|
|
349
365
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
366
|
+
if (!shouldLoop) return
|
|
367
|
+
|
|
368
|
+
// Adjust for container padding if present (looping only)
|
|
369
|
+
const containerPaddingLeft = parseFloat(getComputedStyle(trackElement).paddingLeft) || 0
|
|
353
370
|
|
|
354
371
|
if (containerPaddingLeft > 0) {
|
|
355
372
|
const paddingTime = containerPaddingLeft / pixelsPerSecond
|
|
@@ -365,15 +382,23 @@ function horizontalLoop(app, items, config) {
|
|
|
365
382
|
function updateItemPositions(rawPos) {
|
|
366
383
|
if (!shouldLoop) return
|
|
367
384
|
|
|
368
|
-
// Initialize wrap offsets cache if needed
|
|
369
|
-
if (itemWrapOffsets.length === 0) {
|
|
370
|
-
itemWrapOffsets = new Array(items.length).fill(0)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
385
|
// Items wrap by the full cycle distance (totalWidth) to maintain relative positions
|
|
374
386
|
// This keeps all items within viewing distance as position grows/shrinks
|
|
375
387
|
const cycleDistance = totalWidth
|
|
376
388
|
|
|
389
|
+
// Initialize wrap offsets - calculate correct cycle directly (not incrementally)
|
|
390
|
+
// so items land in the right place even when position is many cycles away
|
|
391
|
+
if (itemWrapOffsets.length === 0) {
|
|
392
|
+
itemWrapOffsets = new Array(items.length)
|
|
393
|
+
for (let i = 0; i < items.length; i++) {
|
|
394
|
+
const distance = offsetLefts[i] - rawPos
|
|
395
|
+
const offset = Math.round(distance / -cycleDistance) * cycleDistance
|
|
396
|
+
itemWrapOffsets[i] = offset
|
|
397
|
+
items[i].style.transform = offset !== 0 ? `translateX(${offset}px)` : 'none'
|
|
398
|
+
}
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
377
402
|
// Wrap threshold: when an item is more than half a cycle from view, wrap it
|
|
378
403
|
const wrapThreshold = cycleDistance / 2
|
|
379
404
|
|
|
@@ -414,28 +439,26 @@ function horizontalLoop(app, items, config) {
|
|
|
414
439
|
animation.pause()
|
|
415
440
|
}
|
|
416
441
|
|
|
417
|
-
if (deep
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
442
|
+
if (deep) {
|
|
443
|
+
// Save pre-resize measurements for proportional position restore
|
|
444
|
+
const oldOriginalItemsWidth = originalItemsWidth
|
|
445
|
+
const oldMaxScrollPosition = maxScrollPosition
|
|
446
|
+
const oldPosition = position.get()
|
|
447
|
+
|
|
448
|
+
if (shouldLoop) {
|
|
449
|
+
// DEEP REFRESH: Reset wrap offset tracking (DOM transforms written by updateItemPositions init)
|
|
450
|
+
itemWrapOffsets = []
|
|
425
451
|
}
|
|
426
|
-
itemWrapOffsets = []
|
|
427
|
-
}
|
|
428
452
|
|
|
429
|
-
|
|
430
|
-
|
|
453
|
+
// Remeasure everything
|
|
454
|
+
populateWidths()
|
|
431
455
|
|
|
432
|
-
if (deep) {
|
|
433
456
|
// Check if we need to replicate more items
|
|
434
457
|
const currentContainerWidth = container.offsetWidth
|
|
435
458
|
const currentTotalWidth = getTotalWidthOfItems()
|
|
436
459
|
|
|
437
|
-
// Use same
|
|
438
|
-
if (shouldLoop && currentTotalWidth < currentContainerWidth *
|
|
460
|
+
// Use same buffer multiplier as replication logic
|
|
461
|
+
if (shouldLoop && currentTotalWidth < currentContainerWidth * CLONE_BUFFER_MULTIPLIER) {
|
|
439
462
|
replicateItemsIfNeeded()
|
|
440
463
|
// Re-cache clone status for any new items
|
|
441
464
|
isCloneCache = items.map((item, i) => i >= originalItemCount)
|
|
@@ -444,20 +467,33 @@ function horizontalLoop(app, items, config) {
|
|
|
444
467
|
|
|
445
468
|
populateSnapTimes()
|
|
446
469
|
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
470
|
+
// Restore position proportionally to preserve scroll progress across resize
|
|
471
|
+
let restoredPos
|
|
472
|
+
if (shouldLoop) {
|
|
473
|
+
const ratio = oldOriginalItemsWidth > 0
|
|
474
|
+
? oldPosition / oldOriginalItemsWidth
|
|
475
|
+
: 1
|
|
476
|
+
restoredPos = ratio * originalItemsWidth
|
|
477
|
+
} else {
|
|
478
|
+
const ratio = oldMaxScrollPosition > 0
|
|
479
|
+
? oldPosition / oldMaxScrollPosition
|
|
480
|
+
: 0
|
|
481
|
+
restoredPos = ratio * maxScrollPosition
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
position.set(restoredPos)
|
|
485
|
+
lastPositionForDirection = restoredPos
|
|
486
|
+
|
|
487
|
+
// Apply position and item wraps synchronously to avoid flash
|
|
488
|
+
// (frame.render is async so we must write DOM directly here)
|
|
489
|
+
trackElement.style.transform = `translateX(${-restoredPos}px)`
|
|
490
|
+
updateItemPositions(restoredPos)
|
|
491
|
+
|
|
492
|
+
// If snap is enabled, settle to nearest snap point
|
|
493
|
+
if (config.snap && !config.crawl) {
|
|
494
|
+
const snapPos = findNearestSnapPoint(restoredPos)
|
|
495
|
+
const clampedPos = shouldLoop ? snapPos : Math.max(0, Math.min(snapPos, maxScrollPosition))
|
|
496
|
+
animate(position, clampedPos, { duration: 0.3, ease: 'easeOut' })
|
|
461
497
|
}
|
|
462
498
|
|
|
463
499
|
// Recreate animation with new measurements
|
|
@@ -500,6 +536,7 @@ function horizontalLoop(app, items, config) {
|
|
|
500
536
|
updateIndexDisplay()
|
|
501
537
|
} else {
|
|
502
538
|
// Light refresh - just update measurements
|
|
539
|
+
populateWidths()
|
|
503
540
|
populateSnapTimes()
|
|
504
541
|
// Update positions based on current scroll
|
|
505
542
|
updateItemPositions(position.get())
|
|
@@ -562,6 +599,14 @@ function horizontalLoop(app, items, config) {
|
|
|
562
599
|
* Initialize the loop animation
|
|
563
600
|
*/
|
|
564
601
|
function init() {
|
|
602
|
+
// Inject CSS for drag pointer-events (H3) - once per page
|
|
603
|
+
if (!document.getElementById('looper-drag-style')) {
|
|
604
|
+
const style = document.createElement('style')
|
|
605
|
+
style.id = 'looper-drag-style'
|
|
606
|
+
style.textContent = '.looper-dragging [data-looper-item] { pointer-events: none; }'
|
|
607
|
+
document.head.appendChild(style)
|
|
608
|
+
}
|
|
609
|
+
|
|
565
610
|
// Store original item count BEFORE replication
|
|
566
611
|
originalItemCount = items.length
|
|
567
612
|
|
|
@@ -579,27 +624,25 @@ function horizontalLoop(app, items, config) {
|
|
|
579
624
|
// whose individual translateX positions fall outside the container's bounds,
|
|
580
625
|
// even when they are visually positioned within the viewport.
|
|
581
626
|
// Apply overflow-x: clip on a parent element instead.
|
|
582
|
-
const
|
|
583
|
-
const containerOverflow = getComputedStyle(itemsContainer).overflowX
|
|
627
|
+
const containerOverflow = getComputedStyle(trackElement).overflowX
|
|
584
628
|
if (containerOverflow === 'clip') {
|
|
585
629
|
console.warn(
|
|
586
|
-
`[Looper]
|
|
587
|
-
|
|
630
|
+
`[Looper] [data-looper] has overflow-x: clip which will hide looped items. Apply overflow-x: clip on a parent wrapper element instead.`,
|
|
631
|
+
trackElement
|
|
588
632
|
)
|
|
589
633
|
}
|
|
590
634
|
|
|
591
635
|
// Set initial container position
|
|
592
|
-
|
|
593
|
-
containerElement.style.willChange = 'transform'
|
|
636
|
+
trackElement.style.willChange = 'transform'
|
|
594
637
|
|
|
595
638
|
// For looping (non-center mode): start viewing first CLONE, not originals
|
|
596
639
|
// This positions originals OFF-SCREEN LEFT so backward scroll reveals them smoothly
|
|
597
640
|
if (shouldLoop && !config.centerSlide) {
|
|
598
641
|
position.set(originalItemsWidth)
|
|
599
642
|
lastPositionForDirection = originalItemsWidth
|
|
600
|
-
|
|
643
|
+
trackElement.style.transform = `translateX(${-originalItemsWidth}px)`
|
|
601
644
|
} else {
|
|
602
|
-
|
|
645
|
+
trackElement.style.transform = 'translateX(0px)'
|
|
603
646
|
}
|
|
604
647
|
|
|
605
648
|
// Set up RAF loop to update container position and wrap items
|
|
@@ -608,8 +651,6 @@ function horizontalLoop(app, items, config) {
|
|
|
608
651
|
function startRenderLoop() {
|
|
609
652
|
if (renderUnsubscribe) return // Already running
|
|
610
653
|
|
|
611
|
-
const containerElement = items[0].parentElement
|
|
612
|
-
|
|
613
654
|
// Track scroll direction for wrap logic
|
|
614
655
|
const positionUnsubscribe = position.on('change', latest => {
|
|
615
656
|
const delta = latest - lastPositionForDirection
|
|
@@ -622,7 +663,7 @@ function horizontalLoop(app, items, config) {
|
|
|
622
663
|
renderUnsubscribe = frame.render(() => {
|
|
623
664
|
// Use RAW position (no bounded) for container
|
|
624
665
|
const currentPos = position.get()
|
|
625
|
-
|
|
666
|
+
trackElement.style.transform = `translateX(${-currentPos}px)`
|
|
626
667
|
|
|
627
668
|
// Wrap items based on raw position
|
|
628
669
|
updateItemPositions(currentPos)
|
|
@@ -637,26 +678,26 @@ function horizontalLoop(app, items, config) {
|
|
|
637
678
|
startRenderLoop()
|
|
638
679
|
} else {
|
|
639
680
|
// Non-looping: simple position listener to update container transform
|
|
640
|
-
const containerElement = items[0].parentElement
|
|
641
681
|
positionUnsubscribe = position.on('change', latest => {
|
|
642
|
-
|
|
682
|
+
trackElement.style.transform = `translateX(${-latest}px)`
|
|
643
683
|
})
|
|
644
684
|
}
|
|
645
685
|
|
|
646
686
|
// Create animation by animating the position motionValue
|
|
647
|
-
|
|
687
|
+
// Skip crawl entirely when reduced motion is preferred (H4)
|
|
688
|
+
if (shouldLoop && config.crawl && !reducedMotion) {
|
|
648
689
|
const duration = totalWidth / pixelsPerSecond
|
|
649
690
|
|
|
650
691
|
// Create initial animation (paused)
|
|
651
692
|
animation = startLoopAnimation()
|
|
652
693
|
animation.pause()
|
|
653
|
-
} else if (!shouldLoop && config.crawl) {
|
|
694
|
+
} else if (!shouldLoop && config.crawl && !reducedMotion) {
|
|
654
695
|
// Non-looping: ping-pong animation (crawl to end, reverse to start)
|
|
655
696
|
// Use maxScrollPosition (last item at right edge) instead of totalWidth
|
|
656
697
|
const duration = maxScrollPosition / pixelsPerSecond
|
|
657
698
|
|
|
658
699
|
// Create a ping-pong crawl animation
|
|
659
|
-
function
|
|
700
|
+
startPingPongCrawl = function pingPongCrawl(fromStart = true) {
|
|
660
701
|
const currentPos = position.get()
|
|
661
702
|
const target = fromStart ? maxScrollPosition : 0
|
|
662
703
|
const remainingDist = Math.abs(target - currentPos)
|
|
@@ -672,17 +713,14 @@ function horizontalLoop(app, items, config) {
|
|
|
672
713
|
// When reaching the end, reverse direction
|
|
673
714
|
animation.then(() => {
|
|
674
715
|
// Brief pause at boundary for visual clarity
|
|
675
|
-
setTimeout(() => {
|
|
716
|
+
pingPongTimeout = setTimeout(() => {
|
|
676
717
|
if (animation && animation.speed !== 0) {
|
|
677
|
-
|
|
718
|
+
pingPongCrawl(!fromStart)
|
|
678
719
|
}
|
|
679
|
-
},
|
|
720
|
+
}, PING_PONG_PAUSE_MS)
|
|
680
721
|
})
|
|
681
722
|
}
|
|
682
723
|
|
|
683
|
-
// Store the ping-pong starter for later use
|
|
684
|
-
config.startPingPongCrawl = startPingPongCrawl
|
|
685
|
-
|
|
686
724
|
// Create initial animation (starts paused)
|
|
687
725
|
animation = animate(position, maxScrollPosition, {
|
|
688
726
|
duration,
|
|
@@ -696,8 +734,10 @@ function horizontalLoop(app, items, config) {
|
|
|
696
734
|
setupDrag()
|
|
697
735
|
}
|
|
698
736
|
|
|
699
|
-
// Setup hover effects
|
|
700
|
-
|
|
737
|
+
// Setup hover effects (skip in reduced motion - H4)
|
|
738
|
+
if (!reducedMotion) {
|
|
739
|
+
setupHoverEffects()
|
|
740
|
+
}
|
|
701
741
|
|
|
702
742
|
// Setup slide index/count display elements
|
|
703
743
|
if (config.wrapper) {
|
|
@@ -731,7 +771,7 @@ function horizontalLoop(app, items, config) {
|
|
|
731
771
|
}
|
|
732
772
|
|
|
733
773
|
// Update index display whenever position changes (closestIndex normalizes internally)
|
|
734
|
-
position.on('change', updateIndexOnChange)
|
|
774
|
+
indexUnsubscribe = position.on('change', updateIndexOnChange)
|
|
735
775
|
}
|
|
736
776
|
}
|
|
737
777
|
|
|
@@ -777,10 +817,23 @@ function horizontalLoop(app, items, config) {
|
|
|
777
817
|
function setupDrag() {
|
|
778
818
|
// isDragging is now module-level so wrap detection can see it
|
|
779
819
|
let startX = 0
|
|
820
|
+
let startY = 0
|
|
780
821
|
let startPosition = 0
|
|
781
822
|
let velocityTracker = [] // Track recent movements for velocity calculation
|
|
782
823
|
let hasDragged = false // Did movement exceed minimumMovement threshold?
|
|
783
|
-
let
|
|
824
|
+
let axisDecided = false // Has the drag axis been determined? (horizontal vs vertical)
|
|
825
|
+
let stoppedAnimation = false // Was an animation stopped by this pointer down? (tap-to-stop)
|
|
826
|
+
let resumeTimeout = null // Delayed crawl resume after tap-to-stop
|
|
827
|
+
let activeMinimumMovement = config.minimumMovement // Adjusted per pointer type (touch vs mouse)
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Capture-phase click handler that prevents link navigation after a drag.
|
|
831
|
+
* Added once per drag and auto-removes via { once: true }.
|
|
832
|
+
*/
|
|
833
|
+
function swallowClick(e) {
|
|
834
|
+
e.preventDefault()
|
|
835
|
+
e.stopPropagation()
|
|
836
|
+
}
|
|
784
837
|
|
|
785
838
|
/**
|
|
786
839
|
* Calculate velocity from recent pointer movements
|
|
@@ -790,8 +843,8 @@ function horizontalLoop(app, items, config) {
|
|
|
790
843
|
function getVelocity() {
|
|
791
844
|
if (velocityTracker.length < 2) return 0
|
|
792
845
|
|
|
793
|
-
// Use last
|
|
794
|
-
const recent = velocityTracker.slice(-
|
|
846
|
+
// Use last 6 movements for smoothing
|
|
847
|
+
const recent = velocityTracker.slice(-6)
|
|
795
848
|
let totalVelocity = 0
|
|
796
849
|
let totalWeight = 0
|
|
797
850
|
|
|
@@ -821,17 +874,35 @@ function horizontalLoop(app, items, config) {
|
|
|
821
874
|
if (e.button !== undefined && e.button !== 0) return
|
|
822
875
|
|
|
823
876
|
isDragging = true
|
|
877
|
+
indexSetByNav = false
|
|
824
878
|
startX = e.clientX
|
|
879
|
+
startY = e.clientY
|
|
825
880
|
startPosition = position.get()
|
|
826
|
-
velocityTracker = [{ x: e.clientX, time:
|
|
881
|
+
velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
|
|
827
882
|
hasDragged = false // Reset - will be set true if movement exceeds threshold
|
|
828
|
-
|
|
883
|
+
axisDecided = false // Reset - will be decided on first significant movement
|
|
884
|
+
|
|
885
|
+
// Use higher threshold for touch to prevent accidental drags from finger imprecision
|
|
886
|
+
activeMinimumMovement = e.pointerType === 'touch'
|
|
887
|
+
? config.touchMinimumMovement
|
|
888
|
+
: config.minimumMovement
|
|
829
889
|
|
|
830
890
|
// Stop autoplay on user interaction
|
|
831
891
|
if (loopController && loopController.stopAutoplay) {
|
|
832
892
|
loopController.stopAutoplay()
|
|
833
893
|
}
|
|
834
894
|
|
|
895
|
+
// Cancel any pending resume from a previous tap-to-stop
|
|
896
|
+
clearTimeout(resumeTimeout)
|
|
897
|
+
|
|
898
|
+
// Detect if we're stopping a running animation (tap-to-stop)
|
|
899
|
+
stoppedAnimation = !!(
|
|
900
|
+
inertiaAnimation ||
|
|
901
|
+
snapAnimation ||
|
|
902
|
+
(animation && animation.speed !== 0) ||
|
|
903
|
+
speedRampAnimation
|
|
904
|
+
)
|
|
905
|
+
|
|
835
906
|
// Stop any ongoing animations
|
|
836
907
|
if (inertiaAnimation) {
|
|
837
908
|
inertiaAnimation.stop()
|
|
@@ -850,14 +921,20 @@ function horizontalLoop(app, items, config) {
|
|
|
850
921
|
speedRampAnimation = null
|
|
851
922
|
}
|
|
852
923
|
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
|
|
924
|
+
// For mouse: prevent default to stop native drag behavior on links/images
|
|
925
|
+
// For touch: don't preventDefault here — we need the browser to be able to scroll
|
|
926
|
+
// if the gesture turns out to be vertical. CSS touch-action: pan-y handles horizontal.
|
|
927
|
+
if (e.pointerType === 'mouse') {
|
|
928
|
+
e.preventDefault()
|
|
929
|
+
}
|
|
856
930
|
|
|
857
|
-
//
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
931
|
+
// Capture pointer to prevent events being lost when finger moves outside container
|
|
932
|
+
try { container.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
|
|
933
|
+
|
|
934
|
+
// Add move/up listeners to container (pointer capture routes events here)
|
|
935
|
+
container.addEventListener('pointermove', onPointerMove, { passive: false })
|
|
936
|
+
container.addEventListener('pointerup', onPointerUp)
|
|
937
|
+
container.addEventListener('pointercancel', onPointerUp)
|
|
861
938
|
}
|
|
862
939
|
|
|
863
940
|
/**
|
|
@@ -867,32 +944,49 @@ function horizontalLoop(app, items, config) {
|
|
|
867
944
|
if (!isDragging) return
|
|
868
945
|
|
|
869
946
|
const currentX = e.clientX
|
|
870
|
-
const currentTime =
|
|
871
|
-
|
|
872
|
-
//
|
|
873
|
-
|
|
947
|
+
const currentTime = e.timeStamp
|
|
948
|
+
|
|
949
|
+
// Axis locking: when movement first exceeds threshold, check if primarily
|
|
950
|
+
// horizontal or vertical. If vertical, abort drag and let the browser scroll.
|
|
951
|
+
if (!axisDecided) {
|
|
952
|
+
const deltaX = Math.abs(currentX - startX)
|
|
953
|
+
const deltaY = Math.abs(e.clientY - startY)
|
|
954
|
+
const maxDelta = Math.max(deltaX, deltaY)
|
|
955
|
+
|
|
956
|
+
// Wait until enough movement to decide
|
|
957
|
+
if (maxDelta < activeMinimumMovement) return
|
|
958
|
+
|
|
959
|
+
axisDecided = true
|
|
960
|
+
|
|
961
|
+
// If clearly vertical, abort — release pointer and let browser scroll.
|
|
962
|
+
// Bias toward carousel interaction: vertical must be 1.2x horizontal to abort.
|
|
963
|
+
if (deltaY > deltaX * 1.2) {
|
|
964
|
+
isDragging = false
|
|
965
|
+
try { container.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
|
|
966
|
+
container.removeEventListener('pointermove', onPointerMove)
|
|
967
|
+
container.removeEventListener('pointerup', onPointerUp)
|
|
968
|
+
container.removeEventListener('pointercancel', onPointerUp)
|
|
969
|
+
// Resume crawl if we stopped it on pointerdown
|
|
970
|
+
if (stoppedAnimation && config.crawl) {
|
|
971
|
+
resumeCrawl()
|
|
972
|
+
}
|
|
973
|
+
return
|
|
974
|
+
}
|
|
874
975
|
|
|
875
|
-
|
|
876
|
-
if (!hasDragged && movementDelta > config.minimumMovement) {
|
|
976
|
+
// Horizontal — commit to drag
|
|
877
977
|
hasDragged = true
|
|
878
|
-
// Now that we know it's a drag, change cursor and disable pointer events on items
|
|
879
978
|
container.style.cursor = 'grabbing'
|
|
880
|
-
|
|
881
|
-
item.style.pointerEvents = 'none'
|
|
882
|
-
})
|
|
979
|
+
trackElement.classList.add('looper-dragging')
|
|
883
980
|
}
|
|
884
981
|
|
|
885
|
-
// Only update position if we've confirmed this is a drag
|
|
886
|
-
if (!hasDragged) return
|
|
887
|
-
|
|
888
982
|
// Prevent default only for actual drags (not clicks)
|
|
889
983
|
e.preventDefault()
|
|
890
984
|
|
|
891
985
|
// Track for velocity calculation
|
|
892
986
|
velocityTracker.push({ x: currentX, time: currentTime })
|
|
893
987
|
|
|
894
|
-
// Keep only recent movements
|
|
895
|
-
while (velocityTracker.length > 0 && currentTime - velocityTracker[0].time >
|
|
988
|
+
// Keep only recent movements
|
|
989
|
+
while (velocityTracker.length > 0 && currentTime - velocityTracker[0].time > VELOCITY_WINDOW_MS) {
|
|
896
990
|
velocityTracker.shift()
|
|
897
991
|
}
|
|
898
992
|
|
|
@@ -912,23 +1006,6 @@ function horizontalLoop(app, items, config) {
|
|
|
912
1006
|
}
|
|
913
1007
|
}
|
|
914
1008
|
|
|
915
|
-
/**
|
|
916
|
-
* Calculate where inertia would land based on velocity
|
|
917
|
-
* Uses same physics as startInertia to predict landing position
|
|
918
|
-
* Motion.js inertia formula: distance = velocity * timeConstant * (power / (1 - power))
|
|
919
|
-
* @param {number} velocity - Cursor velocity in pixels per second
|
|
920
|
-
* @returns {number} Predicted landing position
|
|
921
|
-
*/
|
|
922
|
-
function calculateInertiaTarget(velocity) {
|
|
923
|
-
const currentPos = position.get()
|
|
924
|
-
const motionVelocity = -velocity
|
|
925
|
-
const power = config.throwPower
|
|
926
|
-
const timeConstant = config.throwResistance / 1000
|
|
927
|
-
// Motion.js inertia distance formula
|
|
928
|
-
const estimatedDistance = motionVelocity * timeConstant * (power / (1 - power))
|
|
929
|
-
return currentPos + estimatedDistance
|
|
930
|
-
}
|
|
931
|
-
|
|
932
1009
|
/**
|
|
933
1010
|
* Handle pointer up - end drag and start inertia
|
|
934
1011
|
*/
|
|
@@ -937,31 +1014,57 @@ function horizontalLoop(app, items, config) {
|
|
|
937
1014
|
|
|
938
1015
|
isDragging = false
|
|
939
1016
|
|
|
1017
|
+
// Release pointer capture
|
|
1018
|
+
try { container.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
|
|
1019
|
+
|
|
940
1020
|
// Clean up listeners
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1021
|
+
container.removeEventListener('pointermove', onPointerMove)
|
|
1022
|
+
container.removeEventListener('pointerup', onPointerUp)
|
|
1023
|
+
container.removeEventListener('pointercancel', onPointerUp)
|
|
944
1024
|
|
|
945
1025
|
// Reset cursor and re-enable hover effects (only if we actually dragged)
|
|
946
1026
|
if (hasDragged) {
|
|
947
1027
|
container.style.cursor = 'grab'
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1028
|
+
trackElement.classList.remove('looper-dragging')
|
|
1029
|
+
|
|
1030
|
+
// Swallow the next click event so links don't navigate after a drag.
|
|
1031
|
+
// The browser fires click after pointerup — capture it before it reaches any <a>.
|
|
1032
|
+
container.addEventListener('click', swallowClick, { capture: true, once: true })
|
|
951
1033
|
}
|
|
952
1034
|
|
|
953
|
-
// If this was a click (not a drag)
|
|
1035
|
+
// If this was a click (not a drag):
|
|
1036
|
+
// - If we stopped a running animation, silently stop (like native iOS scroll tap-to-stop)
|
|
1037
|
+
// then resume crawl after a delay so it doesn't feel jittery
|
|
1038
|
+
// - Otherwise, forward the click to the element under the pointer
|
|
954
1039
|
if (!hasDragged) {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1040
|
+
if (!stoppedAnimation) {
|
|
1041
|
+
const clickedElement = document.elementFromPoint(e.clientX, e.clientY)
|
|
1042
|
+
if (clickedElement) {
|
|
1043
|
+
clickedElement.click()
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (stoppedAnimation && config.crawl) {
|
|
1047
|
+
clearTimeout(resumeTimeout)
|
|
1048
|
+
resumeTimeout = setTimeout(() => resumeCrawl(), 5000)
|
|
959
1049
|
}
|
|
960
1050
|
return
|
|
961
1051
|
}
|
|
962
1052
|
|
|
963
|
-
// Calculate
|
|
964
|
-
|
|
1053
|
+
// Calculate velocity from pointermove samples only.
|
|
1054
|
+
// Do NOT use pointerup/pointercancel clientX — iOS Safari often reports 0
|
|
1055
|
+
// on pointercancel which corrupts velocity and direction calculations.
|
|
1056
|
+
let velocity = getVelocity()
|
|
1057
|
+
|
|
1058
|
+
// Use last reliable pointermove position for direction check (not e.clientX
|
|
1059
|
+
// which may be 0 from a pointercancel event on iOS)
|
|
1060
|
+
const lastTrackedX = velocityTracker.length > 0
|
|
1061
|
+
? velocityTracker[velocityTracker.length - 1].x
|
|
1062
|
+
: e.clientX
|
|
1063
|
+
const overallDelta = lastTrackedX - startX
|
|
1064
|
+
|
|
1065
|
+
// Sanity check: velocity direction must match overall drag direction.
|
|
1066
|
+
if (overallDelta > 0 && velocity < 0) velocity = 0
|
|
1067
|
+
if (overallDelta < 0 && velocity > 0) velocity = 0
|
|
965
1068
|
|
|
966
1069
|
// If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
|
|
967
1070
|
// Otherwise use old logic: inertia if velocity, or resume crawl
|
|
@@ -990,12 +1093,14 @@ function horizontalLoop(app, items, config) {
|
|
|
990
1093
|
// Note: This is ALWAYS opposite, regardless of reversed setting
|
|
991
1094
|
// (reversed only affects auto-crawl, not drag)
|
|
992
1095
|
// Apply velocity multiplier for tuning throw feel
|
|
993
|
-
|
|
1096
|
+
// Reduce inertia when reduced motion is preferred (H4)
|
|
1097
|
+
const velocityMultiplier = reducedMotion ? config.throwVelocityMultiplier * 0.3 : config.throwVelocityMultiplier
|
|
1098
|
+
const motionVelocity = -velocity * velocityMultiplier
|
|
994
1099
|
|
|
995
|
-
//
|
|
1100
|
+
// Estimate target for Motion.js (it recomputes internally for type: 'inertia',
|
|
1101
|
+
// but we pass a reasonable target to avoid a zero-distance animation)
|
|
996
1102
|
const power = config.throwPower
|
|
997
|
-
const
|
|
998
|
-
const estimatedDistance = motionVelocity * timeConstant * 0.5
|
|
1103
|
+
const estimatedDistance = power * motionVelocity
|
|
999
1104
|
const targetPos = currentPos + estimatedDistance
|
|
1000
1105
|
|
|
1001
1106
|
// Animate position motionValue with inertia
|
|
@@ -1094,6 +1199,24 @@ function horizontalLoop(app, items, config) {
|
|
|
1094
1199
|
*/
|
|
1095
1200
|
function snapToNearest(velocity = 0) {
|
|
1096
1201
|
const currentPos = position.get()
|
|
1202
|
+
|
|
1203
|
+
// Reduced motion: instant snap with short duration, no inertia (H4)
|
|
1204
|
+
if (reducedMotion) {
|
|
1205
|
+
const snapPos = findNearestSnapPoint(currentPos)
|
|
1206
|
+
const clampedPos = shouldLoop ? snapPos : Math.max(0, Math.min(snapPos, maxScrollPosition))
|
|
1207
|
+
snapAnimation = animate(position, clampedPos, {
|
|
1208
|
+
duration: 0.15,
|
|
1209
|
+
ease: 'easeOut',
|
|
1210
|
+
})
|
|
1211
|
+
snapAnimation
|
|
1212
|
+
.then(() => {
|
|
1213
|
+
snapAnimation = null
|
|
1214
|
+
updateIndexDisplay()
|
|
1215
|
+
})
|
|
1216
|
+
.catch(() => { snapAnimation = null })
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1097
1220
|
// Apply both velocity multipliers for snapped loopers
|
|
1098
1221
|
const motionVelocity =
|
|
1099
1222
|
-velocity * config.throwVelocityMultiplier * config.snapVelocityMultiplier
|
|
@@ -1151,7 +1274,10 @@ function horizontalLoop(app, items, config) {
|
|
|
1151
1274
|
* Reads current position and resumes infinite loop
|
|
1152
1275
|
*/
|
|
1153
1276
|
function resumeCrawl() {
|
|
1154
|
-
if (!config.crawl) return
|
|
1277
|
+
if (!config.crawl || reducedMotion) return
|
|
1278
|
+
|
|
1279
|
+
// Increment generation to invalidate any pending .then() callbacks (C7)
|
|
1280
|
+
const generation = ++resumeCrawlGeneration
|
|
1155
1281
|
|
|
1156
1282
|
// Stop any existing animations
|
|
1157
1283
|
if (animation) {
|
|
@@ -1168,7 +1294,10 @@ function horizontalLoop(app, items, config) {
|
|
|
1168
1294
|
// Calculate position within current cycle (using originalItemsWidth)
|
|
1169
1295
|
// Use proper modulo for negative positions (dragging right/backward)
|
|
1170
1296
|
const cyclePos = ((currentPos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
|
|
1171
|
-
|
|
1297
|
+
// For reversed carousels, remaining distance to complete cycle backward is cyclePos (C6)
|
|
1298
|
+
const remainingDist = config.reversed
|
|
1299
|
+
? (cyclePos || originalItemsWidth)
|
|
1300
|
+
: (originalItemsWidth - cyclePos)
|
|
1172
1301
|
const remainingDuration = remainingDist / pixelsPerSecond
|
|
1173
1302
|
|
|
1174
1303
|
// Animate position to complete this cycle
|
|
@@ -1181,6 +1310,9 @@ function horizontalLoop(app, items, config) {
|
|
|
1181
1310
|
|
|
1182
1311
|
// When cycle completes, restart infinite loop
|
|
1183
1312
|
animation.then(() => {
|
|
1313
|
+
// Check generation to avoid stale .then() callbacks (C7)
|
|
1314
|
+
if (generation !== resumeCrawlGeneration) return
|
|
1315
|
+
|
|
1184
1316
|
// Capture current speed before replacing animation
|
|
1185
1317
|
const currentSpeed = animation.speed
|
|
1186
1318
|
|
|
@@ -1203,9 +1335,8 @@ function horizontalLoop(app, items, config) {
|
|
|
1203
1335
|
if (speedRampAnimation) {
|
|
1204
1336
|
speedRampAnimation.stop()
|
|
1205
1337
|
// Calculate remaining ramp duration based on current speed
|
|
1206
|
-
|
|
1207
|
-
const
|
|
1208
|
-
const remainingRampDuration = 2 * (1 - rampProgress)
|
|
1338
|
+
const rampProgress = (currentSpeed - MIN_CRAWL_SPEED) / (1 - MIN_CRAWL_SPEED)
|
|
1339
|
+
const remainingRampDuration = SPEED_RAMP_DURATION * (1 - rampProgress)
|
|
1209
1340
|
|
|
1210
1341
|
speedRampAnimation = animate(
|
|
1211
1342
|
animation,
|
|
@@ -1216,9 +1347,9 @@ function horizontalLoop(app, items, config) {
|
|
|
1216
1347
|
})
|
|
1217
1348
|
|
|
1218
1349
|
// Start at nearly-stopped speed and ramp up to full speed
|
|
1219
|
-
// Use
|
|
1220
|
-
animation.speed =
|
|
1221
|
-
speedRampAnimation = animate(animation, { speed: 1 }, { duration:
|
|
1350
|
+
// Use MIN_CRAWL_SPEED instead of 0 to keep animation running (speed = 0 completely pauses)
|
|
1351
|
+
animation.speed = MIN_CRAWL_SPEED
|
|
1352
|
+
speedRampAnimation = animate(animation, { speed: 1 }, { duration: SPEED_RAMP_DURATION, ease: 'easeIn' })
|
|
1222
1353
|
}
|
|
1223
1354
|
|
|
1224
1355
|
// Set up touch-action CSS for proper touch handling
|
|
@@ -1232,15 +1363,17 @@ function horizontalLoop(app, items, config) {
|
|
|
1232
1363
|
dragState = {
|
|
1233
1364
|
cleanup: () => {
|
|
1234
1365
|
container.removeEventListener('pointerdown', onPointerDown)
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1366
|
+
container.removeEventListener('click', swallowClick, { capture: true })
|
|
1367
|
+
container.removeEventListener('pointermove', onPointerMove)
|
|
1368
|
+
container.removeEventListener('pointerup', onPointerUp)
|
|
1369
|
+
container.removeEventListener('pointercancel', onPointerUp)
|
|
1238
1370
|
},
|
|
1239
1371
|
}
|
|
1240
1372
|
}
|
|
1241
1373
|
|
|
1242
1374
|
/**
|
|
1243
|
-
* Setup hover slow-down effects
|
|
1375
|
+
* Setup hover slow-down effects using event delegation on container (H1/C1/C5)
|
|
1376
|
+
* Single listener pair on container covers all items including future clones
|
|
1244
1377
|
*/
|
|
1245
1378
|
function setupHoverEffects() {
|
|
1246
1379
|
if (!config.crawl || !animation) return
|
|
@@ -1252,37 +1385,48 @@ function horizontalLoop(app, items, config) {
|
|
|
1252
1385
|
// Track hover animations to prevent accumulation
|
|
1253
1386
|
let hoverAnimation = null
|
|
1254
1387
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1388
|
+
function onMouseEnter(e) {
|
|
1389
|
+
if (!animation) return
|
|
1390
|
+
if (!e.target.closest('[data-looper-item]')) return
|
|
1258
1391
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
}
|
|
1392
|
+
if (hoverAnimation) {
|
|
1393
|
+
hoverAnimation.stop()
|
|
1394
|
+
}
|
|
1263
1395
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1396
|
+
hoverAnimation = animate(
|
|
1397
|
+
animation,
|
|
1398
|
+
{ speed: hoverSpeed },
|
|
1399
|
+
{ duration: config.ease.mouseOver.duration, ease: 'easeOut' }
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1270
1402
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1403
|
+
function onMouseLeave(e) {
|
|
1404
|
+
if (!animation) return
|
|
1405
|
+
if (!e.target.closest('[data-looper-item]')) return
|
|
1273
1406
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
}
|
|
1407
|
+
if (hoverAnimation) {
|
|
1408
|
+
hoverAnimation.stop()
|
|
1409
|
+
}
|
|
1278
1410
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1411
|
+
hoverAnimation = animate(
|
|
1412
|
+
animation,
|
|
1413
|
+
{ speed: targetSpeed },
|
|
1414
|
+
{ duration: config.ease.mouseOut.duration, ease: 'easeOut' }
|
|
1415
|
+
)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
container.addEventListener('mouseenter', onMouseEnter, true)
|
|
1419
|
+
container.addEventListener('mouseleave', onMouseLeave, true)
|
|
1420
|
+
|
|
1421
|
+
// Return cleanup function (C1/C9)
|
|
1422
|
+
hoverCleanup = () => {
|
|
1423
|
+
if (hoverAnimation) {
|
|
1424
|
+
hoverAnimation.stop()
|
|
1425
|
+
hoverAnimation = null
|
|
1426
|
+
}
|
|
1427
|
+
container.removeEventListener('mouseenter', onMouseEnter, true)
|
|
1428
|
+
container.removeEventListener('mouseleave', onMouseLeave, true)
|
|
1429
|
+
}
|
|
1286
1430
|
}
|
|
1287
1431
|
|
|
1288
1432
|
/**
|
|
@@ -1293,6 +1437,11 @@ function horizontalLoop(app, items, config) {
|
|
|
1293
1437
|
function closestIndex(setCurrent = false) {
|
|
1294
1438
|
if (!times || times.length === 0) return 0
|
|
1295
1439
|
|
|
1440
|
+
// If curIndex was set by toIndex (nav button), trust it.
|
|
1441
|
+
// Position-based lookup fails when maxScrollPosition clamps positions
|
|
1442
|
+
// too close together to distinguish items.
|
|
1443
|
+
if (indexSetByNav) return curIndex
|
|
1444
|
+
|
|
1296
1445
|
// Use bounded position to find what's actually visible
|
|
1297
1446
|
const currentPos = position.get()
|
|
1298
1447
|
|
|
@@ -1308,21 +1457,14 @@ function horizontalLoop(app, items, config) {
|
|
|
1308
1457
|
|
|
1309
1458
|
let closest = 0
|
|
1310
1459
|
let closestDist = Infinity
|
|
1460
|
+
const duration = shouldLoop ? originalItemsWidth / pixelsPerSecond : 0
|
|
1311
1461
|
|
|
1312
1462
|
// Only check original items, not clones
|
|
1313
1463
|
for (let i = 0; i < originalItemCount; i++) {
|
|
1314
1464
|
const time = times[i]
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (shouldLoop) {
|
|
1319
|
-
const duration = originalItemsWidth / pixelsPerSecond
|
|
1320
|
-
const wrappedDist = Math.min(
|
|
1321
|
-
Math.abs(time + duration - currentTime),
|
|
1322
|
-
Math.abs(time - duration - currentTime)
|
|
1323
|
-
)
|
|
1324
|
-
dist = Math.min(dist, wrappedDist)
|
|
1325
|
-
}
|
|
1465
|
+
const dist = shouldLoop
|
|
1466
|
+
? Math.abs(symmetricMod(time - currentTime, duration))
|
|
1467
|
+
: Math.abs(time - currentTime)
|
|
1326
1468
|
|
|
1327
1469
|
if (dist < closestDist) {
|
|
1328
1470
|
closestDist = dist
|
|
@@ -1398,6 +1540,7 @@ function horizontalLoop(app, items, config) {
|
|
|
1398
1540
|
|
|
1399
1541
|
// Update current index
|
|
1400
1542
|
curIndex = targetIndex
|
|
1543
|
+
indexSetByNav = true
|
|
1401
1544
|
|
|
1402
1545
|
// Update display immediately
|
|
1403
1546
|
updateIndexDisplay()
|
|
@@ -1431,19 +1574,19 @@ function horizontalLoop(app, items, config) {
|
|
|
1431
1574
|
|
|
1432
1575
|
const loopController = {
|
|
1433
1576
|
position,
|
|
1434
|
-
animation,
|
|
1577
|
+
get animation() { return animation },
|
|
1435
1578
|
items,
|
|
1436
|
-
times,
|
|
1579
|
+
get times() { return times },
|
|
1437
1580
|
isReversed: config.reversed,
|
|
1438
1581
|
isLooping: shouldLoop,
|
|
1439
1582
|
|
|
1440
1583
|
play() {
|
|
1441
|
-
if (!shouldLoop && config.crawl &&
|
|
1584
|
+
if (!shouldLoop && config.crawl && startPingPongCrawl) {
|
|
1442
1585
|
// Non-looping: start ping-pong crawl
|
|
1443
1586
|
const currentPos = position.get()
|
|
1444
1587
|
// Determine direction based on current position
|
|
1445
1588
|
const goForward = currentPos < maxScrollPosition / 2
|
|
1446
|
-
|
|
1589
|
+
startPingPongCrawl(goForward)
|
|
1447
1590
|
} else if (animation) {
|
|
1448
1591
|
animation.play()
|
|
1449
1592
|
}
|
|
@@ -1544,17 +1687,51 @@ function horizontalLoop(app, items, config) {
|
|
|
1544
1687
|
// Stop autoplay
|
|
1545
1688
|
this.stopAutoplay()
|
|
1546
1689
|
|
|
1547
|
-
// Stop
|
|
1690
|
+
// Stop all animations (C9)
|
|
1548
1691
|
if (animation) {
|
|
1549
1692
|
animation.stop()
|
|
1693
|
+
animation = null
|
|
1694
|
+
}
|
|
1695
|
+
if (speedRampAnimation) {
|
|
1696
|
+
speedRampAnimation.stop()
|
|
1697
|
+
speedRampAnimation = null
|
|
1698
|
+
}
|
|
1699
|
+
if (inertiaAnimation) {
|
|
1700
|
+
inertiaAnimation.stop()
|
|
1701
|
+
inertiaAnimation = null
|
|
1702
|
+
}
|
|
1703
|
+
if (snapAnimation) {
|
|
1704
|
+
snapAnimation.stop()
|
|
1705
|
+
snapAnimation = null
|
|
1706
|
+
}
|
|
1707
|
+
if (navAnimation) {
|
|
1708
|
+
navAnimation.stop()
|
|
1709
|
+
navAnimation = null
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Clear pending timeouts (C4)
|
|
1713
|
+
if (pingPongTimeout) {
|
|
1714
|
+
clearTimeout(pingPongTimeout)
|
|
1715
|
+
pingPongTimeout = null
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// Cleanup hover effects (C1/C9)
|
|
1719
|
+
if (hoverCleanup) {
|
|
1720
|
+
hoverCleanup()
|
|
1721
|
+
hoverCleanup = null
|
|
1550
1722
|
}
|
|
1551
1723
|
|
|
1552
1724
|
// Stop frame.render loop
|
|
1553
1725
|
stopRenderLoop()
|
|
1554
1726
|
|
|
1555
|
-
// Cleanup position
|
|
1727
|
+
// Cleanup position listeners (C3)
|
|
1556
1728
|
if (positionUnsubscribe) {
|
|
1557
1729
|
positionUnsubscribe()
|
|
1730
|
+
positionUnsubscribe = null
|
|
1731
|
+
}
|
|
1732
|
+
if (indexUnsubscribe) {
|
|
1733
|
+
indexUnsubscribe()
|
|
1734
|
+
indexUnsubscribe = null
|
|
1558
1735
|
}
|
|
1559
1736
|
|
|
1560
1737
|
// Cleanup drag
|
|
@@ -1565,6 +1742,23 @@ function horizontalLoop(app, items, config) {
|
|
|
1565
1742
|
// Cleanup resize listener
|
|
1566
1743
|
window.removeEventListener('APPLICATION:RESIZE', handleResize)
|
|
1567
1744
|
|
|
1745
|
+
// Remove clone elements from DOM (C10)
|
|
1746
|
+
const clones = trackElement.querySelectorAll('[data-looper-clone]')
|
|
1747
|
+
clones.forEach(clone => clone.remove())
|
|
1748
|
+
|
|
1749
|
+
// Clear inline styles on track element (C10)
|
|
1750
|
+
trackElement.style.willChange = ''
|
|
1751
|
+
trackElement.style.transform = ''
|
|
1752
|
+
|
|
1753
|
+
// Clear inline styles on container
|
|
1754
|
+
container.style.touchAction = ''
|
|
1755
|
+
container.style.cursor = ''
|
|
1756
|
+
|
|
1757
|
+
// Clear item wrap transforms
|
|
1758
|
+
items.forEach(item => {
|
|
1759
|
+
item.style.transform = ''
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1568
1762
|
// Destroy position value
|
|
1569
1763
|
position.destroy()
|
|
1570
1764
|
},
|
|
@@ -1603,7 +1797,7 @@ export default class Looper {
|
|
|
1603
1797
|
|
|
1604
1798
|
if (!wrapper) {
|
|
1605
1799
|
console.error(
|
|
1606
|
-
'[Looper]
|
|
1800
|
+
'[Looper] No wrapper element found (expected [data-looper-container] or .looper-wrapper)'
|
|
1607
1801
|
)
|
|
1608
1802
|
}
|
|
1609
1803
|
|
|
@@ -1689,6 +1883,7 @@ export default class Looper {
|
|
|
1689
1883
|
snapDuration: this.opts.snapDuration,
|
|
1690
1884
|
snapBounce: this.opts.snapBounce,
|
|
1691
1885
|
minimumMovement: this.opts.minimumMovement,
|
|
1886
|
+
touchMinimumMovement: this.opts.touchMinimumMovement,
|
|
1692
1887
|
},
|
|
1693
1888
|
})
|
|
1694
1889
|
})
|
|
@@ -1720,22 +1915,26 @@ export default class Looper {
|
|
|
1720
1915
|
// Replace stub with real loop
|
|
1721
1916
|
element.$loop = loop
|
|
1722
1917
|
|
|
1723
|
-
// Setup navigation buttons if present
|
|
1918
|
+
// Setup navigation buttons if present (C2 - store refs for cleanup)
|
|
1724
1919
|
const next = Dom.find(element, '[data-panner-next]')
|
|
1725
1920
|
const previous = Dom.find(element, '[data-panner-previous]')
|
|
1921
|
+
const navCleanups = []
|
|
1726
1922
|
|
|
1727
1923
|
if (next) {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1924
|
+
const onNext = () => loop.next({ duration: 0.85, ease: 'easeInOut' })
|
|
1925
|
+
next.addEventListener('click', onNext)
|
|
1926
|
+
navCleanups.push(() => next.removeEventListener('click', onNext))
|
|
1731
1927
|
}
|
|
1732
1928
|
|
|
1733
1929
|
if (previous) {
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1930
|
+
const onPrev = () => loop.previous({ duration: 0.85, ease: 'easeInOut' })
|
|
1931
|
+
previous.addEventListener('click', onPrev)
|
|
1932
|
+
navCleanups.push(() => previous.removeEventListener('click', onPrev))
|
|
1737
1933
|
}
|
|
1738
1934
|
|
|
1935
|
+
// Store nav cleanup on the loop controller
|
|
1936
|
+
loop._navCleanups = navCleanups
|
|
1937
|
+
|
|
1739
1938
|
// Reveal lazyload images: immediately reveal off-screen items (no visible transition),
|
|
1740
1939
|
// defer reveal of viewport items until after wrapper fade-in for a nice per-image fade
|
|
1741
1940
|
if (this.app?.lazyload && wrapper) {
|
|
@@ -1770,7 +1969,14 @@ export default class Looper {
|
|
|
1770
1969
|
}
|
|
1771
1970
|
|
|
1772
1971
|
destroy() {
|
|
1773
|
-
this.loopers.forEach(loop =>
|
|
1972
|
+
this.loopers.forEach(loop => {
|
|
1973
|
+
// Clean up navigation button listeners (C2)
|
|
1974
|
+
if (loop._navCleanups) {
|
|
1975
|
+
loop._navCleanups.forEach(fn => fn())
|
|
1976
|
+
loop._navCleanups = null
|
|
1977
|
+
}
|
|
1978
|
+
loop.destroy()
|
|
1979
|
+
})
|
|
1774
1980
|
this.loopers = []
|
|
1775
1981
|
this.pendingLoopers = []
|
|
1776
1982
|
}
|