library-picturebook-activity/lesingle-creation-frontend/src/components/AntdIconPicker.vue

353 lines
9.1 KiB
Vue
Raw Normal View History

<template>
<div class="antd-icon-picker">
<a-input-group compact class="antd-icon-picker-row">
<a-input
v-model:value="iconName"
:placeholder="placeholder"
:maxlength="maxlength"
allow-clear
class="antd-icon-picker-input"
>
<template v-if="iconName" #prefix>
<span class="antd-icon-picker-prefix">
<component v-if="previewRender" :is="previewRender" />
</span>
</template>
</a-input>
<a-popover
v-model:open="pickerOpen"
trigger="click"
placement="bottomLeft"
:overlay-style="{ width: '400px' }"
>
<template #content>
<a-form-item-rest>
<a-input-search
v-model:value="keyword"
placeholder="搜索图标名称"
allow-clear
class="mb-2"
/>
<a-tabs v-model:active-key="iconStyleTab" size="small" class="antd-icon-picker-tabs mb-2">
<a-tab-pane key="outlined" tab="线条" />
<a-tab-pane key="filled" tab="实底" />
<a-tab-pane key="twoTone" tab="双色" />
</a-tabs>
<!-- 虚拟列表只挂载可视区域内的行避免一次性渲染上千个图标组件 -->
<div
v-if="filteredNames.length > 0"
ref="scrollRef"
class="antd-icon-picker-grid"
@scroll.passive="onGridScroll"
>
<div class="antd-icon-picker-grid-spacer" :style="{ height: `${totalScrollHeight}px` }">
<div
class="antd-icon-picker-grid-inner"
:style="{ transform: `translateY(${virtualOffsetY}px)` }"
>
<div
v-for="(row, rowIdx) in visibleRows"
:key="virtualStartRow + rowIdx"
class="antd-icon-picker-grid-row"
>
<button
v-for="name in row"
:key="name"
type="button"
:title="name"
class="antd-icon-picker-cell"
:class="{ 'is-active': iconName === name }"
@click="selectIcon(name)"
>
<component :is="getIconRenderCached(name)" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="text-center text-gray-400 py-4 text-sm">无匹配图标</div>
</a-form-item-rest>
</template>
<a-button type="default" class="antd-icon-picker-trigger">
<template #icon><AppstoreOutlined /></template>
选择
</a-button>
</a-popover>
</a-input-group>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, ref, watch } from 'vue'
import type { VNode } from 'vue'
import * as Icons from '@ant-design/icons-vue'
import { AppstoreOutlined } from '@ant-design/icons-vue'
withDefaults(
defineProps<{
placeholder?: string
maxlength?: number
}>(),
{
placeholder: '请输入或点击选择图标Ant Design Icons',
maxlength: 50,
}
)
const iconName = defineModel<string>('value', { default: '' })
const keyword = ref('')
const pickerOpen = ref(false)
const scrollRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
/** 每行高度(与样式 .antd-icon-picker-grid-row 一致) */
const ROW_HEIGHT = 44
const COLS = 8
const BUFFER_ROWS = 3
const GRID_MAX_HEIGHT = 280
const EXCLUDED_ICON_MODULE_KEYS = new Set([
'default',
'createFromIconfontCN',
'Icon',
'getTwoToneColor',
'setTwoToneColor',
])
function isVueIconExport(val: unknown): boolean {
if (val === null || val === undefined) return false
const t = typeof val
return t === 'function' || t === 'object'
}
function buildNamesBySuffix(suffix: 'Outlined' | 'Filled' | 'TwoTone'): string[] {
return Object.keys(Icons)
.filter((k) => {
if (EXCLUDED_ICON_MODULE_KEYS.has(k)) return false
if (!k.endsWith(suffix)) return false
return isVueIconExport((Icons as Record<string, unknown>)[k])
})
.sort((a, b) => a.localeCompare(b))
}
/** 线条 / 实底 / 双色 三套列表,分页切换减少单次渲染量 */
const OUTLINED_ICON_NAMES = buildNamesBySuffix('Outlined')
const FILLED_ICON_NAMES = buildNamesBySuffix('Filled')
const TWO_TONE_ICON_NAMES = buildNamesBySuffix('TwoTone')
type IconStyleTab = 'outlined' | 'filled' | 'twoTone'
const iconStyleTab = ref<IconStyleTab>('outlined')
const namesByTab = computed(() => {
switch (iconStyleTab.value) {
case 'filled':
return FILLED_ICON_NAMES
case 'twoTone':
return TWO_TONE_ICON_NAMES
default:
return OUTLINED_ICON_NAMES
}
})
const filteredNames = computed(() => {
const q = keyword.value.trim().toLowerCase()
const list = namesByTab.value
if (!q) return list
return list.filter((n) => n.toLowerCase().includes(q))
})
const totalRows = computed(() => Math.ceil(filteredNames.value.length / COLS))
const totalScrollHeight = computed(() => Math.max(0, totalRows.value * ROW_HEIGHT))
const virtualStartRow = computed(() => {
const tr = totalRows.value
if (tr === 0) return 0
const top = scrollTop.value
const start = Math.max(0, Math.floor(top / ROW_HEIGHT) - BUFFER_ROWS)
const maxStart = Math.max(0, tr - 1)
return Math.min(start, maxStart)
})
const virtualEndRow = computed(() => {
const tr = totalRows.value
if (tr === 0) return 0
const h = GRID_MAX_HEIGHT
const top = scrollTop.value
const end = Math.ceil((top + h) / ROW_HEIGHT) + BUFFER_ROWS
return Math.min(tr, Math.max(0, end))
})
/** 当前窗口内要渲染的行(每行最多 COLS 个名称) */
const visibleRows = computed(() => {
const list = filteredNames.value
const start = virtualStartRow.value
const end = virtualEndRow.value
const rows: string[][] = []
for (let r = start; r < end; r++) {
const row: string[] = []
for (let c = 0; c < COLS; c++) {
const i = r * COLS + c
if (i < list.length) row.push(list[i]!)
}
if (row.length) rows.push(row)
}
return rows
})
const virtualOffsetY = computed(() => virtualStartRow.value * ROW_HEIGHT)
function onGridScroll(e: Event) {
const el = e.target as HTMLElement
scrollTop.value = el.scrollTop
}
function resetGridScroll() {
scrollTop.value = 0
nextTick(() => {
if (scrollRef.value) scrollRef.value.scrollTop = 0
})
}
function inferTabFromIconName(name: string): IconStyleTab {
if (name.endsWith('TwoTone')) return 'twoTone'
if (name.endsWith('Filled')) return 'filled'
return 'outlined'
}
const iconRenderCache = new Map<string, () => VNode>()
function getIconRenderCached(name: string) {
let fn = iconRenderCache.get(name)
if (!fn) {
const C = (Icons as Record<string, unknown>)[name]
if (!C) {
fn = () => h('span')
} else {
const Comp = C as Parameters<typeof h>[0]
fn = () => h(Comp)
}
iconRenderCache.set(name, fn)
}
return fn
}
const previewRender = computed(() => {
const n = iconName.value
if (!n) return null
return getIconRenderCached(n)
})
function selectIcon(name: string) {
iconName.value = name
pickerOpen.value = false
keyword.value = ''
}
watch(keyword, () => {
resetGridScroll()
})
watch(iconStyleTab, () => {
resetGridScroll()
})
watch(pickerOpen, (open) => {
if (!open) {
keyword.value = ''
return
}
iconStyleTab.value = inferTabFromIconName(iconName.value || '')
resetGridScroll()
})
</script>
<style scoped>
.antd-icon-picker-row {
display: flex;
width: 100%;
}
.antd-icon-picker-input {
flex: 1;
min-width: 0;
}
.antd-icon-picker-prefix {
display: inline-flex;
align-items: center;
font-size: 16px;
line-height: 1;
}
.antd-icon-picker-trigger {
flex-shrink: 0;
}
.antd-icon-picker-tabs :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
.antd-icon-picker-tabs :deep(.ant-tabs-tab) {
padding: 6px 10px;
}
.antd-icon-picker-grid {
max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
}
.antd-icon-picker-grid-spacer {
position: relative;
width: 100%;
}
.antd-icon-picker-grid-inner {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.antd-icon-picker-grid-row {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
height: 44px;
margin-bottom: 0;
box-sizing: border-box;
}
.antd-icon-picker-cell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0;
height: 40px;
padding: 0;
margin: 0;
border: 1px solid var(--ant-color-border-secondary, #f0f0f0);
border-radius: 6px;
background: var(--ant-color-bg-container, #fff);
cursor: pointer;
font-size: 18px;
line-height: 1;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.antd-icon-picker-cell:hover {
border-color: var(--ant-color-primary, #0958d9);
color: var(--ant-color-primary, #0958d9);
}
.antd-icon-picker-cell.is-active {
border-color: var(--ant-color-primary, #0958d9);
background: var(--ant-color-primary-bg, #e6f4ff);
color: var(--ant-color-primary, #0958d9);
}
</style>