从 0 到 1 实现一个支持 @ 提及用户的输入框组件(Vue3 实战)

🎯 核心功能

这是一个支持 @提及 的编辑器组件,类似于微博、企业微信等应用中的 @提及用户 功能,当用户在输入框中输入 "@" 时,自动弹出一个用户列表,可通过键盘或鼠标选择用户,被选中后插入到输入框中。

一、功能演示

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();
  }
});
相关推荐
崔庆才丨静觅12 分钟前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅1 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax