Files
PicList/src/renderer/components/VirtualScroller.vue

224 lines
5.4 KiB
Vue

<template>
<div
ref="containerRef"
class="virtual-scroller"
:style="{ height: `${containerHeight}px` }"
@scroll="handleScroll"
>
<div
class="virtual-scroller-content"
:style="contentStyles"
>
<div
class="virtual-scroller-viewport"
:class="{ 'is-grid': isGridMode, 'is-list': !isGridMode }"
:style="viewportStyle"
>
<div
v-for="realIndex in visibleIndexes"
:key="itemsRef[realIndex] && itemsRef[realIndex][props.keyField || 'id'] ? itemsRef[realIndex][props.keyField || 'id'] : realIndex"
class="virtual-scroller-item"
:style="itemStyle"
>
<slot
:item="itemsRef[realIndex]"
:index="realIndex"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useVirtualGrid } from '@/hooks/useVirtualGrid'
type Item = any
interface Breakpoint { min: number; cols: number }
const props = withDefaults(defineProps<{
items: Item[]
itemHeight: number
height?: number
gridItems?: number
gridBreakpoints?: Breakpoint[]
bufferFactor?: number
pageMode?: boolean
keyField?: string
itemPadding?: number
viewMode?: 'list' | 'grid'
}>(), {
height: 400,
gridItems: 1,
gridBreakpoints: () => [],
bufferFactor: 0.5,
pageMode: false,
keyField: 'id',
itemPadding: 0,
viewMode: 'grid'
})
const containerRef = ref<HTMLElement | null>(null)
const containerHeight = ref<number>(props.pageMode ? 0 : props.height)
const containerWidth = ref<number>(0)
const itemsRef = ref<Item[]>(props.items)
watch(() => props.items, v => { itemsRef.value = v })
const localViewMode = ref< 'list' | 'grid'>(props.viewMode)
watch(() => props.viewMode, v => { localViewMode.value = v })
const sortedBreakpoints = computed<Breakpoint[]>(() =>
[...props.gridBreakpoints].sort((a, b) => a.min - b.min)
)
const isForcedList = computed(() => localViewMode.value === 'list')
const effectiveCols = computed<number>(() => {
if (isForcedList.value) return 1
const base = Math.max(1, props.gridItems || 1)
const w = containerWidth.value || 0
let cols = base
for (const bp of sortedBreakpoints.value) {
if (w >= bp.min) cols = Math.max(1, bp.cols)
}
return cols
})
const isGridMode = computed(() => effectiveCols.value > 1)
const {
gridCalculations,
visibleIndexes,
viewportOffset,
updateScrollTop,
scrollToItem, scrollToTop, scrollToBottom
} = useVirtualGrid({
items: itemsRef,
itemHeight: props.itemHeight,
containerHeight,
gridItems: effectiveCols,
bufferFactor: props.bufferFactor
})
const contentStyles = computed(() => ({
height: `${gridCalculations.value.totalHeight}px`
}))
const viewportStyle = computed(() => {
const base: Record<string, string> = {
transform: `translateY(${viewportOffset.value}px)`
}
if (isGridMode.value) {
base['--items-per-row'] = String(effectiveCols.value)
base['--row-height'] = `${props.itemHeight}px`
base['--item-gap'] = `${props.itemPadding}px`
}
return base
})
const itemStyle = computed(() =>
isGridMode.value
? {}
: { height: `${props.itemHeight}px`, padding: `${props.itemPadding}px` }
)
function handleScroll () {
const c = containerRef.value
if (!c) return
updateScrollTop(c.scrollTop)
}
let ro: ResizeObserver | null = null
function updateContainerMetrics () {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
containerWidth.value = rect.width
if (props.pageMode) {
containerHeight.value = Math.max(200, window.innerHeight - rect.top - 12)
} else {
containerHeight.value = props.height
}
}
onMounted(() => {
const el = containerRef.value
if (!el) return
ro = new ResizeObserver(updateContainerMetrics)
ro.observe(el)
if (props.pageMode) ro.observe(document.documentElement)
updateContainerMetrics()
if (props.pageMode) {
window.addEventListener('resize', updateContainerMetrics, { passive: true })
}
})
onBeforeUnmount(() => {
if (ro) ro.disconnect()
window.removeEventListener('resize', updateContainerMetrics)
})
function scrollTo (index: number) { scrollToItem(index) }
function setViewMode (mode: 'list' | 'grid') {
localViewMode.value = mode
}
function toggleViewMode () {
setViewMode(isGridMode.value ? 'list' : 'grid')
}
function refresh () {
updateContainerMetrics()
if (containerRef.value) {
updateScrollTop(containerRef.value.scrollTop)
}
}
defineExpose({ scrollTo, scrollToTop, scrollToBottom, setViewMode, toggleViewMode, refresh })
</script>
<style scoped>
.virtual-scroller {
position: relative;
overflow: auto;
contain: layout style paint;
will-change: transform;
-webkit-overflow-scrolling: touch;
}
.virtual-scroller-content {
position: relative;
width: 100%;
}
/* Base viewport (list mode) stacks children; offset applied via translateY */
.virtual-scroller-viewport {
position: absolute;
inset: 0 auto auto 0;
will-change: transform;
backface-visibility: hidden;
}
.virtual-scroller-viewport.is-grid {
display: grid;
grid-template-columns: repeat(var(--items-per-row, 1), minmax(0, 1fr));
grid-auto-rows: var(--row-height, 1px);
gap: var(--item-gap, 0px);
}
.virtual-scroller-viewport.is-list .virtual-scroller-item {
width: 100%;
}
.virtual-scroller-viewport.is-grid .virtual-scroller-item {
width: 100%;
}
</style>