
本文详解如何在用户输入 `[` 时,实时计算光标在输入框中的像素级坐标(x/y),并动态渲染绝对定位的标签建议下拉列表,实现类似 ide 的智能补全体验。
在构建模板化文本编辑器(如邮件/消息批量替换系统)时,仅支持 [NAME] → Robert 这类静态替换远远不够——真正的用户体验升级在于上下文感知的标签自动补全:当用户键入 [ 后,立即在光标正下方弹出匹配的标签列表(如 NAME、EMaiL、COMPANY),支持键盘导航与回车选择。
核心难点不在数据过滤,而在于精准定位下拉列表的显示位置:它必须紧贴光标右侧(或下方),且随滚动、缩放、字体变化自适应。以下是经过生产验证的完整实现方案:
✅ 关键定位四要素
要让
- 精准悬浮于光标处,需同时获取:
| 属性 | 获取方式 | 说明 |
|---|---|---|
| 输入框视口坐标 | input.parentElement.getBoundingClientRect() | 推荐绑定到父容器(如 |
| 光标字符索引 | input.selectionEnd | 返回光标前已输入的字符数(UTF-16 code units),直接用于估算水平偏移 |
| 页面滚动偏移 | window.scrollX, window.scrollY | 防止滚动后下拉菜单错位 |
| 字体像素宽度 | parseInt(getComputedStyle(input).fontSize) | 假设等宽字体(如 monospace);若用比例字体,需用 canvas.measureText() 精确计算 |
✅ 定位计算代码(含边界保护)
function positionTagList(input, fs = 14) { const parentRect = input.parentElement.getBoundingClientRect(); const scrollX = window.scrollX; const scrollY = window.scrollY; // 基础坐标:父容器左上角 + 滚动偏移 let left = parentRect.left + scrollX; let top = parentRect.top + parentRect.height + scrollY; // 关键:用 selectionEnd * 字体大小估算光标X位置(等宽字体适用) const cursorXOffset = input.selectionEnd * fs; const maxLeft = parentRect.left + parentRect.width + scrollX; // 防止下拉菜单超出输入框右边界 left = Math.min(left + cursorXOffset, maxLeft - 200); // 200px为下拉列表预估宽度 const tagList = document.getElementById('taglist'); if (tagList) { tagList.style.left = `${left}px`; tagList.style.top = `${top}px`; } }
✅ 完整集成示例
const input = document.getElementById('templateInput'); const tagList = document.getElementById('taglist'); // 监听 keyup,仅在触发 '[' 时激活 input.addEventListener('keyup', (e) => { if (e.key === '[') { // 1. 过滤标签(示例:匹配包含当前输入片段的标签) const query = input.value.substring(input.selectionStart - 1).replace(/[/g, ''); const filtered = Array.from(alltags).filter(tag => tag.toUpperCase().includes(query.toUpperCase()) ); // 2. 渲染列表 tagList.innerHTML = filtered.map(tag => `${tag} ` ).join(''); // 3. 定位 + 显示 positionTagList(input); tagList.style.display = 'block'; // 4. 点击插入标签 tagList.querySelectorAll('li').forEach(li => { li.addEventListener('click', () => { const tag = li.dataset.tag; const start = input.selectionStart; const end = input.selectionEnd; const newValue = input.value.substring(0, start) + `[${tag}]` + input.value.substring(end); input.value = newValue; input.setSelectionRange(start + tag.length + 2, start + tag.length + 2); // 光标置于 ] 后 tagList.style.display = 'none'; }); }); } }); // 隐藏逻辑:点击外部或按 Esc document.addEventListener('click', (e) => { if (!input.contains(e.target) && e.target !== tagList && !tagList.contains(e.target)) { tagList.style.display = 'none'; } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') tagList.style.display = 'none'; });
⚠️ 注意事项
- 字体适配:若输入框使用非等宽字体(如 Arial),selectionEnd * fontSize 会失准。此时需用 canvas 测量实际像素宽度:
function getTextWidth(text, font) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = font; return ctx.measureText(text).width; } // 计算光标前文本宽度:getTextWidth(input.value.substring(0, input.selectionEnd), getComputedStyle(input).font) - 性能优化:对长文本频繁调用 getBoundingClientRect() 可能触发重排,建议节流(throttle)或监听 scroll/resize 事件后更新。
- 无障碍支持:为
- 添加 role=”option” 和键盘导航(ArrowUp/Down + Enter),确保符合 WCAG 标准。
通过这套方案,你将获得一个轻量、可靠、可扩展的标签补全组件——它不依赖第三方库,深度契合原生 dom 行为,且已在实际邮件模板系统中稳定运行。