mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
## Summary Enable vertical scrollbar in Media Assets sidebar for better navigation when content overflows. The original implementation hid the scrollbar by default. Since the intent behind hiding it wasn't documented, this change preserves the default behavior (showScrollbar: false) while allowing components to opt-in to visible scrollbars when needed. fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/6914 ## Screenshots (if applicable) <img width="681" height="890" alt="image" src="https://github.com/user-attachments/assets/af48a440-6d04-4226-9482-eb17f8d11a40" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7227-feat-add-showScrollbar-prop-to-VirtualGrid-2c36d73d365081c8955ee632c6c644f7) by [Unito](https://www.unito.io)
124 lines
3.4 KiB
Vue
124 lines
3.4 KiB
Vue
<template>
|
|
<div ref="container" class="scroll-container">
|
|
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
|
<div :style="gridStyle">
|
|
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
|
<slot name="item" :item="item" />
|
|
</div>
|
|
</div>
|
|
<div
|
|
:style="{
|
|
height: `${((items.length - state.end) / cols) * itemHeight}px`
|
|
}"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts" generic="T">
|
|
import { useElementSize, useScroll, whenever } from '@vueuse/core'
|
|
import { clamp, debounce } from 'es-toolkit/compat'
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import type { CSSProperties } from 'vue'
|
|
|
|
type GridState = {
|
|
start: number
|
|
end: number
|
|
isNearEnd: boolean
|
|
}
|
|
|
|
const {
|
|
items,
|
|
bufferRows = 1,
|
|
scrollThrottle = 64,
|
|
resizeDebounce = 64,
|
|
defaultItemHeight = 200,
|
|
defaultItemWidth = 200
|
|
} = defineProps<{
|
|
items: (T & { key: string })[]
|
|
gridStyle: Partial<CSSProperties>
|
|
bufferRows?: number
|
|
scrollThrottle?: number
|
|
resizeDebounce?: number
|
|
defaultItemHeight?: number
|
|
defaultItemWidth?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
/**
|
|
* Emitted when `bufferRows` (or fewer) rows remaining between scrollY and grid bottom.
|
|
*/
|
|
'approach-end': []
|
|
}>()
|
|
|
|
const itemHeight = ref(defaultItemHeight)
|
|
const itemWidth = ref(defaultItemWidth)
|
|
const container = ref<HTMLElement | null>(null)
|
|
const { width, height } = useElementSize(container)
|
|
const { y: scrollY } = useScroll(container, {
|
|
throttle: scrollThrottle,
|
|
eventListenerOptions: { passive: true }
|
|
})
|
|
|
|
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
|
|
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
|
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
|
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
|
|
|
const state = computed<GridState>(() => {
|
|
const fromRow = offsetRows.value - bufferRows
|
|
const toRow = offsetRows.value + bufferRows + viewRows.value
|
|
|
|
const fromCol = fromRow * cols.value
|
|
const toCol = toRow * cols.value
|
|
const remainingCol = items.length - toCol
|
|
const hasMoreToRender = remainingCol >= 0
|
|
|
|
return {
|
|
start: clamp(fromCol, 0, items?.length),
|
|
end: clamp(toCol, fromCol, items?.length),
|
|
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
|
|
}
|
|
})
|
|
const renderedItems = computed(() =>
|
|
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
|
)
|
|
|
|
whenever(
|
|
() => state.value.isNearEnd,
|
|
() => {
|
|
emit('approach-end')
|
|
}
|
|
)
|
|
|
|
const updateItemSize = () => {
|
|
if (container.value) {
|
|
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
|
|
|
// Don't update item size if the first item is not rendered yet
|
|
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
|
|
|
if (itemHeight.value !== firstItem.clientHeight) {
|
|
itemHeight.value = firstItem.clientHeight
|
|
}
|
|
if (itemWidth.value !== firstItem.clientWidth) {
|
|
itemWidth.value = firstItem.clientWidth
|
|
}
|
|
}
|
|
}
|
|
const onResize = debounce(updateItemSize, resizeDebounce)
|
|
watch([width, height], onResize, { flush: 'post' })
|
|
whenever(() => items, updateItemSize, { flush: 'post' })
|
|
onBeforeUnmount(() => {
|
|
onResize.cancel() // Clear pending debounced calls
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.scroll-container {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--dialog-surface) transparent;
|
|
}
|
|
</style>
|