@v-c/virtual-list 0.0.1
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/LICENSE +21 -0
- package/dist/Filler.cjs +1 -0
- package/dist/Filler.d.ts +33 -0
- package/dist/Filler.js +57 -0
- package/dist/Item.cjs +1 -0
- package/dist/Item.d.ts +18 -0
- package/dist/Item.js +27 -0
- package/dist/List.cjs +1 -0
- package/dist/List.d.ts +108 -0
- package/dist/List.js +276 -0
- package/dist/ScrollBar.cjs +1 -0
- package/dist/ScrollBar.d.ts +116 -0
- package/dist/ScrollBar.js +193 -0
- package/dist/hooks/useDiffItem.cjs +1 -0
- package/dist/hooks/useDiffItem.d.ts +2 -0
- package/dist/hooks/useDiffItem.js +21 -0
- package/dist/hooks/useFrameWheel.cjs +1 -0
- package/dist/hooks/useFrameWheel.d.ts +11 -0
- package/dist/hooks/useFrameWheel.js +52 -0
- package/dist/hooks/useGetSize.cjs +1 -0
- package/dist/hooks/useGetSize.d.ts +7 -0
- package/dist/hooks/useGetSize.js +24 -0
- package/dist/hooks/useHeights.cjs +1 -0
- package/dist/hooks/useHeights.d.ts +9 -0
- package/dist/hooks/useHeights.js +43 -0
- package/dist/hooks/useMobileTouchMove.cjs +1 -0
- package/dist/hooks/useMobileTouchMove.d.ts +2 -0
- package/dist/hooks/useMobileTouchMove.js +44 -0
- package/dist/hooks/useOriginScroll.cjs +1 -0
- package/dist/hooks/useOriginScroll.d.ts +2 -0
- package/dist/hooks/useOriginScroll.js +17 -0
- package/dist/hooks/useScrollDrag.cjs +1 -0
- package/dist/hooks/useScrollDrag.d.ts +3 -0
- package/dist/hooks/useScrollDrag.js +51 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/interface.cjs +1 -0
- package/dist/interface.d.ts +27 -0
- package/dist/interface.js +1 -0
- package/dist/utils/CacheMap.cjs +1 -0
- package/dist/utils/CacheMap.d.ts +16 -0
- package/dist/utils/CacheMap.js +29 -0
- package/dist/utils/isFirefox.cjs +1 -0
- package/dist/utils/isFirefox.d.ts +2 -0
- package/dist/utils/isFirefox.js +4 -0
- package/dist/utils/scrollbarUtil.cjs +1 -0
- package/dist/utils/scrollbarUtil.d.ts +1 -0
- package/dist/utils/scrollbarUtil.js +7 -0
- package/docs/basic.vue +175 -0
- package/docs/height.vue +48 -0
- package/docs/nest.vue +60 -0
- package/docs/no-virtual.vue +127 -0
- package/docs/switch.vue +101 -0
- package/docs/virtual-list.stories.vue +31 -0
- package/package.json +38 -0
- package/src/Filler.tsx +72 -0
- package/src/Item.tsx +34 -0
- package/src/List.tsx +577 -0
- package/src/ScrollBar.tsx +298 -0
- package/src/__tests__/List.test.ts +59 -0
- package/src/hooks/useDiffItem.ts +27 -0
- package/src/hooks/useFrameWheel.ts +141 -0
- package/src/hooks/useGetSize.ts +44 -0
- package/src/hooks/useHeights.ts +106 -0
- package/src/hooks/useMobileTouchMove.ts +131 -0
- package/src/hooks/useOriginScroll.ts +47 -0
- package/src/hooks/useScrollDrag.ts +123 -0
- package/src/index.ts +5 -0
- package/src/interface.ts +32 -0
- package/src/utils/CacheMap.ts +42 -0
- package/src/utils/isFirefox.ts +3 -0
- package/src/utils/scrollbarUtil.ts +10 -0
- package/vite.config.ts +18 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { computed, type CSSProperties, defineComponent, onMounted, onUnmounted, type PropType, ref, shallowRef, watch } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface ScrollBarProps {
|
|
4
|
+
prefixCls: string
|
|
5
|
+
scrollOffset: number
|
|
6
|
+
scrollRange: number
|
|
7
|
+
rtl: boolean
|
|
8
|
+
onScroll: (scrollOffset: number, horizontal?: boolean) => void
|
|
9
|
+
onStartMove: () => void
|
|
10
|
+
onStopMove: () => void
|
|
11
|
+
horizontal?: boolean
|
|
12
|
+
style?: CSSProperties
|
|
13
|
+
thumbStyle?: CSSProperties
|
|
14
|
+
spinSize: number
|
|
15
|
+
containerSize: number
|
|
16
|
+
showScrollBar?: boolean | 'optional'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ScrollBarRef {
|
|
20
|
+
delayHidden: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getPageXY(
|
|
24
|
+
e: MouseEvent | TouchEvent,
|
|
25
|
+
horizontal: boolean,
|
|
26
|
+
): number {
|
|
27
|
+
const obj = 'touches' in e ? e.touches[0] : e
|
|
28
|
+
return obj[horizontal ? 'pageX' : 'pageY'] - window[horizontal ? 'scrollX' : 'scrollY']
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default defineComponent({
|
|
32
|
+
name: 'ScrollBar',
|
|
33
|
+
props: {
|
|
34
|
+
prefixCls: { type: String, required: true },
|
|
35
|
+
scrollOffset: { type: Number, required: true },
|
|
36
|
+
scrollRange: { type: Number, required: true },
|
|
37
|
+
rtl: { type: Boolean, default: false },
|
|
38
|
+
onScroll: { type: Function as PropType<(scrollOffset: number, horizontal?: boolean) => void>, required: true },
|
|
39
|
+
onStartMove: { type: Function as PropType<() => void>, required: true },
|
|
40
|
+
onStopMove: { type: Function as PropType<() => void>, required: true },
|
|
41
|
+
horizontal: { type: Boolean, default: false },
|
|
42
|
+
style: Object as PropType<CSSProperties>,
|
|
43
|
+
thumbStyle: Object as PropType<CSSProperties>,
|
|
44
|
+
spinSize: { type: Number, required: true },
|
|
45
|
+
containerSize: { type: Number, required: true },
|
|
46
|
+
showScrollBar: { type: [Boolean, String] as PropType<boolean | 'optional'> },
|
|
47
|
+
},
|
|
48
|
+
setup(props, { expose }) {
|
|
49
|
+
const dragging = ref(false)
|
|
50
|
+
const pageXY = ref<number | null>(null)
|
|
51
|
+
const startTop = ref<number | null>(null)
|
|
52
|
+
|
|
53
|
+
const isLTR = computed(() => !props.rtl)
|
|
54
|
+
|
|
55
|
+
// Refs
|
|
56
|
+
const scrollbarRef = shallowRef<HTMLDivElement>()
|
|
57
|
+
const thumbRef = shallowRef<HTMLDivElement>()
|
|
58
|
+
|
|
59
|
+
// Visible
|
|
60
|
+
// When showScrollBar is 'optional', start as visible (true)
|
|
61
|
+
// When showScrollBar is true/false, use that value
|
|
62
|
+
const visible = ref(props.showScrollBar === 'optional' ? true : props.showScrollBar)
|
|
63
|
+
let visibleTimeout: ReturnType<typeof setTimeout> | null = null
|
|
64
|
+
|
|
65
|
+
const delayHidden = () => {
|
|
66
|
+
// Don't auto-hide if showScrollBar is explicitly true or false
|
|
67
|
+
if (props.showScrollBar === true || props.showScrollBar === false)
|
|
68
|
+
return
|
|
69
|
+
if (visibleTimeout)
|
|
70
|
+
clearTimeout(visibleTimeout)
|
|
71
|
+
visible.value = true
|
|
72
|
+
visibleTimeout = setTimeout(() => {
|
|
73
|
+
visible.value = false
|
|
74
|
+
}, 3000)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Range
|
|
78
|
+
const enableScrollRange = computed(() => props.scrollRange - props.containerSize || 0)
|
|
79
|
+
const enableOffsetRange = computed(() => props.containerSize - props.spinSize || 0)
|
|
80
|
+
|
|
81
|
+
// Top position
|
|
82
|
+
const top = computed(() => {
|
|
83
|
+
if (props.scrollOffset === 0 || enableScrollRange.value === 0) {
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
86
|
+
const ptg = props.scrollOffset / enableScrollRange.value
|
|
87
|
+
return ptg * enableOffsetRange.value
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// State ref for event handlers
|
|
91
|
+
const stateRef = shallowRef({
|
|
92
|
+
top: top.value,
|
|
93
|
+
dragging: dragging.value,
|
|
94
|
+
pageY: pageXY.value,
|
|
95
|
+
startTop: startTop.value,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
watch([top, dragging, pageXY, startTop], () => {
|
|
99
|
+
stateRef.value = {
|
|
100
|
+
top: top.value,
|
|
101
|
+
dragging: dragging.value,
|
|
102
|
+
pageY: pageXY.value,
|
|
103
|
+
startTop: startTop.value,
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const onContainerMouseDown = (e: MouseEvent) => {
|
|
108
|
+
e.stopPropagation()
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const onThumbMouseDown = (e: MouseEvent | TouchEvent) => {
|
|
113
|
+
dragging.value = true
|
|
114
|
+
pageXY.value = getPageXY(e, props.horizontal || false)
|
|
115
|
+
startTop.value = stateRef.value.top
|
|
116
|
+
|
|
117
|
+
props.onStartMove()
|
|
118
|
+
e.stopPropagation()
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Effect: Add passive:false event listeners
|
|
123
|
+
onMounted(() => {
|
|
124
|
+
const onScrollbarTouchStart = (e: TouchEvent) => {
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const scrollbarEle = scrollbarRef.value
|
|
129
|
+
const thumbEle = thumbRef.value
|
|
130
|
+
|
|
131
|
+
if (scrollbarEle && thumbEle) {
|
|
132
|
+
scrollbarEle.addEventListener('touchstart', onScrollbarTouchStart, { passive: false })
|
|
133
|
+
thumbEle.addEventListener('touchstart', onThumbMouseDown as any, { passive: false })
|
|
134
|
+
|
|
135
|
+
onUnmounted(() => {
|
|
136
|
+
scrollbarEle.removeEventListener('touchstart', onScrollbarTouchStart)
|
|
137
|
+
thumbEle.removeEventListener('touchstart', onThumbMouseDown as any)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Effect: Handle dragging
|
|
143
|
+
watch(dragging, (isDragging) => {
|
|
144
|
+
if (isDragging) {
|
|
145
|
+
let moveRafId: number | null = null
|
|
146
|
+
|
|
147
|
+
const onMouseMove = (e: MouseEvent | TouchEvent) => {
|
|
148
|
+
const {
|
|
149
|
+
dragging: stateDragging,
|
|
150
|
+
pageY: statePageY,
|
|
151
|
+
startTop: stateStartTop,
|
|
152
|
+
} = stateRef.value
|
|
153
|
+
|
|
154
|
+
if (moveRafId)
|
|
155
|
+
cancelAnimationFrame(moveRafId)
|
|
156
|
+
|
|
157
|
+
const rect = scrollbarRef.value!.getBoundingClientRect()
|
|
158
|
+
const scale = props.containerSize / (props.horizontal ? rect.width : rect.height)
|
|
159
|
+
|
|
160
|
+
if (stateDragging) {
|
|
161
|
+
const offset = (getPageXY(e, props.horizontal || false) - (statePageY || 0)) * scale
|
|
162
|
+
let newTop = stateStartTop || 0
|
|
163
|
+
|
|
164
|
+
if (!isLTR.value && props.horizontal) {
|
|
165
|
+
newTop -= offset
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
newTop += offset
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tmpEnableScrollRange = enableScrollRange.value
|
|
172
|
+
const tmpEnableOffsetRange = enableOffsetRange.value
|
|
173
|
+
|
|
174
|
+
const ptg: number = tmpEnableOffsetRange ? newTop / tmpEnableOffsetRange : 0
|
|
175
|
+
|
|
176
|
+
let newScrollTop = Math.ceil(ptg * tmpEnableScrollRange)
|
|
177
|
+
newScrollTop = Math.max(newScrollTop, 0)
|
|
178
|
+
newScrollTop = Math.min(newScrollTop, tmpEnableScrollRange)
|
|
179
|
+
|
|
180
|
+
moveRafId = requestAnimationFrame(() => {
|
|
181
|
+
props.onScroll(newScrollTop, props.horizontal)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const onMouseUp = () => {
|
|
187
|
+
dragging.value = false
|
|
188
|
+
props.onStopMove()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.addEventListener('mousemove', onMouseMove, { passive: true } as any)
|
|
192
|
+
window.addEventListener('touchmove', onMouseMove, { passive: true } as any)
|
|
193
|
+
window.addEventListener('mouseup', onMouseUp, { passive: true } as any)
|
|
194
|
+
window.addEventListener('touchend', onMouseUp, { passive: true } as any)
|
|
195
|
+
|
|
196
|
+
onUnmounted(() => {
|
|
197
|
+
window.removeEventListener('mousemove', onMouseMove)
|
|
198
|
+
window.removeEventListener('touchmove', onMouseMove)
|
|
199
|
+
window.removeEventListener('mouseup', onMouseUp)
|
|
200
|
+
window.removeEventListener('touchend', onMouseUp)
|
|
201
|
+
|
|
202
|
+
if (moveRafId)
|
|
203
|
+
cancelAnimationFrame(moveRafId)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Effect: Delay hidden on scroll offset change
|
|
209
|
+
watch(() => props.scrollOffset, () => {
|
|
210
|
+
delayHidden()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
onUnmounted(() => {
|
|
214
|
+
if (visibleTimeout)
|
|
215
|
+
clearTimeout(visibleTimeout)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Imperative handle
|
|
219
|
+
expose({
|
|
220
|
+
delayHidden,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return () => {
|
|
224
|
+
const scrollbarPrefixCls = `${props.prefixCls}-scrollbar`
|
|
225
|
+
|
|
226
|
+
const containerStyle: CSSProperties = {
|
|
227
|
+
position: 'absolute',
|
|
228
|
+
visibility: visible.value ? undefined : 'hidden',
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const thumbStyle: CSSProperties = {
|
|
232
|
+
position: 'absolute',
|
|
233
|
+
borderRadius: '99px',
|
|
234
|
+
background: 'var(--vc-virtual-list-scrollbar-bg, rgba(0, 0, 0, 0.5))',
|
|
235
|
+
cursor: 'pointer',
|
|
236
|
+
userSelect: 'none',
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (props.horizontal) {
|
|
240
|
+
Object.assign(containerStyle, {
|
|
241
|
+
height: '8px',
|
|
242
|
+
left: 0,
|
|
243
|
+
right: 0,
|
|
244
|
+
bottom: 0,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
Object.assign(thumbStyle, {
|
|
248
|
+
height: '100%',
|
|
249
|
+
width: `${props.spinSize}px`,
|
|
250
|
+
[isLTR.value ? 'left' : 'right']: `${top.value}px`,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
Object.assign(containerStyle, {
|
|
255
|
+
width: '8px',
|
|
256
|
+
top: 0,
|
|
257
|
+
bottom: 0,
|
|
258
|
+
[isLTR.value ? 'right' : 'left']: 0,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
Object.assign(thumbStyle, {
|
|
262
|
+
width: '100%',
|
|
263
|
+
height: `${props.spinSize}px`,
|
|
264
|
+
top: `${top.value}px`,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div
|
|
270
|
+
ref={scrollbarRef}
|
|
271
|
+
class={[
|
|
272
|
+
scrollbarPrefixCls,
|
|
273
|
+
{
|
|
274
|
+
[`${scrollbarPrefixCls}-horizontal`]: props.horizontal,
|
|
275
|
+
[`${scrollbarPrefixCls}-vertical`]: !props.horizontal,
|
|
276
|
+
[`${scrollbarPrefixCls}-visible`]: visible.value,
|
|
277
|
+
},
|
|
278
|
+
]}
|
|
279
|
+
style={{ ...containerStyle, ...props.style }}
|
|
280
|
+
onMousedown={onContainerMouseDown}
|
|
281
|
+
onMousemove={delayHidden}
|
|
282
|
+
>
|
|
283
|
+
<div
|
|
284
|
+
ref={thumbRef}
|
|
285
|
+
class={[
|
|
286
|
+
`${scrollbarPrefixCls}-thumb`,
|
|
287
|
+
{
|
|
288
|
+
[`${scrollbarPrefixCls}-thumb-moving`]: dragging.value,
|
|
289
|
+
},
|
|
290
|
+
]}
|
|
291
|
+
style={{ ...thumbStyle, ...props.thumbStyle }}
|
|
292
|
+
onMousedown={onThumbMouseDown}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { h } from 'vue'
|
|
4
|
+
import VirtualList from '../List'
|
|
5
|
+
|
|
6
|
+
describe('virtualList', () => {
|
|
7
|
+
it('should render basic list', () => {
|
|
8
|
+
const data = Array.from({ length: 100 }, (_, i) => ({ id: i, text: `Item ${i}` }))
|
|
9
|
+
|
|
10
|
+
const wrapper = mount(VirtualList, {
|
|
11
|
+
props: {
|
|
12
|
+
data,
|
|
13
|
+
height: 200,
|
|
14
|
+
itemHeight: 20,
|
|
15
|
+
itemKey: 'id',
|
|
16
|
+
},
|
|
17
|
+
slots: {
|
|
18
|
+
default: ({ item }: any) => h('div', `${item.text}`),
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
expect(wrapper.exists()).toBe(true)
|
|
23
|
+
expect(wrapper.find('.vc-virtual-list').exists()).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should handle empty data', () => {
|
|
27
|
+
const wrapper = mount(VirtualList, {
|
|
28
|
+
props: {
|
|
29
|
+
data: [],
|
|
30
|
+
height: 200,
|
|
31
|
+
itemHeight: 20,
|
|
32
|
+
itemKey: 'id',
|
|
33
|
+
},
|
|
34
|
+
slots: {
|
|
35
|
+
default: ({ item }: any) => h('div', `${item.text}`),
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(wrapper.exists()).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should work with function itemKey', () => {
|
|
43
|
+
const data = Array.from({ length: 100 }, (_, i) => ({ id: i, text: `Item ${i}` }))
|
|
44
|
+
|
|
45
|
+
const wrapper = mount(VirtualList, {
|
|
46
|
+
props: {
|
|
47
|
+
data,
|
|
48
|
+
height: 200,
|
|
49
|
+
itemHeight: 20,
|
|
50
|
+
itemKey: (item: any) => item.id,
|
|
51
|
+
},
|
|
52
|
+
slots: {
|
|
53
|
+
default: ({ item }: any) => h('div', `${item.text}`),
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(wrapper.exists()).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ref, type Ref, watch } from 'vue'
|
|
2
|
+
|
|
3
|
+
export default function useDiffItem<T>(data: Ref<T[]>, getKey: (item: T) => any): Ref<T | undefined> {
|
|
4
|
+
const prevDataRef = ref<T[]>([])
|
|
5
|
+
const diffItem = ref<T>()
|
|
6
|
+
|
|
7
|
+
watch(
|
|
8
|
+
data,
|
|
9
|
+
(newData) => {
|
|
10
|
+
const prevData = prevDataRef.value
|
|
11
|
+
|
|
12
|
+
if (newData !== prevData) {
|
|
13
|
+
// Find added item
|
|
14
|
+
const addedItem = newData.find((item) => {
|
|
15
|
+
const key = getKey(item)
|
|
16
|
+
return !prevData.some(prevItem => getKey(prevItem) === key)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
diffItem.value = addedItem
|
|
20
|
+
prevDataRef.value = newData
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{ immediate: true },
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return diffItem
|
|
27
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { onUnmounted, ref, type Ref } from 'vue'
|
|
2
|
+
import isFF from '../utils/isFirefox'
|
|
3
|
+
import useOriginScroll from './useOriginScroll'
|
|
4
|
+
|
|
5
|
+
interface FireFoxDOMMouseScrollEvent {
|
|
6
|
+
detail: number
|
|
7
|
+
preventDefault: VoidFunction
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function useFrameWheel(
|
|
11
|
+
inVirtual: Ref<boolean>,
|
|
12
|
+
isScrollAtTop: Ref<boolean>,
|
|
13
|
+
isScrollAtBottom: Ref<boolean>,
|
|
14
|
+
isScrollAtLeft: Ref<boolean>,
|
|
15
|
+
isScrollAtRight: Ref<boolean>,
|
|
16
|
+
horizontalScroll: boolean,
|
|
17
|
+
/**
|
|
18
|
+
* Return `true` when you need to prevent default event
|
|
19
|
+
*/
|
|
20
|
+
onWheelDelta: (offset: number, horizontal: boolean) => void,
|
|
21
|
+
): [(e: WheelEvent) => void, (e: FireFoxDOMMouseScrollEvent) => void] {
|
|
22
|
+
const offsetRef = ref(0)
|
|
23
|
+
let nextFrame: number | null = null
|
|
24
|
+
|
|
25
|
+
// Firefox patch
|
|
26
|
+
const wheelValueRef = ref<number | null>(null)
|
|
27
|
+
const isMouseScrollRef = ref<boolean>(false)
|
|
28
|
+
|
|
29
|
+
// Scroll status sync
|
|
30
|
+
const originScroll = useOriginScroll(
|
|
31
|
+
isScrollAtTop,
|
|
32
|
+
isScrollAtBottom,
|
|
33
|
+
isScrollAtLeft,
|
|
34
|
+
isScrollAtRight,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
function onWheelY(e: WheelEvent, deltaY: number) {
|
|
38
|
+
if (nextFrame)
|
|
39
|
+
cancelAnimationFrame(nextFrame)
|
|
40
|
+
|
|
41
|
+
// Do nothing when scroll at the edge, Skip check when is in scroll
|
|
42
|
+
if (originScroll(false, deltaY))
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
// Skip if nest List has handled this event
|
|
46
|
+
const event = e as WheelEvent & {
|
|
47
|
+
_virtualHandled?: boolean
|
|
48
|
+
}
|
|
49
|
+
if (!event._virtualHandled) {
|
|
50
|
+
event._virtualHandled = true
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
offsetRef.value += deltaY
|
|
57
|
+
wheelValueRef.value = deltaY
|
|
58
|
+
|
|
59
|
+
// Proxy of scroll events
|
|
60
|
+
if (!isFF) {
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
nextFrame = requestAnimationFrame(() => {
|
|
65
|
+
// Patch a multiple for Firefox to fix wheel number too small
|
|
66
|
+
const patchMultiple = isMouseScrollRef.value ? 10 : 1
|
|
67
|
+
onWheelDelta(offsetRef.value * patchMultiple, false)
|
|
68
|
+
offsetRef.value = 0
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onWheelX(event: WheelEvent, deltaX: number) {
|
|
73
|
+
onWheelDelta(deltaX, true)
|
|
74
|
+
|
|
75
|
+
if (!isFF) {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for which direction does wheel do. `sx` means `shift + wheel`
|
|
81
|
+
const wheelDirectionRef = ref<'x' | 'y' | 'sx' | null>(null)
|
|
82
|
+
let wheelDirectionClean: number | null = null
|
|
83
|
+
|
|
84
|
+
function onWheel(event: WheelEvent) {
|
|
85
|
+
if (!inVirtual.value)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
// Wait for 2 frame to clean direction
|
|
89
|
+
if (wheelDirectionClean)
|
|
90
|
+
cancelAnimationFrame(wheelDirectionClean)
|
|
91
|
+
wheelDirectionClean = requestAnimationFrame(() => {
|
|
92
|
+
wheelDirectionRef.value = null
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const { deltaX, deltaY, shiftKey } = event
|
|
96
|
+
|
|
97
|
+
let mergedDeltaX = deltaX
|
|
98
|
+
let mergedDeltaY = deltaY
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
wheelDirectionRef.value === 'sx'
|
|
102
|
+
|| (!wheelDirectionRef.value && (shiftKey || false) && deltaY && !deltaX)
|
|
103
|
+
) {
|
|
104
|
+
mergedDeltaX = deltaY
|
|
105
|
+
mergedDeltaY = 0
|
|
106
|
+
|
|
107
|
+
wheelDirectionRef.value = 'sx'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const absX = Math.abs(mergedDeltaX)
|
|
111
|
+
const absY = Math.abs(mergedDeltaY)
|
|
112
|
+
|
|
113
|
+
if (wheelDirectionRef.value === null) {
|
|
114
|
+
wheelDirectionRef.value = horizontalScroll && absX > absY ? 'x' : 'y'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (wheelDirectionRef.value === 'y') {
|
|
118
|
+
onWheelY(event, mergedDeltaY)
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
onWheelX(event, mergedDeltaX)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A patch for firefox
|
|
126
|
+
function onFireFoxScroll(event: FireFoxDOMMouseScrollEvent) {
|
|
127
|
+
if (!inVirtual.value)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
isMouseScrollRef.value = event.detail === wheelValueRef.value
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onUnmounted(() => {
|
|
134
|
+
if (nextFrame)
|
|
135
|
+
cancelAnimationFrame(nextFrame)
|
|
136
|
+
if (wheelDirectionClean)
|
|
137
|
+
cancelAnimationFrame(wheelDirectionClean)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return [onWheel, onFireFoxScroll]
|
|
141
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { GetKey } from '../interface'
|
|
2
|
+
import type CacheMap from '../utils/CacheMap'
|
|
3
|
+
import { computed, type ComputedRef, type Ref } from 'vue'
|
|
4
|
+
|
|
5
|
+
export function useGetSize<T>(
|
|
6
|
+
mergedData: Ref<T[]>,
|
|
7
|
+
getKey: GetKey<T>,
|
|
8
|
+
heights: CacheMap,
|
|
9
|
+
itemHeight: number,
|
|
10
|
+
): ComputedRef<(startKey: any, endKey?: any) => { top: number, bottom: number }> {
|
|
11
|
+
return computed(() => {
|
|
12
|
+
return (startKey: any, endKey?: any) => {
|
|
13
|
+
let topIndex = 0
|
|
14
|
+
let bottomIndex = mergedData.value.length - 1
|
|
15
|
+
|
|
16
|
+
if (startKey !== undefined && startKey !== null) {
|
|
17
|
+
topIndex = mergedData.value.findIndex(item => getKey(item) === startKey)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (endKey !== undefined && endKey !== null) {
|
|
21
|
+
bottomIndex = mergedData.value.findIndex(item => getKey(item) === endKey)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let top = 0
|
|
25
|
+
for (let i = 0; i < topIndex; i += 1) {
|
|
26
|
+
const key = getKey(mergedData.value[i])
|
|
27
|
+
const cacheHeight = heights.get(key)
|
|
28
|
+
top += cacheHeight === undefined ? itemHeight : cacheHeight
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let bottom = 0
|
|
32
|
+
for (let i = mergedData.value.length - 1; i > bottomIndex; i -= 1) {
|
|
33
|
+
const key = getKey(mergedData.value[i])
|
|
34
|
+
const cacheHeight = heights.get(key)
|
|
35
|
+
bottom += cacheHeight === undefined ? itemHeight : cacheHeight
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
top,
|
|
40
|
+
bottom,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Key } from '@v-c/util/dist/type'
|
|
2
|
+
import type { GetKey } from '../interface'
|
|
3
|
+
import { onUnmounted, ref, type Ref } from 'vue'
|
|
4
|
+
import CacheMap from '../utils/CacheMap'
|
|
5
|
+
|
|
6
|
+
function parseNumber(value: string) {
|
|
7
|
+
const num = parseFloat(value)
|
|
8
|
+
return isNaN(num) ? 0 : num
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function useHeights<T>(
|
|
12
|
+
getKey: GetKey<T>,
|
|
13
|
+
onItemAdd?: (item: T) => void,
|
|
14
|
+
onItemRemove?: (item: T) => void,
|
|
15
|
+
): [
|
|
16
|
+
setInstanceRef: (item: T, instance: HTMLElement | null) => void,
|
|
17
|
+
collectHeight: (sync?: boolean) => void,
|
|
18
|
+
cacheMap: CacheMap,
|
|
19
|
+
updatedMark: Ref<number>,
|
|
20
|
+
] {
|
|
21
|
+
const updatedMark = ref(0)
|
|
22
|
+
const instanceRef = ref(new Map<Key, HTMLElement>())
|
|
23
|
+
const heightsRef = ref(new CacheMap())
|
|
24
|
+
|
|
25
|
+
const promiseIdRef = ref<number>(0)
|
|
26
|
+
|
|
27
|
+
function cancelRaf() {
|
|
28
|
+
promiseIdRef.value += 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectHeight(sync = false) {
|
|
32
|
+
cancelRaf()
|
|
33
|
+
|
|
34
|
+
const doCollect = () => {
|
|
35
|
+
let changed = false
|
|
36
|
+
|
|
37
|
+
instanceRef.value.forEach((element, key) => {
|
|
38
|
+
if (element && element.offsetParent) {
|
|
39
|
+
const { offsetHeight } = element
|
|
40
|
+
const { marginTop, marginBottom } = getComputedStyle(element)
|
|
41
|
+
|
|
42
|
+
const marginTopNum = parseNumber(marginTop)
|
|
43
|
+
const marginBottomNum = parseNumber(marginBottom)
|
|
44
|
+
const totalHeight = offsetHeight + marginTopNum + marginBottomNum
|
|
45
|
+
|
|
46
|
+
if (heightsRef.value.get(key) !== totalHeight) {
|
|
47
|
+
heightsRef.value.set(key, totalHeight)
|
|
48
|
+
changed = true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Always trigger update mark to tell parent that should re-calculate heights when resized
|
|
54
|
+
if (changed) {
|
|
55
|
+
updatedMark.value += 1
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (sync) {
|
|
60
|
+
doCollect()
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
promiseIdRef.value += 1
|
|
64
|
+
const id = promiseIdRef.value
|
|
65
|
+
Promise.resolve().then(() => {
|
|
66
|
+
if (id === promiseIdRef.value) {
|
|
67
|
+
doCollect()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setInstanceRef(item: T, instance: HTMLElement | null) {
|
|
74
|
+
const key = getKey(item)
|
|
75
|
+
const origin = instanceRef.value.get(key)
|
|
76
|
+
|
|
77
|
+
// Only update if the instance actually changed
|
|
78
|
+
if (origin === instance) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (instance) {
|
|
83
|
+
instanceRef.value.set(key, instance)
|
|
84
|
+
collectHeight()
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
instanceRef.value.delete(key)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Instance changed
|
|
91
|
+
if (!origin !== !instance) {
|
|
92
|
+
if (instance) {
|
|
93
|
+
onItemAdd?.(item)
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
onItemRemove?.(item)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onUnmounted(() => {
|
|
102
|
+
cancelRaf()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return [setInstanceRef, collectHeight, heightsRef.value, updatedMark]
|
|
106
|
+
}
|