从 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();
  }
});
相关推荐
东土也1 小时前
Vue 项目 Nginx 部署路径差异分析与部署指南
前端
云枫晖1 小时前
Vue3 响应式原理:手写实现 ref 函数
前端·vue.js
合作小小程序员小小店1 小时前
web网页开发,在线%宠物销售%系统,基于Idea,html,css,jQuery,java,ssh,mysql。
java·前端·数据库·mysql·jdk·intellij-idea·宠物
荔枝吖1 小时前
html2canvas+pdfjs 打印html
前端·javascript·html
文心快码BaiduComate1 小时前
全运会,用文心快码做个微信小程序帮我找「观赛搭子」
前端·人工智能·微信小程序
合作小小程序员小小店1 小时前
web网页开发,在线%档案管理%系统,基于Idea,html,css,jQuery,java,ssh,mysql。
java·前端·mysql·jdk·html·ssh·intellij-idea
合作小小程序员小小店1 小时前
web网页开发,在线%物流配送管理%系统,基于Idea,html,css,jQuery,java,ssh,mysql。
java·前端·css·数据库·jdk·html·intellij-idea
三门1 小时前
web接入扣子私有化智能体
前端
林小帅1 小时前
AI “自动驾驶” 的使用分享
前端