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

说在前面

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

效果预览

体验地址

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

相关推荐
YUELEI11830 分钟前
vue3 使用sass变量
前端·css·sass
枣仁_1 小时前
大型语言模型(LLM)深度解析
前端·javascript·面试
程序员马晓博1 小时前
用上OpenManus啦,这玩意有点像...
前端
鱼樱前端1 小时前
36道我命由我不由天的JavaScript 基础面试题详解
前端·javascript·面试
嘉琪coder1 小时前
显示器报废,win笔记本远程连接mac mini4 3种方法实测
前端·windows·mac
hrrrrb2 小时前
【CSS3】筑基篇
前端·css·css3
boy快快长大2 小时前
【VUE】day01-vue基本使用、调试工具、指令与过滤器
前端·javascript·vue.js
三原2 小时前
五年使用vue2、vue3经验,我直接上手react
前端·javascript·react.js