实现一个带@功能的输入框组件

说在前面

微信里的@功能大家应该都用过了吧,那么有没有想过它是怎么实现的呢?今天我们就一起来简单实现一个带@功能的输入框组件。

效果预览

体验地址

jyeontu.xyz/jvuewheel/#...

功能实现

div代替输入框

这里我们使用 div 来代替输入框,可以直接将 divcontenteditable 属性设为 true

html 复制代码
<div class="j-mention" contenteditable="true"></div>

生成备选列表

入参需要传入一个数组,用于生成可以选择@用户的备选列表。

传入参数

id作为唯一标识,name为在输入框中显示的名字。

javascript 复制代码
userList: [
    {
        id: 1,
        name: "张三",
    },
    {
        id: 2,
        name: "李四",
    },
    ............
]

获取当前输入字符

1、获取选择范围

通过window.getSelection()获取用户当前选中的文本范围对象

javascript 复制代码
const selection = window.getSelection();
2、验证有效性

检查是否存在有效选择范围,没有则直接返回

javascript 复制代码
if (selection.rangeCount <= 0) return;
3、解析选择位置

获取第一个选择范围的起始偏移量和起始容器节点

javascript 复制代码
const range = selection.getRangeAt(0);
const startOffset = range.startOffset;
const startContainer = range.startContainer;
4、拼接光标前文本

遍历文本节点,获取到当前光标前的文本并将其拼接起来

javascript 复制代码
// 文本节点直接截取
if (startContainer.nodeType === Node.TEXT_NODE) {
  textBeforeCursor = startContainer.textContent.slice(0, startOffset);
}
// 元素节点遍历子文本节点
else if (startContainer.nodeType === Node.ELEMENT_NODE) {
  startContainer.childNodes.forEach((node, i) => {
    if (node.nodeType === Node.TEXT_NODE) {
      if (i === startContainer.childNodes.length - 1) {
        textBeforeCursor += node.textContent.slice(0, startOffset);
      } else {
        textBeforeCursor += node.textContent;
      }
    }
  });
}
5、返回最后字符

截取并返回拼接文本的最后一个字符,即是当前输入的字符

javascript 复制代码
return textBeforeCursor.slice(-1);
6、完整代码
javascript 复制代码
getLastChar() {
  const selection = window.getSelection();
  if (selection.rangeCount <= 0) return;
  const range = selection.getRangeAt(0);
  const startOffset = range.startOffset;
  const startContainer = range.startContainer;
  let textBeforeCursor = "";
  if (startContainer.nodeType === Node.TEXT_NODE) {
      textBeforeCursor = startContainer.textContent.slice(
          0,
          startOffset
      );
  } else if (startContainer.nodeType === Node.ELEMENT_NODE) {
      const childNodes = startContainer.childNodes;
      for (let i = 0; i < childNodes.length; i++) {
          const node = childNodes[i];
          if (node.nodeType === Node.TEXT_NODE) {
              if (i === childNodes.length - 1) {
                  textBeforeCursor += node.textContent.slice(
                      0,
                      startOffset
                  );
              } else {
                  textBeforeCursor += node.textContent;
              }
          }
      }
  }
  return textBeforeCursor.slice(-1);
},

显示@备选列表

1、获取输入框的相关信息
javascript 复制代码
const JMention = this.$refs.JMention;
const offsetLeft = JMention.offsetLeft;
const offsetTop = JMention.offsetTop;
const width = JMention.offsetWidth;
const height = JMention.offsetHeight;
2、获取选择范围

获取当前用户的文本选择范围,验证有效性后取第一个范围

javascript 复制代码
const selection = window.getSelection();
if (selection.rangeCount <= 0) return { top: 0, left: 0 };
const range = selection.getRangeAt(0);
3、获取视口位置

获取选择范围相对于视口(viewport)的矩形信息

javascript 复制代码
const rect = range.getBoundingClientRect();
4、计算光标所在定位
javascript 复制代码
let top =
    rect.top +
    window.scrollY +
    JMention.scrollTop -
    offsetTop +
    rect.height;
let left =
    rect.left + window.scrollX + JMention.scrollLeft - offsetLeft;
