@brandocms/jupiter 5.0.0-beta.15 → 5.0.0-beta.16

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.15",
3
+ "version": "5.0.0-beta.16",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -147,6 +147,23 @@ function horizontalLoop(app, items, config) {
147
147
  // Cache track element reference (M3)
148
148
  const trackElement = items[0].parentElement
149
149
 
150
+ /**
151
+ * The element the pointer actually lands on.
152
+ *
153
+ * `trackElement` is what gets translated, so once the carousel has been
154
+ * dragged its box has left the viewport — and with it the `pointerdown`
155
+ * listener, the `touch-action` and the `grab` cursor. The visible row is then
156
+ * made of item boxes with dead gaps between them: pressing a slide still
157
+ * works (the event bubbles up through the track) but pressing between two
158
+ * slides hits nothing. It reads as "drag works until you have dragged".
159
+ *
160
+ * So when `container` is the track itself, the surface moves to the wrapper,
161
+ * which stays put and spans the same visible area. When `center` gave us a
162
+ * separate container it is already stationary and keeps the job.
163
+ */
164
+ const dragSurface =
165
+ (container === trackElement ? config.wrapper : container) || container
166
+
150
167
  /**
151
168
  * Measure total width of all items as currently laid out
152
169
  * @returns {number} Total width in pixels
@@ -877,6 +894,18 @@ function horizontalLoop(app, items, config) {
877
894
  // Only handle primary pointer (left click, first touch)
878
895
  if (e.button !== undefined && e.button !== 0) return
879
896
 
897
+ // The drag surface is the wrapper, and next/previous buttons live inside
898
+ // it — pressing one must not also arm a drag, or the tap that follows
899
+ // snaps the row on top of the navigation the button just did. Slides are
900
+ // deliberately not exempt: a slide may be a link and still be draggable.
901
+ // `[data-looper-no-drag]` opts anything else in the wrapper out.
902
+ if (
903
+ e.target.closest &&
904
+ e.target.closest('[data-panner-next], [data-panner-previous], [data-looper-no-drag]')
905
+ ) {
906
+ return
907
+ }
908
+
880
909
  isDragging = true
881
910
  indexSetByNav = false
882
911
  startX = e.clientX
@@ -976,7 +1005,7 @@ function horizontalLoop(app, items, config) {
976
1005
 
977
1006
  // Horizontal — commit to drag
978
1007
  hasDragged = true
979
- container.style.cursor = 'grabbing'
1008
+ dragSurface.style.cursor = 'grabbing'
980
1009
  trackElement.classList.add('looper-dragging')
981
1010
  }
982
1011
 
@@ -1022,12 +1051,12 @@ function horizontalLoop(app, items, config) {
1022
1051
 
1023
1052
  // Reset cursor and re-enable hover effects (only if we actually dragged)
1024
1053
  if (hasDragged) {
1025
- container.style.cursor = 'grab'
1054
+ dragSurface.style.cursor = 'grab'
1026
1055
  trackElement.classList.remove('looper-dragging')
1027
1056
 
1028
1057
  // Swallow the next click event so links don't navigate after a drag.
1029
1058
  // The browser fires click after pointerup — capture it before it reaches any <a>.
1030
- container.addEventListener('click', swallowClick, { capture: true, once: true })
1059
+ dragSurface.addEventListener('click', swallowClick, { capture: true, once: true })
1031
1060
  }
1032
1061
 
1033
1062
  // If this was a click (not a drag):
@@ -1351,17 +1380,17 @@ function horizontalLoop(app, items, config) {
1351
1380
  }
1352
1381
 
1353
1382
  // Set up touch-action CSS for proper touch handling
1354
- container.style.touchAction = 'pan-y' // Allow vertical scroll, prevent horizontal
1383
+ dragSurface.style.touchAction = 'pan-y' // Allow vertical scroll, prevent horizontal
1355
1384
 
1356
1385
  // Add pointer down listener
1357
- container.style.cursor = 'grab'
1358
- container.addEventListener('pointerdown', onPointerDown)
1386
+ dragSurface.style.cursor = 'grab'
1387
+ dragSurface.addEventListener('pointerdown', onPointerDown)
1359
1388
 
1360
1389
  // Store cleanup function
1361
1390
  dragState = {
1362
1391
  cleanup: () => {
1363
- container.removeEventListener('pointerdown', onPointerDown)
1364
- container.removeEventListener('click', swallowClick, { capture: true })
1392
+ dragSurface.removeEventListener('pointerdown', onPointerDown)
1393
+ dragSurface.removeEventListener('click', swallowClick, { capture: true })
1365
1394
  window.removeEventListener('pointermove', onPointerMove)
1366
1395
  window.removeEventListener('pointerup', onPointerUp)
1367
1396
  window.removeEventListener('pointercancel', onPointerUp)
@@ -1748,9 +1777,9 @@ function horizontalLoop(app, items, config) {
1748
1777
  trackElement.style.willChange = ''
1749
1778
  trackElement.style.transform = ''
1750
1779
 
1751
- // Clear inline styles on container
1752
- container.style.touchAction = ''
1753
- container.style.cursor = ''
1780
+ // Clear inline styles on the drag surface
1781
+ dragSurface.style.touchAction = ''
1782
+ dragSurface.style.cursor = ''
1754
1783
 
1755
1784
  // Clear item wrap transforms
1756
1785
  items.forEach(item => {
@@ -1,8 +1,16 @@
1
- import { animate } from 'motion'
1
+ import { animate, motionValue } from 'motion'
2
2
  import _defaultsDeep from 'lodash.defaultsdeep'
3
3
  import Dom from '../Dom'
4
4
  import { set, clearProps } from '../../utils/motion-helpers'
5
5
 
6
+ // Velocity sampling window for throws (ms)
7
+ const VELOCITY_WINDOW_MS = 150
8
+ // Speed the crawl ramps up from after a throw settles
9
+ const MIN_CRAWL_SPEED = 0.001
10
+ const SPEED_RAMP_DURATION = 1.2
11
+ // Crawl speed while the pointer rests on the marquee (slowDownOnHover)
12
+ const HOVER_SPEED = 0.5
13
+
6
14
  const DEFAULT_OPTIONS = {
7
15
  speed: 100,
8
16
  extraHeight: 0,
@@ -11,6 +19,18 @@ const DEFAULT_OPTIONS = {
11
19
  startProgress: 0,
12
20
  spacer: '<span>&nbsp;&mdash;&nbsp;</span>',
13
21
 
22
+ // Crawl direction. false = scroll content leftward (default), true = rightward.
23
+ // Speed is controlled independently via `speed` (and the live timeline.speed).
24
+ reversed: false,
25
+
26
+ // Drag + throw (inertia). Opt-in — existing marquees are unaffected.
27
+ draggable: false,
28
+ minimumMovement: 3, // Pixels for mouse - below this a press is a click, above is a drag
29
+ touchMinimumMovement: 10, // Higher threshold for touch (finger imprecision)
30
+ throwResistance: 325, // Inertia time constant (higher = longer glide)
31
+ throwPower: 0.8, // Deceleration curve (0-1)
32
+ throwVelocityMultiplier: 0.8, // Scale release velocity
33
+
14
34
  onReveal: marqueeEl => {
15
35
  animate(marqueeEl, { opacity: 1 }, { ease: 'linear' })
16
36
  }
@@ -25,24 +45,83 @@ export default class Marquee {
25
45
  this.elements.$marquee = Dom.find(this.elements.$el, '[data-marquee]')
26
46
  this.elements.$holder = Dom.find(this.elements.$el, '[data-marquee-holder]')
27
47
  this.elements.$item = Dom.find(this.elements.$el, '[data-marquee-item]')
28
- this.timeline = null
48
+
49
+ // Position model: a single motionValue is the source of truth. The crawl
50
+ // animation, drag and inertia all write to it; a change listener applies a
51
+ // seamless (modulo holderWidth) transform to the marquee element.
52
+ this.position = motionValue(0)
53
+ this.holderWidth = 0
54
+ this.duration = 0
55
+
56
+ this.timeline = null // crawl animation (kept named `timeline` for back-compat)
57
+ this.speedAnimation = null
58
+ this.inertiaAnimation = null
29
59
  this.observer = null
60
+ this.playing = false
61
+
62
+ // Tracks whether the pointer is over the marquee, so a crawl recreated
63
+ // after a drag or throw resumes at the hover speed rather than full speed.
64
+ this.hovering = false
65
+
66
+ // Bound handlers (stored so they can be removed in destroy)
67
+ this._onResize = this.updateMarquee.bind(this)
68
+ this._onReveal = this.revealMarquee.bind(this)
69
+ this._onMouseEnter = this.slowDown.bind(this)
70
+ this._onMouseLeave = this.speedUp.bind(this)
71
+ this._positionUnsubscribe = null
72
+ this._dragCleanup = null
30
73
 
31
74
  this.initialize()
32
75
  }
33
76
 
34
77
  initialize() {
35
- set(this.elements.$marquee, { opacity: 0 })
36
- window.addEventListener('APPLICATION:RESIZE', this.updateMarquee.bind(this))
37
- window.addEventListener('APPLICATION:REVEALED', this.revealMarquee.bind(this))
78
+ set(this.elements.$marquee, { opacity: 0, willChange: 'transform' })
79
+
80
+ // Apply the position to the DOM whenever it changes (crawl, drag or inertia)
81
+ this._positionUnsubscribe = this.position.on('change', latest => {
82
+ this.render(latest)
83
+ })
84
+
85
+ window.addEventListener('APPLICATION:RESIZE', this._onResize)
86
+ window.addEventListener('APPLICATION:REVEALED', this._onReveal)
38
87
  this.updateMarquee()
39
88
  this.setupObserver()
89
+
40
90
  if (this.opts.slowDownOnHover) {
41
- this.elements.$el.addEventListener('mouseenter', this.slowDown.bind(this))
42
- this.elements.$el.addEventListener('mouseleave', this.speedUp.bind(this))
91
+ this.elements.$el.addEventListener('mouseenter', this._onMouseEnter)
92
+ this.elements.$el.addEventListener('mouseleave', this._onMouseLeave)
93
+ }
94
+
95
+ if (this.opts.draggable) {
96
+ this.setupDrag()
43
97
  }
44
98
  }
45
99
 
100
+ /**
101
+ * Apply the current raw position to the marquee element.
102
+ * Holders are laid out side by side at i * holderWidth, so translating by
103
+ * the position modulo holderWidth produces a seamless infinite scroll.
104
+ */
105
+ render(rawPos) {
106
+ if (!this.holderWidth) return
107
+ const m = ((rawPos % this.holderWidth) + this.holderWidth) % this.holderWidth
108
+ this.elements.$marquee.style.transform = `translateX(${-m}px) translateZ(0)`
109
+ }
110
+
111
+ /**
112
+ * Detect whether the marquee element is horizontally mirrored (scaleX(-1)),
113
+ * as used for "reverse" rows. Read from the live computed transform so it
114
+ * works no matter how the flip is applied (and survives responsive changes).
115
+ */
116
+ isAxisFlipped() {
117
+ const t = getComputedStyle(this.elements.$el).transform
118
+ if (!t || t === 'none') return false
119
+ // matrix(a, b, c, d, e, f) / matrix3d(a, ...) — a < 0 means a horizontal flip
120
+ const inner = t.slice(t.indexOf('(') + 1)
121
+ const a = parseFloat(inner)
122
+ return Number.isFinite(a) && a < 0
123
+ }
124
+
46
125
  revealMarquee(e) {
47
126
  this.updateMarquee()
48
127
  this.opts.onReveal(this.elements.$marquee)
@@ -61,14 +140,10 @@ export default class Marquee {
61
140
  this.fillText()
62
141
  this.setHeight()
63
142
 
64
- const holderWidth = this.elements.$holder.offsetWidth
143
+ this.holderWidth = this.elements.$holder.offsetWidth
65
144
  const $allHolders = Dom.all(this.elements.$el, '[data-marquee-holder]')
66
- const marqueeWidth = holderWidth * $allHolders.length
67
- // Cap duration at 40s to prevent precision issues at slow speeds
68
- this.duration = Math.min(
69
- (holderWidth + marqueeWidth) / this.opts.speed,
70
- 40
71
- )
145
+ const marqueeWidth = this.holderWidth * $allHolders.length
146
+ this.duration = this.holderWidth / this.opts.speed
72
147
 
73
148
  set(this.elements.$marquee, { width: marqueeWidth })
74
149
  this.initializeTween()
@@ -84,16 +159,23 @@ export default class Marquee {
84
159
  }
85
160
 
86
161
  killTweens() {
87
- if (this.timeline) {
88
- this.timeline.stop()
89
- this.timeline = null
90
- }
91
162
  if (this.speedAnimation) {
92
163
  this.speedAnimation.stop()
93
164
  this.speedAnimation = null
94
165
  }
166
+ if (this.inertiaAnimation) {
167
+ this.inertiaAnimation.stop()
168
+ this.inertiaAnimation = null
169
+ }
170
+ if (this.timeline) {
171
+ this.timeline.stop()
172
+ this.timeline = null
173
+ }
95
174
  }
96
175
 
176
+ /**
177
+ * Lay out holders and create the (paused) crawl animation.
178
+ */
97
179
  initializeTween() {
98
180
  const $allHolders = Dom.all(this.elements.$el, '[data-marquee-holder]')
99
181
 
@@ -106,50 +188,85 @@ export default class Marquee {
106
188
  })
107
189
  })
108
190
 
109
- this.timeline = animate(
110
- $allHolders,
111
- { transform: ['translateX(0) translateZ(0)', 'translateX(-100%) translateZ(0)'] },
112
- { duration: this.duration, ease: 'linear', repeat: Infinity }
113
- )
191
+ // Set initial progress before creating the crawl so it starts from there.
192
+ // On resize rebuilds the existing position is preserved (modulo render
193
+ // keeps it seamless regardless of the new holderWidth).
194
+ if (!this._initialized && this.opts.startProgress > 0) {
195
+ this.position.set(this.opts.startProgress * this.holderWidth)
196
+ }
197
+ this._initialized = true
198
+ this.render(this.position.get())
199
+
200
+ this.timeline = this.createCrawl()
114
201
  this.timeline.pause()
202
+ }
115
203
 
