优化屏幕放大时虚拟滚动显示的问题,优化整体宽度可自动

This commit is contained in:
王佑琳 2026-05-05 19:44:00 +08:00
parent 0a996eac08
commit 44c7309608
7 changed files with 1147 additions and 342 deletions

4
components.d.ts vendored
View File

@ -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']
} }
} }

View File

@ -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;

View File

@ -1,26 +1,42 @@
<template> <template>
<div class="virtual-scroller" :style="containerStyle">
<div <div
ref="scrollContainerRef" ref="containerRef"
class="virtual-scroller-container" class="virtual-scroller"
:style="scrollContainerStyle" :style="containerStyle"
>
<div
ref="wrapperRef"
class="virtual-scroller-wrapper"
:style="wrapperStyle"
>
<div
ref="renderContainerRef"
class="virtual-scroller-render-container"
:style="renderContainerStyle"
@scroll.passive="handleScroll" @scroll.passive="handleScroll"
@wheel="handleWheel" @wheel="handleWheel"
> >
<div class="virtual-scroller-spacer" :style="spacerStyle"></div> <div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
<div class="virtual-scroller-placeholder" :style="placeholderStyle"> <div
class="virtual-scroller-bottom-placeholder"
:style="bottomPlaceholderStyle"
>
<slot name="bottom-placeholder" /> <slot name="bottom-placeholder" />
</div> </div>
<div <div
v-for="item in visibleItems" v-for="renderItem in visibleItems"
:key="item.key" :key="getItemKey(renderItem.item, renderItem.index)"
:ref="el => setItemRef(el, item.key)" :ref="el => setItemRef(el, renderItem.index)"
class="virtual-scroller-item" class="virtual-scroller-item"
:style="getItemStyle(item)" :style="getItemStyle(renderItem)"
:data-index="item.index" :data-index="renderItem.index"
:data-key="item.key"
> >
<slot name="default" :item="item.data" :index="item.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)
}
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%', height: '100%',
width: '100%', width: '100%',
position: 'relative', 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,36 +243,90 @@ 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%',
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' willChange: 'transform'
}) }
}
const setItemRef = (el, key) => { const setItemRef = (el, index) => {
if (el) { if (el) {
itemRefs.set(key, el) itemRefs.set(index, el)
} else { } else {
itemRefs.delete(key) itemRefs.delete(index)
} }
} }
const measureItem = (key, element) => { 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
if (height > 0) {
const cachedHeight = itemHeights.value.get(index)
if (cachedHeight !== height) {
const newHeights = new Map(itemHeights.value) const newHeights = new Map(itemHeights.value)
newHeights.set(key, height) newHeights.set(index, height)
itemHeights.value = newHeights itemHeights.value = newHeights
} }
} }
}
const setupResizeObserver = () => { const setupResizeObserver = () => {
if (resizeObserver.value) { if (resizeObserver.value) {
@ -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
requestAnimationFrame(() => { return
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(() => { requestAnimationFrame(() => {
if (!scrollContainerRef.value) return if (!renderContainerRef.value) return
// In inverted scroll, top is scrollTop = max
scrollContainerRef.value.scrollTo({ renderContainerRef.value.scrollTo({
top: scrollContainerRef.value.scrollHeight, top: 0,
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) {
reset()
} }
nextTick(observeItems) const isAtPageTop = () => {
}, { deep: true }) if (!renderContainerRef.value) return false
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
return scrollHeight - scrollTop - clientHeight <= 0
}
watch(visibleItems, () => { const observeVisibleItems = () => {
nextTick(observeItems) 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 }) }, { 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 {
contain: layout style;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
width: 0; width: 6px;
height: 0; 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);
} }
} }
&-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;
} }
} }

View File

@ -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>

View File

@ -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)
}) })
}) })

View File

@ -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;
} }

View File

@ -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;