let right = null;
let bottom = null;
  • top 的计算
    • rect.top 是文本范围相对于视口顶部的距离。
    • window.scrollY 是页面垂直滚动的距离。
    • JMention.scrollTop 是目标元素内部内容垂直滚动的距离。
    • offsetTop 是目标元素相对于其父元素的上偏移量。
    • rect.height 是文本范围的高度。
    • 综合这些值可以得到光标相对于目标元素顶部的距离。
  • left 的计算:原理与 top 类似,是计算光标相对于目标元素左侧的距离。
  • right 和 bottom:初始化为 null,后续会根据情况进行赋值。
5、避免列表超出输入框
javascript 复制代码
if (width / 2 < left) {
    right = width - left;
    left = null;
}

如果光标相对于目标元素左侧的距离大于元素宽度的一半,那么就用 right 来描述光标相对于目标元素右侧的距离,同时将 left 置为 null。

javascript 复制代码
if (height / 2 < top) {
    bottom = height - top;
    top = null;
}

如果光标相对于目标元素顶部的距离大于元素高度的一半,那么就用 bottom 来描述光标相对于目标元素底部的距离,同时将 top 置为 null。

6、显示效果
  • 右下角显示
  • 左下角显示
  • 右上角显示
  • 右下角显示
7、完整代码
javascript 复制代码
getCaretPosition() {
    const JMention = this.$refs.JMention;
    const offsetLeft = JMention.offsetLeft;
    const offsetTop = JMention.offsetTop;
    const width = JMention.offsetWidth;
    const height = JMention.offsetHeight;

    const selection = window.getSelection();
    if (selection.rangeCount <= 0) return { top: 0, left: 0 };
    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();
    let top =
        rect.top +
        window.scrollY +
        JMention.scrollTop -
        offsetTop +
        rect.height;
    let left =
        rect.left + window.scrollX + JMention.scrollLeft - offsetLeft;
    let right = null;
    let bottom = null;

    if (width / 2 < left) {
        right = width - left;
        left = null;
    }

    if (height / 2 < top) {
        bottom = height - top;
        top = null;
    }
    return {
        top,
        left,
        right,
        bottom,
    };
},
divInput() {
    const lastChar = this.getLastChar();
    if (lastChar === "@") {
        const { top, left, right, bottom } = this.getCaretPosition();
        this.positionStyle = "";
        if (top) this.positionStyle += `top:${top}px;`;
        if (left) this.positionStyle += `left:${left}px;`;
        if (right) this.positionStyle += `right:${right}px;`;
        if (bottom) this.positionStyle += `bottom:${bottom}px;`;
        this.showDropdown = true;
    } else {
        this.showDropdown = false;
    }
},

@功能实现

1、键盘方向键选择

  • 监听键盘事件

vue中直接绑定keydown事件即可

html 复制代码
<div
  class="j-mention"
  contenteditable="true"
  @input="divInput"
  ref="JMention"
  @keydown="dropDownKeydown"
>
</div>

@备选列表不显示的时候直接返回不作处理

javascript 复制代码
if (!this.showDropdown) return;

判断按下按键,不是上下方向键和回车键的时候直接返回不作处理。

javascript 复制代码
const { keyCode } = event;
const keyCodeList = [13, 38, 40];
if (!keyCodeList.includes(keyCode)) return;

上下按键切换选择列表下标,回车键确认选择。

javascript 复制代码
event.preventDefault();
event.stopPropagation();
if (keyCode === 13) {
    this.selectUser(this.userList[this.chooseIndex]);
    return;
}
const map = {
    38: -1,
    40: 1,
};
this.chooseIndex += map[keyCode] || 0;
this.chooseIndex = Math.max(
    0,
    Math.min(this.chooseIndex, this.userList.length - 1)
);

完整代码如下

javascript 复制代码
dropDownKeydown(event) {
    if (!this.showDropdown) return;
    const { keyCode } = event;
    const keyCodeList = [13, 38, 40];
    if (!keyCodeList.includes(keyCode)) return;
    event.preventDefault();
    event.stopPropagation();
    if (keyCode === 13) {
        this.selectUser(this.userList[this.chooseIndex]);
        return;
    }
    const map = {
        38: -1,
        40: 1,
    };
    this.chooseIndex += map[keyCode] || 0;
    this.chooseIndex = Math.max(
        0,
        Math.min(this.chooseIndex, this.userList.length - 1)
    );
},
  • 备选列表键盘切换滚动,保持选择项可见

