AI_Painting_V2.0/src/components/virtual-scroller/VirtualScroller copy.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>