改为typescript支持
This commit is contained in:
@@ -1,63 +1,45 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { ListItem, ListConfig } from '@/types'
|
||||
|
||||
const props = defineProps({
|
||||
// 列表数据
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// 配置参数
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
itemHeight: 50, // 列表项基础高度
|
||||
itemPadding: 16, // 列表项上下padding总和
|
||||
bufferCount: 2, // 上下缓冲区域的项目数量
|
||||
scrollDebounceTime: 16 // 滚动防抖时间
|
||||
})
|
||||
},
|
||||
// 容器高度
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
frozen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disableHover: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
interface Props {
|
||||
data: ListItem[]
|
||||
config: ListConfig
|
||||
height?: string | number
|
||||
frozen?: boolean
|
||||
disableHover?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '100%',
|
||||
frozen: false,
|
||||
disableHover: false
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'click', 'contextmenu', 'showToast'])
|
||||
const emit = defineEmits<{
|
||||
select: [item: ListItem]
|
||||
click: [item: ListItem]
|
||||
contextmenu: [data: { item: ListItem }]
|
||||
showToast: [message: string]
|
||||
}>()
|
||||
|
||||
// 计算列表项实际总高度
|
||||
const totalItemHeight = computed(() =>
|
||||
props.config.itemHeight + props.config.itemPadding
|
||||
)
|
||||
|
||||
/**
|
||||
* 响应式状态管理
|
||||
*/
|
||||
const containerHeight = ref(0) // 容器高度
|
||||
const startIndex = ref(0) // 可视区域起始索引
|
||||
const selectedIndex = ref(0) // 当前选中项索引
|
||||
const isKeyboardNavigating = ref(false) // 键盘导航状态
|
||||
let keyboardTimer = null // 键盘导航定时器
|
||||
// 响应式状态管理
|
||||
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(null)
|
||||
const listRef = ref(null)
|
||||
// DOM 引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
* 计算属性
|
||||
*/
|
||||
// 计算可区域能显示的列表项数量
|
||||
// 计算可视区域能显示的列表项数量
|
||||
const visibleCount = computed(() =>
|
||||
Math.floor(containerHeight.value / totalItemHeight.value)
|
||||
)
|
||||
@@ -86,64 +68,79 @@ const phantomHeight = computed(() =>
|
||||
props.data.length * totalItemHeight.value
|
||||
)
|
||||
|
||||
// ... 其他方法保持不变,只需将 CONFIG 替换为 props.config ...
|
||||
|
||||
// 选择处理
|
||||
const handleSelect = (item) => {
|
||||
if (props.frozen) return // 如果列表被冻结,不响应选择
|
||||
selectedIndex.value = item.index
|
||||
const handleSelect = (item: ListItem): void => {
|
||||
if (props.frozen) return
|
||||
selectedIndex.value = item.index ?? 0
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
// 点击处理
|
||||
const handleClick = (item) => {
|
||||
if (props.frozen) return // 如果列表被冻结,不响应点击
|
||||
handleSelect(item) // 点击时同时触发选中
|
||||
/**
|
||||
* 处理列表项点击
|
||||
* @param item 点击的列表项
|
||||
*/
|
||||
const handleClick = (item: ListItem): void => {
|
||||
if (props.frozen) return
|
||||
handleSelect(item)
|
||||
emit('click', item)
|
||||
}
|
||||
|
||||
// 修改更新容器高度的方法
|
||||
function updateContainerHeight() {
|
||||
/**
|
||||
* 更新容器高度
|
||||
* 从父元素或窗口获取实际高度
|
||||
*/
|
||||
function updateContainerHeight(): void {
|
||||
if (containerRef.value) {
|
||||
const parentHeight = containerRef.value.parentElement?.clientHeight
|
||||
containerHeight.value = parentHeight || window.innerHeight
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 ResizeObserver 来监听父容器尺寸变化
|
||||
let resizeObserver = null
|
||||
/** ResizeObserver 实例,用于监听容器尺寸变化 */
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// 添加防抖函数
|
||||
function debounce(fn, delay) {
|
||||
let timer = null
|
||||
return function (...args) {
|
||||
/**
|
||||
* 防抖函数
|
||||
* 在指定延迟后执行函数,如果在延迟期间再次调用则重置延迟
|
||||
* @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 = setTimeout(() => {
|
||||
timer = window.setTimeout(() => {
|
||||
fn.apply(this, args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动处理
|
||||
/**
|
||||
* 处理滚动事件(已防抖)
|
||||
* 根据滚动位置更新可视区域的起始索引
|
||||
*/
|
||||
const handleScroll = debounce(() => {
|
||||
if (!containerRef.value) return
|
||||
const scrollTop = containerRef.value.scrollTop
|
||||
// 计算新的起始索引
|
||||
const newStartIndex = Math.floor(scrollTop / totalItemHeight.value)
|
||||
|
||||
// 只有当起始索引发生变化时才更新
|
||||
if (newStartIndex !== startIndex.value) {
|
||||
startIndex.value = newStartIndex
|
||||
}
|
||||
}, props.config.scrollDebounceTime)
|
||||
|
||||
// 添加一个状态来控制是否禁用鼠标悬浮
|
||||
const disableHover = ref(false)
|
||||
let hoverTimer = null
|
||||
/** 鼠标悬停禁用状态 */
|
||||
const disableHover = ref<boolean>(false)
|
||||
/** 鼠标悬停禁用定时器 */
|
||||
let hoverTimer: number | null = null
|
||||
|
||||
// 修改鼠标移入处理函数
|
||||
function handleMouseEnter(index) {
|
||||
if (props.frozen || disableHover.value || props.disableHover) return // 增加 props.disableHover 的判断
|
||||
/**
|
||||
* 处理鼠标移入事件
|
||||
* 在非键盘导航状态下更新选中项
|
||||
* @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 })
|
||||
@@ -151,14 +148,21 @@ function handleMouseEnter(index) {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加记录上次到达边界的时间
|
||||
const lastReachBoundaryTime = ref({
|
||||
/**
|
||||
* 记录到达列表边界的时间
|
||||
* 用于实现边界循环导航的延迟判断
|
||||
*/
|
||||
const lastReachBoundaryTime = ref<{ top: number; bottom: number }>({
|
||||
top: 0,
|
||||
bottom: 0
|
||||
})
|
||||
|
||||
// 修改键盘事件处理函数
|
||||
function handleKeyDown(e) {
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* 实现键盘导航、边界循环和项目选择功能
|
||||
* @param e 键盘事件对象
|
||||
*/
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (props.frozen) return
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
@@ -176,14 +180,12 @@ function handleKeyDown(e) {
|
||||
if (e.key === 'ArrowUp' && isFirstItem) {
|
||||
const timeSinceLastTop = now - lastReachBoundaryTime.value.top
|
||||
if (timeSinceLastTop < 5000) {
|
||||
// 5秒内再次点击,跳转到底部
|
||||
disableHover.value = true // 禁用鼠标悬浮
|
||||
disableHover.value = true
|
||||
selectedIndex.value = props.data.length - 1
|
||||
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
|
||||
ensureSelectedItemVisible()
|
||||
// 1秒后恢复鼠标悬浮功能
|
||||
if (hoverTimer) clearTimeout(hoverTimer)
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
disableHover.value = false
|
||||
}, 1000)
|
||||
} else {
|
||||
@@ -196,14 +198,12 @@ function handleKeyDown(e) {
|
||||
if (e.key === 'ArrowDown' && isLastItem) {
|
||||
const timeSinceLastBottom = now - lastReachBoundaryTime.value.bottom
|
||||
if (timeSinceLastBottom < 5000) {
|
||||
// 5秒内再次点击,跳转到顶部
|
||||
disableHover.value = true // 禁用鼠标悬浮
|
||||
disableHover.value = true
|
||||
selectedIndex.value = 0
|
||||
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
|
||||
ensureSelectedItemVisible()
|
||||
// 1秒后恢复鼠标悬浮功能
|
||||
if (hoverTimer) clearTimeout(hoverTimer)
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
disableHover.value = false
|
||||
}, 1000)
|
||||
} else {
|
||||
@@ -213,7 +213,6 @@ function handleKeyDown(e) {
|
||||
return
|
||||
}
|
||||
|
||||
// 原有的上下键处理逻辑
|
||||
isKeyboardNavigating.value = true
|
||||
if (keyboardTimer) clearTimeout(keyboardTimer)
|
||||
|
||||
@@ -223,7 +222,7 @@ function handleKeyDown(e) {
|
||||
emit('select', { ...props.data[nextIndex], index: nextIndex })
|
||||
|
||||
const itemTop = nextIndex * totalItemHeight.value
|
||||
if (itemTop < containerRef.value?.scrollTop && containerRef.value) {
|
||||
if (itemTop < (containerRef.value?.scrollTop ?? 0) && containerRef.value) {
|
||||
containerRef.value.scrollTop = itemTop
|
||||
}
|
||||
} else {
|
||||
@@ -232,51 +231,57 @@ function handleKeyDown(e) {
|
||||
emit('select', { ...props.data[nextIndex], index: nextIndex })
|
||||
|
||||
const itemBottom = (nextIndex + 1) * totalItemHeight.value
|
||||
const scrollBottom = (containerRef.value?.scrollTop || 0) + containerHeight.value
|
||||
const scrollBottom = (containerRef.value?.scrollTop ?? 0) + containerHeight.value
|
||||
if (itemBottom > scrollBottom && containerRef.value) {
|
||||
containerRef.value.scrollTop = itemBottom - containerHeight.value
|
||||
}
|
||||
}
|
||||
|
||||
keyboardTimer = setTimeout(() => {
|
||||
keyboardTimer = window.setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保选项可见
|
||||
function ensureSelectedItemVisible() {
|
||||
/**
|
||||
* 确保选中项在可视区域内
|
||||
* 如果选中项不可见,则滚动到合适位置
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 修改右键点击处理函数
|
||||
const handleContextMenu = (e, item) => {
|
||||
if (props.frozen) return // 如果列表被冻结,不响应右键点击
|
||||
e.preventDefault() // 阻止默认右键菜单
|
||||
emit('contextmenu', { item }) // 不需要传递事件对象
|
||||
/**
|
||||
* 处理右键点击事件
|
||||
* @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
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateContainerHeight()
|
||||
})
|
||||
@@ -286,34 +291,32 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化容器高度
|
||||
updateContainerHeight()
|
||||
// 添加全局事件监听
|
||||
window.addEventListener('resize', updateContainerHeight)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理所有事件监听和定时器
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
|
||||
// 清理 ResizeObserver
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', updateContainerHeight)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
if (keyboardTimer) {
|
||||
clearTimeout(keyboardTimer)
|
||||
}
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
}
|
||||
if (keyboardTimer) clearTimeout(keyboardTimer)
|
||||
if (hoverTimer) clearTimeout(hoverTimer)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 虚拟列表容器 -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="virtual-list-container"
|
||||
@@ -321,15 +324,18 @@ onBeforeUnmount(() => {
|
||||
:style="{ height: '100%' }"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- 虚拟高度占位元素 -->
|
||||
<div
|
||||
ref="listRef"
|
||||
class="virtual-list-phantom"
|
||||
:style="{ height: `${phantomHeight}px` }"
|
||||
>
|
||||
<!-- 实际渲染的列表内容 -->
|
||||
<div
|
||||
class="virtual-list"
|
||||
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||
>
|
||||
<!-- 列表项 -->
|
||||
<div
|
||||
v-for="item in visibleData"
|
||||
:key="item.index"
|
||||
@@ -343,14 +349,18 @@ onBeforeUnmount(() => {
|
||||
<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"
|
||||
@@ -362,6 +372,7 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 路径信息 -->
|
||||
<div class="item-info">
|
||||
<div class="item-path">{{ item.path }}</div>
|
||||
</div>
|
||||
@@ -375,6 +386,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 虚拟列表容器:可滚动区域 */
|
||||
.virtual-list-container {
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ccc;
|
||||
@@ -384,11 +396,13 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 虚拟高度容器:用于保持滚动条比例 */
|
||||
.virtual-list-phantom {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 实际列表内容容器:通过 transform 实现位置偏移 */
|
||||
.virtual-list {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -396,6 +410,7 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 列表项基础样式 */
|
||||
.list-item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
@@ -405,12 +420,12 @@ onBeforeUnmount(() => {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* 简单的选中高亮效果 */
|
||||
/* 选中状态样式 */
|
||||
.list-item.selected {
|
||||
background-color: rgba(64, 169, 255, 0.15);
|
||||
}
|
||||
|
||||
/* 默认列表项样式 */
|
||||
/* 列表项布局容器 */
|
||||
.default-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -418,6 +433,7 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 图标容器 */
|
||||
.item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
@@ -427,6 +443,7 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 内容区域布局 */
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -435,6 +452,7 @@ onBeforeUnmount(() => {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 标题行布局 */
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -442,6 +460,7 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 项目名称样式 */
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
@@ -453,11 +472,13 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 信息行布局 */
|
||||
.item-info {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 路径文本样式 */
|
||||
.item-path {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
@@ -467,6 +488,7 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 标签容器样式 */
|
||||
.item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -475,6 +497,7 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 单个标签样式 */
|
||||
.item-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
@@ -484,10 +507,10 @@ onBeforeUnmount(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 添加冻结状态的样式 */
|
||||
/* 列表冻结状态样式 */
|
||||
.list-frozen {
|
||||
pointer-events: none; /* 禁用鼠标事件 */
|
||||
opacity: 0.7; /* 降低透明度表示冻结状态 */
|
||||
user-select: none; /* 禁用文本选择 */
|
||||
opacity: 0.7; /* 降低透明度 */
|
||||
user-select: none; /* 禁用文本选择 */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user