向上切换到不可见项时保持选中项在容器可见范围顶部

向下切换到不可见项时保持选中项在容器可见范围底部

javascript 复制代码
scrollToSelectedItem(index) {
    // 获取当前选中的列表项元素
    const selectedItem = this.$refs[`listItem${index}`][0];
    // 获取下拉列表容器
    const dropdownList = this.$refs.dropdownList;
    if (!selectedItem || !dropdownList) return;

    // 获取元素位置和容器尺寸
    const itemTop = selectedItem.offsetTop;
    const containerHeight = dropdownList.offsetHeight;
    const itemHeight = selectedItem.offsetHeight;
    const scrollTop = dropdownList.scrollTop;
    if (itemTop < scrollTop) {
        dropdownList.scrollTop = itemTop;
        return;
    }
    if (itemTop + itemHeight > scrollTop + containerHeight) {
        dropdownList.scrollTop = itemTop - containerHeight + itemHeight;
        return;
    }
},

2、将选中信息回填到输入框

  • 获取光标前一个字符位置
javascript 复制代码
const selection = window.getSelection();
if (selection.rangeCount <= 0) return;
const range = selection.getRangeAt(0);
const startOffset = range.startOffset;
const startContainer = range.startContainer;
let prevCharOffset = startOffset - 1;
let prevCharNode = startContainer;
if (prevCharOffset < 0) {
    // 如果当前节点没有前一个字符,查找前一个兄弟节点
    let prevSibling = startContainer.previousSibling;
    while (prevSibling) {
        if (prevSibling.nodeType === Node.TEXT_NODE) {
            prevCharOffset = prevSibling.textContent.length - 1;
            prevCharNode = prevSibling;
            break;
        }
        prevSibling = prevSibling.previousSibling;
    }
}
  • 将原本输入的@字符替换成"@用户"
javascript 复制代码
if (prevCharNode.nodeType === Node.TEXT_NODE) {
    prevCharNode.replaceData(prevCharOffset, 1, " ");
}
const span = document.createElement("span");
span.contentEditable = "false";
span.classList.add("j-mention-at");
span.textContent = `@${text}`;
const newRange = document.createRange();
newRange.setStart(prevCharNode, prevCharOffset);
newRange.insertNode(span);
  • 将光标移动到 span 元素外部
javascript 复制代码
let nextNode = span.nextSibling;
if (!nextNode) {
    nextNode = document.createTextNode("");
    span.parentNode.appendChild(nextNode);
}
range.setStart(nextNode, 1);
range.setEnd(nextNode, 1);
selection.removeAllRanges();
selection.addRange(range);

3、记录被@的列表

保存已被@的用户列表,并更新可以被@的用户列表,可通过canRepeat 参数配置是否可以重复@同一个用户,为 false 时,被@过的用户将不会再出现在@备选列表中。

javaScript 复制代码
selectUser(user) {
  ......
  this.selectedList.push(user);
  this.selectedIdSet.add(userid);
  this.initShowUserList();
}
initShowUserList() {
    this.showUserList = this.userList.filter((user) => {
        if (this.canRepeat) return true;
        return !this.selectedIdSet.has(user.id);
    });
},

4、删除已选@用户

  • 获取 DOM 元素:通过 this.$refs.JMention 获取可编辑区域的 DOM 元素,然后使用 querySelectorAll 方法获取该区域内所有带有 j-mention-at 类名的元素。
  • 条件判断:检查 nodeList 的长度是否与 this.selectedList 的长度相等,并且 this.atText 是否为空,同时下拉框是否不显示。如果满足这些条件,则直接返回,不执行后续更新操作。
  • 清空数据:将 this.selectedList 重置为空数组,将 this.selectedIdSet 重置为空集合。
  • 遍历元素并更新数据:遍历 nodeList 中的每个元素,获取其 data-id 属性值,从 userList 中查找对应的用户信息,将用户信息添加到 this.selectedList 中,将用户 ID 添加到 this.selectedIdSet 中。
  • 初始化可显示用户列表:调用 initShowUserList 方法,根据更新后的 selectedIdSet 重新初始化可显示的用户列表。

