🎯 核心功能
这是一个支持 @提及 的编辑器组件,类似于微博、企业微信等应用中的 @提及用户 功能,当用户在输入框中输入 "@" 时,自动弹出一个用户列表,可通过键盘或鼠标选择用户,被选中后插入到输入框中。
一、功能演示
1.1 使用效果
用户输入:实现 @远道而来点个赞 提及成员 @远道而来收入囊中
↓
实际存储:
实际存储json
[
{ type: 'text', content: '实现 ' },
{ type: 'user', content: '远道而来点个赞', id: '123' },
{ type: 'text', content: ' 提及成员 ' },
{ type: 'user', content: '远道而来收入囊中', id: '456' },
]
1.2 核心交互流程
交互流程
┌──────────────┐
| 用户输入 @ |
└──────┬───────┘
↓
┌──────────────────────┐
| 弹出下拉选择框 |
| (跟随光标位置) |
└──────┬───────────────┘
↓
┌──────────────────────┐
|搜索 / 选择内容 |
└──────┬───────────────┘
↓
┌──────────────────────┐
| 插入蓝色高亮标签 |
|(不可编辑的 span) |
└──────────────────────┘
1.3 📺演示效果

看完效果埋头苦干

🏗️ 架构组成
1️⃣ MentionInput.vue(主编辑器)
- 基础能力:可编辑的 contenteditable 输入区域
html
<div class="mention-wrapper">
<!-- 编辑区域 -->
<div
ref="editorRef"
class="mention-editor"
contenteditable="true"
@input="onInput"
@paste="onPaste"
@compositionstart="onCompositionStart"
@compositionupdate="onCompositionUpdate"
@compositionend="onCompositionEnd"
></div>
<mention-modal
v-if="showDropdown"
:list="List"
@hide-mention-modal="showDropdown = false"
@insert-mention="selectUser"
></mention-modal>
<!-- 字符数统计 -->
<div class="char-counter" :class="{ 'over-limit': currentCharCount > MAX_CHARACTERS }">
{{ currentCharCount }}/{{ MAX_CHARACTERS }}
</div>
</div>
-
智能提及:输入 @ 触发List下拉选择
-
数据双向转换:
-
HTML ↔️ JSON 结构化数据互转
-
便于存储和回显
js
// ==================== 类型定义 ====================
interface IMentionUser {
id: string | number;
name: string;
}
interface IContentItem {
type: string;
content: string;
id?: string;
}
// ==================== 常量定义 ====================
const MAX_CHARACTERS = 1000;
const CURSOR_OFFSET_TOP = 12;
const CURSOR_OFFSET_LEFT = 5;
const MODAL_SAFE_MARGIN = 8;
const TEXT_SEARCH_MAX_LENGTH = 50;
// ==================== Props & Emits ====================
const props = defineProps<{
decisionContents: IContentItem[];
}>();
const emit = defineEmits<{
(e: 'update:contents', value: { content: IContentItem[] }): void;
}>();
// ==================== 响应式状态 ====================
const editorRef = ref<HTMLDivElement | null>(null);
const showDropdown = ref(false);
const queryText = ref('');
const activeIndex = ref(0);
const modalRef = ref<HTMLElement | null>(null);
const top = ref('');
const left = ref('');
const solutionList = ref<SolutionListItems[]>([]);
const currentCharCount = ref(0);
const isComposing = ref(false);
const savedSelection = ref<Selection | null>(null);
const savedRange = ref<Range | null>(null);
// ==================== DOM 元素创建工具 ====================
/**
* 创建 mention span 元素
* @param name - 显示名称
* @param id - 唯一标识
* @returns 创建的 span 元素
*/
const createMentionSpan = (name: string, id: string | number): HTMLSpanElement => {
const span = document.createElement('span');
span.textContent = name;
span.className = 'mention-span';
span.contentEditable = 'false';
span.setAttribute('data-id', String(id));
span.setAttribute('data-name', name);
span.setAttribute('data-type', 'solution');
span.setAttribute('data-info', JSON.stringify({ id, name }));
return span;
};
/**
* 创建文本节点
* @param content - 文本内容
* @returns 创建的文本节点
*/
const createTextNode = (content: string): Text => {
return document.createTextNode(content);
};
/**
* 创建换行元素
* @returns 创建的 br 元素
*/
const createBreakElement = (): HTMLBRElement => {
return document.createElement('br');
};
// ==================== 光标操作工具 ====================
/**
* 更新选择范围
* @param range - 要设置的范围
*/
const updateSelection = (range: Range): void => {
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
};
/**
* 将光标移动到节点末尾
* @param range - 当前范围
* @param node - 目标节点
*/
const moveCursorToEnd = (range: Range, node: Node): void => {
if (node?.parentNode) {
range.setStartAfter(node);
} else if (editorRef.value) {
range.selectNodeContents(editorRef.value);
range.collapse(false);
}
range.collapse(true);
updateSelection(range);
};
/**
* 获取当前选择范围
* @returns 当前选择范围
*/
const getCurrentRange = (): Range | null => {
const sel = window.getSelection();
return sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
};
/**
* 保存当前光标位置
*/
const saveCursorPosition = (): void => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedSelection.value = sel;
savedRange.value = sel.getRangeAt(0).cloneRange();
}
};
/**
* 恢复保存的光标位置
*/
const restoreCursorPosition = (): void => {
if (savedSelection.value && savedRange.value) {
savedSelection.value.removeAllRanges();
savedSelection.value.addRange(savedRange.value);
}
};
/**
* 重置下拉状态
*/
const resetDropdownState = (): void => {
showDropdown.value = false;
queryText.value = '';
activeIndex.value = 0;
savedSelection.value = null;
savedRange.value = null;
};
// ==================== 光标位置计算 ====================
/**
* 获取光标在可编辑容器中的相对坐标
* @param container - 可编辑容器元素
* @returns 光标相对位置
*/
const getCursorPositionInDiv = (container: Element): { left: number; top: number } | null => {
const selection = window.getSelection();
if (!selection?.rangeCount) return null;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top,
};
};
/**
* 约束弹层不超出容器右边界
* @param container - 可编辑容器元素
* @param desiredLeft - 期望的左偏移(相对容器)
* @returns 修正后的左偏移
*/
const clampLeftWithinContainer = (container: Element, desiredLeft: number): number => {
const modalEl = modalRef.value;
if (!modalEl) return Math.max(0, desiredLeft);
const containerRect = container.getBoundingClientRect();
const modalRect = modalEl.getBoundingClientRect();
const modalWidth = modalRect.width || modalEl.offsetWidth || 0;
const containerWidth = containerRect.width;
const overflowRight = desiredLeft + modalWidth - containerWidth;
if (overflowRight > 0) {
return Math.max(0, desiredLeft - overflowRight - MODAL_SAFE_MARGIN);
}
return Math.max(0, desiredLeft);
};
/**
* 更新下拉框位置
*/
const updateDropdownPosition = (): void => {
const container = editorRef.value;
if (!container) return;
const position = getCursorPositionInDiv(container);
if (!position) return;
const desiredTop = position.top + CURSOR_OFFSET_TOP;
const desiredLeft = position.left + CURSOR_OFFSET_LEFT;
nextTick(() => {
const correctedLeft = clampLeftWithinContainer(container, desiredLeft);
top.value = `${desiredTop}px`;
left.value = `${correctedLeft}px`;
});
};
// ==================== 内容获取工具 ====================
/**
* 获取 HTML 内容
* @returns HTML 字符串
*/
const getHtml = (): string => editorRef.value?.innerHTML ?? '';
/**
* 获取纯文本内容(用于字符数统计)
* @returns 纯文本内容
*/
const getPlainText = (): string => {
if (!editorRef.value) return '';
return editorRef.value.textContent || editorRef.value.innerText || '';
};
/**
* 检查字符数是否超限
* @param text - 要检查的文本
* @returns 是否超限
*/
const isOverCharacterLimit = (text: string): boolean => {
return text.length > MAX_CHARACTERS;
};
// ==================== 内容解析工具 ====================
/**
* 解析 mention 节点的 data-info 属性
* @param element - HTML 元素
* @returns mention 信息
*/
const parseMentionInfo = (element: HTMLElement): { id: string; name: string } => {
let id = '';
let name = element.textContent || '';
try {
const dataInfo = element.getAttribute('data-info');
if (dataInfo) {
const infoObj = JSON.parse(decodeURIComponent(dataInfo));
id = infoObj.id || '';
name = infoObj.name || name;
}
} catch (e) {
console.warn('解析 mention 节点 data-info 出错:', e);
}
return { id, name };
};
/**
* 检查节点是否有有效内容
* @param child - 子节点
* @returns 是否有有效内容
*/
const hasValidContent = (child: Node): boolean => {
if (child.nodeType === Node.TEXT_NODE) {
return (child.textContent || '').trim().length > 0;
}
if (child.nodeType === Node.ELEMENT_NODE) {
const el = child as HTMLElement;
return el.getAttribute('data-type') === 'solution' || el.tagName.toLowerCase() !== 'br';
}
return false;
};
/**
* 将 HTML 内容转换为结构化数据
* @param htmlContent - HTML 内容
* @returns 结构化内容数组
*/
const getContent = (htmlContent: string): IContentItem[] => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const result: IContentItem[] = [];
const walk = (node: Node): void => {
switch (node.nodeType) {
case Node.TEXT_NODE: {
const text = node.textContent;
if (text) {
result.push({ type: 'text', content: text });
}
break;
}
case Node.ELEMENT_NODE: {
const element = node as HTMLElement;
const tagName = element.tagName?.toLowerCase();
const mentionType = element.getAttribute('data-type');
if (mentionType === 'solution') {
const { id, name } = parseMentionInfo(element);
result.push({ type: 'solution', content: name, id });
} else if (tagName === 'br') {
result.push({ type: 'text', content: '\n' });
} else if (tagName === 'div' || tagName === 'p') {
const childNodes = Array.from(node.childNodes);
const hasContent = childNodes.some(hasValidContent);
if (hasContent) {
childNodes.forEach(walk);
result.push({ type: 'text', content: '\n' });
} else {
result.push({ type: 'text', content: '\n' });
}
} else {
node.childNodes.forEach(walk);
}
break;
}
default:
break;
}
};
doc.body.childNodes.forEach(walk);
return result;
};
// ==================== 内容更新 ====================
/**
* 输入变化处理
*/
const onChange = (): void => {
currentCharCount.value = getPlainText().length;
emit('update:contents', {
content: getContent(getHtml()),
});
};
// ==================== 文本节点处理 ====================
/**
* 在 br 标签后创建文本节点
* @param element - br 元素
* @param selection - 当前选择对象
* @returns 创建的文本节点
*/
const createTextNodeAfterBr = (element: HTMLElement, selection: Selection): Text => {
const range = selection.getRangeAt(0);
const textNode = document.createTextNode('');
if (element.nextSibling) {
element.parentNode?.insertBefore(textNode, element.nextSibling);
} else {
element.parentNode?.appendChild(textNode);
}
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection.removeAllRanges();
selection.addRange(range);
return textNode;
};
/**
* 获取光标所在或附近的文本节点
* @param selection - 当前选择对象
* @returns 文本节点
*/
const getTextNodeAtCursor = (selection: Selection): Text | null => {
if (!selection?.anchorNode) return null;
const { anchorNode } = selection;
if (anchorNode.nodeType === Node.TEXT_NODE) {
return anchorNode as Text;
}
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
const element = anchorNode as HTMLElement;
const tagName = element.tagName?.toLowerCase();
if (tagName === 'br') {
return createTextNodeAfterBr(element, selection);
}
const textNode = Array.from(element.childNodes).find((node) => node.nodeType === Node.TEXT_NODE) as
| Text
| undefined;
if (textNode) return textNode;
}
return null;
};
/**
* 检查节点是否应该停止搜索
* @param node - 节点
* @returns 是否应该停止
*/
const shouldStopSearch = (node: Node): boolean => {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as HTMLElement;
return el.getAttribute('data-type') === 'solution' || el.tagName?.toLowerCase() === 'br';
};
/**
* 获取光标位置前后的文本内容(包括跨节点)
* @param selection - 当前选择对象
* @param maxLength - 最大搜索长度
* @returns 文本内容和光标位置
*/
const getTextAroundCursor = (
selection: Selection,
maxLength = TEXT_SEARCH_MAX_LENGTH
): { text: string; cursorPos: number } | null => {
if (!selection?.anchorNode || !editorRef.value) return null;
const { anchorNode, anchorOffset } = selection;
if (anchorNode.nodeType === Node.TEXT_NODE) {
const text = anchorNode.textContent ?? '';
return { text, cursorPos: anchorOffset };
}
const beforeText: string[] = [];
let cursorPos = 0;
let foundCursor = false;
// 向前搜索
let node: Node | null = anchorNode;
while (node && beforeText.join('').length < maxLength) {
if (node.nodeType === Node.TEXT_NODE) {
const nodeText = node.textContent ?? '';
if (node === anchorNode) {
beforeText.unshift(nodeText.slice(0, anchorOffset));
cursorPos = nodeText.slice(0, anchorOffset).length;
foundCursor = true;
} else {
beforeText.unshift(nodeText);
if (!foundCursor) {
cursorPos += nodeText.length;
}
}
} else if (shouldStopSearch(node)) {
break;
}
node = node.previousSibling;
}
// 向后搜索
const afterText: string[] = [];
node = anchorNode.nextSibling;
while (node && beforeText.join('').length + afterText.join('').length < maxLength) {
if (node.nodeType === Node.TEXT_NODE) {
afterText.push(node.textContent ?? '');
} else if (shouldStopSearch(node)) {
break;
}
node = node.nextSibling;
}
return {
text: beforeText.join('') + afterText.join(''),
cursorPos,
};
};
// ==================== 输入处理 ====================
/**
* 恢复编辑器内容(用于字符数超限时)
*/
const restoreEditorContent = (): void => {
const editor = editorRef.value;
if (!editor) return;
const content = getContent(getHtml());
editor.innerHTML = '';
content.forEach((item) => {
if (item.type === 'text') {
editor.appendChild(createTextNode(item.content));
} else if (item.type === 'solution') {
editor.appendChild(createMentionSpan(item.content, item.id ?? ''));
editor.appendChild(createTextNode(' '));
}
});
};
/**
* 检查并处理 @ 符号
* @param text - 文本内容
* @param cursorPos - 光标位置
* @returns 是否检测到 @ 符号
*/
const checkAtSymbol = (text: string, cursorPos: number): boolean => {
const lastAt = text.lastIndexOf('@', cursorPos - 1);
const isAtSymbol = lastAt >= 0 && cursorPos === lastAt + 1;
if (isAtSymbol) {
queryText.value = text.slice(lastAt + 1, cursorPos);
showDropdown.value = true;
saveCursorPosition();
updateDropdownPosition();
} else {
showDropdown.value = false;
}
return isAtSymbol;
};
/**
* 输入监听
*/
const onInput = (): void => {
if (isComposing.value) return;
const currentText = getPlainText();
if (isOverCharacterLimit(currentText)) {
Message.warning(`内容不能超过 ${MAX_CHARACTERS} 个字符`);
restoreEditorContent();
return;
}
const sel = window.getSelection();
if (!sel?.anchorNode) {
showDropdown.value = false;
onChange();
return;
}
const textNode = getTextNodeAtCursor(sel);
if (textNode) {
const text = textNode.textContent ?? '';
const cursorPos = sel.anchorOffset;
checkAtSymbol(text, cursorPos);
} else {
const textInfo = getTextAroundCursor(sel);
if (textInfo) {
checkAtSymbol(textInfo.text, textInfo.cursorPos);
} else {
showDropdown.value = false;
}
}
onChange();
};
// ==================== Mention 插入 ====================
/**
* 插入 mention
* @param user - 用户信息
*/
const selectUser = (user: IMentionUser): void => {
const currentText = getPlainText();
const newTextLength = currentText.length + user.name.length;
if (newTextLength > MAX_CHARACTERS) {
Message.warning(`插入内容后不能超过 ${MAX_CHARACTERS} 个字符`);
return;
}
if (savedRange.value) {
restoreCursorPosition();
}
const sel = window.getSelection();
if (!sel?.rangeCount) return;
const range = sel.getRangeAt(0);
const textNode = getTextNodeAtCursor(sel);
if (textNode) {
const cursorPos = sel.anchorOffset;
const text = textNode.textContent ?? '';
const lastAt = text.lastIndexOf('@', cursorPos - 1);
if (lastAt >= 0) {
textNode.deleteData(lastAt, cursorPos - lastAt);
}
}
const span = createMentionSpan(user.name, user.id);
range.insertNode(span);
range.collapse(false);
updateSelection(range);
resetDropdownState();
onChange();
};
// ==================== 中文输入法处理 ====================
/**
* 中文输入法开始
*/
const onCompositionStart = (): void => {
isComposing.value = true;
};
/**
* 中文输入法更新
*/
const onCompositionUpdate = (): void => {
isComposing.value = true;
};
/**
* 中文输入法结束
*/
const onCompositionEnd = (): void => {
isComposing.value = false;
setTimeout(() => {
onInput();
}, 0);
};
// ==================== 内容插入工具 ====================
/**
* 将文本内容转换为 DOM 节点(处理换行符)
* @param text - 文本内容
* @param fragment - 文档片段
*/
const appendTextWithBreaks = (text: string, fragment: DocumentFragment): void => {
const textParts = text.split('\n');
textParts.forEach((part, index) => {
if (part) {
fragment.appendChild(createTextNode(part));
}
if (index < textParts.length - 1) {
fragment.appendChild(createBreakElement());
}
});
};
/**
* 在光标位置插入内容
* @param content - 要插入的内容数组
* @param range - 当前光标范围
*/
const insertContentAtCursor = (content: IContentItem[], range: Range): void => {
const fragment = document.createDocumentFragment();
content.forEach((item) => {
if (item.type === 'text') {
appendTextWithBreaks(item.content, fragment);
} else if (item.type === 'solution') {
fragment.appendChild(createMentionSpan(item.content, item.id ?? ''));
}
});
range.insertNode(fragment);
const { lastChild } = fragment;
if (lastChild?.parentNode) {
moveCursorToEnd(range, lastChild);
} else if (editorRef.value) {
range.selectNodeContents(editorRef.value);
range.collapse(false);
updateSelection(range);
}
};
// ==================== 粘贴处理 ====================
/**
* 粘贴处理
* @param e - 粘贴事件
*/
const onPaste = (e: ClipboardEvent): void => {
e.preventDefault();
const range = getCurrentRange();
if (!range) return;
const text = e.clipboardData?.getData('text/plain') ?? '';
const html = e.clipboardData?.getData('text/html') ?? '';
const currentText = getPlainText();
let pasteText = '';
if (html) {
const content = getContent(html);
pasteText = content.map((item) => item.content).join('');
} else if (text) {
pasteText = text;
}
if (currentText.length + pasteText.length > MAX_CHARACTERS) {
Message.warning(`粘贴内容后不能超过 ${MAX_CHARACTERS} 个字符`);
return;
}
if (html) {
const content = getContent(html);
insertContentAtCursor(content, range);
} else if (text) {
const fragment = document.createDocumentFragment();
appendTextWithBreaks(text, fragment);
range.insertNode(fragment);
const { lastChild } = fragment;
if (lastChild?.parentNode) {
moveCursorToEnd(range, lastChild);
} else if (editorRef.value) {
range.selectNodeContents(editorRef.value);
range.collapse(false);
updateSelection(range);
}
}
onChange();
};
// ==================== 内容设置 ====================
/**
* 从 JSON 渲染回编辑器
* @param content - 内容数组
*/
const setContent = (content: IContentItem[]): void => {
if (!editorRef.value) return;
editorRef.value.innerHTML = '';
const editor = editorRef.value;
content.forEach((item) => {
if (item.type === 'text') {
appendTextWithBreaks(item.content, editor as unknown as DocumentFragment);
} else if (item.type === 'solution') {
editor.appendChild(createMentionSpan(item.content, item.id ?? ''));
editor.appendChild(createTextNode(' '));
}
});
};
// ==================== 数据获取 ====================
// ==================== 生命周期 ====================
onMounted(() => {
//数据获取
// 传递格式化后的json 调用setContent 恢复html
});
2️⃣ MentionModal.vue(选择弹窗)
html
<div id="mention-modal" ref="modalRef" :style="{ top: top, left: left }">
<mtd-select
ref="selectRef"
class="select-width"
filterable
value-key="id"
@change="insertMentionHandler"
@update:visible="updateVisible"
>
<mtd-option v-for="item in searchedList" :key="item.id" :label="item.name" :value="item"> </mtd-option>
</mtd-select>
</div>
-
智能定位:跟随光标位置弹出
-
搜索过滤:支持方案名称快速筛选
-
边界处理:自动调整位置防止超出容器
js
// 定义emit
const emit = defineEmits(['hideMentionModal', 'insertMention']);
const selectRef = ref();
const modalRef = ref<HTMLElement | null>(null);
// 响应式数据
const top = ref('');
const left = ref('');
const searchVal = ref('');
// 计算属性:根据搜索值筛选列表
const searchedList = computed(() => {
const searchValue = searchVal.value.trim().toLowerCase();
return list.filter((item) => {
const name = item.name.toLowerCase();
return name.indexOf(searchValue) >= 0;
});
});
/**
* 插入提及处理
* @param {object} value - 选中项
* @param {string} value.id - 方案ID
* @param {string} value.name - 方案名称
*/
const insertMentionHandler = (value: { id: string; name: string }) => {
emit('insertMention', value);
emit('hideMentionModal'); // 隐藏 modal
};
const updateVisible = (visible: boolean) => {
if (!visible) {
emit('hideMentionModal');
}
};
/**
* 获取光标在可编辑容器中的相对坐标
* @param {Element} container - 可编辑容器元素
* @returns {{ left: number; top: number } | null}
*/
const getCursorPositionInDiv = (container: Element) => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// 获取容器相对于视口的位置
const containerRect = container.getBoundingClientRect();
// 计算光标相对于容器的位置
const relativePosition = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top,
};
return relativePosition;
}
return null;
};
/**
* 约束弹层不超出容器右边界
* @param {Element} container - 可编辑容器元素
* @param {number} desiredLeft - 期望的左偏移(相对容器)
* @returns {number} - 修正后的左偏移
*/
const clampLeftWithinContainer = (container: Element, desiredLeft: number): number => {
const containerRect = container.getBoundingClientRect();
const modalEl = modalRef.value;
if (!modalEl) {
return Math.max(0, desiredLeft);
}
const modalRect = modalEl.getBoundingClientRect();
const modalWidth = modalRect.width || modalEl.offsetWidth || 0;
const containerWidth = containerRect.width;
// 右侧溢出量(>0 表示会溢出)
const overflowRight = desiredLeft + modalWidth - containerWidth;
if (overflowRight > 0) {
// 预留8px安全边距
return Math.max(0, desiredLeft - overflowRight - 8);
}
// 不允许出现负数(左侧越界)
return Math.max(0, desiredLeft);
};
// 组件挂载后执行
onMounted(async () => {
// 等待DOM更新完成
await nextTick();
// 获取光标位置
const container = document.querySelector('.mention-wrapper');
if (container) {
const position = getCursorPositionInDiv(container);
console.log(position, 'position===');
if (position) {
// 初始定位
const desiredTop = position.top + 12;
const desiredLeft = position.left + 5;
// 再次等待以确保modal尺寸可测量
await nextTick();
const correctedLeft = clampLeftWithinContainer(container, desiredLeft);
top.value = `${desiredTop}px`;
left.value = `${correctedLeft}px`;
}
}
// focus input
if (selectRef.value) {
selectRef.value?.focus();
}
});