235 lines
6.7 KiB
Vue
235 lines
6.7 KiB
Vue
<template>
|
||
<div class="mention-container" ref="containerRef">
|
||
<slot></slot>
|
||
|
||
<div ref="mirrorRef" class="textarea-mirror" aria-hidden="true"></div>
|
||
|
||
<transition name="el-zoom-in-top">
|
||
<div
|
||
v-if="showPopover"
|
||
class="mention-popover"
|
||
:style="{ top: popoverPos.top + 'px', left: popoverPos.left + 'px' }"
|
||
>
|
||
<div class="mention-list" v-if="filteredList.length">
|
||
<div
|
||
v-for="(user, index) in filteredList"
|
||
:key="user.id"
|
||
:class="['mention-item', { active: index === selectIndex }]"
|
||
@mousedown.prevent="handleSelect(user)"
|
||
@mouseenter="selectIndex = index"
|
||
>
|
||
<name-avatar :name="user.nickname" :size="20" />
|
||
<span class="name">{{ user.nickname }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="mention-empty">未找到人员</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, nextTick, watch } from 'vue';
|
||
|
||
const props = defineProps({
|
||
modelValue: String,
|
||
users: { type: Array, default: () => [] }
|
||
});
|
||
|
||
const emit = defineEmits(['update:modelValue', 'select']);
|
||
|
||
const containerRef = ref(null);
|
||
const mirrorRef = ref(null);
|
||
const showPopover = ref(false);
|
||
const searchKey = ref("");
|
||
const selectIndex = ref(0);
|
||
const popoverPos = ref({ top: 0, left: 0 });
|
||
let targetInput = null; // 存储原生 textarea 引用
|
||
|
||
const filteredList = computed(() => {
|
||
return props.users.filter(u => u.nickname.includes(searchKey.value));
|
||
});
|
||
|
||
// 计算像素坐标的核心逻辑
|
||
const computeCaretPosition = () => {
|
||
if (!targetInput || !mirrorRef.value) return;
|
||
|
||
const val = targetInput.value;
|
||
const cursorIndex = targetInput.selectionStart;
|
||
|
||
// 找到最近的一个 @
|
||
const textBefore = val.slice(0, cursorIndex);
|
||
const lastAtPos = textBefore.lastIndexOf("@");
|
||
|
||
// 获取 @ 到光标之间的内容作为搜索词
|
||
searchKey.value = textBefore.slice(lastAtPos + 1);
|
||
|
||
// --- 镜像同步逻辑 ---
|
||
const style = window.getComputedStyle(targetInput);
|
||
const mirror = mirrorRef.value;
|
||
|
||
// 复制所有会影响排版的样式
|
||
const propList = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'paddingTop', 'paddingLeft', 'paddingRight', 'paddingBottom', 'borderWidth', 'boxSizing', 'letterSpacing', 'wordBreak'];
|
||
propList.forEach(prop => mirror.style[prop] = style[prop]);
|
||
mirror.style.width = targetInput.offsetWidth + 'px';
|
||
|
||
// 将内容填入镜像,在 @ 位置插入标记元素
|
||
const contentBeforeAt = val.slice(0, lastAtPos);
|
||
const contentAtToCursor = val.slice(lastAtPos, cursorIndex);
|
||
|
||
// 使用 textContent 防止 XSS,使用 <br> 处理换行
|
||
mirror.textContent = contentBeforeAt;
|
||
const marker = document.createElement('span');
|
||
marker.textContent = '@';
|
||
marker.style.color = 'red'; // 仅调试可见
|
||
mirror.appendChild(marker);
|
||
|
||
// 继续填充剩余内容以保持排版一致
|
||
const remaining = document.createTextNode(contentAtToCursor);
|
||
mirror.appendChild(remaining);
|
||
|
||
// 获取标记位的相对坐标
|
||
nextTick(() => {
|
||
popoverPos.value = {
|
||
// offsetTop 是输入框顶部的偏移 + 标记位高度 - 滚动条高度
|
||
top: targetInput.offsetTop + marker.offsetTop + parseInt(style.lineHeight) - targetInput.scrollTop,
|
||
left: targetInput.offsetLeft + marker.offsetLeft
|
||
};
|
||
});
|
||
};
|
||
|
||
const handleInput = (e) => {
|
||
const el = e.target;
|
||
const val = el.value;
|
||
const pos = el.selectionStart; // 当前光标位置
|
||
|
||
// 1. 获取光标之前的文本
|
||
const textBefore = val.slice(0, pos);
|
||
|
||
// 2. 找到光标前最近的一个 @
|
||
const lastAtPos = textBefore.lastIndexOf("@");
|
||
|
||
if (lastAtPos !== -1) {
|
||
// 3. 关键逻辑:获取 @ 到光标之间的内容
|
||
const contentBetween = textBefore.slice(lastAtPos + 1);
|
||
|
||
// 4. 判断逻辑:
|
||
// - 如果 @ 和光标之间有空格,说明这一段提及已结束,不弹窗
|
||
// - 如果 @ 前面不是空格且不是开头,说明可能是邮箱地址,不弹窗
|
||
const charBeforeAt = textBefore[lastAtPos - 1];
|
||
const isAtStartOrAfterSpace = !charBeforeAt || /\s/.test(charBeforeAt);
|
||
const hasSpaceBetween = /\s/.test(contentBetween);
|
||
|
||
if (isAtStartOrAfterSpace && !hasSpaceBetween) {
|
||
searchKey.value = contentBetween;
|
||
showPopover.value = true;
|
||
computeCaretPosition(); // 重新计算位置
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 其余情况全部关闭
|
||
showPopover.value = false;
|
||
};
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (!showPopover.value) return;
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
selectIndex.value = (selectIndex.value - 1 + filteredList.value.length) % filteredList.value.length;
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
selectIndex.value = (selectIndex.value + 1) % filteredList.value.length;
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (filteredList.value[selectIndex.value]) handleSelect(filteredList.value[selectIndex.value]);
|
||
} else if (e.key === 'Escape') {
|
||
showPopover.value = false;
|
||
}
|
||
};
|
||
|
||
const handleSelect = (user) => {
|
||
const val = props.modelValue;
|
||
const pos = targetInput.selectionStart;
|
||
const textBefore = val.slice(0, pos);
|
||
const lastAtPos = textBefore.lastIndexOf("@");
|
||
|
||
const newValue = val.slice(0, lastAtPos) + `@${user.nickname} ` + val.slice(pos);
|
||
emit('update:modelValue', newValue);
|
||
emit('select', user);
|
||
showPopover.value = false;
|
||
nextTick(() => targetInput.focus());
|
||
};
|
||
|
||
onMounted(() => {
|
||
targetInput = containerRef.value.querySelector('textarea');
|
||
if (targetInput) {
|
||
targetInput.addEventListener('input', handleInput);
|
||
targetInput.addEventListener('keydown', handleKeyDown);
|
||
targetInput.addEventListener('scroll', () => {
|
||
if (showPopover.value) computeCaretPosition();
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.mention-container {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 镜像层:必须绝对定位且隐藏 */
|
||
.textarea-mirror {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: -999;
|
||
visibility: hidden;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mention-popover {
|
||
position: absolute;
|
||
z-index: 3000;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||
border: 1px solid #e4e7ed;
|
||
min-width: 160px;
|
||
}
|
||
|
||
.mention-list {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
padding: 4px 0;
|
||
|
||
.mention-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
|
||
&.active {
|
||
background-color: #f0f7ff;
|
||
color: #409eff;
|
||
}
|
||
|
||
.name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
|
||
.mention-empty {
|
||
padding: 12px;
|
||
text-align: center;
|
||
color: #909399;
|
||
font-size: 13px;
|
||
}
|
||
</style> |