通过这种方式,确保 selectedListselectedIdSet 中的数据与 DOM 中实际选中的用户信息保持一致。

javascript 复制代码
updateSelectedList() {
    const JMention = this.$refs.JMention;
    const nodeList = JMention.querySelectorAll(".j-mention-at");
    if (
        nodeList.length === this.selectedList.length &&
        !this.atText &&
        !this.showDropdown
    )
        return;
    this.selectedList = [];
    this.selectedIdSet = new Set();
    for (const node of nodeList) {
        const { id } = node.dataset;
        const item = this.userList.find((item) => item.id == id);
        this.selectedList.push(item);
        this.selectedIdSet.add(id);
    }
    this.initShowUserList();
}

5、根据输入内容筛选@备选列表

首先我们需要获取当前光标位置前最近的 @ 符号后的文本内容

  • 获取选择范围 :通过 window.getSelection() 获取当前用户的选择范围,用于确定光标的位置。
  • 处理文本节点
    • 如果光标位于文本节点内,直接截取从文本开头到光标的文本内容。
  • 处理元素节点
    • 如果光标位于元素节点内,遍历其子节点,收集所有文本节点的内容,并在最后一个子节点中截取到光标的位置。
  • 提取 @ 后的文本
    • 查找收集到的文本中最后一个 @ 符号的位置。
    • 如果存在 @ 符号,截取 @ 之后的文本作为结果;否则返回空字符串。
javascript 复制代码
getLastAtText() {
    const selection = window.getSelection();
    if (selection.rangeCount <= 0) return;
    const range = selection.getRangeAt(0);
    const startOffset = range.startOffset;
    const startContainer = range.startContainer;
    let textBeforeCursor = "";
    if (startContainer.nodeType === Node.TEXT_NODE) {
        textBeforeCursor = startContainer.textContent.slice(
            0,
            startOffset
        );
    } else if (startContainer.nodeType === Node.ELEMENT_NODE) {
        // 若光标在元素节点内,获取其内部文本
        const childNodes = startContainer.childNodes;
        for (let i = 0; i < childNodes.length; i++) {
            const node = childNodes[i];
            if (node.nodeType === Node.TEXT_NODE) {
                if (i === childNodes.length - 1) {
                    textBeforeCursor += node.textContent.slice(
                        0,
                        startOffset
                    );
                } else {
                    textBeforeCursor += node.textContent;
                }
            }
        }
    }
    const lastAtIndex = textBeforeCursor.lastIndexOf("@");
    if (lastAtIndex === -1) return "";
    return textBeforeCursor.slice(lastAtIndex + 1);
}

获取到当前光标位置前最近的 @ 符号后的文本内容之后,我们需要根据内容对备选列表进行筛选

javascript 复制代码
checkStartWith(text) {
    const list = this.userList.filter((user) => {
        return user.name.startsWith(text);
    });
    return list.length > 0;
},

组件库

组件文档

目前该组件也已经收录到我的组件库,组件文档地址如下: jyeontu.xyz/jvuewheel/#...

组件内容

组件库中还有许多好玩有趣的组件,如:

  • 悬浮按钮
  • 评论组件
  • 词云
  • 瀑布流照片容器
  • 视频动态封面
  • 3D轮播图
  • web桌宠
  • 贡献度面板
  • 拖拽上传
  • 自动补全输入框
  • 图片滑块验证

等等......

组件库源码

组件库已开源到gitee,有兴趣的也可以到这里看看:gitee.com/zheng_yongt...

  • 🌟觉得有帮助的可以点个star~

  • 🖊有什么问题或错误可以指出,欢迎pr~

  • 📬有什么想要实现的组件或想法可以联系我~

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容。

发送『 组件库 』可以获取源码地址

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

相关推荐
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
吹牛不交税6 小时前
admin.net-v2 框架使用笔记-netcore8.0/10.0版
vue.js·.netcore