@haventmet/carousel 1.0.0
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/.mcp.json +8 -0
- package/CLAUDE.md +177 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/biome.json +174 -0
- package/package.json +33 -0
- package/packages/alpine/index.html +50 -0
- package/packages/alpine/package.json +70 -0
- package/packages/alpine/src/index.ts +46 -0
- package/packages/alpine/style.css +76 -0
- package/packages/alpine/tsconfig.json +28 -0
- package/packages/alpine/vite.config.ts +28 -0
- package/packages/core/README.md +69 -0
- package/packages/core/package.json +45 -0
- package/packages/core/src/carousel.ts +842 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/style.css +46 -0
- package/packages/core/vite.config.js +14 -0
- package/packages/react/.vite/deps/_metadata.json +25 -0
- package/packages/react/.vite/deps/chunk-7CQ73C3Q.js +1908 -0
- package/packages/react/.vite/deps/chunk-7CQ73C3Q.js.map +7 -0
- package/packages/react/.vite/deps/package.json +3 -0
- package/packages/react/.vite/deps/react-dom_client.js +21712 -0
- package/packages/react/.vite/deps/react-dom_client.js.map +7 -0
- package/packages/react/.vite/deps/react.js +4 -0
- package/packages/react/.vite/deps/react.js.map +7 -0
- package/packages/react/README.md +75 -0
- package/packages/react/index.html +12 -0
- package/packages/react/package.json +74 -0
- package/packages/react/src/App.tsx +38 -0
- package/packages/react/src/BlossomCarousel.tsx +77 -0
- package/packages/react/src/index.ts +2 -0
- package/packages/react/src/main.jsx +6 -0
- package/packages/react/src/style.css +50 -0
- package/packages/react/tsconfig.json +17 -0
- package/packages/react/vite.config.js +27 -0
- package/packages/svelte/README.md +58 -0
- package/packages/svelte/index.html +12 -0
- package/packages/svelte/package.json +71 -0
- package/packages/svelte/src/App.svelte +12 -0
- package/packages/svelte/src/BlossomCarousel.svelte +39 -0
- package/packages/svelte/src/BlossomCarousel.svelte.d.ts +8 -0
- package/packages/svelte/src/index.ts +2 -0
- package/packages/svelte/src/main.js +10 -0
- package/packages/svelte/src/style.css +52 -0
- package/packages/svelte/tsconfig.json +16 -0
- package/packages/svelte/vite.config.js +45 -0
- package/packages/vue/README.md +70 -0
- package/packages/vue/index.html +12 -0
- package/packages/vue/package.json +69 -0
- package/packages/vue/src/App.vue +82 -0
- package/packages/vue/src/BlossomCarousel.vue +52 -0
- package/packages/vue/src/index.ts +2 -0
- package/packages/vue/src/main.js +5 -0
- package/packages/vue/src/style.css +18 -0
- package/packages/vue/vite.config.js +25 -0
- package/packages/web/README.md +44 -0
- package/packages/web/index.html +29 -0
- package/packages/web/package.json +65 -0
- package/packages/web/src/index.ts +27 -0
- package/packages/web/src/style.css +53 -0
- package/packages/web/style.css +54 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +23 -0
- package/pnpm-workspace.yaml +8 -0
- package/public/cover.jpg +0 -0
- package/turbo.json +12 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
interface Point {
|
|
2
|
+
x: number
|
|
3
|
+
y: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface HasOverflow {
|
|
7
|
+
x: boolean
|
|
8
|
+
y: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CarouselOptions {
|
|
12
|
+
repeat?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CurrentIndex {
|
|
16
|
+
value: number
|
|
17
|
+
onChange: ((index: number) => void) | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Blossom = (scroller: HTMLElement, options: CarouselOptions) => {
|
|
21
|
+
let snap = <boolean>true
|
|
22
|
+
const pointerStart: Point = { x: 0, y: 0 }
|
|
23
|
+
const target: Point = { x: 0, y: 0 }
|
|
24
|
+
const velocity: Point = { x: 0, y: 0 }
|
|
25
|
+
const distanceMovedSincePointerDown: Point = new Proxy(
|
|
26
|
+
{ x: 0, y: 0 },
|
|
27
|
+
{
|
|
28
|
+
set(target, prop: keyof Point, value) {
|
|
29
|
+
if (!nativeScroll) {
|
|
30
|
+
const old = target[prop]
|
|
31
|
+
if (old === value) return true
|
|
32
|
+
|
|
33
|
+
target[prop] = value
|
|
34
|
+
|
|
35
|
+
if (target.x >= 10 || target.y >= 10) {
|
|
36
|
+
setIsTicking(true)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return true
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const hasOverflow: HasOverflow = new Proxy(
|
|
46
|
+
{ x: false, y: false },
|
|
47
|
+
{
|
|
48
|
+
set(target, prop: keyof HasOverflow, value) {
|
|
49
|
+
const old = target[prop]
|
|
50
|
+
if (old === value) return true
|
|
51
|
+
|
|
52
|
+
target[prop] = value
|
|
53
|
+
|
|
54
|
+
if (target.x || target.y) {
|
|
55
|
+
scroller.setAttribute('has-overflow', 'true')
|
|
56
|
+
scroller.addEventListener('touchstart', onPointerDown, { passive: false })
|
|
57
|
+
scroller.addEventListener('pointerdown', onPointerDown, { passive: false })
|
|
58
|
+
scroller.addEventListener('wheel', onWheel, { passive: false })
|
|
59
|
+
} else {
|
|
60
|
+
scroller.removeAttribute('has-overflow')
|
|
61
|
+
scroller.removeEventListener('touchstart', onPointerDown)
|
|
62
|
+
scroller.removeEventListener('pointerdown', onPointerDown)
|
|
63
|
+
scroller.removeEventListener('wheel', onWheel)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return true
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const currentIndex: CurrentIndex = new Proxy(
|
|
72
|
+
{ value: 0, onChange: null },
|
|
73
|
+
{
|
|
74
|
+
set(target, prop: keyof CurrentIndex, value: any) {
|
|
75
|
+
const old = target[prop]
|
|
76
|
+
if (old === value) return true
|
|
77
|
+
|
|
78
|
+
;(target as any)[prop] = value
|
|
79
|
+
|
|
80
|
+
// Dispatch custom event when value changes
|
|
81
|
+
if (prop === 'value') {
|
|
82
|
+
const event = new CustomEvent('change', {
|
|
83
|
+
bubbles: true,
|
|
84
|
+
detail: { index: value },
|
|
85
|
+
})
|
|
86
|
+
scroller?.dispatchEvent(event)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return true
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
let end = 300
|
|
95
|
+
let raf: number | null = null
|
|
96
|
+
let isDragging = false
|
|
97
|
+
let scrollerScrollWidth = 300
|
|
98
|
+
let scrollerWidth = 300
|
|
99
|
+
let scrollerScrollHeight = 300
|
|
100
|
+
let scrollerHeight = 300
|
|
101
|
+
const securityMargin = 0
|
|
102
|
+
const padding = { start: 0, end: 0 }
|
|
103
|
+
const scrollPadding = { start: 0, end: 0 }
|
|
104
|
+
let gap = 0
|
|
105
|
+
let snapPoints: number[] = []
|
|
106
|
+
let snapElements: HTMLElement[] = []
|
|
107
|
+
let snapAlignments: ScrollLogicalPosition[] = []
|
|
108
|
+
const virtualSnapPoints: number[] = []
|
|
109
|
+
let slides: HTMLElement[] = []
|
|
110
|
+
let resizeObserver: ResizeObserver | null = null
|
|
111
|
+
let mutationObserver: MutationObserver | null = null
|
|
112
|
+
let hasSnap = false
|
|
113
|
+
let hasMouse = false
|
|
114
|
+
let nativeScroll = true
|
|
115
|
+
let restoreScrollMethods: () => void
|
|
116
|
+
let dir = 1
|
|
117
|
+
|
|
118
|
+
function init() {
|
|
119
|
+
scroller?.setAttribute('blossom-carousel', 'true')
|
|
120
|
+
slides = Array.from(scroller.children) as HTMLElement[]
|
|
121
|
+
|
|
122
|
+
window.addEventListener('keydown', onKeydown)
|
|
123
|
+
scroller.addEventListener('scroll', onScroll)
|
|
124
|
+
|
|
125
|
+
dir = scroller.closest('[dir="rtl"]') ? -1 : 1
|
|
126
|
+
|
|
127
|
+
const { scrollSnapType } = window.getComputedStyle(scroller)
|
|
128
|
+
hasSnap = scrollSnapType !== 'none'
|
|
129
|
+
scroller.style.setProperty('--snap-type', scrollSnapType)
|
|
130
|
+
scroller.setAttribute('has-repeat', options?.repeat ? 'true' : 'false')
|
|
131
|
+
|
|
132
|
+
hasMouse = window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
|
133
|
+
|
|
134
|
+
nativeScroll = !hasMouse && !options?.repeat
|
|
135
|
+
if (!nativeScroll) {
|
|
136
|
+
scroller.style.scrollSnapType = 'none'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
restoreScrollMethods = interceptScrollIntoViewCalls((target) => {
|
|
140
|
+
if (target === scroller || scroller.contains(target)) setIsTicking(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
resizeObserver = new ResizeObserver(onResize)
|
|
144
|
+
// If scroller width matches parent width, observe parent for resize events
|
|
145
|
+
// (ResizeObserver may not fire on elements sized by their containers)
|
|
146
|
+
const parent = scroller.parentElement
|
|
147
|
+
if (parent && scroller.clientWidth === parent.clientWidth) {
|
|
148
|
+
resizeObserver.observe(parent)
|
|
149
|
+
} else {
|
|
150
|
+
resizeObserver.observe(scroller)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
mutationObserver = new MutationObserver(onMutation)
|
|
154
|
+
mutationObserver.observe(scroller, {
|
|
155
|
+
attributes: false,
|
|
156
|
+
childList: true,
|
|
157
|
+
subtree: false,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function destroy() {
|
|
162
|
+
scroller.removeAttribute('blossom-carousel')
|
|
163
|
+
resizeObserver?.disconnect()
|
|
164
|
+
mutationObserver?.disconnect()
|
|
165
|
+
if (raf) cancelAnimationFrame(raf)
|
|
166
|
+
|
|
167
|
+
window.removeEventListener('keydown', onKeydown)
|
|
168
|
+
scroller.removeEventListener('scroll', onScroll)
|
|
169
|
+
|
|
170
|
+
restoreScrollMethods?.()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function onResize(): void {
|
|
174
|
+
if (!scroller) return
|
|
175
|
+
|
|
176
|
+
setIsTicking(false)
|
|
177
|
+
|
|
178
|
+
// reset translates
|
|
179
|
+
for (let i = 0; i < snapElements.length; i++) {
|
|
180
|
+
const el = snapElements[i]
|
|
181
|
+
el.style.translate = ''
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
scrollerScrollWidth = scroller.scrollWidth
|
|
185
|
+
scrollerWidth = scroller.clientWidth
|
|
186
|
+
scrollerScrollHeight = scroller.scrollHeight
|
|
187
|
+
scrollerHeight = scroller.clientHeight
|
|
188
|
+
|
|
189
|
+
const styles = window.getComputedStyle(scroller)
|
|
190
|
+
hasOverflow.x =
|
|
191
|
+
// !hasTouch &&
|
|
192
|
+
scrollerScrollWidth > scrollerWidth && ['auto', 'scroll'].includes(styles.getPropertyValue('overflow-x'))
|
|
193
|
+
hasOverflow.y =
|
|
194
|
+
// !hasTouch &&
|
|
195
|
+
scrollerScrollHeight > scrollerHeight && ['auto', 'scroll'].includes(styles.getPropertyValue('overflow-y'))
|
|
196
|
+
padding.start = Number.parseInt(styles.paddingInlineStart, 10) || 0
|
|
197
|
+
padding.end = Number.parseInt(styles.paddingInlineEnd, 10) || 0
|
|
198
|
+
scrollPadding.start = Number.parseInt(styles.scrollPaddingInlineStart, 10) || 0
|
|
199
|
+
scrollPadding.end = Number.parseInt(styles.scrollPaddingInlineEnd, 10) || 0
|
|
200
|
+
dir = scroller.closest('[dir="rtl"]') ? -1 : 1
|
|
201
|
+
gap = Number.parseInt(styles.gap, 10) || Number.parseInt(styles.columnGap, 10) || 0
|
|
202
|
+
end = (scrollerScrollWidth - scrollerWidth - securityMargin + gap) * dir
|
|
203
|
+
|
|
204
|
+
const result = !hasSnap ? { points: [], elements: [], alignments: [], sizes: [] } : findSnapPoints(scroller)
|
|
205
|
+
snapPoints = result.points
|
|
206
|
+
snapElements = result.elements
|
|
207
|
+
snapAlignments = result.alignments
|
|
208
|
+
|
|
209
|
+
target.x = virtualScroll.x = snapPoints[currentIndex.value]
|
|
210
|
+
scroller.scrollTo({ left: virtualScroll.x, behavior: 'instant' })
|
|
211
|
+
if (options?.repeat) {
|
|
212
|
+
onRepeat()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function onMutation(): void {
|
|
217
|
+
onResize()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function findSnapPoints(scroller: HTMLElement): {
|
|
221
|
+
points: number[]
|
|
222
|
+
elements: HTMLElement[]
|
|
223
|
+
alignments: ScrollLogicalPosition[]
|
|
224
|
+
} {
|
|
225
|
+
const points: { align: string; el: HTMLElement | Element }[] = []
|
|
226
|
+
|
|
227
|
+
let cycles = 0
|
|
228
|
+
const traverseDOM = (node: HTMLElement | Element) => {
|
|
229
|
+
cycles++
|
|
230
|
+
// break if the max depth is reached
|
|
231
|
+
if (cycles > 100) return
|
|
232
|
+
|
|
233
|
+
const styles = window.getComputedStyle(node)
|
|
234
|
+
const scrollSnapAlign = styles.scrollSnapAlign
|
|
235
|
+
|
|
236
|
+
// break if a snap-type value is found
|
|
237
|
+
if (scrollSnapAlign !== 'none') {
|
|
238
|
+
points.push({
|
|
239
|
+
align: scrollSnapAlign,
|
|
240
|
+
el: node,
|
|
241
|
+
})
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// traverse all children
|
|
246
|
+
const children = node.children
|
|
247
|
+
if (children.length === 0) return
|
|
248
|
+
for (const child of children) {
|
|
249
|
+
traverseDOM(child)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
traverseDOM(scroller)
|
|
253
|
+
|
|
254
|
+
// precompute snap point for all slides
|
|
255
|
+
const scrollerRect = scroller.getBoundingClientRect()
|
|
256
|
+
const snapData = points.map(({ el, align }) => {
|
|
257
|
+
const elementRect = (el as HTMLElement).getBoundingClientRect()
|
|
258
|
+
const clientWidth = (el as HTMLElement).clientWidth
|
|
259
|
+
const left = elementRect.left - scrollerRect.left + scroller.scrollLeft
|
|
260
|
+
|
|
261
|
+
let position: number | null = null
|
|
262
|
+
switch (align) {
|
|
263
|
+
case 'start':
|
|
264
|
+
position = left - scrollPadding.start
|
|
265
|
+
break
|
|
266
|
+
case 'end':
|
|
267
|
+
position = left + clientWidth - scrollerWidth + scrollPadding.end
|
|
268
|
+
break
|
|
269
|
+
case 'center':
|
|
270
|
+
position = left + clientWidth * 0.5 - scrollerWidth / 2
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { position, element: el as HTMLElement, align }
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// Filter out duplicates (i.e. in case of multiple rows)
|
|
278
|
+
const filteredData: { position: number; element: HTMLElement; align: ScrollLogicalPosition }[] = []
|
|
279
|
+
const seenPositions = new Set<number>()
|
|
280
|
+
|
|
281
|
+
for (const item of snapData) {
|
|
282
|
+
if (item.position !== null && !seenPositions.has(item.position)) {
|
|
283
|
+
seenPositions.add(item.position)
|
|
284
|
+
filteredData.push({ position: item.position, element: item.element, align: item.align as ScrollLogicalPosition })
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
points: filteredData.map((d) => d.position),
|
|
290
|
+
elements: filteredData.map((d) => d.element),
|
|
291
|
+
alignments: filteredData.map((d) => d.align),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function onScroll() {
|
|
296
|
+
if (isDragging || !scroller) return
|
|
297
|
+
|
|
298
|
+
if (!isTicking) {
|
|
299
|
+
virtualScroll.x = target.x = scroller.scrollLeft
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (options?.repeat) {
|
|
303
|
+
onRepeat()
|
|
304
|
+
updateCurrentIndex()
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const scrollStart = scroller.scrollLeft
|
|
309
|
+
const maxScroll = getMaxScrollPosition()
|
|
310
|
+
|
|
311
|
+
if (scrollStart < 0) {
|
|
312
|
+
const left = scrollStart * -1
|
|
313
|
+
dispatchOverscrollEvent(left)
|
|
314
|
+
} else if (scrollStart > maxScroll) {
|
|
315
|
+
const left = scrollStart * -1 + maxScroll
|
|
316
|
+
dispatchOverscrollEvent(left)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
updateCurrentIndex()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/*********************
|
|
323
|
+
**** Drag events ****
|
|
324
|
+
*********************/
|
|
325
|
+
|
|
326
|
+
const virtualScroll: Point = {
|
|
327
|
+
x: 0,
|
|
328
|
+
y: 0,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function addPointerListeners(): void {
|
|
332
|
+
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
|
333
|
+
window.addEventListener('touchmove', onPointerMove, { passive: true })
|
|
334
|
+
window.addEventListener('pointerup', onPointerUp, { passive: true })
|
|
335
|
+
window.addEventListener('touchend', onPointerUp, { passive: true })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function removePointerListeners(): void {
|
|
339
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
340
|
+
window.removeEventListener('touchmove', onPointerMove)
|
|
341
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
342
|
+
window.removeEventListener('touchend', onPointerUp)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onPointerDown(e: PointerEvent | TouchEvent): void {
|
|
346
|
+
if (!scroller) return
|
|
347
|
+
|
|
348
|
+
const clientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX
|
|
349
|
+
const clientY = 'touches' in e ? e.touches[0]?.clientY : e.clientY
|
|
350
|
+
|
|
351
|
+
if (hasOverflow.x) handleAxisPointerDown('x', clientX)
|
|
352
|
+
if (hasOverflow.y) handleAxisPointerDown('y', clientY)
|
|
353
|
+
|
|
354
|
+
distanceMovedSincePointerDown.x = 0
|
|
355
|
+
isDragging = true
|
|
356
|
+
|
|
357
|
+
addPointerListeners()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function onPointerMove(e: PointerEvent | TouchEvent): void {
|
|
361
|
+
const clientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX
|
|
362
|
+
const clientY = 'touches' in e ? e.touches[0]?.clientY : e.clientY
|
|
363
|
+
|
|
364
|
+
if (hasOverflow.x) handleAxisPointerMove('x', clientX)
|
|
365
|
+
if (hasOverflow.y) handleAxisPointerMove('y', clientY)
|
|
366
|
+
|
|
367
|
+
if (distanceMovedSincePointerDown.x > 2 || distanceMovedSincePointerDown.y > 2) scroller.classList.add('blossom-dragging')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function onPointerUp(): void {
|
|
371
|
+
removePointerListeners()
|
|
372
|
+
|
|
373
|
+
isDragging = false
|
|
374
|
+
scroller.classList.remove('blossom-dragging')
|
|
375
|
+
|
|
376
|
+
if (distanceMovedSincePointerDown.x <= 10) return
|
|
377
|
+
if (hasOverflow.x) velocity.x *= 2
|
|
378
|
+
if (hasOverflow.y) velocity.y *= 2
|
|
379
|
+
|
|
380
|
+
dragSnap()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function onWheel(e: WheelEvent): void {
|
|
384
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
385
|
+
setIsTicking(false)
|
|
386
|
+
if (isDragging || !scroller) return
|
|
387
|
+
if (hasOverflow.x) virtualScroll.x = scroller.scrollLeft
|
|
388
|
+
if (hasOverflow.y) virtualScroll.y = scroller.scrollTop
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
393
|
+
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) setIsTicking(false)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function dragSnap(): void {
|
|
397
|
+
//TODO: add support for vertical snapping
|
|
398
|
+
let slideX: number | undefined
|
|
399
|
+
|
|
400
|
+
if (options?.repeat && snapElements.length > 0) {
|
|
401
|
+
// Strict single-step using helpers
|
|
402
|
+
const n = snapPoints.length
|
|
403
|
+
if (n >= 2) {
|
|
404
|
+
const ci = ((currentIndex.value % n) + n) % n
|
|
405
|
+
const step: 1 | -1 = velocity.x * dir >= 0 ? 1 : -1
|
|
406
|
+
const targetIdx = (ci + step + n) % n
|
|
407
|
+
slideX = computeRepeatSlideX(targetIdx, step)
|
|
408
|
+
} else {
|
|
409
|
+
slideX = snapSelect({ axis: 'x' })
|
|
410
|
+
}
|
|
411
|
+
} else if (!options?.repeat && snapElements.length) {
|
|
412
|
+
const bounds = getScrollBounds()
|
|
413
|
+
slideX = clamp(snapSelect({ axis: 'x' }), bounds.min, bounds.max)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (slideX === undefined) return
|
|
417
|
+
|
|
418
|
+
animateToSlideX(slideX)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/*********************
|
|
422
|
+
***** WRAP *****
|
|
423
|
+
*********************/
|
|
424
|
+
|
|
425
|
+
// Reuse these allocations to avoid GC pressure in repeat mode
|
|
426
|
+
const repeatVirtualCache: number[] = []
|
|
427
|
+
const repeatElementOffsetCache = new Map<HTMLElement, number>()
|
|
428
|
+
|
|
429
|
+
function onRepeat(): void {
|
|
430
|
+
if (!scroller) return
|
|
431
|
+
|
|
432
|
+
const loopWidth = getLoopWidth()
|
|
433
|
+
if (loopWidth === 0) return
|
|
434
|
+
|
|
435
|
+
if (virtualScroll.x >= end) {
|
|
436
|
+
virtualScroll.x -= loopWidth
|
|
437
|
+
target.x -= loopWidth
|
|
438
|
+
if (!isTicking) {
|
|
439
|
+
scroller.scrollTo({ left: virtualScroll.x, behavior: 'instant' })
|
|
440
|
+
}
|
|
441
|
+
} else if (virtualScroll.x <= securityMargin) {
|
|
442
|
+
virtualScroll.x += loopWidth
|
|
443
|
+
target.x += loopWidth
|
|
444
|
+
if (!isTicking) {
|
|
445
|
+
scroller.scrollTo({ left: virtualScroll.x, behavior: 'instant' })
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const scrollLeft = virtualScroll.x
|
|
450
|
+
|
|
451
|
+
// Clear and reuse cached arrays/maps
|
|
452
|
+
repeatElementOffsetCache.clear()
|
|
453
|
+
repeatVirtualCache.length = snapPoints.length
|
|
454
|
+
|
|
455
|
+
// Compute offsets for snapped elements first to produce a dense virtualSnapPoints
|
|
456
|
+
for (let i = 0; i < snapElements.length; i++) {
|
|
457
|
+
const el = snapElements[i]
|
|
458
|
+
const basePoint = snapPoints[i] ?? 0
|
|
459
|
+
const alignement = snapAlignments[i] ?? 'start'
|
|
460
|
+
const dx = alignement === 'start' ? 0 : alignement === 'end' ? (scrollerWidth - el.clientWidth) / 2 : (-scrollerWidth + el.clientWidth) / 2
|
|
461
|
+
const raw = (scrollLeft - basePoint + dx) / loopWidth
|
|
462
|
+
const k = Math.round(raw + (velocity.x >= 0 ? 0.001 : -0.001))
|
|
463
|
+
const offsetX = k * loopWidth
|
|
464
|
+
el.style.translate = `${offsetX}px 0`
|
|
465
|
+
repeatElementOffsetCache.set(el, offsetX)
|
|
466
|
+
repeatVirtualCache[i] = basePoint + offsetX
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Translate non-snap slides to the nearest previous snapped slide's cycle
|
|
470
|
+
for (let i = 0; i < slides.length; i++) {
|
|
471
|
+
const el = slides[i]
|
|
472
|
+
if (repeatElementOffsetCache.has(el)) continue
|
|
473
|
+
let prevTranslate = 0
|
|
474
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
475
|
+
const prevEl = slides[j]
|
|
476
|
+
const val = repeatElementOffsetCache.get(prevEl)
|
|
477
|
+
if (val !== undefined) {
|
|
478
|
+
prevTranslate = val
|
|
479
|
+
break
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
el.style.translate = `${prevTranslate}px 0`
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Replace contents of virtualSnapPoints with the dense array
|
|
486
|
+
virtualSnapPoints.length = 0
|
|
487
|
+
for (let i = 0; i < repeatVirtualCache.length; i++) virtualSnapPoints.push(repeatVirtualCache[i])
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/******************
|
|
491
|
+
***** Ticker *****
|
|
492
|
+
******************/
|
|
493
|
+
|
|
494
|
+
const FRICTION = 0.72
|
|
495
|
+
const DAMPING = 0.12
|
|
496
|
+
let isTicking = false
|
|
497
|
+
|
|
498
|
+
function setIsTicking(bool: boolean): void {
|
|
499
|
+
if (!scroller) return
|
|
500
|
+
|
|
501
|
+
if (bool && !isTicking) {
|
|
502
|
+
lastTick = performance.now()
|
|
503
|
+
if (hasOverflow.x) target.x = scroller.scrollLeft
|
|
504
|
+
if (hasOverflow.y) target.y = scroller.scrollTop
|
|
505
|
+
|
|
506
|
+
if (!raf) {
|
|
507
|
+
raf = requestAnimationFrame(tick)
|
|
508
|
+
}
|
|
509
|
+
} else if (!bool && raf) {
|
|
510
|
+
cancelAnimationFrame(raf)
|
|
511
|
+
raf = null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
isTicking = bool
|
|
515
|
+
snap = !bool
|
|
516
|
+
|
|
517
|
+
scroller.setAttribute('has-snap', snap ? 'true' : 'false')
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let frameDelta = 0
|
|
521
|
+
let lastTick = 0
|
|
522
|
+
function tick(t: number): void {
|
|
523
|
+
raf = requestAnimationFrame(tick)
|
|
524
|
+
frameDelta = t - lastTick
|
|
525
|
+
|
|
526
|
+
if (!scroller) return
|
|
527
|
+
|
|
528
|
+
if (hasOverflow.x) handleAxisTick('x')
|
|
529
|
+
if (hasOverflow.y) handleAxisTick('y')
|
|
530
|
+
|
|
531
|
+
if (options?.repeat) {
|
|
532
|
+
onRepeat()
|
|
533
|
+
} else {
|
|
534
|
+
applyRubberBanding(round(virtualScroll.x, 2))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
__scrollingInternally = true
|
|
538
|
+
scroller.scrollTo({
|
|
539
|
+
left: virtualScroll.x,
|
|
540
|
+
top: virtualScroll.y,
|
|
541
|
+
behavior: 'instant' as ScrollBehavior,
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
updateCurrentIndex()
|
|
545
|
+
|
|
546
|
+
lastTick = t
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let rubberBandOffset = 0
|
|
550
|
+
function applyRubberBanding(left: number): void {
|
|
551
|
+
if (!scroller) return
|
|
552
|
+
|
|
553
|
+
//TODO: add support for vertical rubber banding
|
|
554
|
+
const edge = end
|
|
555
|
+
|
|
556
|
+
let targetOffset = 0
|
|
557
|
+
if (left * dir <= 0) {
|
|
558
|
+
targetOffset = isDragging ? left * -0.2 : 0
|
|
559
|
+
} else if (left * dir > edge * dir) {
|
|
560
|
+
targetOffset = isDragging ? (left - edge) * -0.2 : 0
|
|
561
|
+
}
|
|
562
|
+
rubberBandOffset = damp(rubberBandOffset, targetOffset, isDragging ? 0.8 : DAMPING, frameDelta)
|
|
563
|
+
|
|
564
|
+
if (Math.abs(rubberBandOffset) > 0.01) {
|
|
565
|
+
const evt = dispatchOverscrollEvent(rubberBandOffset)
|
|
566
|
+
if (evt.defaultPrevented) return
|
|
567
|
+
scroller.style.transform = `translateX(${round(rubberBandOffset, 3)}px)`
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
scroller.style.transform = ''
|
|
572
|
+
rubberBandOffset = 0
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function dispatchOverscrollEvent(left: number): CustomEvent<{ left: number }> {
|
|
576
|
+
const overscrollEvent = new CustomEvent('overscroll', {
|
|
577
|
+
bubbles: true,
|
|
578
|
+
cancelable: true,
|
|
579
|
+
detail: { left },
|
|
580
|
+
})
|
|
581
|
+
scroller?.dispatchEvent(overscrollEvent)
|
|
582
|
+
return overscrollEvent
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/******************************
|
|
586
|
+
********* METHODS **************
|
|
587
|
+
******************************/
|
|
588
|
+
|
|
589
|
+
let __scrollingInternally = false
|
|
590
|
+
|
|
591
|
+
const scrollTo = scroller.scrollTo.bind(scroller)
|
|
592
|
+
scroller.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
|
|
593
|
+
const internal = __scrollingInternally === true
|
|
594
|
+
if (!internal) setIsTicking(false)
|
|
595
|
+
__scrollingInternally = false
|
|
596
|
+
if (typeof optionsOrX === 'number') {
|
|
597
|
+
scrollTo(optionsOrX, y ?? 0)
|
|
598
|
+
} else {
|
|
599
|
+
scrollTo(optionsOrX)
|
|
600
|
+
}
|
|
601
|
+
}) as typeof scroller.scrollTo
|
|
602
|
+
|
|
603
|
+
const scrollBy = scroller.scrollBy.bind(scroller)
|
|
604
|
+
scroller.scrollBy = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
|
|
605
|
+
const internal = __scrollingInternally === true
|
|
606
|
+
if (!internal) setIsTicking(false)
|
|
607
|
+
__scrollingInternally = false
|
|
608
|
+
if (typeof optionsOrX === 'number') {
|
|
609
|
+
scrollBy(optionsOrX, y ?? 0)
|
|
610
|
+
} else {
|
|
611
|
+
scrollBy(optionsOrX)
|
|
612
|
+
}
|
|
613
|
+
}) as typeof scroller.scrollBy
|
|
614
|
+
|
|
615
|
+
function interceptScrollIntoViewCalls(onExternalScroll: (target: Element, method: string, args: any[]) => void) {
|
|
616
|
+
const stopFns: Array<() => void> = []
|
|
617
|
+
|
|
618
|
+
// Patch scrollIntoView for all elements
|
|
619
|
+
const originalScrollIntoView = Element.prototype.scrollIntoView
|
|
620
|
+
if (originalScrollIntoView) {
|
|
621
|
+
Element.prototype.scrollIntoView = function (arg?: boolean | ScrollIntoViewOptions): void {
|
|
622
|
+
onExternalScroll(this, 'scrollIntoView', [arg])
|
|
623
|
+
originalScrollIntoView.call(this, arg)
|
|
624
|
+
}
|
|
625
|
+
stopFns.push(() => {
|
|
626
|
+
Element.prototype.scrollIntoView = originalScrollIntoView
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return () =>
|
|
631
|
+
stopFns.forEach((fn) => {
|
|
632
|
+
fn()
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/******************************
|
|
637
|
+
********* UTILS **************
|
|
638
|
+
******************************/
|
|
639
|
+
|
|
640
|
+
interface AxisOption {
|
|
641
|
+
axis: 'x' | 'y'
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function handleAxisPointerDown(axis: 'x' | 'y', clientPos: number): void {
|
|
645
|
+
const scrollProp = axis === 'x' ? 'scrollLeft' : 'scrollTop'
|
|
646
|
+
virtualScroll[axis] = scroller[scrollProp]
|
|
647
|
+
target[axis] = scroller[scrollProp]
|
|
648
|
+
pointerStart[axis] = clientPos
|
|
649
|
+
velocity[axis] = 0
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function handleAxisPointerMove(axis: 'x' | 'y', clientPos: number): void {
|
|
653
|
+
const delta = pointerStart[axis] - clientPos
|
|
654
|
+
target[axis] += delta
|
|
655
|
+
velocity[axis] += delta
|
|
656
|
+
pointerStart[axis] = clientPos
|
|
657
|
+
distanceMovedSincePointerDown[axis] += Math.abs(delta)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function handleAxisTick(axis: 'x' | 'y'): void {
|
|
661
|
+
velocity[axis] *= FRICTION
|
|
662
|
+
if (!isDragging) {
|
|
663
|
+
target[axis] += velocity[axis]
|
|
664
|
+
virtualScroll[axis] = damp(virtualScroll[axis], target[axis], DAMPING, frameDelta)
|
|
665
|
+
} else {
|
|
666
|
+
virtualScroll[axis] = damp(virtualScroll[axis], target[axis], FRICTION, frameDelta)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function project({ axis = 'x' }: AxisOption): number {
|
|
671
|
+
return target[axis] + velocity[axis] / (1 - FRICTION)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function snapSelect({ axis = 'x' }: AxisOption): number {
|
|
675
|
+
const restingX = project({ axis })
|
|
676
|
+
const points = virtualSnapPoints.length ? virtualSnapPoints : snapPoints
|
|
677
|
+
return points.reduce((prev, curr) => (Math.abs(curr - restingX) < Math.abs(prev - restingX) ? curr : prev))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function lerp(x: number, y: number, t: number): number {
|
|
681
|
+
return (1 - t) * x + t * y
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function damp(x: number, y: number, t: number, delta: number): number {
|
|
685
|
+
return lerp(x, y, 1 - Math.exp(Math.log(1 - t) * (delta / (1000 / 60))))
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function clamp(value: number, min: number, max: number): number {
|
|
689
|
+
if (value < min) return min
|
|
690
|
+
if (value > max) return max
|
|
691
|
+
return value
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function round(value: number, precision = 0): number {
|
|
695
|
+
const multiplier = 10 ** precision
|
|
696
|
+
return Math.round(value * multiplier) / multiplier
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/******************************
|
|
700
|
+
********* REPEAT UTILS *********
|
|
701
|
+
******************************/
|
|
702
|
+
|
|
703
|
+
function getLoopWidth(): number {
|
|
704
|
+
return scrollerScrollWidth - scrollerWidth + gap
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function getMaxScrollPosition(): number {
|
|
708
|
+
return scrollerScrollWidth - scrollerWidth
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function getScrollBounds(): { min: number; max: number } {
|
|
712
|
+
const maxScroll = getMaxScrollPosition()
|
|
713
|
+
return {
|
|
714
|
+
min: Math.min(maxScroll * dir, 0),
|
|
715
|
+
max: Math.max(maxScroll * dir, 0),
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function normalizeToCycle(point: number, reference: number): number {
|
|
720
|
+
const w = getLoopWidth()
|
|
721
|
+
if (w <= 0) return point
|
|
722
|
+
const k = Math.round((reference - point) / w)
|
|
723
|
+
return point + k * w
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function animateToSlideX(slideX: number, enforceDir?: 1 | -1): void {
|
|
727
|
+
if (slideX === undefined || Number.isNaN(slideX)) return
|
|
728
|
+
|
|
729
|
+
setIsTicking(true)
|
|
730
|
+
target.x = virtualScroll.x
|
|
731
|
+
let distance = slideX - virtualScroll.x
|
|
732
|
+
if (options?.repeat) {
|
|
733
|
+
const w = getLoopWidth()
|
|
734
|
+
if (w > 0) {
|
|
735
|
+
// shortest path wrapping
|
|
736
|
+
distance = distance - Math.round(distance / w) * w
|
|
737
|
+
// optionally enforce intended direction (for prev/next)
|
|
738
|
+
if (enforceDir === 1 && distance * dir <= 0) distance += w * dir
|
|
739
|
+
if (enforceDir === -1 && distance * dir >= 0) distance -= w * dir
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const force = distance * (1 - FRICTION) * (1 / FRICTION)
|
|
743
|
+
velocity.x = force
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function computeRepeatSlideX(targetIndex: number, directionSign: 1 | -1): number {
|
|
747
|
+
const base = snapPoints[targetIndex] ?? 0
|
|
748
|
+
const candidate = normalizeToCycle(base, virtualScroll.x)
|
|
749
|
+
const w = getLoopWidth()
|
|
750
|
+
if (w <= 0) return candidate
|
|
751
|
+
const delta = candidate - virtualScroll.x
|
|
752
|
+
if (directionSign === 1 && delta * dir <= 0) return candidate + w * dir
|
|
753
|
+
if (directionSign === -1 && delta * dir >= 0) return candidate - w * dir
|
|
754
|
+
return candidate
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/******************************
|
|
758
|
+
********* NAVIGATION *********
|
|
759
|
+
******************************/
|
|
760
|
+
|
|
761
|
+
function getCurrentSnapIndex(): number {
|
|
762
|
+
const points = options?.repeat && virtualSnapPoints.length ? virtualSnapPoints : snapPoints
|
|
763
|
+
if (!points.length) return 0
|
|
764
|
+
|
|
765
|
+
const currentScroll = scroller.scrollLeft
|
|
766
|
+
let closestIndex = 0
|
|
767
|
+
let closestDistance = Math.abs(points[0] - currentScroll)
|
|
768
|
+
|
|
769
|
+
for (let i = 1; i < points.length; i++) {
|
|
770
|
+
const distance = Math.abs(points[i] - currentScroll)
|
|
771
|
+
if (distance < closestDistance) {
|
|
772
|
+
closestDistance = distance
|
|
773
|
+
closestIndex = i
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return closestIndex
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function updateCurrentIndex(): void {
|
|
781
|
+
const newIndex = getCurrentSnapIndex()
|
|
782
|
+
if (currentIndex.value !== newIndex) {
|
|
783
|
+
currentIndex.value = newIndex
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function navigate(direction: 1 | -1): void {
|
|
788
|
+
if (snapElements.length <= 1) return
|
|
789
|
+
|
|
790
|
+
const points = snapPoints
|
|
791
|
+
const targetIndex = options?.repeat
|
|
792
|
+
? (currentIndex.value + direction + points.length) % points.length
|
|
793
|
+
: direction === 1
|
|
794
|
+
? Math.min(currentIndex.value + 1, points.length - 1)
|
|
795
|
+
: Math.max(currentIndex.value - 1, 0)
|
|
796
|
+
|
|
797
|
+
if (nativeScroll) {
|
|
798
|
+
const align = snapAlignments[targetIndex] ?? 'start'
|
|
799
|
+
;(snapElements[targetIndex] as HTMLElement).scrollIntoView({
|
|
800
|
+
behavior: 'smooth',
|
|
801
|
+
block: 'nearest',
|
|
802
|
+
inline: align,
|
|
803
|
+
})
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
let slideX: number | undefined
|
|
808
|
+
if (options?.repeat && points.length >= 2) {
|
|
809
|
+
const loopWidth = getLoopWidth()
|
|
810
|
+
const base = points[targetIndex]
|
|
811
|
+
const k = loopWidth > 0 ? Math.round((virtualScroll.x - base) / loopWidth) : 0
|
|
812
|
+
let candidate = base + k * loopWidth
|
|
813
|
+
const delta = candidate - virtualScroll.x
|
|
814
|
+
if (direction === 1 && delta * dir <= 0) candidate += loopWidth * dir
|
|
815
|
+
if (direction === -1 && delta * dir >= 0) candidate -= loopWidth * dir
|
|
816
|
+
slideX = candidate
|
|
817
|
+
} else {
|
|
818
|
+
const bounds = getScrollBounds()
|
|
819
|
+
slideX = clamp(points[targetIndex], bounds.min, bounds.max)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
animateToSlideX(slideX, direction)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function next(): void {
|
|
826
|
+
navigate(1)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function prev(): void {
|
|
830
|
+
navigate(-1)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
init,
|
|
835
|
+
snap,
|
|
836
|
+
hasOverflow,
|
|
837
|
+
currentIndex: () => currentIndex.value,
|
|
838
|
+
next,
|
|
839
|
+
prev,
|
|
840
|
+
destroy,
|
|
841
|
+
}
|
|
842
|
+
}
|