116
- // Set initial progress if specified
117
- if (this.opts.startProgress > 0) {
118
- this.timeline.currentTime = this.opts.startProgress * this.duration
119
- }
204
+ /**
205
+ * Create an infinite crawl animation from the current position.
206
+ * Animating exactly one holderWidth and relying on the modulo render keeps
207
+ * the loop reset (repeat) invisible. Always recreated from the live position
208
+ * so it stays correct after drags and throws.
209
+ */
210
+ createCrawl() {
211
+ const from = this.position.get()
212
+ // Direction is baked into the target; speed stays positive so timeline.speed
213
+ // (hover slow-down, pause ramps, etc.) keeps working regardless of direction.
214
+ const target = this.opts.reversed ? from - this.holderWidth : from + this.holderWidth
215
+ return animate(this.position, target, {
216
+ duration: this.duration,
217
+ ease: 'linear',
218
+ repeat: Infinity
219
+ })
220
+ }
120
221
 
121
- window.timeline = this.timeline
122
- window.marquee = this
222
+ /**
223
+ * The speed the crawl should settle at. Half speed while the pointer rests
224
+ * on the marquee, so a crawl recreated after a drag or throw doesn't lose
225
+ * the hover slow-down.
226
+ * @returns {number}
227
+ */
228
+ targetSpeed() {
229
+ return this.opts.slowDownOnHover && this.hovering ? HOVER_SPEED : 1
123
230
  }
