说在前面
微信里的@功能大家应该都用过了吧,那么有没有想过它是怎么实现的呢?今天我们就一起来简单实现一个带@功能的输入框组件。
效果预览
体验地址
功能实现
div代替输入框
这里我们使用 div 来代替输入框,可以直接将 div 的 contenteditable 属性设为 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 重新初始化可显示的用户列表。
通过这种方式,确保 selectedList 和 selectedIdSet 中的数据与 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,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『
前端也能这么有趣
』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。