@ithinkdt/naive
Version:
iThinkDT Naive UI
770 lines (711 loc) • 28.6 kB
JSX
import { ref, shallowRef, computed, defineComponent, watch, h, shallowReactive } from 'vue'
import {
NScrollbar,
NImage,
NIcon,
NDivider,
NVirtualList,
NInput,
NP,
NUpload,
NUploadDragger,
NButton,
NFlex,
} from 'ithinkdt-ui'
import { useFormItem } from 'ithinkdt-ui/es/_mixins/index'
import { debouncedWatch, asyncComputed, useElementSize } from '@vueuse/core'
import { useModal, useTheme } from '@ithinkdt/core'
import {
c,
cB,
cE,
cM,
flex,
fullWH,
flexCenter,
flexDirCol,
flexAlignCenter,
flexJustifySB,
flexGap,
CSS_MOUNT_ANCHOR_META_NAME,
CSS_STYLE_PREFIX as p,
} from '@ithinkdt/core/cssr'
import { changeColor } from 'seemly'
import { parseIconSet } from '@iconify/utils/lib/icon-set/parse'
import { iconToSVG } from '@iconify/utils/lib/svg/build'
import { defaultIconCustomisations } from '@iconify/utils/lib/customisations/defaults'
import { IImgError, IImg2, IPlus, ISearch, IDel } from './assets'
import { DATA_CATEGORY, DATA_NAME, SVG_PREFIX } from '../frame/icon'
const cls = `${p}-icon-select`
const modalCls = `${cls}-modal`
export const DtIconSelect = defineComponent({
name: 'IconSelect',
props: {
value: { type: Object, default: undefined },
disabled: { type: Boolean, default: undefined },
status: { type: String, default: undefined },
accept: { type: String, default: 'image/*' },
colls: { type: Object, default: () => ({}) },
loader: { type: Function, default: undefined },
},
emits: {
'update:value': () => true,
'update-value': () => true,
},
setup(props, { emit }) {
const theme = useTheme()
watch(
() => theme.isDark,
(isDark) => {
style?.unmount()
style = undefined
createStyle(cls, theme.vars, isDark)
},
{ immediate: true },
)
// form-item
const formItem = useFormItem(props)
const { mergedDisabledRef, mergedStatusRef } = formItem
// 错误处理
const error = ref(false)
watch(
() => props.value?.body,
() => {
error.value = false
},
{ immediate: true },
)
const onError = () => {
error.value = true
}
// 图标选取
const collMap = shallowReactive(new Map())
const colls = computed(() => {
collMap.clear()
const arr = Object.entries(props.colls).map(([key, coll]) => {
coll = {
key,
load: () => {
return props.loader(coll.key)
},
...coll,
}
const load = coll.load
coll = {
...coll,
onClick: () => {
currColl.value = coll.key
},
load: async () => {
if (coll.result) return coll.result
const icons = await load()
const result = (coll.result = [])
parseIconSet(icons, (name, data) => {
if (!data) {
return
}
const renderData = iconToSVG(data, {
...defaultIconCustomisations,
height: 'auto',
})
const svgAttributes = {
...renderData.attributes,
width: '1em',
height: '1em',
[DATA_CATEGORY]: icons.prefix,
[DATA_NAME]: name,
}
const svgAttributesStr = Object.entries(svgAttributes)
.map(([attr, val]) => `${attr}="${val}"`)
.join(' ')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" ${svgAttributesStr}>${renderData.body}</svg>`
result.push([
name,
svg,
() =>
h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
...svgAttributes,
innerHTML: renderData.body,
}),
`${icons.prefix}:${name}`,
icons.prefix,
{ [DATA_CATEGORY]: icons.prefix, [DATA_NAME]: name + '.svg' },
result.length,
])
})
return result
},
}
collMap.set(coll.key, coll)
return coll
})
const arr2 = [
{
key: '',
name: '全部',
total: arr.reduce((cnt, it) => cnt + it.total, 0),
onClick: () => {
currColl.value = ''
},
load: () => {
return Promise.all(arr.map((coll) => coll.load())).then((results) => {
return results.flat()
})
},
},
...arr,
]
collMap.set('', arr2[0])
return arr2
})
const filterColl = ref('')
const currColl = ref('mdi')
const _colls = shallowRef()
debouncedWatch(
[filterColl, colls],
([s, colls]) => {
s = s?.trim()
_colls.value = colls
if (s) {
_colls.value = colls.filter(([k, { name }]) => k.includes(s) || name.includes(s))
}
},
{ debounce: 300, immediate: true },
)
const curr = ref()
const Icons = defineComponent({
setup() {
const loading = ref(false)
let nameIndex = 0
const iconSet = asyncComputed(() => {
loading.value = true
nameIndex = currColl.value ? 0 : 3
return collMap
.get(currColl.value)
.load()
.finally(() => {
loading.value = false
})
})
const elRef = ref()
const filter = ref('')
const _icons = ref([])
function updateIcons(icons, filter) {
const s = filter?.trim()
_icons.value = s ? icons.filter((it) => it[0].includes(s)) : icons
}
watch(iconSet, (set) => updateIcons(set, filter.value))
setTimeout(() => {
updateIcons(iconSet.value, filter.value)
}, 500)
debouncedWatch(filter, (filter) => updateIcons(iconSet.value, filter), {
immediate: true,
debounce: 300,
})
const itemSize = 48
const { width } = useElementSize(elRef)
let cols = 10
const icons = computed(() => {
let array = []
if (_icons.value?.length) {
cols = Math.max(Math.floor(width.value / itemSize), 10)
const rows = Math.ceil(_icons.value.length / cols)
array = Array.from({ length: rows })
for (let row = 0; row < rows; row++) {
array[row] = {
key: row,
icons: _icons.value.slice(row * cols, row * cols + cols),
}
}
}
return array
})
function onClick(ev) {
const dataset = ev.currentTarget.dataset
const it = _icons.value[dataset.index]
curr.value = {
...dataset,
body: SVG_PREFIX + btoa(it[1]),
source: it[1],
svg: true,
it,
index: it[6],
}
}
return () => (
<div class={`${modalCls}__icons`}>
<div class={`${modalCls}__icons-search`}>
<div style="flex: 1 0 auto; ">
<span style="font-weight: bold; font-size: 18px">
{collMap.get(currColl.value || '')?.name}
</span>
<span style="margin-left: 12px; color: #777">
{collMap.get(currColl.value)?.total} 个
</span>
</div>
<NInput v-model:value={filter.value} clearable placeholder="搜索图标" style="width: 300px">
{{
suffix: () => (
<NIcon>
<ISearch />
</NIcon>
),
}}
</NInput>
</div>
{loading.value ? (
'加载中...'
) : (
<div class={`${modalCls}__list`} ref={elRef}>
<NVirtualList
items={icons.value}
ignoreItemResize
itemSize={itemSize}
paddingBottom={30}
scrollbarProps={{ size: 10 }}
>
{{
default: ({ item, index }) => {
return (
<div class={`${modalCls}__row`}>
{item.icons.map((it, i) => (
<div
class={`${modalCls}__item`}
key={it[3]}
{...it[5]}
data-index={cols * index + i}
onClick={onClick}
>
<div class={`${modalCls}__item-icon`}>{it[2]()}</div>
<div class={`${modalCls}__item-name`}>{it[nameIndex]}</div>
</div>
))}
</div>
)
},
}}
</NVirtualList>
</div>
)}
</div>
)
},
})
const fileList = []
const onFileSelect = async ([file]) => {
curr.value = undefined
if (!file) return
curr.value = {
category: '',
name: file.name,
body: await new Promise((resolve) => {
const fr = new FileReader()
fr.addEventListener('load', () => {
resolve(fr.result)
})
fr.readAsDataURL(file.file)
}),
svg: file.name?.endsWith('.svg'),
}
}
function emitVal(val = curr.value ?? props.value ?? undefined) {
emit('update:value', val)
emit('update-value', val)
}
const { show, hide } = useModal({
title: '选择图标',
width: '160vh',
style: {
maxWidth: '87vw',
maxHeight: '100%',
},
onAfterLeave: () => {
curr.value = undefined
},
content: () => {
const image = curr.value ?? props.value ?? {}
return (
<div class={modalCls}>
<div class={`${modalCls}__categories`}>
<NScrollbar>
<NInput
class={`${modalCls}__categories-search`}
v-model:value={filterColl.value}
clearable
placeholder="搜索类别"
>
{{
suffix: () => (
<NIcon>
<ISearch />
</NIcon>
),
}}
</NInput>
<Category categories={_colls.value} curr={currColl.value} />
</NScrollbar>
</div>
<NDivider vertical style="height: 100%; flex: 0 0 auto" />
<div class={`${modalCls}__container`}>
<Icons />
</div>
<NDivider vertical style="height: 100%; flex: 0 0 auto" />
<div class={`${modalCls}__info`}>
<div style="font-weight: bold; font-size: 18px; margin-bottom: 16px;">图标</div>
<Selected {...image} />
<NUpload
fileList={fileList}
defaultUpload={false}
accept={props.accept}
onUpdateFileList={onFileSelect}
>
<NUploadDragger class={`${modalCls}__dragger`}>
<div style="font-size: 16px; color: #333">点击或拖动文件至此</div>
<NP depth="3" style="font-size: 13px">
SVG 文件建议
<br />
宽度 1em 高度 1em
<br />
颜色 currentColor
</NP>
</NUploadDragger>
</NUpload>
<NFlex justify="flex-end">
<NButton onClick={hide}>取 消</NButton>
<NButton
type="primary"
onClick={() => {
emitVal()
hide()
}}
>
确 定
</NButton>
</NFlex>
</div>
</div>
)
},
})
return () => {
return (
<div
class={[
cls,
mergedStatusRef.value ? `${cls}--${mergedStatusRef.value}-status` : '',
mergedDisabledRef.value ? `${cls}--disabled` : '',
]}
tabindex="0"
onClick={() => {
!mergedDisabledRef.value && show()
}}
>
{!props.value || error.value ? (
<NIcon class={[`${cls}__wrapper`, error.value ? `${cls}__error` : `${cls}__add`]}>
{error.value ? <IImgError /> : <IPlus />}
</NIcon>
) : (
<NImage
class={[`${cls}__wrapper`, `${cls}__img`]}
width="62%"
height="62%"
src={props.value.body || 'none'}
onError={onError}
previewDisabled
/>
)}
{props.value && !mergedDisabledRef.value ? (
<div
class={`${cls}__remove`}
onClick={(e) => {
e.stopPropagation()
// eslint-disable-next-line unicorn/no-null
emitVal(null)
}}
>
<NButton text type="error" style="font-size: 24px">
<IDel />
</NButton>
</div>
) : undefined}
</div>
)
}
},
})
const Category = defineComponent({
props: {
categories: {
type: Object,
required: true,
},
curr: {
type: String,
default: undefined,
},
},
setup(props) {
return () =>
props.categories.map(({ key, name, onClick }) => (
<div
key={name}
class={[`${modalCls}__category`, props.curr === key ? `${modalCls}__category--curr` : '']}
onClick={onClick}
>
{name}
</div>
))
},
})
const Selected = defineComponent({
props: {
category: { type: String, default: undefined },
name: { type: String, default: undefined },
body: { type: String, default: undefined },
svg: { type: Boolean, default: false },
},
setup(props) {
return () => {
const { svg, body, category, name } = props
return (
<div class={`${modalCls}__selected`}>
{body ? (
<>
<NImage
width={120}
height={120}
src={body}
style={{
opacity: svg ? 0.618 : 1,
}}
/>
<div style={`text-align: center; margin-top: 12px; font-size: 16px`}>
{category ? category + ':' : ''}
{name}
</div>
</>
) : (
<>
<NIcon size={120} color="#ccc">
<IImg2 />
</NIcon>
<div style="margin-top: 12px; color: #999; font-size: 16px">未选择</div>
</>
)}
</div>
)
}
},
})
let style
function createStyle(cls, vars, isDark) {
if (!style) {
style = c([
cB(
'icon-select',
{
width: '96px',
height: '96px',
cursor: 'pointer',
boxSizing: 'border-box',
border: `1px solid var(--${p}-border-color)`,
borderRadius: `var(--${p}-border-radius)`,
transition: 'color, border-color 0.3s ease-in-out',
position: 'relative',
overflow: 'hidden',
},
[
c('&:hover, &:focus', {
borderColor: `var(--${p}-primary-color-hover)`,
}),
c('&:focus', {
outline: 'none',
boxShadow: isDark
? `0 0 0 2px ${changeColor(vars.primaryColor, { alpha: 0.2 })}`
: `0 0 8px 0 ${changeColor(vars.primaryColor, { alpha: 0.3 })}`,
}),
cM('disabled', {
cursor: 'not-allowed',
border: `1px solid var(--${p}-border-color)`,
background: `var(--${p}-input-color-disabled)`,
boxShadow: 'none',
}),
...['success', 'warning', 'error'].map((s) =>
cM(
`${s}-status`,
{
borderColor: `var(--${p}-${s}-color-hover)`,
},
[
c('&:focus', {
boxShadow: isDark
? `0 0 0 2px ${changeColor(vars[s + 'Color'], { alpha: 0.2 })}`
: `0 0 8px 0 ${changeColor(vars[s + 'Color'], { alpha: 0.3 })}`,
}),
],
),
),
cE('wrapper', {
fontSize: '32px',
...fullWH,
...flexCenter,
}),
cE('error', {
color: `var(--${p}-error-color)`,
}),
cE(
'add',
{
fontSize: '24px',
},
[
c('&:hover', {
borderColor: `var(--${p}-primary-color)`,
}),
],
),
cE(
'remove',
{
position: 'absolute',
left: 0,
top: 0,
...fullWH,
...flexCenter,
background: isDark ? '#00000070' : '#ffffffe0',
opacity: '0',
transition: 'opacity 0.3s ease-in-out',
},
[
c('&:hover', {
opacity: '1',
}),
],
),
],
),
cB('icon-select-modal', { height: 'calc(85vh - 40px)', overflow: 'hidden', ...flex }, [
cE('categories', {
flex: '0 0 240px',
}),
cE(
'category',
{
cursor: 'pointer',
fontSize: '16px',
padding: '8px 0 8px 20px',
},
[
c('&:hover', {
color: `var(--${p}-primary-color-hover)`,
}),
cM('curr', {
color: `var(--${p}-primary-color)`,
}),
],
),
cE('categories-search', {
width: 'calc(100% - 20px)',
position: 'sticky',
top: 0,
marginBottom: '12px',
}),
cE('container', {
flex: '1 1 auto',
}),
cE('icons', {
height: '100%',
overflow: 'hidden',
...flexDirCol,
padding: '0 20px',
...flexGap('20px'),
}),
cE('icons-search', {
...flexAlignCenter,
...flexJustifySB,
...flexGap('12px'),
}),
cE('list', {
flex: '1 1 auto',
overflow: 'hidden',
fontSize: '28px',
color: '#666',
}),
cE('row', {
textAlign: 'center',
lineHeight: '1',
}),
cE(
'item',
{
display: 'inline-block',
textAlign: 'center',
width: '48px',
height: '48px',
position: 'relative',
cursor: 'pointer',
boxSizing: 'border-box',
},
[
c(`&:hover`, [
c(`.${p}-icon-select-modal__item-icon`, {
color: `var(--${p}-primary-color)`,
borderColor: `var(--${p}-primary-color)`,
}),
c(`.${p}-icon-select-modal__item-name`, {
display: 'block',
}),
]),
],
),
cE('item-icon', {
width: '34px',
height: '34px',
...flexCenter,
border: `1px solid transparent`,
borderRadius: `var(--${p}-border-radius)`,
}),
cE('item-name', {
fontSize: '14px',
position: 'absolute',
bottom: '-20px',
left: '20px',
transform: 'translateX(-50%)',
pointerEvents: 'none',
zIndex: '2',
whiteSpace: 'nowrap',
display: 'none',
textAlign: 'center',
color: `var(--${p}-primary-color)`,
background: `var(--${p}-base-color)`,
boxShadow: `var(--${p}-box-shadow1)`,
padding: '6px 8px',
}),
cE('info', {
flex: '0 0 240px',
padding: '0 8px 8px 20px',
...flexDirCol,
...flexJustifySB,
overflow: 'hidden',
}),
cE('selected', {
...flexDirCol,
...flexCenter,
flex: '0 0 200px',
}),
cE('dragger', {
background: 'transparent',
height: 'calc(85vh - 420px)',
...flexDirCol,
...flexCenter,
}),
]),
])
style.mount({
id: cls,
anchorMetaName: CSS_MOUNT_ANCHOR_META_NAME,
})
}
}