UNPKG

@ithinkdt/naive

Version:

iThinkDT Naive UI

770 lines (711 loc) 28.6 kB
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, }) } }