124
231
 
125
232
  play(rampUp = false) {
126
233
  this.playing = true
127
234
  if (this.speedAnimation) {
128
235
  this.speedAnimation.stop()
236
+ this.speedAnimation = null
129
237
  }
130
238
 
239
+ if (!this.holderWidth) return
240
+
241
+ // Recreate the crawl from the live position so it is always seamless,
242
+ // regardless of where a drag/throw left the marquee.
243
+ if (this.timeline) this.timeline.stop()
244
+ this.timeline = this.createCrawl()
245
+
131
246
  if (rampUp) {
132
247
  this.timeline.play()
133
- const state = { speed: this.timeline.speed || 0 }
248
+ const state = { speed: MIN_CRAWL_SPEED }
249
+ this.timeline.speed = MIN_CRAWL_SPEED
134
250
  this.speedAnimation = animate(
135
251
  state,
136
- { speed: 1 },
252
+ { speed: this.targetSpeed() },
137
253
  {
138
254
  duration: 0.8,
139
255
  ease: 'easeIn',
140
256
  onUpdate: () => {
141
- this.timeline.speed = state.speed
257
+ if (this.timeline) this.timeline.speed = state.speed
142
258
  }
143
259
  }
144
260
  )
145
261
  } else {
146
- this.timeline.speed = 1
262
+ this.timeline.speed = this.targetSpeed()
147
263
  this.timeline.play()
148
264
  }
149
265
  }
