Files
kairos/apps/server/src/modules/bookmarks/components/IconPickerDialog.tsx
T
imbytecat 001d171111 feat: 重写图标选择器 — 全量 lucide 图标 + 虚拟滚动 + 可清除
- 动态获取所有 ~1500 个 lucide-react 图标,替代硬编码 57 个
- 引入 @tanstack/react-virtual 虚拟滚动,流畅渲染大量图标
- 使用 useState callback ref 解决 Dialog 内 virtualizer 初始化问题
- 新增清除图标按钮,允许将图标置空
- 搜索覆盖全量图标,输入时自动滚回顶部
2026-03-31 17:25:27 +08:00

152 lines
4.7 KiB
TypeScript

import { useVirtualizer } from '@tanstack/react-virtual'
import * as icons from 'lucide-react'
import { Search, X } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
const ALL_ICON_NAMES: string[] = Object.keys(icons)
.filter((key) => /^[A-Z]/.test(key) && !key.endsWith('Icon'))
.sort()
const COLUMNS = 8
const ROW_HEIGHT = 40
interface IconPickerDialogProps {
value: string | null
onChange: (iconName: string | null) => void
}
export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const filteredIcons = filter
? ALL_ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
: ALL_ICON_NAMES
const rowCount = Math.ceil(filteredIcons.length / COLUMNS)
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
const CurrentIcon = (value && allIcons[value]) || null
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen)
if (!isOpen) setFilter('')
}}
>
<DialogTrigger render={<Button type="button" variant="outline" className="w-full justify-start gap-2" />}>
{CurrentIcon ? <CurrentIcon className="size-4" /> : <Search className="size-4 text-muted-foreground" />}
<span className={cn('truncate', !value && 'text-muted-foreground')}>{value ?? '选择图标'}</span>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> · {ALL_ICON_NAMES.length} </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={filter}
onChange={(e) => {
setFilter(e.target.value)
scrollElement?.scrollTo({ top: 0 })
}}
placeholder="搜索图标..."
className="flex-1"
/>
{value && (
<Button
type="button"
variant="outline"
size="icon"
title="清除图标"
onClick={() => {
onChange(null)
setOpen(false)
}}
>
<X className="size-4" />
</Button>
)}
</div>
<div ref={setScrollElement} className="max-h-80 overflow-y-auto rounded-lg border p-2">
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * COLUMNS
const rowIcons = filteredIcons.slice(startIndex, startIndex + COLUMNS)
return (
<div
key={virtualRow.key}
className="grid grid-cols-8 gap-1"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowIcons.map((name) => {
const Icon = allIcons[name]
if (!Icon) return null
return (
<Button
key={name}
type="button"
size="icon"
variant={value === name ? 'secondary' : 'ghost'}
title={name}
onClick={() => {
onChange(name)
setOpen(false)
}}
>
<Icon className="size-4" />
</Button>
)
})}
</div>
)
})}
</div>
</div>
{filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground"></p>}
</DialogContent>
</Dialog>
)
}