519 lines
12 KiB
Vue
519 lines
12 KiB
Vue
<template>
|
|
<div class="virtual-scroller" :style="containerStyle">
|
|
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
|
|
<div
|
|
ref="scrollContainerRef"
|
|
class="virtual-scroller-container"
|
|
:style="containerInnerStyle"
|
|
@scroll.passive="handleScroll"
|
|
@wheel="handleWheel"
|
|
>
|
|
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
|
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
|
<slot name="bottom-placeholder" />
|
|
</div>
|
|
<div
|
|
v-for="item in visibleItems"
|
|
:key="item.key"
|
|
:ref="el => setItemRef(el, item.key)"
|
|
class="virtual-scroller-item"
|
|
:style="getItemStyle(item)"
|
|
:data-index="item.index"
|
|
:data-key="item.key"
|
|
>
|
|
<slot name="default" :item="item.data" :index="item.index" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
const props = defineProps({
|
|
data: {
|
|
type: Array,
|
|
required: true,
|
|
default: () => []
|
|
},
|
|
itemKey: {
|
|
type: [String, Function],
|
|
default: 'id'
|
|
},
|
|
estimatedHeight: {
|
|
type: Number,
|
|
default: 100
|
|
},
|
|
buffer: {
|
|
type: Number,
|
|
default: 5
|
|
},
|
|
placeholderHeight: {
|
|
type: Number,
|
|
default: 350
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
|
|
|
const scrollContainerRef = ref(null)
|
|
const itemRefs = new Map()
|
|
const itemHeights = ref(new Map())
|
|
const resizeObserver = ref(null)
|
|
const isScrolling = ref(false)
|
|
const scrollTimeout = ref(null)
|
|
|
|
const getKey = (item, index) => {
|
|
if (typeof props.itemKey === 'function') {
|
|
return props.itemKey(item, index)
|
|
}
|
|
if (item && typeof item === 'object') {
|
|
return item[props.itemKey] ?? index
|
|
}
|
|
return index
|
|
}
|
|
|
|
const getItemHeight = (key) => {
|
|
return itemHeights.value.get(key) ?? props.estimatedHeight
|
|
}
|
|
|
|
const containerStyle = computed(() => ({
|
|
height: '100%',
|
|
width: '100%',
|
|
position: 'relative'
|
|
}))
|
|
|
|
const wrapperStyle = computed(() => ({
|
|
height: '100%',
|
|
width: '100%',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
transform: 'rotate(180deg)',
|
|
direction: 'rtl'
|
|
}))
|
|
|
|
const containerInnerStyle = computed(() => ({
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
overflowX: 'hidden',
|
|
overflowY: 'auto',
|
|
direction: 'ltr'
|
|
}))
|
|
|
|
const totalDataHeight = computed(() => {
|
|
let height = 0
|
|
for (let i = 0; i < props.data.length; i++) {
|
|
const key = getKey(props.data[i], i)
|
|
height += getItemHeight(key)
|
|
}
|
|
return height
|
|
})
|
|
|
|
const totalHeight = computed(() => {
|
|
return props.placeholderHeight + totalDataHeight.value
|
|
})
|
|
|
|
const spacerStyle = computed(() => ({
|
|
height: `${totalHeight.value}px`,
|
|
width: '100%',
|
|
flexShrink: 0
|
|
}))
|
|
|
|
const placeholderStyle = computed(() => ({
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: `${props.placeholderHeight}px`,
|
|
zIndex: 1
|
|
}))
|
|
|
|
const getItemOffsets = () => {
|
|
const offsets = []
|
|
let offset = 0
|
|
for (let i = 0; i < props.data.length; i++) {
|
|
offsets.push(offset)
|
|
const key = getKey(props.data[i], i)
|
|
offset += getItemHeight(key)
|
|
}
|
|
return offsets
|
|
}
|
|
|
|
const visibleRange = computed(() => {
|
|
const count = props.data.length
|
|
if (count === 0) {
|
|
return { start: 0, end: 0, offset: 0 }
|
|
}
|
|
|
|
const el = scrollContainerRef.value
|
|
if (!el) {
|
|
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
|
}
|
|
|
|
const scrollTop = el.scrollTop
|
|
const viewportHeight = el.clientHeight || 600
|
|
const bufferCount = props.buffer
|
|
|
|
// In inverted scroll (180deg rotation):
|
|
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
|
|
// - scrollTop = max: visual TOP (shows older data, higher index)
|
|
// - Items are positioned from top: placeholderHeight, then data items
|
|
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
|
|
|
// When scrollTop = 0, we're at visual bottom, showing items near the START of data
|
|
// When scrollTop = max, we're at visual top, showing items near the END of data
|
|
|
|
// The visible area in data coordinates:
|
|
// - scrollTop 0 means we see items at offset 0 (start of data)
|
|
// - scrollTop increases means we see items at higher offsets (end of data)
|
|
|
|
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
|
const visibleEnd = visibleStart + viewportHeight
|
|
|
|
let startIndex = 0
|
|
let endIndex = count - 1
|
|
let startOffset = 0
|
|
let currentOffset = 0
|
|
|
|
// Find startIndex: first item that ends after visibleStart
|
|
for (let i = 0; i < count; i++) {
|
|
const key = getKey(props.data[i], i)
|
|
const height = getItemHeight(key)
|
|
const itemEnd = currentOffset + height
|
|
|
|
if (itemEnd > visibleStart) {
|
|
startIndex = Math.max(0, i - bufferCount)
|
|
break
|
|
}
|
|
|
|
currentOffset += height
|
|
}
|
|
|
|
// Calculate startOffset for startIndex
|
|
startOffset = 0
|
|
for (let i = 0; i < startIndex; i++) {
|
|
const key = getKey(props.data[i], i)
|
|
startOffset += getItemHeight(key)
|
|
}
|
|
|
|
// Find endIndex: last item that starts before visibleEnd
|
|
currentOffset = startOffset
|
|
for (let i = startIndex; i < count; i++) {
|
|
const key = getKey(props.data[i], i)
|
|
const height = getItemHeight(key)
|
|
|
|
// Check if this item is visible (item starts before visibleEnd)
|
|
if (currentOffset >= visibleEnd) {
|
|
// This item starts after visibleEnd, so previous item is the last visible
|
|
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
|
break
|
|
}
|
|
|
|
endIndex = i
|
|
currentOffset += height
|
|
}
|
|
|
|
return { start: startIndex, end: endIndex, offset: startOffset }
|
|
})
|
|
|
|
const visibleItems = computed(() => {
|
|
const { start, end, offset } = visibleRange.value
|
|
const items = []
|
|
const count = props.data.length
|
|
|
|
if (count === 0) return items
|
|
|
|
const safeStart = Math.max(0, start)
|
|
const safeEnd = Math.min(count - 1, end)
|
|
|
|
if (safeStart > safeEnd) return items
|
|
|
|
let currentOffset = offset + props.placeholderHeight
|
|
const seenKeys = new Set()
|
|
|
|
for (let i = safeStart; i <= safeEnd; i++) {
|
|
const data = props.data[i]
|
|
if (!data) continue
|
|
|
|
const key = getKey(data, i)
|
|
|
|
// Deduplicate by key
|
|
if (seenKeys.has(key)) continue
|
|
seenKeys.add(key)
|
|
|
|
const height = getItemHeight(key)
|
|
|
|
items.push({
|
|
data,
|
|
index: i,
|
|
key,
|
|
offset: currentOffset,
|
|
height
|
|
})
|
|
|
|
currentOffset += height
|
|
}
|
|
|
|
return items
|
|
})
|
|
|
|
const getItemStyle = (item) => ({
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
transform: `translateY(${item.offset}px)`,
|
|
willChange: 'transform'
|
|
})
|
|
|
|
const setItemRef = (el, key) => {
|
|
if (el) {
|
|
itemRefs.set(key, el)
|
|
} else {
|
|
itemRefs.delete(key)
|
|
}
|
|
}
|
|
|
|
const measureItem = (key, element) => {
|
|
if (!element) return
|
|
|
|
const target = element.firstElementChild || element
|
|
const height = target.getBoundingClientRect().height
|
|
|
|
if (height > 0 && height !== itemHeights.value.get(key)) {
|
|
const newHeights = new Map(itemHeights.value)
|
|
newHeights.set(key, height)
|
|
itemHeights.value = newHeights
|
|
}
|
|
}
|
|
|
|
const setupResizeObserver = () => {
|
|
if (resizeObserver.value) {
|
|
resizeObserver.value.disconnect()
|
|
}
|
|
|
|
resizeObserver.value = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const key = entry.target.dataset.key
|
|
if (key !== undefined) {
|
|
measureItem(key, entry.target)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const observeItems = () => {
|
|
if (!resizeObserver.value) return
|
|
|
|
resizeObserver.value.disconnect()
|
|
|
|
for (const [key, element] of itemRefs) {
|
|
if (element) {
|
|
resizeObserver.value.observe(element)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleWheel = (event) => {
|
|
if (!scrollContainerRef.value) return
|
|
|
|
scrollContainerRef.value.scrollBy({
|
|
top: -event.deltaY,
|
|
behavior: 'instant'
|
|
})
|
|
|
|
event.preventDefault()
|
|
}
|
|
|
|
const handleScroll = (event) => {
|
|
const target = event.target
|
|
const scrollHeight = target.scrollHeight
|
|
const scrollTop = target.scrollTop
|
|
const clientHeight = target.clientHeight
|
|
|
|
isScrolling.value = true
|
|
|
|
if (scrollTimeout.value) {
|
|
clearTimeout(scrollTimeout.value)
|
|
}
|
|
|
|
scrollTimeout.value = setTimeout(() => {
|
|
isScrolling.value = false
|
|
}, 150)
|
|
|
|
// In inverted scroll:
|
|
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
|
// - distanceToBottom (visual bottom) = scrollTop
|
|
// - isAtTop (visual top, older data) = distanceToTop <= threshold
|
|
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
|
|
const distanceToTop = scrollHeight - scrollTop - clientHeight
|
|
const distanceToBottom = scrollTop
|
|
const threshold = 5
|
|
|
|
const isAtTop = distanceToTop <= threshold
|
|
const isAtBottom = distanceToBottom <= threshold
|
|
|
|
emit('scroll', {
|
|
scrollTop,
|
|
scrollHeight,
|
|
clientHeight,
|
|
distanceToTop,
|
|
distanceToBottom,
|
|
isAtTop,
|
|
isAtBottom
|
|
})
|
|
|
|
// scroll-start: reached visual top (older data, need to load more)
|
|
if (isAtTop) {
|
|
emit('scroll-start')
|
|
}
|
|
|
|
// scroll-end: reached visual bottom (newer data)
|
|
if (isAtBottom) {
|
|
emit('scroll-end')
|
|
}
|
|
}
|
|
|
|
const scrollToIndex = (index, behavior = 'auto') => {
|
|
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
|
|
|
let offset = 0
|
|
for (let i = 0; i < index; i++) {
|
|
const key = getKey(props.data[i], i)
|
|
offset += getItemHeight(key)
|
|
}
|
|
|
|
const targetScrollTop = offset + props.placeholderHeight
|
|
|
|
scrollContainerRef.value.scrollTo({
|
|
top: targetScrollTop,
|
|
behavior
|
|
})
|
|
}
|
|
|
|
const scrollToBottom = (behavior = 'smooth') => {
|
|
if (!scrollContainerRef.value) return
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!scrollContainerRef.value) return
|
|
// In inverted scroll, bottom is scrollTop = 0
|
|
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
|
})
|
|
}
|
|
|
|
const scrollToTop = (behavior = 'smooth') => {
|
|
if (!scrollContainerRef.value) return
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!scrollContainerRef.value) return
|
|
// In inverted scroll, top is scrollTop = max
|
|
scrollContainerRef.value.scrollTo({
|
|
top: scrollContainerRef.value.scrollHeight,
|
|
behavior
|
|
})
|
|
})
|
|
}
|
|
|
|
const getScrollElement = () => scrollContainerRef.value
|
|
|
|
const isAtTop = () => {
|
|
if (!scrollContainerRef.value) return false
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
|
return scrollHeight - scrollTop - clientHeight <= 5
|
|
}
|
|
|
|
const isAtBottom = () => {
|
|
if (!scrollContainerRef.value) return false
|
|
return scrollContainerRef.value.scrollTop <= 5
|
|
}
|
|
|
|
const reset = () => {
|
|
itemHeights.value = new Map()
|
|
itemRefs.clear()
|
|
}
|
|
|
|
watch(() => props.data, (newData, oldData) => {
|
|
const newLength = newData?.length || 0
|
|
const oldLength = oldData?.length || 0
|
|
|
|
if (newLength < oldLength) {
|
|
reset()
|
|
}
|
|
|
|
nextTick(observeItems)
|
|
}, { deep: true })
|
|
|
|
watch(visibleItems, () => {
|
|
nextTick(observeItems)
|
|
}, { deep: true })
|
|
|
|
onMounted(() => {
|
|
setupResizeObserver()
|
|
nextTick(observeItems)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (resizeObserver.value) {
|
|
resizeObserver.value.disconnect()
|
|
}
|
|
|
|
if (scrollTimeout.value) {
|
|
clearTimeout(scrollTimeout.value)
|
|
}
|
|
|
|
itemRefs.clear()
|
|
})
|
|
|
|
defineExpose({
|
|
scrollToIndex,
|
|
scrollToBottom,
|
|
scrollToTop,
|
|
getScrollElement,
|
|
isAtTop,
|
|
isAtBottom,
|
|
reset,
|
|
scrollContainerRef
|
|
})
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
.virtual-scroller {
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
|
|
&-wrapper {
|
|
contain: content;
|
|
}
|
|
|
|
&-container {
|
|
contain: layout style;
|
|
|
|
&::-webkit-scrollbar {
|
|
display: none;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
}
|
|
|
|
&-spacer {
|
|
flex-shrink: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
&-item {
|
|
contain: layout style;
|
|
backface-visibility: hidden;
|
|
perspective: 1000px;
|
|
}
|
|
|
|
&-placeholder {
|
|
contain: layout style;
|
|
}
|
|
}
|
|
</style>
|