pref: optimize virtual scroll (#111)

This commit is contained in:
Hayden
2025-02-02 16:42:25 +08:00
committed by GitHub
parent 448ea4b1ba
commit 56a2deb4eb
4 changed files with 84 additions and 306 deletions

View File

@@ -31,20 +31,14 @@
</div> </div>
</div> </div>
<ResponseScroll <ResponseScroll :items="list" :itemSize="itemSize" class="h-full flex-1">
ref="responseScroll"
:items="list"
:itemSize="itemSize"
:row-key="(item) => item.map(genModelKey).join(',')"
class="h-full flex-1"
>
<template #item="{ item }"> <template #item="{ item }">
<div <div
class="grid grid-cols-1 justify-center gap-8 px-8" class="grid grid-cols-1 justify-center gap-8 px-8"
:style="contentStyle" :style="contentStyle"
> >
<ModelCard <ModelCard
v-for="model in item" v-for="model in item.row"
:key="genModelKey(model)" :key="genModelKey(model)"
:model="model" :model="model"
></ModelCard> ></ModelCard>
@@ -75,7 +69,7 @@ import { chunk } from 'lodash'
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings' import { Model } from 'types/typings'
import { genModelKey } from 'utils/model' import { genModelKey } from 'utils/model'
import { computed, ref, watch } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { isMobile, cardWidth, gutter, aspect } = useConfig() const { isMobile, cardWidth, gutter, aspect } = useConfig()
@@ -89,8 +83,6 @@ const { $2xl: $toolbar_2xl } = useContainerQueries(toolbarContainer)
const contentContainer = ref<HTMLElement | null>(null) const contentContainer = ref<HTMLElement | null>(null)
const { $lg: $content_lg } = useContainerQueries(contentContainer) const { $lg: $content_lg } = useContainerQueries(contentContainer)
const responseScroll = ref()
const searchContent = ref<string>() const searchContent = ref<string>()
const currentType = ref('all') const currentType = ref('all')
@@ -133,10 +125,6 @@ const sortOrderOptions = ref(
}), }),
) )
watch([searchContent, currentType], () => {
responseScroll.value.init()
})
const itemSize = computed(() => { const itemSize = computed(() => {
let itemWidth = cardWidth let itemWidth = cardWidth
let itemGutter = gutter let itemGutter = gutter
@@ -193,7 +181,9 @@ const list = computed(() => {
const sortedList = filterList.sort(sortStrategy) const sortedList = filterList.sort(sortStrategy)
return chunk(sortedList, cols.value) return chunk(sortedList, cols.value).map((row) => {
return { key: row.map(genModelKey).join(','), row }
})
}) })
const contentStyle = computed(() => ({ const contentStyle = computed(() => ({

View File

@@ -1,57 +1,35 @@
<template> <template>
<div data-scroll-area class="group/scroll relative overflow-hidden"> <div class="group/scroll relative overflow-hidden">
<div <div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
ref="viewport" <div ref="content">
data-scroll-viewport
class="h-full w-full overflow-auto scrollbar-none"
:style="{ contain: items ? 'strict' : undefined }"
>
<div data-scroll-content class="relative min-w-full">
<slot name="default"> <slot name="default">
<div <slot v-if="renderedItems.length === 0" name="empty">
v-for="(item, index) in loadedItems"
:key="genRowKey(item, index)"
:style="{ height: `${itemSize}px` }"
>
<slot name="item" :item="item"></slot>
</div>
<slot v-if="loadedItems.length === 0" name="empty">
<div class="absolute w-full py-20 text-center">No Data</div> <div class="absolute w-full py-20 text-center">No Data</div>
</slot> </slot>
<div :style="{ height: `${headHeight}px` }"></div>
<div>
<div
v-for="item in renderedItems"
:key="item.key"
:style="{ height: `${itemSize}px` }"
data-virtual-item
>
<slot name="item" :item="item"></slot>
</div>
</div>
<div :style="{ height: `${tailHeight}px` }"></div>
</slot> </slot>
</div> </div>
<div
data-scroll-space
class="pointer-events-none absolute left-0 top-0 h-px w-px"
:style="spaceStyle"
></div>
</div> </div>
<div <div class="absolute right-0 top-0 h-full w-2">
v-for="scroll in scrollbars"
:key="scroll.direction"
v-show="scroll.visible"
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
:class="[
'pointer-events-none absolute z-auto h-full w-full rounded-full',
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
]"
>
<div <div
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
:class="[ :class="[
'pointer-events-auto absolute h-full w-full rounded-full', 'absolute w-full cursor-pointer rounded-full bg-gray-500',
'cursor-pointer bg-black dark:bg-white', 'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-30',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
]" ]"
:style="{ :style="{ height: `${thumbSize}px`, top: `${thumbOffset}px` }"
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
opacity: isDragging ? 0.1 : '',
}"
@mousedown="startDragThumb"
></div> ></div>
</div> </div>
</div> </div>
@@ -60,258 +38,75 @@
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import { useContainerResize } from 'hooks/resize' import { useContainerResize } from 'hooks/resize'
import { useContainerScroll } from 'hooks/scroll' import { useContainerScroll } from 'hooks/scroll'
import { clamp, throttle } from 'lodash' import { clamp } from 'lodash'
import { nextTick, onUnmounted, ref, watch } from 'vue' import { computed, ref } from 'vue'
interface ScrollAreaProps { interface ScrollAreaProps {
items?: T[][] items?: (T & { key: string })[]
itemSize?: number itemSize?: number
scrollbar?: boolean
rowKey?: string | ((item: T[]) => string)
} }
const props = withDefaults(defineProps<ScrollAreaProps>(), { const props = defineProps<ScrollAreaProps>()
scrollbar: true,
})
type ScrollbarDirection = 'horizontal' | 'vertical'
interface Scrollbar {
direction: ScrollbarDirection
visible: boolean
size: number
offset: number
}
interface ScrollbarAttribute {
clientSize: string
scrollOffset: string
pagePosition: string
offset: string
size: string
}
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
horizontal: {
clientSize: 'clientWidth',
scrollOffset: 'scrollLeft',
pagePosition: 'pageX',
offset: 'left',
size: 'width',
},
vertical: {
clientSize: 'clientHeight',
scrollOffset: 'scrollTop',
pagePosition: 'pageY',
offset: 'top',
size: 'height',
},
}
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
horizontal: {
direction: 'horizontal',
visible: props.scrollbar,
size: 0,
offset: 0,
},
vertical: {
direction: 'vertical',
visible: props.scrollbar,
size: 0,
offset: 0,
},
})
const isDragging = ref(false)
const spaceStyle = ref({})
const loadedItems = ref<T[][]>([])
const genRowKey = (item: any | any[], index: number) => {
if (typeof props.rowKey === 'function') {
return props.rowKey(item)
}
return item[props.rowKey ?? 'key'] ?? index
}
const setSpacerSize = () => {
const items = props.items
if (items) {
const itemSize = props.itemSize ?? 0
spaceStyle.value = { height: `${itemSize * items.length}px` }
} else {
spaceStyle.value = {}
}
}
const getContainerContent = (raw?: boolean): HTMLElement => {
const container = viewport.value as HTMLElement
if (props.items && !raw) {
return container.querySelector('[data-scroll-space]')!
}
return container.querySelector('[data-scroll-content]')!
}
const init = () => {
const container = viewport.value as HTMLElement
container.scrollTop = 0
getContainerContent().style.transform = ''
}
const calculateLoadItems = () => {
let visibleItems: any[] = []
if (props.items) {
const container = viewport.value as HTMLElement
const content = getContainerContent(true)
const resolveVisibleItems = (items: any[], attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const itemSize = props.itemSize!
const viewCount = Math.ceil(containerSize / itemSize)
let start = Math.floor(container[attr.scrollOffset] / itemSize)
const offset = start * itemSize
let end = start + viewCount
end = Math.min(end + viewCount, items.length)
content.style.transform = `translateY(${offset}px)`
return items.slice(start, end)
}
visibleItems = resolveVisibleItems(props.items, scrollbarAttrs.vertical)
}
loadedItems.value = visibleItems
}
const calculateScrollThumbSize = () => {
const container = viewport.value as HTMLElement
const content = getContainerContent()
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize: number = container[attr.clientSize]
const contentSize: number = content[attr.clientSize]
item.visible = props.scrollbar && contentSize > containerSize
item.size = Math.max(Math.pow(containerSize, 2) / contentSize, 16)
}
nextTick(() => {
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
}
const viewport = ref<HTMLElement | null>(null) const viewport = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)
const { width, height } = useContainerResize(viewport) const { height: viewportHeight } = useContainerResize(viewport)
const { height: contentHeight } = useContainerResize(content)
const { y: scrollY } = useContainerScroll(viewport)
watch([width, height], () => { const itemSize = computed(() => props.itemSize || 0)
if (isDragging.value) return
calculateScrollThumbSize() const viewRows = computed(() =>
Math.ceil(viewportHeight.value / itemSize.value),
)
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
const items = computed(() => {
return props.items ?? []
}) })
useContainerScroll(viewport, { const state = computed(() => {
onScroll: (event) => { const bufferRows = viewRows.value
if (isDragging.value) return
const container = event.target as HTMLDivElement const fromRow = offsetRows.value - bufferRows
const content = getContainerContent() const toRow = offsetRows.value + bufferRows + viewRows.value
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => { const itemCount = items.value.length
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
const scrollOffset = container[attr.scrollOffset]
item.offset = return {
(scrollOffset / (contentSize - containerSize)) * start: clamp(fromRow, 0, itemCount),
(containerSize - item.size) end: clamp(toRow, fromRow, itemCount),
}
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
calculateLoadItems()
},
})
const draggingDirection = ref<ScrollbarDirection>()
const prevDraggingEvent = ref<MouseEvent>()
const moveThumb = throttle((event: MouseEvent) => {
if (isDragging.value) {
const container = viewport.value!
const content = getContainerContent()
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
// Resolve thumb position
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
const currPagePos = event[attr.pagePosition]
const offset = currPagePos - prevPagePos
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
// Resolve scroll position
const scrollOffset = containerSize - item.size
const offsetSize = contentSize - containerSize
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
}
const scrollDirection = draggingDirection.value!
resolveOffset(
scrollbars.value[scrollDirection],
scrollbarAttrs[scrollDirection],
)
prevDraggingEvent.value = event
calculateLoadItems()
} }
}) })
const stopMoveThumb = () => { const renderedItems = computed(() => {
isDragging.value = false const { start, end } = state.value
draggingDirection.value = undefined
prevDraggingEvent.value = undefined
document.removeEventListener('mousemove', moveThumb)
document.removeEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
const startDragThumb = (event: MouseEvent) => { return props.items?.slice(start, end) ?? []
isDragging.value = true
const target = event.target as HTMLElement
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
prevDraggingEvent.value = event
document.addEventListener('mousemove', moveThumb)
document.addEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'default'
}
watch(
() => props.items,
() => {
setSpacerSize()
calculateScrollThumbSize()
calculateLoadItems()
},
)
onUnmounted(() => {
stopMoveThumb()
}) })
defineExpose({ const headHeight = computed(() => {
viewport, return state.value.start * itemSize.value
init, })
const tailHeight = computed(() => {
return (items.value.length - state.value.end) * itemSize.value
})
const thumbSize = computed(() => {
if (viewportHeight.value >= contentHeight.value) {
return 0
}
const thumbHeight = Math.pow(viewportHeight.value, 2) / contentHeight.value
return Math.max(thumbHeight, 16)
})
const thumbOffset = computed(() => {
return (
(scrollY.value / (contentHeight.value - viewportHeight.value)) *
(viewportHeight.value - thumbSize.value)
)
}) })
</script> </script>

View File

@@ -1,13 +1,6 @@
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { type Ref, onUnmounted, ref, toRef, watch } from 'vue' import { type Ref, onUnmounted, ref, toRef, watch } from 'vue'
export const defineResizeCallback = (
callback: ResizeObserverCallback,
wait?: number,
) => {
return throttle(callback, wait ?? 100)
}
export const useContainerResize = ( export const useContainerResize = (
el: HTMLElement | null | Ref<HTMLElement | null>, el: HTMLElement | null | Ref<HTMLElement | null>,
) => { ) => {
@@ -20,11 +13,11 @@ export const useContainerResize = (
toRef(el), toRef(el),
(el) => { (el) => {
if (el) { if (el) {
const onResize = defineResizeCallback((entries) => { const onResize = throttle((entries: ResizeObserverEntry[]) => {
const entry = entries[0] const entry = entries[0]
width.value = entry.contentRect.width width.value = entry.contentRect.width
height.value = entry.contentRect.height height.value = entry.contentRect.height
}) }, 64)
observer.value = new ResizeObserver(onResize) observer.value = new ResizeObserver(onResize)
observer.value.observe(el) observer.value.observe(el)

View File

@@ -22,29 +22,29 @@ export const useContainerScroll = (
scrollLeft.value = container.value.scrollLeft scrollLeft.value = container.value.scrollLeft
scrollTop.value = container.value.scrollTop scrollTop.value = container.value.scrollTop
} }
}, options?.throttle || 100) }, options?.throttle ?? 64)
watch( watch(
container, container,
(el) => { (el) => {
if (el) { if (el) {
el.addEventListener('scroll', onScroll) el.addEventListener('scroll', onScroll, { passive: true })
} }
}, },
{ immediate: true }, { immediate: true },
) )
const x = computed({ const x = computed({
get: () => scrollLeft, get: () => scrollLeft.value,
set: (val) => { set: (val) => {
container.value?.scrollTo({ left: val.value }) container.value?.scrollTo({ left: val })
}, },
}) })
const y = computed({ const y = computed({
get: () => scrollTop, get: () => scrollTop.value,
set: (val) => { set: (val) => {
container.value?.scrollTo({ top: val.value }) container.value?.scrollTo({ top: val })
}, },
}) })