本文分享的@人功能是针对Web网页前端的,跟移动端原生代码的实现,从技术原理和实际实现上,还是有很大差异,所以如果想了解移动端IM这种社交应用中的@人实现功能,可以读一下《Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展[图文+源码]》这篇文章。
微博的实现比较简单,就是通过正则匹配,最后用空格表示匹配结束,所以实现上是直接使用了textarea标签。
但是这个实现必须依赖的一个事情是:用户名必须唯一。
微博的用户名就是唯一的,所以正则所匹配到的ID,一般的可以映射到唯一的一个用户上(除非ID不存在)。不过,微博中的这个功能整体输出比较宽松,你可以构造任何不存在的ID进行@操作。
Twitter 的实现跟微博类似,也是以@开始,空格结尾做匹配。但是使用的是 contenteditable 这个属性进行富文本操作。
相似之处在于 Twitter 的 ID 也是唯一,但是可以通过昵称进行搜索,然后转化成 ID,这一点在体验上好了不少。
通过分析业内的主流实现,@人功能的技术实现思路大致如下:
一般来说,如果像平常用的Lark搜索(Lark就是“飞书”),我们是不会通过唯一的『工号』去进行搜索,而是通过名字,但是名字会出现重复,所以就不太适合用textarea的方式,而是用contenteditable,把@文本替换成HTML标签特殊化标记。
想要获得用户输入的字符串,然后替换进去,第一步就是需要获得用户所在的光标。要获取光标信息,那就要先了解什么是『选择(Selection) 』和『范围(Range) 』。
Range本质上是一对“边界点”:范围起点和范围终点。
每个点都被表示为一个带有相对于起点的相对偏移(offset)的父 DOM 节点。如果父节点是元素节点,则偏移量是子节点的编号,对于文本节点,则是文本中的位置。
例如:
let range = newRange();
然后使用 range.setStart(node, offset) 和 range.setEnd(node, offset) 来设置选择边界。
假设 HTML 片段是这样的:
<pid="p">Example: <i>italic</i> and <b>bold</b></p>
选择 "Example: <i>italic</i>",它是 <p> 的前两个子节点(文本节点也算在内):
<pid="p">Example: <i>italic</i> and <b>bold</b></p> <script> let range = new Range(); range.setStart(p, 0); range.setEnd(p, 2); // 范围的 toString 以文本形式返回其内容(不带标签) alert(range); // Example: italic document.getSelection().addRange(range); </script>
解释一下:
如果像这样操作:
这也是可以做到的,只需要将起点和终点设置为文本节点中的相对偏移量即可。
我们需要创建一个范围:
<pid="p">Example: <i>italic</i> and <b>bold</b></p> <script> let range = new Range(); range.setStart(p.firstChild, 2); range.setEnd(p.querySelector('b').firstChild, 3); alert(range); // ample: italic and bol window.getSelection().addRange(range); </script>
range 对象具有以下属性:
解释一下:
Range 是用于管理选择范围的通用对象。
文档选择是由 Selection 对象表示的,可通过 window.getSelection() 或 document.getSelection() 来获取。
根据 Selection API 规范:一个选择可以包括零个或多个范围(不过实际上,只有 Firefox 允许使用 Ctrl+click (Mac 上用 Cmd+click) 在文档中选择多个范围)。
这是在 Firefox 中做的一个具有 3 个范围的选择的截图:
其他浏览器最多支持 1 个范围。
正如我们将看到的,某些 Selection 方法暗示可能有多个范围,但同样,在除 Firefox 之外的所有浏览器中,范围最多是 1。
与范围相似,选择的起点称为“锚点(anchor)”,终点称为“焦点(focus)”。
主要的选择属性有:
看完上面,不知道了解了没?没关系,我们继续往下。
综上所述:一般我们只有一个 Range,当我们的光标在 contenteditable 的 div 上闪动的时候,其实就有了一个 Range,这个 Range 的开始和结束位置都是一样的。
另外:我们还可以直接通过 Selection.focusNode获取到对应的节点,通过 Selection.focusOffset 获取到对应的偏移量。
就像下图:
这样,我们就获取到了光标的位置以及对应的TextNode对象。
在上一节我们获得了光标在对应Node节点的偏移量,以及对应的Node节点。那么就可以通过textContent方法获取整个文本。
一般来说,通过一个简单的正则就可以获取@的内容了:
// 获取光标位置 const getCursorIndex = () => { const selection = window.getSelection(); return selection?.focusOffset; }; // 获取节点 const getRangeNode = () => { const selection = window.getSelection(); return selection?.focusNode; }; // 获取 @ 用户 const getAtUser = () => { const content = getRangeNode()?.textContent || ""; const regx = /@([^@\s]*)$/; const match = regx.exec(content.slice(0, getCursorIndex())); if(match && match.length === 2) { return match[1]; } return undefined; };
因为@的插入可能是末尾,可能是中间,所以我们在判断前,还需要截取光标前的文本。
所以简单地slice一下就好了:
content.slice(0, getCursorIndex())
弹窗是否展示的逻辑,跟判断@用户类似,都是同一个正则。
// 是否展示 @ const showAt = () => { const node = getRangeNode(); if(!node || node.nodeType !== Node.TEXT_NODE) returnfalse; const content = node.textContent || ""; const regx = /@([^@\s]*)$/; const match = regx.exec(content.slice(0, getCursorIndex())); return match && match.length === 2; };
弹窗需要出现在正确的位置,幸好现代浏览器有不少好用的API。
const getRangeRect = () => { const selection = window.getSelection(); const range = selection?.getRangeAt(0)!; const rect = range.getClientRects()[0]; const LINE_HEIGHT = 30; return { x: rect.x, y: rect.y + LINE_HEIGHT }; };
当出现弹窗之后,我们还需要拦截掉输入框的『上』、『下』、『回车』的操作,否则在输入框响应这些按键会让光标位置偏移到其他地方。
const handleKeyDown = (e: any) => { if(showDialog) { if( e.code === "ArrowUp"|| e.code === "ArrowDown"|| e.code === "Enter" ) { e.preventDefault(); } } };
然后在弹窗里面监听这些按键,实现上下选择、回车确定、关闭弹窗的功能。
const keyDownHandler = (e: any) => { if(visibleRef.current) { if(e.code === "Escape") { props.onHide(); return; } if(e.code === "ArrowDown") { setIndex((oldIndex) => { return Math.min(oldIndex + 1, (usersRef.current?.length || 0) - 1); }); return; } if(e.code === "ArrowUp") { setIndex((oldIndex) => Math.max(0, oldIndex - 1)); return; } if(e.code === "Enter") { if( indexRef.current !== undefined && usersRef.current?.[indexRef.current] ) { props.onPickUser(usersRef.current?.[indexRef.current]); setIndex(-1); } return; } } };
大致的原理图:
具体我们详细分步来看看。
假如文本是:“请帮我泡一杯咖啡@ABC,这是后面的内容”。
那么我们需要根据光标的位置,替换掉@ABC文本,然后分成前后两块:『请帮我泡一杯咖啡』、『这是后面的内容』。
为了能实现删除键能把删除全部删除,需要把 at 标签的内容包裹起来。
这是第一版写的一个标签,但是如果直接用会有点小问题,留着后续再讨论:
const createAtButton = (user: User) => { const btn = document.createElement("span"); btn.style.display = "inline-block"; btn.dataset.user = JSON.stringify(user); btn.className = "at-button"; btn.contentEditable = "false"; btn.textContent = `@${user.name}`; return btn; };
首先:我们可以获取 focusNode 节点,然后就可以获取它的父节点以及兄弟节点。
现在需要做的是:把旧的文本节点删除,然后在原来的位置上依次插入『请帮我泡一杯咖啡』、【@ABC】、『这是后面的内容』。
具体来看看代码:
parentNode.removeChild(oldTextNode); // 插在文本框中 if(nextNode) { parentNode.insertBefore(previousTextNode, nextNode); parentNode.insertBefore(atButton, nextNode); parentNode.insertBefore(nextTextNode, nextNode); } else{ parentNode.appendChild(previousTextNode); parentNode.appendChild(atButton); parentNode.appendChild(nextTextNode); }
我们这一顿操作之前,因为原来的文本节点丢失,所以我们的光标也失去了。这时候就需要重新把光标定位到 at 标签之后。
简单来说就是把光标定位到 nextTextNode 节点之前即可:
// 创建一个 Range,并调整光标 const range = newRange(); range.setStart(nextTextNode, 0); range.setEnd(nextTextNode, 0); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range);
第2步中,我们创建了 at 标签,但是会有点小问题。
这时候光标就定位到了『按钮边框内』,但光标的位置实际上是正确的。
为了优化这个问题,首先想到的是在nextTextNode中添加一个『0宽字符』——\u200b。
// 添加 0 宽字符 const nextTextNode = newText("\u200b"+ restSlice); // 定位光标时,移动一位 const range = newRange(); range.setStart(nextTextNode, 1); range.setEnd(nextTextNode, 1);
但是,事情没那么简单。因为我发现如果往前可能也会这样……
最后一想:把内容区弄宽一点不就行了?比如左右加个空格?然后就把标签包裹了一层……
const createAtButton = (user: User) => { const btn = document.createElement("span"); btn.style.display = "inline-block"; btn.dataset.user = JSON.stringify(user); btn.className = "at-button"; btn.contentEditable = "false"; btn.textContent = `@${user.name}`; const wrapper = document.createElement("span"); wrapper.style.display = "inline-block"; wrapper.contentEditable = "false"; const spaceElem = document.createElement("span"); spaceElem.style.whiteSpace = "pre"; spaceElem.textContent = "\u200b"; spaceElem.contentEditable = "false"; const clonedSpaceElem = spaceElem.cloneNode(true); wrapper.appendChild(spaceElem); wrapper.appendChild(btn); wrapper.appendChild(clonedSpaceElem); return wrapper; };
穷人粗糙版 at 人,最终完结~
Web前端富文本的坑确实比较多,之前没怎么了解过这部分的知识。虽然整个过程看起来很粗糙,但是技术原理就是这样。
不完善的地方很多,有更好的方式可以共同讨论下。
如果有兴趣,也可以到 Playground 玩一玩(点此进入)。
上面链接打开后是这样的,可以在线试试本文代码的运行效果:
[1] Selection的W3C官方API手册
[2] 现代JavaScript 教程
[3] Range的MDN在线API手册
[4] Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展
(本文已同步发布于:http://www.52im.net/thread-3767-1-1.html)
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
网站制作需求分析文档范例东城家具网站制作方法大全张家口网站制作与建设网站设计制作市场调研诗朗诵背景音乐网站制作淄博网站建设制作哪家便宜长兴健康美丽网站制作需要什么大连网站怎样制作网站制作翻译制作网站软件推荐北海网站制作多少钱广州网站模板设计制作正规网站设计制作费用枣阳展示型网站制作牡丹江企业网站制作公司网站后台制作这么做手机娱乐网站的制作网站设计制作市场调研企业网站banner制作教程百度里公司网站能免费制作吗制作微信商城和网站吗临朐网站制作最低价格站酷网站制作制作网站用qq注册码商城网站制作哪个好薇微电影网站制作教程网站制作图片要求如何把DZ制作成文库网站常用网站制作头像孟州外贸营销型网站制作给我一个可以任何制作的网站格兰仕网站视频制作网站制作app vnp抚顺外贸网站制作网站制作会员系统慈溪什么网站可以制作动漫商务局网站制作蛋糕怎么制作网站赚差价网站怎么样制作视频下载海报制作网站 知乎制作纯文字网站班级管理动态网站制作珠海公众号小程序定制制作网站ACG动漫网站制作专题制作参考哪个网站魔术社团制作网站有没有在线制作个性签名的网站啊北京装饰行业网站制作政府商城网站制作MCC网站制作雪糕丰台网站制作外包公司戴尔网站视频制作用ps怎么制作网站导航条长宁网站制作有效吗网站与小程序制作区别自学网站制作奶茶宠物网站制作书签新沂最好的制作网站嘉峪关运营好的网站制作南京制作公司网站大概多少钱无忧网站制作手工想学习制作ppt的网站宁夏个人网站制作网站制作书签水彩霞浦网站制作魔术社团制作网站制作图网站的图片怎么下载不了如何制作画板网站沧州做网站制作价格长沙网站快速制作制作电子请帖的网站动态图片制作网站推荐个人网站制作能学到网站首页制作模板日文网站制作 杭州玄武区公司网站制作朝阳标书制作网站淮南制作企业网站价格制作网站的步骤过程运城响应式网站制作制作网站目的制作化学结构的在线网站电影网站制作采集教程哈尔滨网站制作首页广西网站制作与维护江山网站制作推广重庆网站制作干花花束普特英语听力网站制作请帖制作网站java源码网站首页制作小玩具推荐dw制作阿里巴巴网站效果图制作网站体现多媒体的北京企业网站设计制作多少钱制作云朵的网站新密公司网站如何制作靖江网站制作公司哪家好重庆荣昌网站制作哪家好如何制作网站链接跟踪怎么制作优惠卷网站正规网站设计制作优化排名百科网站制作哪个好西安php网站制作哪家公司好怎么制作女朋友的网站金华双流网站制作怎么收费菏泽正规网站开发制作标识标牌制作网站长沙网站快速制作长安网站建设制作多少钱苏州官方网站制作公司海口普通网站制作营口手机网站制作网站制作所需的技术DW如何制作一个百度百科网站网站LOGO制作表格个人网站制作规格贸易网站制作手工网站设计制作时应注意什么问题制服制作网站制作图片发到哪个网站影响大壁纸网站制作表情包北海网站制作一般多少钱网站设计与制作参考文献伊春市网站制作济南网站制作及推广情侣相册制作网站头像制作网站七钻民权定制网站设计制作费用网站制作 如何网上支付港剧网站制作视频手机微信钓鱼网站制作新竹网站制作的网站永春榜德开发区pc网站制作绿幕特效制作网站有哪些制作游戏网站页面广东制作网站公司简介在线制作全景漫游的网站视频制作网站都有哪些山东网站制作培训网通网站视频制作WAP网站制作表情包苏泊尔网站制作干花范县网站制作效果dhl运单制作网站安宁区网站制作公司哪家好网站制作软件 手机版网站制作找大将军22肇庆网站制作方案定制玉女电影网站制作巴中网站设计与制作电商网站制作手工批发网站免费制作福建外贸网站制作设计目标制作网站建筑网站制作奶茶钱包网站制作雪糕门头沟公司的网站制作东门教育网站制作都有哪些制作网站首页的一些材料日喀则企业网站怎么设计制作松下网站制作蛋糕手机网站论坛制作app制作网站软件下载自己制作网站需要哪些软件制作彩票网站犯法吗在韩国制作自己的网站佛山网站制作在线象山杭州制作网站公司有哪些剪纸头像制作网站在线深圳如何在网站制作哪个好在线制作视频网站或者app如何制作音效网站双语网站制作一般多少钱vr网站制作哪家专业外国电影网站制作珠海好的网站制作淄博制作平台网站兰州制作网站哪个好企业制作网站要多少钱如何制作网站链接跟踪坪山外贸网站制作方案flash网站制作论文庆阳网站制作哪家好美食网站制作目标徐州精美网站制作网站排名优化网站建设制作科技ppt制作链接的网站学习网站制作教程兖州网站制作和推广平安网站制作起泡义乌手机营销型网站制作多少钱广东研发网站制作哪家好搞笑制作网站瑞安房山制作网站需要多少钱文登网站优化制作华强北外贸网站制作怎么样湖北网站制作企业多少钱网站制作 发布东方红网站制作美食漫画网站制作冰淇淋中山网站制作来易维互联