@brandocms/jupiter 5.0.0-beta.7 → 5.0.0-beta.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandocms/jupiter",
3
- "version": "5.0.0-beta.7",
3
+ "version": "5.0.0-beta.8",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -36,6 +36,7 @@
36
36
  "playwright:dataloader": "playwright test e2e/dataloader.spec.js --project=chromium --reporter line",
37
37
  "playwright:dataloader-url-sync": "playwright test e2e/dataloader-url-sync.spec.js --project=chromium --reporter line",
38
38
  "playwright:parallax": "playwright test e2e/parallax.spec.js --project=chromium --reporter line",
39
+ "playwright:looper": "playwright test e2e/looper.spec.js --project=chromium --reporter line",
39
40
  "vite": "vite",
40
41
  "vite:build": "vite build",
41
42
  "vite:preview": "vite preview"
@@ -77,9 +77,7 @@ function horizontalLoop(app, items, config) {
77
77
  let originalItemsWidth = 0 // Width of ONLY original items (for wrapping)
78
78
  let pixelsPerSecond = (config.speed || 1) * 100
79
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
80
+ let position = motionValue(0) // Source of truth for position (raw, unbounded)
83
81
  let originalItemCount = 0 // Track count of ORIGINAL items (before clones)
84
82
  let maxScrollPosition = 0 // For non-looping: max scroll where last item is at right edge
85
83
 
@@ -174,9 +172,9 @@ function horizontalLoop(app, items, config) {
174
172
  let count = 0
175
173
  let previousTotalWidth = totalWidth
176
174
 
177
- // Always create at least one set of clones - the wrapping logic depends on clones existing
175
+ // Always create at least TWO sets of clones - needed for starting at first clone position
178
176
  // Then continue until we have enough width for seamless looping
179
- while ((count === 0 || totalWidth < minRequiredWidth) && count < maxReplications) {
177
+ while ((count < 2 || totalWidth < minRequiredWidth) && count < maxReplications) {
180
178
  // Clone ONLY original items
181
179
  for (let i = 0; i < originalItemCount; i++) {
182
180
  const clone = items[i].cloneNode(true)
@@ -358,84 +356,48 @@ function horizontalLoop(app, items, config) {
358
356
 
359
357
  /**
360
358
  * Check item positions and wrap when needed
361
- * Container is animated DIRECTLY (not updated here!)
362
- * This function only reads position to determine wrapping
363
- * @param {number} pos - Current position value (can grow infinitely)
359
+ * Container uses RAW position - items wrap individually when far off-screen
360
+ * @param {number} rawPos - Current raw position value (unbounded)
364
361
  */
365
- function updateItemPositions(pos) {
366
- const containerElement = items[0].parentElement
367
-
368
- if (!shouldLoop) {
369
- // Non-looping: we'll handle this with direct animation
370
- return
371
- }
372
-
373
- // Calculate bounded position for checking item wrap points
374
- // Use same wrapping formula as frame.render to handle negative positions (reversed mode)
375
- const boundedPos = ((pos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
362
+ function updateItemPositions(rawPos) {
363
+ if (!shouldLoop) return
376
364
 
377
365
  // Initialize wrap offsets cache if needed
378
366
  if (itemWrapOffsets.length === 0) {
379
367
  itemWrapOffsets = new Array(items.length).fill(0)
380
368
  }
381
369
 
382
- // TICKER PATTERN: ONLY move ORIGINAL items to the back, NEVER touch clones!
383
- // This massively reduces DOM manipulation and style recalculation
384
- items.forEach((item, i) => {
385
- // Skip clones - they stay in natural flow! (use cached value for performance)
386
- if (isCloneCache[i]) {
387
- return
388
- }
370
+ // Items wrap by the full cycle distance (totalWidth) to maintain relative positions
371
+ // This keeps all items within viewing distance as position grows/shrinks
372
+ const cycleDistance = totalWidth
389
373
 
390
- // Calculate where this ORIGINAL item is on screen (relative to bounded container)
391
- const itemLeft = offsetLefts[i] - boundedPos
392
-
393
- // Original items only ever have -totalWidth, 0, or +totalWidth offset
394
- // This positions them AFTER all clones (not just after wrapping area)
395
- let newOffset = 0
396
-
397
- // Ticker boundary pattern: Check if item should be at the END or at the START
398
- // When container cycles (boundedPos wraps from ~originalItemsWidth to ~0),
399
- // items with large offsets get reset back to 0
400
-
401
- // Wrap distance includes the trailing gap for seamless cycling
402
- const wrapOffset = totalWidth + gap
403
-
404
- // Check if we're in the "reset zone" near wrap boundaries
405
- const nearForwardWrap = boundedPos > originalItemsWidth - gap
406
- const nearReverseWrap = boundedPos < gap
407
-
408
- // RESET: When in reset zone, reset items and SKIP wrap checks to avoid fighting
409
- if (nearForwardWrap && itemWrapOffsets[i] === wrapOffset) {
410
- // Container about to wrap (forward), reset items at END back to START
411
- newOffset = 0
412
- } else if (nearReverseWrap && itemWrapOffsets[i] === -wrapOffset) {
413
- // Container about to wrap (reverse), reset items at START back to END
414
- newOffset = 0
415
- } else if (nearForwardWrap || nearReverseWrap) {
416
- // In reset zone but item doesn't need reset → keep current offset
417
- newOffset = itemWrapOffsets[i]
418
- } else if (itemLeft < -(widths[i] + containerWidth * 0.5)) {
419
- // Item exited LEFT edge - only wrap during forward scroll
420
- // During reverse scroll (scrollDirection < 0), items off-screen left
421
- // will naturally scroll back into view - don't wrap them
422
- const isForwardScroll = scrollDirection >= 0
423
- newOffset = (isForwardScroll && boundedPos < originalItemsWidth / 2) ? wrapOffset : 0
424
- } else if (itemLeft > containerWidth + containerWidth * 0.5) {
425
- // Item exited RIGHT edge
426
- // This shouldn't happen much, but handle it
427
- newOffset = 0
428
- } else {
429
- // Keep current offset
430
- newOffset = itemWrapOffsets[i]
374
+ // Wrap threshold: when an item is more than half a cycle from view, wrap it
375
+ const wrapThreshold = cycleDistance / 2
376
+
377
+ for (let i = 0; i < items.length; i++) {
378
+ // Calculate this item's effective position (DOM position + wrap offset)
379
+ const effectivePos = offsetLefts[i] + itemWrapOffsets[i]
380
+
381
+ // Distance from current view position
382
+ // Positive = item is ahead (to the right), Negative = item is behind (to the left)
383
+ const distanceFromView = effectivePos - rawPos
384
+
385
+ let newOffset = itemWrapOffsets[i]
386
+
387
+ if (distanceFromView < -wrapThreshold) {
388
+ // Item is too far left (behind), wrap it forward (to the right)
389
+ newOffset = itemWrapOffsets[i] + cycleDistance
390
+ } else if (distanceFromView > wrapThreshold + containerWidth) {
391
+ // Item is too far right (ahead), wrap it backward (to the left)
392
+ newOffset = itemWrapOffsets[i] - cycleDistance
431
393
  }
432
394
 
433
- // ONLY update transform if the offset has changed!
395
+ // Only update DOM if offset changed
434
396
  if (newOffset !== itemWrapOffsets[i]) {
435
- item.style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
397
+ items[i].style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
436
398
  itemWrapOffsets[i] = newOffset
437
399
  }
438
- })
400
+ }
439
401
  }
440
402
 
441
403
  /**
@@ -443,32 +405,58 @@ function horizontalLoop(app, items, config) {
443
405
  * @param {boolean} deep - Whether to rebuild animation (on resize)
444
406
  */
445
407
  function refresh(deep = false) {
446
- // Save progress to preserve position
447
- const progress = animation ? animation.time / animation.duration : 0
448
- const currentPos = position.get()
449
-
450
408
  // Pause animation if running
451
409
  const wasPlaying = animation && animation.speed !== 0
452
410
  if (animation) {
453
411
  animation.pause()
454
412
  }
455
413
 
414
+ if (deep && shouldLoop) {
415
+ // DEEP REFRESH: Reset everything for new dimensions
416
+ // Clear all item wrap offsets and transforms
417
+ for (let i = 0; i < items.length; i++) {
418
+ items[i].style.transform = 'none'
419
+ if (itemWrapOffsets[i] !== undefined) {
420
+ itemWrapOffsets[i] = 0
421
+ }
422
+ }
423
+ itemWrapOffsets = []
424
+ }
425
+
456
426
  // Remeasure everything
457
427
  populateWidths()
458
428
 
459
429
  if (deep) {
460
430
  // Check if we need to replicate more items
461
- const containerWidth = container.offsetWidth
431
+ const currentContainerWidth = container.offsetWidth
462
432
  const currentTotalWidth = getTotalWidthOfItems()
463
433
 
464
434
  // Use same 2.5x buffer as replication logic
465
- if (shouldLoop && currentTotalWidth < containerWidth * 2.5) {
435
+ if (shouldLoop && currentTotalWidth < currentContainerWidth * 2.5) {
466
436
  replicateItemsIfNeeded()
437
+ // Re-cache clone status for any new items
438
+ isCloneCache = items.map((item, i) => i >= originalItemCount)
467
439
  populateWidths()
468
440
  }
469
441
 
470
442
  populateSnapTimes()
471
443
 
444
+ // Reset position to start at first clone (like initial state)
445
+ if (shouldLoop && !config.centerSlide) {
446
+ position.set(originalItemsWidth)
447
+ lastPositionForDirection = originalItemsWidth
448
+ items[0].parentElement.style.transform = `translateX(${-originalItemsWidth}px)`
449
+ } else if (shouldLoop && config.centerSlide) {
450
+ // For center mode, go to middle slide
451
+ const middleIndex = Math.floor(originalItemCount / 2)
452
+ const targetTime = times[middleIndex]
453
+ const initialPos = targetTime * pixelsPerSecond
454
+ position.set(initialPos)
455
+ lastPositionForDirection = initialPos
456
+ items[0].parentElement.style.transform = `translateX(${-initialPos}px)`
457
+ curIndex = middleIndex
458
+ }
459
+
472
460
  // Recreate animation with new measurements
473
461
  if (shouldLoop && config.crawl) {
474
462
  // Stop old animation
@@ -476,7 +464,7 @@ function horizontalLoop(app, items, config) {
476
464
  animation.stop()
477
465
  }
478
466
 
479
- // Use startLoopAnimation for bounded position (no repeat: Infinity!)
467
+ // Recreate loop animation with new measurements
480
468
  animation = startLoopAnimation()
481
469
 
482
470
  // Restore playback state
@@ -499,19 +487,20 @@ function horizontalLoop(app, items, config) {
499
487
  })
500
488
 
501
489
  if (wasPlaying) {
502
- animation.time = progress * animation.duration
503
490
  animation.play()
504
491
  } else {
505
492
  animation.pause()
506
493
  }
507
494
  }
495
+
496
+ // Update index display
497
+ updateIndexDisplay()
508
498
  } else {
509
499
  // Light refresh - just update measurements
510
500
  populateSnapTimes()
501
+ // Update positions based on current scroll
502
+ updateItemPositions(position.get())
511
503
  }
512
-
513
- // Update positions based on current scroll
514
- updateItemPositions(currentPos)
515
504
  }
516
505
 
517
506
  /**
@@ -541,7 +530,7 @@ function horizontalLoop(app, items, config) {
541
530
  : currentPos + originalItemsWidth
542
531
 
543
532
  // Animate the position motionValue
544
- // frame.render loop will apply bounded position to DOM
533
+ // frame.render loop will apply raw position to container and wrap items
545
534
  animation = animate(position, target, {
546
535
  duration,
547
536
  repeat: Infinity,
@@ -551,6 +540,21 @@ function horizontalLoop(app, items, config) {
551
540
  return animation
552
541
  }
553
542
 
543
+ /**
544
+ * Stop the frame.render loop and cleanup listeners
545
+ * Called from init() and destroy()
546
+ */
547
+ function stopRenderLoop() {
548
+ if (renderUnsubscribe) {
549
+ // Unsubscribe from motionValue listener
550
+ if (renderUnsubscribe.positionUnsubscribe) {
551
+ renderUnsubscribe.positionUnsubscribe()
552
+ }
553
+ cancelFrame(renderUnsubscribe)
554
+ renderUnsubscribe = null
555
+ }
556
+ }
557
+
554
558
  /**
555
559
  * Initialize the loop animation
556
560
  */
@@ -571,82 +575,45 @@ function horizontalLoop(app, items, config) {
571
575
  // Set initial container position
572
576
  const containerElement = items[0].parentElement
573
577
  containerElement.style.willChange = 'transform'
574
- containerElement.style.transform = 'translateX(0px)'
575
578
 
576
- // Set up RAF loop to check item positions for wrapping
577
- // Frame.render loop to apply bounded position to DOM
579
+ // For looping (non-center mode): start viewing first CLONE, not originals
580
+ // This positions originals OFF-SCREEN LEFT so backward scroll reveals them smoothly
581
+ if (shouldLoop && !config.centerSlide) {
582
+ position.set(originalItemsWidth)
583
+ lastPositionForDirection = originalItemsWidth
584
+ containerElement.style.transform = `translateX(${-originalItemsWidth}px)`
585
+ } else {
586
+ containerElement.style.transform = 'translateX(0px)'
587
+ }
588
+
589
+ // Set up RAF loop to update container position and wrap items
590
+ // Uses RAW position (no modulo) for container transform
578
591
  // This is Motion's optimized render loop - prevents layout thrashing
579
592
  function startRenderLoop() {
580
593
  if (renderUnsubscribe) return // Already running
581
594
 
582
595
  const containerElement = items[0].parentElement
583
596
 
584
- // Set up boundedPos motionValue to automatically sync with position
585
- // This calculates the bounded position (0 to originalItemsWidth)
597
+ // Track scroll direction for wrap logic
586
598
  const positionUnsubscribe = position.on('change', latest => {
587
- // Track scroll direction for wrap logic
588
599
  const delta = latest - lastPositionForDirection
589
600
  if (Math.abs(delta) > 1) {
590
601
  scrollDirection = delta > 0 ? 1 : -1
591
602
  }
592
603
  lastPositionForDirection = latest
593
-
594
- const bounded = ((latest % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
595
- boundedPos.set(bounded)
596
- })
597
-
598
- // Detect when boundedPos wraps (makes large jump) and reset all items
599
- // This prevents stuck items during fast drags in either direction
600
- const boundedPosUnsubscribe = boundedPos.on('change', latest => {
601
- const delta = Math.abs(latest - lastBoundedValue)
602
-
603
- // If boundedPos jumped by more than 40% of the width, it wrapped
604
- // Using 40% instead of 50% to catch edge cases
605
- const didWrap = delta > originalItemsWidth * 0.4
606
-
607
- if (didWrap) {
608
- // NOTE: We intentionally do NOT reset item transforms here anymore.
609
- // Resetting here caused flash because it happens between render frames.
610
- // The updateItemPositions() in frame.render handles wrapping correctly
611
- // when called with the new boundedPos.
612
-
613
- // Sync position to bounded value (only when not dragging/animating)
614
- if (!snapAnimation && !navAnimation && !isDragging) {
615
- position.set(latest)
616
- }
617
- }
618
-
619
- lastBoundedValue = latest
620
604
  })
621
605
 
622
606
  renderUnsubscribe = frame.render(() => {
623
- // Read bounded position from motionValue
624
- const currentBoundedPos = boundedPos.get()
625
-
626
- // Apply bounded transform to container
627
- containerElement.style.transform = `translateX(${-currentBoundedPos}px)`
607
+ // Use RAW position (no bounded) for container
608
+ const currentPos = position.get()
609
+ containerElement.style.transform = `translateX(${-currentPos}px)`
628
610
 
629
- // Wrap items based on bounded position
630
- updateItemPositions(currentBoundedPos)
611
+ // Wrap items based on raw position
612
+ updateItemPositions(currentPos)
631
613
  }, true) // true = keep alive
632
614
 
633
- // Store unsubscribe functions for cleanup
615
+ // Store unsubscribe function for cleanup
634
616
  renderUnsubscribe.positionUnsubscribe = positionUnsubscribe
635
- renderUnsubscribe.boundedPosUnsubscribe = boundedPosUnsubscribe
636
- }
637
-
638
- function stopRenderLoop() {
639
- if (renderUnsubscribe) {
640
- // Unsubscribe from motionValue listeners
641
- if (renderUnsubscribe.positionUnsubscribe) {
642
- renderUnsubscribe.positionUnsubscribe()
643
- }
644
- if (renderUnsubscribe.boundedPosUnsubscribe) {
645
- renderUnsubscribe.boundedPosUnsubscribe()
646
- }
647
- cancelFrame(renderUnsubscribe)
648
- renderUnsubscribe = null
649
- }
650
617
  }
651
618
 
652
619
  // Start the frame.render loop
@@ -734,7 +701,7 @@ function horizontalLoop(app, items, config) {
734
701
  // Update display in real-time as position changes
735
702
  let lastDisplayedIndex = -1
736
703
  const updateIndexOnChange = () => {
737
- // Find closest slide to current bounded position
704
+ // Find closest slide to current position
738
705
  const closest = closestIndex(false)
739
706
 
740
707
  // Only update DOM if index changed (avoid thrashing)
@@ -747,12 +714,8 @@ function horizontalLoop(app, items, config) {
747
714
  }
748
715
  }
749
716
 
750
- // For looping, use boundedPos; for non-looping, use position directly
751
- if (shouldLoop) {
752
- boundedPos.on('change', updateIndexOnChange)
753
- } else {
754
- position.on('change', updateIndexOnChange)
755
- }
717
+ // Update index display whenever position changes (closestIndex normalizes internally)
718
+ position.on('change', updateIndexOnChange)
756
719
  }
757
720
  }
758
721
 
@@ -922,9 +885,9 @@ function horizontalLoop(app, items, config) {
922
885
  const newPosition = startPosition + deltaX
923
886
 
924
887
  // Update position motionValue
925
- // frame.render loop will apply bounded transform to DOM
888
+ // frame.render loop applies raw position to container
926
889
  if (shouldLoop) {
927
- // For looping, allow unbounded position (frame.render will bound it)
890
+ // For looping, position grows freely - items wrap as groups
928
891
  position.set(newPosition)
929
892
  } else {
930
893
  // For non-looping, clamp position to maxScrollPosition (last item at right edge)
@@ -1513,13 +1476,18 @@ function horizontalLoop(app, items, config) {
1513
1476
  closestIndex(true)
1514
1477
  let nextIndex = curIndex + 1
1515
1478
 
1516
- // Non-looping: reset to start when reaching the end
1479
+ // Non-looping: clamp at boundaries
1517
1480
  if (!shouldLoop) {
1518
1481
  const currentPos = position.get()
1519
1482
  const atEnd = currentPos >= maxScrollPosition - 1
1520
1483
 
1521
1484
  if (nextIndex >= originalItemCount || atEnd) {
1522
- nextIndex = 0
1485
+ // Autoplay resets to start, user navigation clamps
1486
+ if (options.autoplay) {
1487
+ nextIndex = 0
1488
+ } else {
1489
+ return // Clamp - do nothing at boundary
1490
+ }
1523
1491
  }
1524
1492
  }
1525
1493
 
@@ -1535,9 +1503,14 @@ function horizontalLoop(app, items, config) {
1535
1503
  closestIndex(true)
1536
1504
  let prevIndex = curIndex - 1
1537
1505
 
1538
- // Non-looping: reset to end when at the start
1506
+ // Non-looping: clamp at boundaries
1539
1507
  if (!shouldLoop && prevIndex < 0) {
1540
- prevIndex = originalItemCount - 1
1508
+ // Autoplay resets to end, user navigation clamps
1509
+ if (options.autoplay) {
1510
+ prevIndex = originalItemCount - 1
1511
+ } else {
1512
+ return // Clamp - do nothing at boundary
1513
+ }
1541
1514
  }
1542
1515
 
1543
1516
  return toIndex(prevIndex, vars)