AI_Painting_V2.0/src/components/virtual-scroller/VirtualScroller.vue

655 lines
14 KiB
Vue

<template>
<div
ref="containerRef"
class="virtual-scroller"
:style="containerStyle"
>
<div
ref="wrapperRef"
class="virtual-scroller-wrapper"
:style="wrapperStyle"
>
<div
ref="renderContainerRef"
class="virtual-scroller-render-container"
:style="renderContainerStyle"
@scroll.passive="handleScroll"
@wheel="handleWheel"
>
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
<div
class="virtual-scroller-bottom-placeholder"
:style="bottomPlaceholderStyle"
>
<slot name="bottom-placeholder" />
</div>
<div
v-for="renderItem in visibleItems"
:key="getItemKey(renderItem.item, renderItem.index)"
:ref="el => setItemRef(el, renderItem.index)"
class="virtual-scroller-item"
:style="getItemStyle(renderItem)"
:data-index="renderItem.index"
>
<slot
name="default"
:item="renderItem.item"
:index="renderItem.index"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
data: {
type: Array,
required: false,
default: () => []
},
items: {
type: Array,
required: false,
default: () => []
},
itemKey: {
type: [String, Function],
default: 'id'
},
keyField: {
type: String,
default: 'id'
},
estimatedHeight: {
type: Number,
default: 100
},
buffer: {
type: Number,
default: 3
},
bufferSize: {
type: Number,
default: 3
},
height: {
type: [String, Number],
default: '100%'
},
width: {
type: [String, Number],
default: '100%'
},
renderMode: {
type: String,
default: 'default',
validator: (value) => ['default', 'top'].includes(value)
},
direction: {
type: String,
default: 'reverse',
validator: (value) => ['normal', 'reverse'].includes(value)
},
bottomPlaceholderHeight: {
type: Number,
default: 350
}
})
const computedData = computed(() => {
return props.data.length > 0 ? props.data : props.items
})
const computedItemKey = computed(() => {
if (typeof props.itemKey === 'function') return props.itemKey
if (props.itemKey !== 'id') return props.itemKey
return props.keyField
})
const computedBuffer = computed(() => {
return props.buffer !== 3 ? props.buffer : props.bufferSize
})
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
const containerRef = ref(null)
const wrapperRef = ref(null)
const renderContainerRef = ref(null)
const itemRefs = new Map()
const itemHeights = ref(new Map())
const resizeObserver = ref(null)
const scrollTop = ref(0)
const isScrolling = ref(false)
const scrollTimeout = ref(null)
const isInitialized = ref(false)
const pendingScrollToBottom = ref(false)
const previousDataLength = ref(0)
const containerStyle = computed(() => {
return {
height: '100%',
width: '100%',
position: 'relative'
}
})
const totalHeight = computed(() => {
let height = 0
const len = computedData.value.length
for (let i = 0; i < len; i++) {
const cachedHeight = itemHeights.value.get(i)
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
}
height += props.bottomPlaceholderHeight
return height
})
const getItemPosition = (index) => {
let offset = 0
for (let i = 0; i < index; i++) {
const cachedHeight = itemHeights.value.get(i)
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
}
const height = itemHeights.value.get(index) ?? props.estimatedHeight
return { offset, height }
}
const containerHeight = computed(() => {
if (!renderContainerRef.value) return 0
return renderContainerRef.value.clientHeight
})
const visibleRange = computed(() => {
if (!renderContainerRef.value || computedData.value.length === 0) {
return { start: 0, end: 0, offset: 0 }
}
const viewportHeight = containerHeight.value
const currentScrollTop = scrollTop.value
const bufferCount = computedBuffer.value
let startIndex = 0
let endIndex = computedData.value.length - 1
let startOffset = 0
let offset = 0
for (let i = 0; i < computedData.value.length; i++) {
const cachedHeight = itemHeights.value.get(i)
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
if (offset + height > currentScrollTop) {
startIndex = Math.max(0, i - bufferCount)
break
}
offset += height
}
startOffset = 0
for (let i = 0; i < startIndex; i++) {
const cachedHeight = itemHeights.value.get(i)
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
}
offset = startOffset
endIndex = startIndex
for (let i = startIndex; i < computedData.value.length; i++) {
const cachedHeight = itemHeights.value.get(i)
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
offset += height
if (offset > currentScrollTop + viewportHeight) {
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
break
}
endIndex = i
}
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
})
const visibleItems = computed(() => {
const { start, end, offset } = visibleRange.value
const items = []
let currentOffset = offset
for (let i = start; i <= end && i < computedData.value.length; i++) {
const cachedHeight = itemHeights.value.get(i)
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
items.push({
item: computedData.value[i],
index: i,
offset: currentOffset + props.bottomPlaceholderHeight,
height
})
currentOffset += height
}
return items
})
const wrapperStyle = computed(() => ({
direction: 'rtl',
height: '100%',
position: 'relative',
scrollbarWidth: 'auto',
overflow: 'hidden',
transform: 'rotate(180deg)',
width: '100%'
}))
const renderContainerStyle = computed(() => ({
direction: 'ltr',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
bottom: 0,
left: 0,
overflowX: 'hidden',
overflowY: 'auto',
position: 'absolute',
right: 0,
top: 0,
width: '100%'
}))
const bottomPlaceholderStyle = computed(() => ({
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
height: `${props.bottomPlaceholderHeight}px`,
transform: `translateY(0px)`,
zIndex: 1
}))
const getItemKey = (item, index) => {
const keyField = computedItemKey.value
if (typeof keyField === 'function') {
return keyField(item, index)
}
if (typeof keyField === 'string' && item && typeof item === 'object') {
return item[keyField] ?? index
}
return index
}
const getItemStyle = (renderItem) => {
return {
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
transform: `translateY(${renderItem.offset}px)`,
willChange: 'transform'
}
}
const setItemRef = (el, index) => {
if (el) {
itemRefs.set(index, el)
} else {
itemRefs.delete(index)
}
}
const measureItem = (index, element) => {
if (!element) return
const firstChild = element.firstElementChild
const targetElement = firstChild || element
const height = Math.ceil(targetElement.offsetHeight)
if (height > 0) {
const cachedHeight = itemHeights.value.get(index)
if (cachedHeight !== height) {
const newHeights = new Map(itemHeights.value)
newHeights.set(index, height)
itemHeights.value = newHeights
}
}
}
const setupResizeObserver = () => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = parseInt(entry.target.dataset.index, 10)
if (!isNaN(index)) {
measureItem(index, entry.target)
}
}
})
}
const handleWheel = (event) => {
if (!renderContainerRef.value) return
const { deltaY } = event
const el = renderContainerRef.value
el.scrollBy({
top: -deltaY,
behavior: 'instant'
})
event.preventDefault()
}
const handleScroll = (event) => {
const target = event.target
scrollTop.value = target.scrollTop
isScrolling.value = true
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
scrollTimeout.value = setTimeout(() => {
isScrolling.value = false
}, 150)
const st = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.clientHeight
const distanceToContainerTop = st
const distanceToContainerBottom = scrollHeight - st - clientHeight
const distanceToPageTop = distanceToContainerBottom
const distanceToPageBottom = distanceToContainerTop
const isAtPageTop = distanceToPageTop <= 0
const isAtPageBottom = distanceToPageBottom <= 0
emit('scroll', {
target,
scrollTop: st,
scrollHeight,
clientHeight,
distanceToPageTop,
distanceToPageBottom,
isAtPageTop,
isAtPageBottom
})
if (isAtPageTop) {
emit('scroll-start')
}
if (isAtPageBottom) {
emit('scroll-end')
}
}
const scrollToIndex = (index, behavior = 'auto') => {
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
const position = getItemPosition(index)
renderContainerRef.value.scrollTo({
top: position.offset,
behavior
})
}
const scrollToBottom = (behavior = 'smooth') => {
if (!renderContainerRef.value) {
pendingScrollToBottom.value = true
return
}
requestAnimationFrame(() => {
if (!renderContainerRef.value) return
renderContainerRef.value.scrollTo({
top: 0,
behavior
})
})
}
const scrollToTop = (behavior = 'smooth') => {
if (!renderContainerRef.value) return
requestAnimationFrame(() => {
if (!renderContainerRef.value) return
const scrollHeight = renderContainerRef.value.scrollHeight
renderContainerRef.value.scrollTo({
top: scrollHeight,
behavior
})
})
}
const getScrollElement = () => renderContainerRef.value
const getVisibleIndices = () => {
const { start, end } = visibleRange.value
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
}
const resetMeasurements = () => {
itemHeights.value = new Map()
itemRefs.clear()
}
const isAtPageBottom = () => {
if (!renderContainerRef.value) return false
const { scrollTop } = renderContainerRef.value
return scrollTop <= 0
}
const isAtPageTop = () => {
if (!renderContainerRef.value) return false
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
return scrollHeight - scrollTop - clientHeight <= 0
}
const observeVisibleItems = () => {
if (!resizeObserver.value) return
resizeObserver.value.disconnect()
for (const [index, element] of itemRefs) {
if (element) {
resizeObserver.value.observe(element)
}
}
}
watch(() => computedData.value, (newData, oldData) => {
const oldLength = oldData?.length || 0
const newLength = newData.length
if (newLength !== oldLength) {
const newHeights = new Map()
const minLen = Math.min(oldLength, newLength)
for (let i = 0; i < minLen; i++) {
if (itemHeights.value.has(i)) {
newHeights.set(i, itemHeights.value.get(i))
}
}
itemHeights.value = newHeights
nextTick(() => {
observeVisibleItems()
})
}
previousDataLength.value = newLength
}, { deep: false })
watch(visibleItems, (newItems) => {
nextTick(() => {
observeVisibleItems()
cleanupExtraItems(newItems)
})
if (newItems.length > 0) {
const firstItem = newItems[0]
const lastItem = newItems[newItems.length - 1]
emit('visible-change', firstItem.index, lastItem.index)
}
}, { deep: true })
const cleanupExtraItems = (visibleItems) => {
if (!itemRefs.size || !visibleItems.length) return
const visibleIndices = new Set(visibleItems.map(item => item.index))
const existingIndices = Array.from(itemRefs.keys()).sort((a, b) => a - b)
const toRemove = []
let lastValidIndex = -1
for (const index of existingIndices) {
if (visibleIndices.has(index)) {
if (lastValidIndex !== -1 && index !== lastValidIndex + 1) {
for (let i = lastValidIndex + 1; i < index; i++) {
if (itemRefs.has(i)) {
toRemove.push(i)
}
}
}
lastValidIndex = index
} else {
toRemove.push(index)
}
}
for (const index of toRemove) {
const element = itemRefs.get(index)
if (element && element.parentNode) {
element.parentNode.removeChild(element)
}
itemRefs.delete(index)
}
if (toRemove.length > 0) {
console.log(`清理了 ${toRemove.length} 个多余项目:`, toRemove)
}
}
onMounted(() => {
setupResizeObserver()
isInitialized.value = true
previousDataLength.value = computedData.value.length
nextTick(() => {
if (pendingScrollToBottom.value) {
pendingScrollToBottom.value = false
scrollToBottom()
}
observeVisibleItems()
cleanupExtraItems(visibleItems.value)
})
})
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
itemRefs.clear()
})
defineExpose({
scrollToIndex,
scrollToItem: scrollToIndex,
scrollToBottom,
scrollToTop,
getScrollElement,
getVisibleIndices,
resetMeasurements,
containerRef,
isAtPageBottom,
isAtPageTop
})
</script>
<style lang="less" scoped>
.virtual-scroller {
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
.virtual-scroller-wrapper {
contain: content;
}
.virtual-scroller-spacer {
flex-shrink: 0;
width: 100%;
}
.virtual-scroller-placeholder {
width: 100%;
}
.virtual-scroller-render-container {
contain: layout style;
&::-webkit-scrollbar {
display: none;
width: 6px;
height: 6px;
}
}
.virtual-scroller-item {
contain: layout style;
backface-visibility: hidden;
perspective: 1000px;
}
.virtual-scroller-bottom-placeholder {
contain: layout style;
}
}
</style>