Files
utools-list/src/components/VirtualList.vue

551 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { ListItem } from '@/types'
interface Props {
data: ListItem[]
height?: string | number
frozen?: boolean
disableHover?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: '100%',
frozen: false,
disableHover: false
})
const emit = defineEmits<{
select: [item: ListItem]
click: [item: ListItem]
contextmenu: [data: { item: ListItem }]
showToast: [message: string]
}>()
// ===== 列表配置常量 =====
/** 列表项基础高度(像素) */
const ITEM_HEIGHT = 50
/** 列表项内边距总和(像素) */
const ITEM_PADDING = 16
/** 上下缓冲区域的项目数量 */
const BUFFER_COUNT = 10
/** 滚动防抖时间(毫秒) */
const SCROLL_DEBOUNCE_TIME = 16
/** 虚拟滚动阈值,少于此数量则直接渲染全部 */
const VIRTUALIZATION_THRESHOLD = 500
// 计算列表项实际总高度
const totalItemHeight = computed(() =>
ITEM_HEIGHT + ITEM_PADDING
)
// 响应式状态管理
const containerHeight = ref<number>(0)
const startIndex = ref<number>(0)
const selectedIndex = ref<number>(0)
const isKeyboardNavigating = ref<boolean>(false)
let keyboardTimer: number | null = null
// DOM 引用
const containerRef = ref<HTMLElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
// 计算可视区域能显示的列表项数量
const visibleCount = computed(() =>
Math.floor(containerHeight.value / totalItemHeight.value)
)
/**
* 是否需要虚拟滚动
* 当数据量小于阈值时直接渲染全部
*/
const needVirtualization = computed<boolean>(() =>
props.data.length > VIRTUALIZATION_THRESHOLD
)
// 计算当前需要渲染的数据
const visibleData = computed(() => {
// 数据量小于阈值时,直接返回全部数据
if (!needVirtualization.value) {
return props.data.map((item, index) => ({
...item,
index
}))
}
// 否则使用虚拟滚动
const visibleStart = Math.max(0, startIndex.value - BUFFER_COUNT)
const visibleEnd = Math.min(
props.data.length,
startIndex.value + visibleCount.value + BUFFER_COUNT
)
return props.data.slice(visibleStart, visibleEnd).map((item, index) => ({
...item,
index: visibleStart + index
}))
})
// 计算列表偏移量
const offsetY = computed(() =>
needVirtualization.value
? Math.max(0, (startIndex.value - BUFFER_COUNT) * totalItemHeight.value)
: 0
)
// 计算虚拟列表总高度
const phantomHeight = computed(() =>
needVirtualization.value
? props.data.length * totalItemHeight.value
: 'auto'
)
// 选择处理
const handleSelect = (item: ListItem): void => {
if (props.frozen) return
selectedIndex.value = item.index ?? 0
emit('select', item)
}
/**
* 处理列表项点击
* @param item 点击的列表项
*/
const handleClick = (item: ListItem): void => {
if (props.frozen) return
handleSelect(item)
emit('click', item)
}
/**
* 更新容器高度
* 从父元素或窗口获取实际高度
*/
function updateContainerHeight(): void {
if (containerRef.value) {
const parentHeight = containerRef.value.parentElement?.clientHeight
containerHeight.value = parentHeight || window.innerHeight
}
}
/** ResizeObserver 实例,用于监听容器尺寸变化 */
let resizeObserver: ResizeObserver | null = null
/**
* 防抖函数
* 在指定延迟后执行函数,如果在延迟期间再次调用则重置延迟
* @param fn 需要防抖的函数
* @param delay 延迟时间(毫秒)
*/
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timer: number | null = null
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = window.setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
/**
* 处理滚动事件(已防抖)
* 根据滚动位置更新可视区的起始索引
*/
const handleScroll = debounce(() => {
// 不需要虚拟滚动时直接返回
if (!needVirtualization.value) return
if (!containerRef.value) return
const scrollTop = containerRef.value.scrollTop
const newStartIndex = Math.floor(scrollTop / totalItemHeight.value)
if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex
}
}, SCROLL_DEBOUNCE_TIME)
/** 鼠标悬停禁用状态 */
const disableHover = ref<boolean>(false)
/** 鼠标悬停禁用定时器 */
let hoverTimer: number | null = null
/**
* 处理鼠标移入事件
* 在非键盘导航状态下更新选中项
* @param index 目标项的索引
*/
function handleMouseEnter(index: number): void {
if (props.frozen || disableHover.value || props.disableHover) return
if (!isKeyboardNavigating.value) {
selectedIndex.value = index
emit('select', { ...props.data[index], index })
ensureSelectedItemVisible()
}
}
/**
* 记录到达列表边界的时间
* 用于实现边界循环导航的延迟判断
*/
const lastReachBoundaryTime = ref<{ top: number; bottom: number }>({
top: 0,
bottom: 0
})
/**
* 处理键盘事件
* 实现键盘导航、边界循环和项目选择功能
* @param e 键盘事件对象
*/
function handleKeyDown(e: KeyboardEvent): void {
if (props.frozen) return
if (e.key === 'Enter') {
if (selectedIndex.value >= 0 && selectedIndex.value < props.data.length) {
const item = props.data[selectedIndex.value]
emit('click', { ...item, index: selectedIndex.value })
}
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
const isFirstItem = selectedIndex.value === 0
const isLastItem = selectedIndex.value === props.data.length - 1
const now = Date.now()
if (e.key === 'ArrowUp' && isFirstItem) {
const timeSinceLastTop = now - lastReachBoundaryTime.value.top
if (timeSinceLastTop < 5000) {
disableHover.value = true
selectedIndex.value = props.data.length - 1
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
ensureSelectedItemVisible()
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = window.setTimeout(() => {
disableHover.value = false
}, 1000)
} else {
lastReachBoundaryTime.value.top = now
emit('showToast', '已经到列表顶端,再次点击将回到列表底端')
}
return
}
if (e.key === 'ArrowDown' && isLastItem) {
const timeSinceLastBottom = now - lastReachBoundaryTime.value.bottom
if (timeSinceLastBottom < 5000) {
disableHover.value = true
selectedIndex.value = 0
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
ensureSelectedItemVisible()
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = window.setTimeout(() => {
disableHover.value = false
}, 1000)
} else {
lastReachBoundaryTime.value.bottom = now
emit('showToast', '已经到列表底端,再次点击将回到列表顶端')
}
return
}
isKeyboardNavigating.value = true
if (keyboardTimer) clearTimeout(keyboardTimer)
if (e.key === 'ArrowUp') {
const nextIndex = Math.max(0, selectedIndex.value - 1)
selectedIndex.value = nextIndex
emit('select', { ...props.data[nextIndex], index: nextIndex })
const itemTop = nextIndex * totalItemHeight.value
if (itemTop < (containerRef.value?.scrollTop ?? 0) && containerRef.value) {
containerRef.value.scrollTop = itemTop
}
} else {
const nextIndex = Math.min(props.data.length - 1, selectedIndex.value + 1)
selectedIndex.value = nextIndex
emit('select', { ...props.data[nextIndex], index: nextIndex })
const itemBottom = (nextIndex + 1) * totalItemHeight.value
const scrollBottom = (containerRef.value?.scrollTop ?? 0) + containerHeight.value
if (itemBottom > scrollBottom && containerRef.value) {
containerRef.value.scrollTop = itemBottom - containerHeight.value
}
}
keyboardTimer = window.setTimeout(() => {
isKeyboardNavigating.value = false
}, 1000)
}
}
/**
* 保持选中项在可视区域内
* 如果选中项不可见,则滚动到合适位置
*/
function ensureSelectedItemVisible(): void {
if (!containerRef.value) return
const containerTop = containerRef.value.scrollTop
const containerHeight = containerRef.value.clientHeight
const itemTop = selectedIndex.value * totalItemHeight.value
const itemBottom = (selectedIndex.value + 1) * totalItemHeight.value
const scrollBottom = containerTop + containerHeight
if (itemTop < containerTop) {
containerRef.value.scrollTop = itemTop
} else if (itemBottom > scrollBottom) {
containerRef.value.scrollTop = itemBottom - containerHeight
}
}
/**
* 处理右键点击事件
* @param e 鼠标事件对象
* @param item 目标列表项
*/
const handleContextMenu = (e: MouseEvent, item: ListItem): void => {
if (props.frozen) return
e.preventDefault()
emit('contextmenu', { item })
}
// ===== 生命周期钩子 =====
onMounted(() => {
if (containerRef.value) {
// 添加滚动事件监听
containerRef.value.addEventListener('scroll', handleScroll)
// 创建并启用 ResizeObserver
resizeObserver = new ResizeObserver(() => {
updateContainerHeight()
})
if (containerRef.value.parentElement) {
resizeObserver.observe(containerRef.value.parentElement)
}
}
// 初始化容器高度
updateContainerHeight()
// 添加全局事件监听
window.addEventListener('resize', updateContainerHeight)
window.addEventListener('keydown', handleKeyDown)
})
onBeforeUnmount(() => {
// 清理所有事件监听和定时器
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener('resize', updateContainerHeight)
window.removeEventListener('keydown', handleKeyDown)
if (keyboardTimer) clearTimeout(keyboardTimer)
if (hoverTimer) clearTimeout(hoverTimer)
})
</script>
<template>
<!-- 虚拟列表容器 -->
<div
ref="containerRef"
class="virtual-list-container"
:class="{ 'list-frozen': frozen }"
:style="{ height: '100%' }"
tabindex="0"
>
<!-- 虚拟高度占位元素 -->
<div
ref="listRef"
class="virtual-list-phantom"
:style="{ height: needVirtualization ? `${phantomHeight}px` : 'auto' }"
>
<!-- 实际渲染的列表内容 -->
<div
class="virtual-list"
:style="needVirtualization ? { transform: `translateY(${offsetY}px)` } : {}"
>
<!-- 列表项 -->
<div
v-for="item in visibleData"
:key="item.index"
class="list-item"
:class="{ 'selected': selectedIndex === item.index }"
:style="{ height: `${ITEM_HEIGHT}px` }"
@mouseenter="handleMouseEnter(item.index)"
@click="handleClick(item)"
@contextmenu="handleContextMenu($event, item)"
>
<slot name="item" :item="item">
<!-- 默认列表项样式 -->
<div class="default-item">
<!-- 项目图标 -->
<div class="item-icon">
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="#1a1a1a" />
</svg>
</div>
<!-- 项目内容 -->
<div class="item-content">
<!-- 标题行名称和标签 -->
<div class="item-header">
<div class="item-name">{{ item.name }}</div>
<!-- 标签列表 -->
<div class="item-tags" v-if="item.tags?.length">
<span
v-for="tag in item.tags"
:key="tag.id"
class="item-tag"
:style="{ backgroundColor: `${tag.color}15`, color: tag.color }"
>
{{ tag.name }}
</span>
</div>
</div>
<!-- 路径信息 -->
<div class="item-info">
<div class="item-path">{{ item.path }}</div>
</div>
</div>
</div>
</slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 虚拟列表容器:可滚动区域 */
.virtual-list-container {
overflow-y: auto;
border: 1px solid #ccc;
position: relative;
background-color: #f4f4f4;
width: 100%;
height: 100%;
}
/* 虚拟高度容器:用于保持滚动条比例 */
.virtual-list-phantom {
position: relative;
width: 100%;
}
/* 实际列表内容容器:通过 transform 实现位置偏移 */
.virtual-list {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
/* 列表项基础样式 */
.list-item {
padding: 8px 16px;
display: flex;
align-items: center;
transition: background-color 0.2s ease, opacity 0.2s ease;
cursor: pointer;
background-color: white;
}
/* 选中状态样式 */
.list-item.selected {
background-color: rgba(64, 169, 255, 0.15);
}
/* 列表项布局容器 */
.default-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
/* 图标容器 */
.item-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
/* 内容区域布局 */
.item-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
/* 标题行布局 */
.item-header {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
/* 项目名称样式 */
.item-name {
font-size: 14px;
color: #1a1a1a;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
/* 信息行布局 */
.item-info {
display: flex;
gap: 4px;
}
/* 路径文本样式 */
.item-path {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* 标签容器样式 */
.item-tags {
display: flex;
gap: 6px;
flex-wrap: nowrap;
flex-shrink: 0;
overflow: hidden;
}
/* 单个标签样式 */
.item-tag {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
/* 列表冻结状态样式 */
.list-frozen {
pointer-events: none; /* 禁用鼠标事件 */
opacity: 0.7; /* 降低透明度 */
user-select: none; /* 禁用文本选择 */
}
</style>