150
266
 
151
267
  pause() {
152
268
  this.playing = false
269
+ if (!this.timeline) return
153
270
  const state = { speed: this.timeline.speed || 1 }
154
271
  this.speedAnimation = animate(
155
272
  state,
@@ -157,41 +274,48 @@ export default class Marquee {
157
274
  {
158
275
  duration: 0.8,
159
276
  onUpdate: () => {
160
- this.timeline.speed = state.speed
277
+ if (this.timeline) this.timeline.speed = state.speed
161
278
  }
162
279
  }
163
280
  )
164
- this.speedAnimation.finished.then(() => {
165
- // Only pause if we're still in paused state (haven't called play() in the meantime)
166
- if (!this.playing) {
167
- this.timeline.pause()
168
- }
169
- })
281
+ this.speedAnimation.finished
282
+ .then(() => {
283
+ if (!this.playing && this.timeline) {
284
+ this.timeline.pause()
285
+ }
286
+ })
287
+ // The ramp is routinely stopped by slowDown/play/resumeCrawl — that
288
+ // rejects `finished`, and an unhandled rejection is not our problem.
289
+ .catch(() => {})
170
290
  }
171
291
 
172
292
  slowDown() {
293
+ this.hovering = true
173
294
  if (this.speedAnimation) {
174
295
  this.speedAnimation.stop()
175
296
  }
297
+ if (!this.timeline) return
176
298
  const state = { speed: this.timeline.speed || 1 }
177
299
  this.speedAnimation = animate(
178
300
  state,
179
- { speed: 0.5 },
301
+ { speed: HOVER_SPEED },
180
302
  {
181
303
  duration: 0.3,
182
304
  ease: [0.4, 0, 0.2, 1], // ease-out
183
305
  onUpdate: () => {
184
- this.timeline.speed = state.speed
306
+ if (this.timeline) this.timeline.speed = state.speed
185
307
  }
186
308
  }
187
309
  )
188
310
  }
189
311
 
190
312
  speedUp() {
313
+ this.hovering = false
191
314
  if (this.speedAnimation) {
192
315
  this.speedAnimation.stop()
193
316
  }
194
- const state = { speed: this.timeline.speed || 0.5 }
317
+ if (!this.timeline) return
318
+ const state = { speed: this.timeline.speed || HOVER_SPEED }
195
319
  this.speedAnimation = animate(
196
320
  state,
197
321
  { speed: 1 },
@@ -199,7 +323,241 @@ export default class Marquee {
199
323
  duration: 0.3,
200
324
  ease: [0.4, 0, 0.2, 1], // ease-out
201
325
  onUpdate: () => {
202
- this.timeline.speed = state.speed
326
+ if (this.timeline) this.timeline.speed = state.speed
327
+ }
328
+ }
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Setup pointer-based drag + throw interaction.
334
+ * Mirrors the Looper module's physics (velocity sampling + Motion inertia).
335
+ */
336
+ setupDrag() {
337
+ const $el = this.elements.$el
338
+
339
+ let startX = 0
340
+ let startY = 0
341
+ let startPosition = 0
342
+ let velocityTracker = []
343
+ let isDragging = false
344
+ let hasDragged = false
345
+ let axisDecided = false
346
+ let activeMinimumMovement = this.opts.minimumMovement
347
+ let dragDir = 1 // -1 when the marquee is horizontally mirrored (scaleX(-1))
348
+
349
+ const swallowClick = e => {
350
+ e.preventDefault()
351
+ e.stopPropagation()
352
+ }
353
+
354
+ const getVelocity = () => {
355
+ if (velocityTracker.length < 2) return 0
356
+ const recent = velocityTracker.slice(-6)
357
+ let totalVelocity = 0
358
+ let totalWeight = 0
359
+ for (let i = 1; i < recent.length; i++) {
360
+ const prev = recent[i - 1]
361
+ const curr = recent[i]
362
+ const deltaX = curr.x - prev.x
363
+ const deltaTime = curr.time - prev.time
364
+ if (deltaTime > 0) {
365
+ const weight = i / recent.length
366
+ const velocity = (deltaX / deltaTime) * 1000 // px/second
367
+ totalVelocity += velocity * weight
368
+ totalWeight += weight
369
+ }
370
+ }
371
+ return totalWeight > 0 ? totalVelocity / totalWeight : 0
372
+ }
373
+
374
+ const onPointerDown = e => {
375
+ if (e.button !== undefined && e.button !== 0) return
376
+
377
+ isDragging = true
378
+ startX = e.clientX
379
+ startY = e.clientY
380
+ startPosition = this.position.get()
381
+ velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
382
+ hasDragged = false
383
+ axisDecided = false
384
+ activeMinimumMovement =
385
+ e.pointerType === 'touch' ? this.opts.touchMinimumMovement : this.opts.minimumMovement
386
+
387
+ // A reversed row is mirrored with scaleX(-1), so screen-space pointer
388
+ // movement maps to the opposite direction in the marquee's own space.
389
+ dragDir = this.isAxisFlipped() ? -1 : 1
390
+
391
+ // Stop crawl + any ongoing throw / speed ramp
392
+ if (this.speedAnimation) {
393
+ this.speedAnimation.stop()
394
+ this.speedAnimation = null
395
+ }
396
+ if (this.inertiaAnimation) {
397
+ this.inertiaAnimation.stop()
398
+ this.inertiaAnimation = null
399
+ }
400
+ if (this.timeline) {
401
+ this.timeline.stop()
402
+ this.timeline = null
403
+ }
404
+
405
+ if (e.pointerType === 'mouse') {
406
+ e.preventDefault()
407
+ }
408
+
409
+ window.addEventListener('pointermove', onPointerMove, { passive: false })
410
+ window.addEventListener('pointerup', onPointerUp)
411
+ window.addEventListener('pointercancel', onPointerUp)
412
+ }
413
+
414
+ const onPointerMove = e => {
415
+ if (!isDragging) return
416
+
417
+ const currentX = e.clientX
418
+ const currentTime = e.timeStamp
419
+
420
+ if (!axisDecided) {
421
+ const dX = Math.abs(currentX - startX)
422
+ const dY = Math.abs(e.clientY - startY)
423
+ if (Math.max(dX, dY) < activeMinimumMovement) return
424
+ axisDecided = true
425
+
426
+ // Clearly vertical → abort, let the page scroll
427
+ if (dY > dX * 1.2) {
428
+ isDragging = false
429
+ window.removeEventListener('pointermove', onPointerMove)
430
+ window.removeEventListener('pointerup', onPointerUp)
431
+ window.removeEventListener('pointercancel', onPointerUp)
432
+ if (this.playing) this.resumeCrawl()
433
+ return
434
+ }
435
+
436
+ hasDragged = true
437
+ $el.style.cursor = 'grabbing'
438
+ }
439
+
440
+ e.preventDefault()
441
+
442
+ velocityTracker.push({ x: currentX, time: currentTime })
443
+ while (
444
+ velocityTracker.length > 0 &&
445
+ currentTime - velocityTracker[0].time > VELOCITY_WINDOW_MS
446
+ ) {
447
+ velocityTracker.shift()
448
+ }
449
+
450
+ // Drag left (cursor decreases) scrolls content left (position increases).
451
+ // dragDir flips this for mirrored (reversed) rows.
452
+ const deltaX = (startX - currentX) * dragDir
453
+ this.position.set(startPosition + deltaX)
454
+ }
455
+
456
+ const onPointerUp = e => {
457
+ if (!isDragging) return
458
+ isDragging = false
459
+
460
+ window.removeEventListener('pointermove', onPointerMove)
461
+ window.removeEventListener('pointerup', onPointerUp)
462
+ window.removeEventListener('pointercancel', onPointerUp)
463
+
464
+ if (hasDragged) {
465
+ $el.style.cursor = 'grab'
466
+ // Swallow the click the browser fires after pointerup so item links
467
+ // don't navigate at the end of a drag.
468
+ $el.addEventListener('click', swallowClick, { capture: true, once: true })
469
+ } else {
470
+ // A tap that didn't move — just resume crawling.
471
+ if (this.playing) this.resumeCrawl()
472
+ return
473
+ }
474
+
475
+ let velocity = getVelocity()
476
+ const lastTrackedX =
477
+ velocityTracker.length > 0 ? velocityTracker[velocityTracker.length - 1].x : e.clientX
478
+ const overallDelta = lastTrackedX - startX
479
+
480
+ // Velocity direction must match the overall drag direction
481
+ if (overallDelta > 0 && velocity < 0) velocity = 0
482
+ if (overallDelta < 0 && velocity > 0) velocity = 0
483
+
484
+ if (Math.abs(velocity) > 1) {
485
+ // Flip the throw velocity into the marquee's own space for mirrored rows
486
+ this.startInertia(velocity * dragDir)
487
+ } else if (this.playing) {
488
+ this.resumeCrawl()
489
+ }
490
+ }
491
+
492
+ $el.style.touchAction = 'pan-y'
493
+ $el.style.cursor = 'grab'
494
+ $el.addEventListener('pointerdown', onPointerDown)
495
+
496
+ this._dragCleanup = () => {
497
+ $el.removeEventListener('pointerdown', onPointerDown)
498
+ $el.removeEventListener('click', swallowClick, { capture: true })
499
+ window.removeEventListener('pointermove', onPointerMove)
500
+ window.removeEventListener('pointerup', onPointerUp)
501
+ window.removeEventListener('pointercancel', onPointerUp)
502
+ $el.style.touchAction = ''
503
+ $el.style.cursor = ''
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Throw the marquee with momentum after a drag release.
509
+ * @param {number} velocity - Cursor velocity in px/second
510
+ */
511
+ startInertia(velocity) {
512
+ const currentPos = this.position.get()
513
+ // Cursor and content velocity are opposite: drag left → position increases
514
+ const motionVelocity = -velocity * this.opts.throwVelocityMultiplier
515
+ const estimatedDistance = this.opts.throwPower * motionVelocity
516
+ const targetPos = currentPos + estimatedDistance
517
+
518
+ this.inertiaAnimation = animate(this.position, targetPos, {
519
+ type: 'inertia',
520
+ velocity: motionVelocity,
521
+ power: this.opts.throwPower,
522
+ timeConstant: this.opts.throwResistance,
523
+ restSpeed: 10,
524
+ restDelta: 0.5
525
+ })
526
+
527
+ this.inertiaAnimation.finished
528
+ .then(() => {
529
+ this.inertiaAnimation = null
530
+ if (this.playing) this.resumeCrawl()
531
+ })
532
+ .catch(() => {
533
+ this.inertiaAnimation = null
534
+ })
535
+ }
536
+
537
+ /**
538
+ * Resume the auto-crawl from the current position, ramping back up to full
539
+ * speed so it eases out of a throw rather than snapping.
540
+ */
541
+ resumeCrawl() {
542
+ if (this.speedAnimation) {
543
+ this.speedAnimation.stop()
544
+ this.speedAnimation = null
545
+ }
546
+ if (this.timeline) this.timeline.stop()
547
+
548
+ this.timeline = this.createCrawl()
549
+ this.timeline.play()
550
+
551
+ const state = { speed: MIN_CRAWL_SPEED }
552
+ this.timeline.speed = MIN_CRAWL_SPEED
553
+ this.speedAnimation = animate(
554
+ state,
555
+ { speed: this.targetSpeed() },
556
+ {
557
+ duration: SPEED_RAMP_DURATION,
558
+ ease: 'easeIn',
559
+ onUpdate: () => {
560
+ if (this.timeline) this.timeline.speed = state.speed
203
561
  }
204
562
  }
205
563
  )
@@ -255,7 +613,17 @@ export default class Marquee {
255
613
  }
256
614
  }
257
615
 
258
- this.elements.$marquee.appendChild(this.elements.$holder.cloneNode(true))
616
+ // Duplicate holders until they cover the viewport plus a full holder
617
+ // width — guarantees seamless coverage across the whole modulo cycle.
618
+ const holderWidth = this.elements.$holder.offsetWidth
619
+ const required = this.app.size.width + holderWidth
620
+ let totalWidth = holderWidth
621
+ let guard = 0
622
+ while (totalWidth < required && guard < 10) {
623
+ this.elements.$marquee.appendChild(this.elements.$holder.cloneNode(true))
624
+ totalWidth += holderWidth
625
+ guard += 1
626
+ }
259
627
  } else {
260
628
  console.error(
261
629
  'no textWidth! probably image? Set width to elements inside holder',
@@ -271,4 +639,29 @@ export default class Marquee {
271
639
  set(this.elements.$el, { height })
272
640
  set(this.elements.$marquee, { height })
273
641
  }
642
+
643
+ destroy() {
644
+ this.killTweens()
645
+
646
+ if (this.observer) {
647
+ this.observer.disconnect()
648
+ this.observer = null
649
+ }
650
+ if (this._positionUnsubscribe) {
651
+ this._positionUnsubscribe()
652
+ this._positionUnsubscribe = null
653
+ }
654
+ if (this._dragCleanup) {
655
+ this._dragCleanup()
656
+ this._dragCleanup = null
657
+ }
658
+
659
+ if (this.opts.slowDownOnHover) {
660
+ this.elements.$el.removeEventListener('mouseenter', this._onMouseEnter)
661
+ this.elements.$el.removeEventListener('mouseleave', this._onMouseLeave)
662
+ }
663
+
664
+ window.removeEventListener('APPLICATION:RESIZE', this._onResize)
665
+ window.removeEventListener('APPLICATION:REVEALED', this._onReveal)
666
+ }
274
667
  }
@@ -60,9 +60,20 @@ export function set(target, values) {
60
60
  element.style.transform = transformProps.join(' ')
61
61
  }
62
62
 
63
+ // Properties that are unitless (don't need 'px')
64
+ const unitless = new Set([
65
+ 'opacity', 'zIndex', 'fontWeight', 'lineHeight', 'orphans',
66
+ 'widows', 'order', 'flexGrow', 'flexShrink', 'columnCount',
67
+ 'fillOpacity', 'strokeOpacity', 'tabSize',
68
+ ])
69
+
63
70
  // Apply other styles
64
71
  Object.entries(styleProps).forEach(([key, value]) => {
65
- element.style[key] = value
72
+ if (typeof value === 'number' && !unitless.has(key)) {
73
+ element.style[key] = value + 'px'
74
+ } else {
75
+ element.style[key] = value
76
+ }
66
77
  })
67
78
  })
68
79
  }
@@ -46,6 +46,12 @@ export default class DoubleHeader {
46
46
  pin(): void;
47
47
  notSmall(): void;
48
48
  small(): void;
49
+ /**
50
+ * Update the --header-height CSS variable on :root.
51
+ * Uses el height when pinned (el is the main header, auxEl is secondary).
52
+ * Set to 0px when unpinned.
53
+ */
54
+ _updateHeaderHeight(): void;
49
55
  shouldUnpin(toleranceExceeded: any): any;
50
56
  shouldPin(toleranceExceeded: any): any;
51
57
  isOutOfBounds(): boolean;
@@ -108,6 +108,11 @@ export default class FixedHeader {
108
108
  pin(): void;
109
109
  notSmall(): void;
110
110
  small(): void;
111
+ /**
112
+ * Update the --header-height CSS variable on :root.
113
+ * Set to the header's current height when pinned, 0px when unpinned.
114
+ */
115
+ _updateHeaderHeight(): void;
111
116
  notAltBg(): void;
112
117
  altBg(): void;
113
118
  shouldUnpin(toleranceExceeded: any): any;
@@ -3,23 +3,79 @@ export default class Marquee {
3
3
  opts: any;
4
4
  app: any;
5
5
  elements: {};
6
+ position: any;
7
+ holderWidth: number;
8
+ duration: number;
6
9
  timeline: any;
10
+ speedAnimation: any;
11
+ inertiaAnimation: any;
7
12
  observer: IntersectionObserver;
13
+ playing: boolean;
14
+ hovering: boolean;
15
+ _onResize: any;
16
+ _onReveal: any;
17
+ _onMouseEnter: any;
18
+ _onMouseLeave: any;
19
+ _positionUnsubscribe: any;
20
+ _dragCleanup: () => void;
8
21
  initialize(): void;
22
+ /**
23
+ * Apply the current raw position to the marquee element.
24
+ * Holders are laid out side by side at i * holderWidth, so translating by
25
+ * the position modulo holderWidth produces a seamless infinite scroll.
26
+ */
27
+ render(rawPos: any): void;
28
+ /**
29
+ * Detect whether the marquee element is horizontally mirrored (scaleX(-1)),
30
+ * as used for "reverse" rows. Read from the live computed transform so it
31
+ * works no matter how the flip is applied (and survives responsive changes).
32
+ */
33
+ isAxisFlipped(): boolean;
9
34
  revealMarquee(e: any): void;
10
35
  updateMarquee(e: any): void;
11
- duration: number;
12
36
  clearHolders(): void;
13
37
  killTweens(): void;
14
- speedAnimation: any;
38
+ /**
39
+ * Lay out holders and create the (paused) crawl animation.
40
+ */
15
41
  initializeTween(): void;
42
+ _initialized: boolean;
43
+ /**
44
+ * Create an infinite crawl animation from the current position.
45
+ * Animating exactly one holderWidth and relying on the modulo render keeps
46
+ * the loop reset (repeat) invisible. Always recreated from the live position
47
+ * so it stays correct after drags and throws.
48
+ */
49
+ createCrawl(): any;
50
+ /**
51
+ * The speed the crawl should settle at. Half speed while the pointer rests
52
+ * on the marquee, so a crawl recreated after a drag or throw doesn't lose
53
+ * the hover slow-down.
54
+ * @returns {number}
55
+ */
56
+ targetSpeed(): number;
16
57
  play(rampUp?: boolean): void;
17
- playing: boolean;
18
58
  pause(): void;
19
59
  slowDown(): void;
20
60
  speedUp(): void;
61
+ /**
62
+ * Setup pointer-based drag + throw interaction.
63
+ * Mirrors the Looper module's physics (velocity sampling + Motion inertia).
64
+ */
65
+ setupDrag(): void;
66
+ /**
67
+ * Throw the marquee with momentum after a drag release.
68
+ * @param {number} velocity - Cursor velocity in px/second
69
+ */
70
+ startInertia(velocity: number): void;
71
+ /**
72
+ * Resume the auto-crawl from the current position, ramping back up to full
73
+ * speed so it eases out of a throw rather than snapping.
74
+ */
75
+ resumeCrawl(): void;
21
76
  setupObserver(): void;
22
77
  fillText(): void;
23
78
  measuredHeight: any;
24
79
  setHeight(): void;
80
+ destroy(): void;
25
81
  }
@@ -373,7 +373,7 @@ export type MoonwalkOptions = {
373
373
  */
374
374
  on?: string | null;
375
375
  /**
376
- * - Delay before starting animations
376
+ * - Delay in ms before starting animations
377
377
  */
378
378
  initialDelay?: number;
379
379
  /**
@@ -57,6 +57,11 @@ export default class StickyHeader {
57
57
  pin(): void;
58
58
  notSmall(): void;
59
59
  small(): void;
60
+ /**
61
+ * Update the --header-height CSS variable on :root.
62
+ * Set to the header's current height when pinned, 0px when unpinned.
63
+ */
64
+ _updateHeaderHeight(): void;
60
65
  notAltBg(): void;
61
66
  altBg(): void;
62
67
  shouldUnpin(toleranceExceeded: any): any;