优化屏幕放大时虚拟滚动显示的问题,优化整体宽度可自动
This commit is contained in:
parent
0a996eac08
commit
44c7309608
|
|
@ -11,9 +11,7 @@ export {}
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
2: typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
|
||||||
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
||||||
copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
|
||||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
|
@ -21,6 +19,7 @@ declare module 'vue' {
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
IEpCalendar: typeof import('~icons/ep/calendar')['default']
|
IEpCalendar: typeof import('~icons/ep/calendar')['default']
|
||||||
IEpClose: typeof import('~icons/ep/close')['default']
|
IEpClose: typeof import('~icons/ep/close')['default']
|
||||||
|
IEpDocumentCopy: typeof import('~icons/ep/document-copy')['default']
|
||||||
IEpLoading: typeof import('~icons/ep/loading')['default']
|
IEpLoading: typeof import('~icons/ep/loading')['default']
|
||||||
IEpPlus: typeof import('~icons/ep/plus')['default']
|
IEpPlus: typeof import('~icons/ep/plus')['default']
|
||||||
IEpStar: typeof import('~icons/ep/star')['default']
|
IEpStar: typeof import('~icons/ep/star')['default']
|
||||||
|
|
@ -39,5 +38,6 @@ declare module 'vue' {
|
||||||
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
||||||
'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||||
'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||||
|
'VirtualScroller copy 3': typeof import('./src/components/virtual-scroller/VirtualScroller copy 3.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,8 @@ onMounted(async () => {
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
/* 输入区域 */
|
/* 输入区域 */
|
||||||
.input-container {
|
.input-container {
|
||||||
width: 880px;
|
width: 50%;
|
||||||
|
max-width: 880px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,42 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="virtual-scroller" :style="containerStyle">
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="virtual-scroller"
|
||||||
|
:style="containerStyle"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref="scrollContainerRef"
|
ref="wrapperRef"
|
||||||
class="virtual-scroller-container"
|
class="virtual-scroller-wrapper"
|
||||||
:style="scrollContainerStyle"
|
:style="wrapperStyle"
|
||||||
@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
|
<div
|
||||||
v-for="item in visibleItems"
|
ref="renderContainerRef"
|
||||||
:key="item.key"
|
class="virtual-scroller-render-container"
|
||||||
:ref="el => setItemRef(el, item.key)"
|
:style="renderContainerStyle"
|
||||||
class="virtual-scroller-item"
|
@scroll.passive="handleScroll"
|
||||||
:style="getItemStyle(item)"
|
@wheel="handleWheel"
|
||||||
:data-index="item.index"
|
|
||||||
:data-key="item.key"
|
|
||||||
>
|
>
|
||||||
<slot name="default" :item="item.data" :index="item.index" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,197 +48,192 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: false,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
itemKey: {
|
itemKey: {
|
||||||
type: [String, Function],
|
type: [String, Function],
|
||||||
default: 'id'
|
default: 'id'
|
||||||
},
|
},
|
||||||
|
keyField: {
|
||||||
|
type: String,
|
||||||
|
default: 'id'
|
||||||
|
},
|
||||||
estimatedHeight: {
|
estimatedHeight: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 100
|
default: 100
|
||||||
},
|
},
|
||||||
buffer: {
|
buffer: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 5
|
default: 3
|
||||||
},
|
},
|
||||||
placeholderHeight: {
|
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,
|
type: Number,
|
||||||
default: 350
|
default: 350
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
const computedData = computed(() => {
|
||||||
|
return props.data.length > 0 ? props.data : props.items
|
||||||
|
})
|
||||||
|
|
||||||
const scrollContainerRef = ref(null)
|
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 itemRefs = new Map()
|
||||||
const itemHeights = ref(new Map())
|
const itemHeights = ref(new Map())
|
||||||
const resizeObserver = ref(null)
|
const resizeObserver = ref(null)
|
||||||
|
const scrollTop = ref(0)
|
||||||
const isScrolling = ref(false)
|
const isScrolling = ref(false)
|
||||||
const scrollTimeout = ref(null)
|
const scrollTimeout = ref(null)
|
||||||
|
const isInitialized = ref(false)
|
||||||
|
const pendingScrollToBottom = ref(false)
|
||||||
|
const previousDataLength = ref(0)
|
||||||
|
|
||||||
const getKey = (item, index) => {
|
const containerStyle = computed(() => {
|
||||||
if (typeof props.itemKey === 'function') {
|
return {
|
||||||
return props.itemKey(item, index)
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative'
|
||||||
}
|
}
|
||||||
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 scrollContainerStyle = computed(() => ({
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflowX: 'hidden',
|
|
||||||
overflowY: 'auto',
|
|
||||||
direction: 'rtl',
|
|
||||||
transform: 'rotate(180deg)'
|
|
||||||
}))
|
|
||||||
|
|
||||||
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(() => {
|
const totalHeight = computed(() => {
|
||||||
return props.placeholderHeight + totalDataHeight.value
|
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 spacerStyle = computed(() => ({
|
const getItemPosition = (index) => {
|
||||||
height: `${totalHeight.value}px`,
|
let offset = 0
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0
|
|
||||||
}))
|
|
||||||
|
|
||||||
const placeholderStyle = computed(() => ({
|
for (let i = 0; i < index; i++) {
|
||||||
position: 'absolute',
|
const cachedHeight = itemHeights.value.get(i)
|
||||||
top: 0,
|
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||||
left: 0,
|
}
|
||||||
right: 0,
|
|
||||||
height: `${props.placeholderHeight}px`,
|
const height = itemHeights.value.get(index) ?? props.estimatedHeight
|
||||||
zIndex: 1,
|
|
||||||
direction: 'ltr'
|
return { offset, height }
|
||||||
}))
|
}
|
||||||
|
|
||||||
|
const containerHeight = computed(() => {
|
||||||
|
if (!renderContainerRef.value) return 0
|
||||||
|
return renderContainerRef.value.clientHeight
|
||||||
|
})
|
||||||
|
|
||||||
const visibleRange = computed(() => {
|
const visibleRange = computed(() => {
|
||||||
const count = props.data.length
|
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||||
if (count === 0) {
|
|
||||||
return { start: 0, end: 0, offset: 0 }
|
return { start: 0, end: 0, offset: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = scrollContainerRef.value
|
const viewportHeight = containerHeight.value
|
||||||
if (!el) {
|
const currentScrollTop = scrollTop.value
|
||||||
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
const bufferCount = computedBuffer.value
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
|
||||||
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
|
||||||
const visibleEnd = visibleStart + viewportHeight
|
|
||||||
|
|
||||||
let startIndex = 0
|
let startIndex = 0
|
||||||
let endIndex = count - 1
|
let endIndex = computedData.value.length - 1
|
||||||
let startOffset = 0
|
let startOffset = 0
|
||||||
let currentOffset = 0
|
|
||||||
|
|
||||||
// Find startIndex: first item that ends after visibleStart
|
let offset = 0
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < computedData.value.length; i++) {
|
||||||
const key = getKey(props.data[i], i)
|
const cachedHeight = itemHeights.value.get(i)
|
||||||
const height = getItemHeight(key)
|
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||||
const itemEnd = currentOffset + height
|
|
||||||
|
|
||||||
if (itemEnd > visibleStart) {
|
if (offset + height > currentScrollTop) {
|
||||||
startIndex = Math.max(0, i - bufferCount)
|
startIndex = Math.max(0, i - bufferCount)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
currentOffset += height
|
offset += height
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate startOffset for startIndex
|
|
||||||
startOffset = 0
|
startOffset = 0
|
||||||
for (let i = 0; i < startIndex; i++) {
|
for (let i = 0; i < startIndex; i++) {
|
||||||
const key = getKey(props.data[i], i)
|
const cachedHeight = itemHeights.value.get(i)
|
||||||
startOffset += getItemHeight(key)
|
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find endIndex: last item that starts before visibleEnd
|
offset = startOffset
|
||||||
currentOffset = startOffset
|
endIndex = startIndex
|
||||||
for (let i = startIndex; i < count; i++) {
|
for (let i = startIndex; i < computedData.value.length; i++) {
|
||||||
const key = getKey(props.data[i], i)
|
const cachedHeight = itemHeights.value.get(i)
|
||||||
const height = getItemHeight(key)
|
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||||
|
|
||||||
// Check if this item is visible (item starts before visibleEnd)
|
offset += height
|
||||||
if (currentOffset >= visibleEnd) {
|
|
||||||
// This item starts after visibleEnd, so previous item is the last visible
|
if (offset > currentScrollTop + viewportHeight) {
|
||||||
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
endIndex = i
|
endIndex = i
|
||||||
currentOffset += height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleItems = computed(() => {
|
const visibleItems = computed(() => {
|
||||||
const { start, end, offset } = visibleRange.value
|
const { start, end, offset } = visibleRange.value
|
||||||
const items = []
|
const items = []
|
||||||
const count = props.data.length
|
|
||||||
|
|
||||||
if (count === 0) return items
|
let currentOffset = offset
|
||||||
|
|
||||||
const safeStart = Math.max(0, start)
|
for (let i = start; i <= end && i < computedData.value.length; i++) {
|
||||||
const safeEnd = Math.min(count - 1, end)
|
const cachedHeight = itemHeights.value.get(i)
|
||||||
|
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||||
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({
|
items.push({
|
||||||
data,
|
item: computedData.value[i],
|
||||||
index: i,
|
index: i,
|
||||||
key,
|
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||||
offset: currentOffset,
|
|
||||||
height
|
height
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -232,34 +243,88 @@ const visibleItems = computed(() => {
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const getItemStyle = (item) => ({
|
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',
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
width: '100%'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const bottomPlaceholderStyle = computed(() => ({
|
||||||
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
transform: `translateY(${item.offset}px)`,
|
top: 0,
|
||||||
direction: 'ltr',
|
width: '100%',
|
||||||
willChange: 'transform'
|
height: `${props.bottomPlaceholderHeight}px`,
|
||||||
})
|
transform: `translateY(0px)`,
|
||||||
|
zIndex: 1
|
||||||
|
}))
|
||||||
|
|
||||||
const setItemRef = (el, key) => {
|
const getItemKey = (item, index) => {
|
||||||
if (el) {
|
const keyField = computedItemKey.value
|
||||||
itemRefs.set(key, el)
|
if (typeof keyField === 'function') {
|
||||||
} else {
|
return keyField(item, index)
|
||||||
itemRefs.delete(key)
|
}
|
||||||
|
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 measureItem = (key, element) => {
|
const setItemRef = (el, index) => {
|
||||||
|
if (el) {
|
||||||
|
itemRefs.set(index, el)
|
||||||
|
} else {
|
||||||
|
itemRefs.delete(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureItem = (index, element) => {
|
||||||
if (!element) return
|
if (!element) return
|
||||||
|
|
||||||
const target = element.firstElementChild || element
|
const firstChild = element.firstElementChild
|
||||||
const height = target.getBoundingClientRect().height
|
const targetElement = firstChild || element
|
||||||
|
|
||||||
if (height > 0 && height !== itemHeights.value.get(key)) {
|
const height = targetElement.getBoundingClientRect().height
|
||||||
const newHeights = new Map(itemHeights.value)
|
|
||||||
newHeights.set(key, height)
|
if (height > 0) {
|
||||||
itemHeights.value = newHeights
|
const cachedHeight = itemHeights.value.get(index)
|
||||||
|
if (cachedHeight !== height) {
|
||||||
|
const newHeights = new Map(itemHeights.value)
|
||||||
|
newHeights.set(index, height)
|
||||||
|
itemHeights.value = newHeights
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,31 +335,22 @@ const setupResizeObserver = () => {
|
||||||
|
|
||||||
resizeObserver.value = new ResizeObserver((entries) => {
|
resizeObserver.value = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const key = entry.target.dataset.key
|
const index = parseInt(entry.target.dataset.index, 10)
|
||||||
if (key !== undefined) {
|
if (!isNaN(index)) {
|
||||||
measureItem(key, entry.target)
|
measureItem(index, 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) => {
|
const handleWheel = (event) => {
|
||||||
if (!scrollContainerRef.value) return
|
if (!renderContainerRef.value) return
|
||||||
|
|
||||||
scrollContainerRef.value.scrollBy({
|
const { deltaY } = event
|
||||||
top: -event.deltaY,
|
const el = renderContainerRef.value
|
||||||
|
|
||||||
|
el.scrollBy({
|
||||||
|
top: -deltaY,
|
||||||
behavior: 'instant'
|
behavior: 'instant'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -303,10 +359,7 @@ const handleWheel = (event) => {
|
||||||
|
|
||||||
const handleScroll = (event) => {
|
const handleScroll = (event) => {
|
||||||
const target = event.target
|
const target = event.target
|
||||||
const scrollHeight = target.scrollHeight
|
scrollTop.value = target.scrollTop
|
||||||
const scrollTop = target.scrollTop
|
|
||||||
const clientHeight = target.clientHeight
|
|
||||||
|
|
||||||
isScrolling.value = true
|
isScrolling.value = true
|
||||||
|
|
||||||
if (scrollTimeout.value) {
|
if (scrollTimeout.value) {
|
||||||
|
|
@ -317,115 +370,163 @@ const handleScroll = (event) => {
|
||||||
isScrolling.value = false
|
isScrolling.value = false
|
||||||
}, 150)
|
}, 150)
|
||||||
|
|
||||||
// In inverted scroll:
|
const st = target.scrollTop
|
||||||
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
const scrollHeight = target.scrollHeight
|
||||||
// - distanceToBottom (visual bottom) = scrollTop
|
const clientHeight = target.clientHeight
|
||||||
// - 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 distanceToContainerTop = st
|
||||||
const isAtBottom = distanceToBottom <= threshold
|
const distanceToContainerBottom = scrollHeight - st - clientHeight
|
||||||
|
|
||||||
|
const distanceToPageTop = distanceToContainerBottom
|
||||||
|
const distanceToPageBottom = distanceToContainerTop
|
||||||
|
const isAtPageTop = distanceToPageTop <= 0
|
||||||
|
const isAtPageBottom = distanceToPageBottom <= 0
|
||||||
|
|
||||||
emit('scroll', {
|
emit('scroll', {
|
||||||
scrollTop,
|
target,
|
||||||
|
scrollTop: st,
|
||||||
scrollHeight,
|
scrollHeight,
|
||||||
clientHeight,
|
clientHeight,
|
||||||
distanceToTop,
|
distanceToPageTop,
|
||||||
distanceToBottom,
|
distanceToPageBottom,
|
||||||
isAtTop,
|
isAtPageTop,
|
||||||
isAtBottom
|
isAtPageBottom
|
||||||
})
|
})
|
||||||
|
|
||||||
// scroll-start: reached visual top (older data, need to load more)
|
if (isAtPageTop) {
|
||||||
if (isAtTop) {
|
|
||||||
emit('scroll-start')
|
emit('scroll-start')
|
||||||
}
|
}
|
||||||
|
|
||||||
// scroll-end: reached visual bottom (newer data)
|
if (isAtPageBottom) {
|
||||||
if (isAtBottom) {
|
|
||||||
emit('scroll-end')
|
emit('scroll-end')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToIndex = (index, behavior = 'auto') => {
|
const scrollToIndex = (index, behavior = 'auto') => {
|
||||||
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||||
|
|
||||||
let offset = 0
|
const position = getItemPosition(index)
|
||||||
for (let i = 0; i < index; i++) {
|
|
||||||
const key = getKey(props.data[i], i)
|
|
||||||
offset += getItemHeight(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetScrollTop = offset + props.placeholderHeight
|
renderContainerRef.value.scrollTo({
|
||||||
|
top: position.offset,
|
||||||
scrollContainerRef.value.scrollTo({
|
|
||||||
top: targetScrollTop,
|
|
||||||
behavior
|
behavior
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = (behavior = 'smooth') => {
|
const scrollToBottom = (behavior = 'smooth') => {
|
||||||
if (!scrollContainerRef.value) return
|
if (!renderContainerRef.value) {
|
||||||
|
pendingScrollToBottom.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!scrollContainerRef.value) return
|
if (!renderContainerRef.value) return
|
||||||
// In inverted scroll, bottom is scrollTop = 0
|
|
||||||
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToTop = (behavior = 'smooth') => {
|
renderContainerRef.value.scrollTo({
|
||||||
if (!scrollContainerRef.value) return
|
top: 0,
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!scrollContainerRef.value) return
|
|
||||||
// In inverted scroll, top is scrollTop = max
|
|
||||||
scrollContainerRef.value.scrollTo({
|
|
||||||
top: scrollContainerRef.value.scrollHeight,
|
|
||||||
behavior
|
behavior
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getScrollElement = () => scrollContainerRef.value
|
const scrollToTop = (behavior = 'smooth') => {
|
||||||
|
if (!renderContainerRef.value) return
|
||||||
|
|
||||||
const isAtTop = () => {
|
requestAnimationFrame(() => {
|
||||||
if (!scrollContainerRef.value) return false
|
if (!renderContainerRef.value) return
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
|
||||||
return scrollHeight - scrollTop - clientHeight <= 5
|
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||||
|
renderContainerRef.value.scrollTo({
|
||||||
|
top: scrollHeight,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAtBottom = () => {
|
const getScrollElement = () => renderContainerRef.value
|
||||||
if (!scrollContainerRef.value) return false
|
|
||||||
return scrollContainerRef.value.scrollTop <= 5
|
const getVisibleIndices = () => {
|
||||||
|
const { start, end } = visibleRange.value
|
||||||
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
const resetMeasurements = () => {
|
||||||
itemHeights.value = new Map()
|
itemHeights.value = new Map()
|
||||||
itemRefs.clear()
|
itemRefs.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.data, (newData, oldData) => {
|
const isAtPageBottom = () => {
|
||||||
const newLength = newData?.length || 0
|
if (!renderContainerRef.value) return false
|
||||||
const oldLength = oldData?.length || 0
|
const { scrollTop } = renderContainerRef.value
|
||||||
|
return scrollTop <= 0
|
||||||
|
}
|
||||||
|
|
||||||
if (newLength < oldLength) {
|
const isAtPageTop = () => {
|
||||||
reset()
|
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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(observeItems)
|
previousDataLength.value = newLength
|
||||||
}, { deep: true })
|
}, { deep: false })
|
||||||
|
|
||||||
watch(visibleItems, () => {
|
watch(visibleItems, (newItems) => {
|
||||||
nextTick(observeItems)
|
nextTick(() => {
|
||||||
|
observeVisibleItems()
|
||||||
|
})
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
const firstItem = newItems[0]
|
||||||
|
const lastItem = newItems[newItems.length - 1]
|
||||||
|
emit('visible-change', firstItem.index, lastItem.index)
|
||||||
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupResizeObserver()
|
setupResizeObserver()
|
||||||
nextTick(observeItems)
|
isInitialized.value = true
|
||||||
|
previousDataLength.value = computedData.value.length
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (pendingScrollToBottom.value) {
|
||||||
|
pendingScrollToBottom.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
observeVisibleItems()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|
@ -442,44 +543,72 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
|
scrollToItem: scrollToIndex,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
getScrollElement,
|
getScrollElement,
|
||||||
isAtTop,
|
getVisibleIndices,
|
||||||
isAtBottom,
|
resetMeasurements,
|
||||||
reset,
|
containerRef,
|
||||||
scrollContainerRef
|
isAtPageBottom,
|
||||||
|
isAtPageTop
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.virtual-scroller {
|
.virtual-scroller {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
|
|
||||||
&-container {
|
&::-webkit-scrollbar {
|
||||||
contain: layout style;
|
display: none;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar-track {
|
||||||
display: none;
|
background: transparent;
|
||||||
width: 0;
|
}
|
||||||
height: 0;
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-spacer {
|
.virtual-scroller-wrapper {
|
||||||
|
contain: content;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-scroller-spacer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-item {
|
.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;
|
contain: layout style;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-placeholder {
|
.virtual-scroller-bottom-placeholder {
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,615 @@
|
||||||
|
<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()
|
||||||
|
})
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
const firstItem = newItems[0]
|
||||||
|
const lastItem = newItems[newItems.length - 1]
|
||||||
|
emit('visible-change', firstItem.index, lastItem.index)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupResizeObserver()
|
||||||
|
isInitialized.value = true
|
||||||
|
previousDataLength.value = computedData.value.length
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (pendingScrollToBottom.value) {
|
||||||
|
pendingScrollToBottom.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
observeVisibleItems()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
@ -316,7 +316,7 @@ const measureItem = (index, element) => {
|
||||||
const firstChild = element.firstElementChild
|
const firstChild = element.firstElementChild
|
||||||
const targetElement = firstChild || element
|
const targetElement = firstChild || element
|
||||||
|
|
||||||
const height = targetElement.getBoundingClientRect().height
|
const height = Math.ceil(targetElement.offsetHeight)
|
||||||
|
|
||||||
if (height > 0) {
|
if (height > 0) {
|
||||||
const cachedHeight = itemHeights.value.get(index)
|
const cachedHeight = itemHeights.value.get(index)
|
||||||
|
|
@ -506,6 +506,7 @@ watch(() => computedData.value, (newData, oldData) => {
|
||||||
watch(visibleItems, (newItems) => {
|
watch(visibleItems, (newItems) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
observeVisibleItems()
|
observeVisibleItems()
|
||||||
|
cleanupExtraItems(newItems)
|
||||||
})
|
})
|
||||||
if (newItems.length > 0) {
|
if (newItems.length > 0) {
|
||||||
const firstItem = newItems[0]
|
const firstItem = newItems[0]
|
||||||
|
|
@ -514,6 +515,43 @@ watch(visibleItems, (newItems) => {
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { 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(() => {
|
onMounted(() => {
|
||||||
setupResizeObserver()
|
setupResizeObserver()
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
|
|
@ -526,6 +564,7 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
observeVisibleItems()
|
observeVisibleItems()
|
||||||
|
cleanupExtraItems(visibleItems.value)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
min-width: 1500px;
|
min-width: 1300px;
|
||||||
/* min-height: 960px; */
|
/* min-height: 960px; */
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@
|
||||||
|
|
||||||
<div class="prompt-container" ref="promptContainerRef">
|
<div class="prompt-container" ref="promptContainerRef">
|
||||||
<div class="prompt-wrapper" ref="promptWrapperRef">
|
<div class="prompt-wrapper" ref="promptWrapperRef">
|
||||||
<div class="prompt" ref="promptRef" :class="{ 'expanded': isHovering }">
|
<div class="prompt" ref="promptRef" :class="{ 'expanded': isHovering }" @mouseenter="isHovering = true" @mouseleave="isHovering = false">
|
||||||
<span class="prompt-text" @mouseenter="isHovering = true" @mouseleave="isHovering = false">{{ props.item.generateData.prompt || '生成图片' }}</span>
|
<span class="prompt-text">
|
||||||
|
{{ props.item.generateData.prompt || '生成图片' }}
|
||||||
|
<i-ep-DocumentCopy class="Copy" @click.stop="copyPrompt"/>
|
||||||
|
</span>
|
||||||
<div class="generate-data internal" v-show="!isHovering && !showExternalGenerateData">
|
<div class="generate-data internal" v-show="!isHovering && !showExternalGenerateData">
|
||||||
<div class="detailed-data first-detailed-data">{{ props.item.generateData.model }}</div>
|
<div class="detailed-data first-detailed-data">{{ props.item.generateData.model }}</div>
|
||||||
<div class="detailed-data">{{ props.item.generateData.proportion }}</div>
|
<div class="detailed-data">{{ props.item.generateData.proportion }}</div>
|
||||||
|
|
@ -286,15 +289,25 @@ const addCollection = async (url) => {
|
||||||
ElMessage.error('收藏操作失败')
|
ElMessage.error('收藏操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyPrompt = async () => {
|
||||||
|
try {
|
||||||
|
const promptText = props.item.generateData.prompt || '生成图片'
|
||||||
|
await navigator.clipboard.writeText(promptText)
|
||||||
|
ElMessage.success('提示词已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制失败:', error)
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.primary-box{
|
.primary-box{
|
||||||
width: 100%;
|
width: 80%;
|
||||||
max-width: 1118px;
|
max-width: 1118px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +358,15 @@ const addCollection = async (url) => {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
.Copy{
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.Copy:hover{
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
.generate-data{
|
.generate-data{
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue