Files
mversion-ui/src/modules/Comment/mentionEditor.vue
2026-01-05 21:25:45 +08:00

235 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>