|  | 
|  | 1 | +import { defineComponent, ref, onMounted, watch, onUnmounted, nextTick, computed, getCurrentInstance } from 'vue'; | 
|  | 2 | +import { IMentionSuggestionItem, mentionProps, type MentionProps } from './mention-types'; | 
|  | 3 | +import DTextarea from '../../textarea/src/textarea'; | 
|  | 4 | +import DIcon from '../../icon/src/icon'; | 
|  | 5 | +import './mention.scss'; | 
|  | 6 | +import { useNamespace } from '../../shared/hooks/use-namespace'; | 
|  | 7 | +import { debounce } from 'lodash'; | 
|  | 8 | + | 
|  | 9 | +export default defineComponent({ | 
|  | 10 | + name: 'DMention', | 
|  | 11 | + components: { | 
|  | 12 | + DTextarea, | 
|  | 13 | + DIcon, | 
|  | 14 | + }, | 
|  | 15 | + props: mentionProps, | 
|  | 16 | + emits: ['select', 'change'], | 
|  | 17 | + setup(props: MentionProps, { slots, emit }) { | 
|  | 18 | + const ns = useNamespace('mention'); | 
|  | 19 | + const textContext = ref<string>(''); | 
|  | 20 | + const showSuggestions = ref<boolean>(false); | 
|  | 21 | + const currentIndex = ref<number>(0); | 
|  | 22 | + const suggestionsTop = ref<number>(); | 
|  | 23 | + const suggestions = ref<IMentionSuggestionItem[]>([]); | 
|  | 24 | + const filteredSuggestions = ref<IMentionSuggestionItem[]>([]); | 
|  | 25 | + const suggestionsDom = ref<HTMLDivElement>(); | 
|  | 26 | + const loading = computed(() => props.loading); | 
|  | 27 | + const instance = getCurrentInstance(); | 
|  | 28 | + | 
|  | 29 | + const handleUpdate = debounce((val: string) => { | 
|  | 30 | + if (props.trigger.includes(val[0])) { | 
|  | 31 | + showSuggestions.value = true; | 
|  | 32 | + if (props.position === 'top') { | 
|  | 33 | + nextTick(() => { | 
|  | 34 | + const height = window.getComputedStyle(suggestionsDom.value as Element, null).height; | 
|  | 35 | + suggestionsTop.value = -Number(height.replace('px', '')); | 
|  | 36 | + }); | 
|  | 37 | + } | 
|  | 38 | + filteredSuggestions.value = (suggestions.value as IMentionSuggestionItem[]).filter((item: IMentionSuggestionItem) => | 
|  | 39 | + String(item[props.dmValueParse.value as keyof IMentionSuggestionItem]) | 
|  | 40 | + .toLocaleLowerCase() | 
|  | 41 | + .includes(val.slice(1).toLocaleLowerCase()) | 
|  | 42 | + ); | 
|  | 43 | + } else { | 
|  | 44 | + showSuggestions.value = false; | 
|  | 45 | + } | 
|  | 46 | + emit('change', val.slice(1)); | 
|  | 47 | + }, 300); | 
|  | 48 | + | 
|  | 49 | + const handleBlur = () => { | 
|  | 50 | + setTimeout(() => { | 
|  | 51 | + showSuggestions.value = false; | 
|  | 52 | + }, 100); | 
|  | 53 | + }; | 
|  | 54 | + | 
|  | 55 | + const handleFocus = () => { | 
|  | 56 | + if (props.trigger.includes(textContext.value)) { | 
|  | 57 | + showSuggestions.value = true; | 
|  | 58 | + } | 
|  | 59 | + }; | 
|  | 60 | + | 
|  | 61 | + const clickItem = (item: IMentionSuggestionItem, e: MouseEvent) => { | 
|  | 62 | + emit('select', item); | 
|  | 63 | + e.stopPropagation(); | 
|  | 64 | + e.preventDefault(); | 
|  | 65 | + showSuggestions.value = false; | 
|  | 66 | + textContext.value += item[props.dmValueParse.value as keyof IMentionSuggestionItem]; | 
|  | 67 | + }; | 
|  | 68 | + | 
|  | 69 | + const arrowKeyDown = (e: KeyboardEvent) => { | 
|  | 70 | + if (showSuggestions.value && filteredSuggestions.value.length) { | 
|  | 71 | + if (e.key === 'ArrowDown') { | 
|  | 72 | + currentIndex.value++; | 
|  | 73 | + if (currentIndex.value === filteredSuggestions.value.length) { | 
|  | 74 | + currentIndex.value = 0; | 
|  | 75 | + } | 
|  | 76 | + } | 
|  | 77 | + if (e.key === 'ArrowUp') { | 
|  | 78 | + currentIndex.value--; | 
|  | 79 | + if (currentIndex.value === -1) { | 
|  | 80 | + currentIndex.value = filteredSuggestions.value.length - 1; | 
|  | 81 | + } | 
|  | 82 | + } | 
|  | 83 | + const itemDom = instance?.proxy?.$refs[`devui-suggestions-item-${currentIndex.value}`]; | 
|  | 84 | + const itemOffsetTop = (itemDom as HTMLElement)?.offsetTop; | 
|  | 85 | + const clientHeight = (suggestionsDom.value as HTMLElement)?.clientHeight; | 
|  | 86 | + const itemTotal = Math.ceil(clientHeight / (itemDom as HTMLElement).clientHeight); | 
|  | 87 | + if ((e.key === 'ArrowDown' && currentIndex.value >= itemTotal) || e.key === 'ArrowUp') { | 
|  | 88 | + suggestionsDom.value?.scrollTo({ | 
|  | 89 | + top: itemOffsetTop, | 
|  | 90 | + }); | 
|  | 91 | + } | 
|  | 92 | + } | 
|  | 93 | + }; | 
|  | 94 | + | 
|  | 95 | + const enterKeyDown = (e: KeyboardEvent) => { | 
|  | 96 | + if (showSuggestions.value && filteredSuggestions.value.length) { | 
|  | 97 | + if (e.key === 'Enter') { | 
|  | 98 | + e.stopPropagation(); | 
|  | 99 | + e.preventDefault(); | 
|  | 100 | + showSuggestions.value = false; | 
|  | 101 | + textContext.value += filteredSuggestions.value[currentIndex.value][props.dmValueParse.value as keyof IMentionSuggestionItem]; | 
|  | 102 | + emit('select', filteredSuggestions.value[currentIndex.value]); | 
|  | 103 | + } | 
|  | 104 | + } | 
|  | 105 | + }; | 
|  | 106 | + | 
|  | 107 | + watch( | 
|  | 108 | + () => props.suggestions, | 
|  | 109 | + (val) => { | 
|  | 110 | + suggestions.value = val as IMentionSuggestionItem[]; | 
|  | 111 | + filteredSuggestions.value = val as IMentionSuggestionItem[]; | 
|  | 112 | + }, | 
|  | 113 | + { immediate: true, deep: true } | 
|  | 114 | + ); | 
|  | 115 | + | 
|  | 116 | + onMounted(() => { | 
|  | 117 | + window.addEventListener('keydown', arrowKeyDown); | 
|  | 118 | + window.addEventListener('keydown', enterKeyDown); | 
|  | 119 | + }); | 
|  | 120 | + | 
|  | 121 | + onUnmounted(() => { | 
|  | 122 | + window.removeEventListener('keydown', arrowKeyDown); | 
|  | 123 | + window.removeEventListener('keydown', enterKeyDown); | 
|  | 124 | + }); | 
|  | 125 | + | 
|  | 126 | + return () => { | 
|  | 127 | + return ( | 
|  | 128 | + <div class={ns.b()}> | 
|  | 129 | + <d-textarea v-model={textContext.value} onUpdate={handleUpdate} onBlur={handleBlur} onFocus={handleFocus}></d-textarea> | 
|  | 130 | + {showSuggestions.value ? ( | 
|  | 131 | + loading.value ? ( | 
|  | 132 | + <div class={[`${ns.e('suggestions')} ${ns.e('suggestions-loading')}`]}>加载中... </div> | 
|  | 133 | + ) : ( | 
|  | 134 | + <div | 
|  | 135 | + class={ns.e('suggestions')} | 
|  | 136 | + ref={suggestionsDom} | 
|  | 137 | + style={{ | 
|  | 138 | + marginTop: props.position === 'top' ? '0px' : '-16px', | 
|  | 139 | + top: suggestionsTop.value ? suggestionsTop.value + 'px' : 'inherit', | 
|  | 140 | + }}> | 
|  | 141 | + {filteredSuggestions.value.length > 0 ? ( | 
|  | 142 | + filteredSuggestions.value?.map((item, index) => { | 
|  | 143 | + return ( | 
|  | 144 | + <div | 
|  | 145 | + ref={`devui-suggestions-item-${index}`} | 
|  | 146 | + class={`${ns.e('suggestions-item')} | 
|  | 147 | + ${currentIndex.value === index ? `${ns.e('suggestions-item-active')}` : ''}`} | 
|  | 148 | + key={item.id} | 
|  | 149 | + onClick={(e) => clickItem(item, e)}> | 
|  | 150 | + {slots.template ? slots.template({ item }) : item.value} | 
|  | 151 | + </div> | 
|  | 152 | + ); | 
|  | 153 | + }) | 
|  | 154 | + ) : ( | 
|  | 155 | + <div>{props.notFoundContent}</div> | 
|  | 156 | + )} | 
|  | 157 | + </div> | 
|  | 158 | + ) | 
|  | 159 | + ) : null} | 
|  | 160 | + </div> | 
|  | 161 | + ); | 
|  | 162 | + }; | 
|  | 163 | + }, | 
|  | 164 | +}); | 
0 commit comments