实现一个支持@的输入框
近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。
然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:
但是不难发现跟微信飞书对比下,有两个细节没有处理。
@用户没有高亮在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果:
封装之后使用:AtInput
height={150}
onRequest={async(searchStr)={
const{data}=awaitUserFindAll({nickname:searchStr
returndata?.list?.map((v)=({
id:v.uid,
name:v.nickname,
wechatAvatarUrl:v.wechatAvatarUrl,
}}
onChange={(content,selected)={
setAtUsers(selected);
}}
/
那么实现这么一个输入框大概有以下几个点:
高亮效果删除/选中用户时需要整体删除监听@的位置,复制给弹框的坐标,联动效果最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交大多数文本输入框我们会使用input,或者textarea,很明显以上1,2两点实现不了,antd也是使用的textarea,所以也是没有实现这两个效果。
所以这块使用富文本编辑,设置contentEditable,将其变为可编辑去做。输入框以及选择器的dom就如下:
divstyle={{height,position:'relative'}}
{/*编辑器*/}
div
id="atInput"
ref={atRef}
className={'editorDiv'}
contentEditable
onInput={editorChange}
onClick={editorClick}
/
{/*选择用户框*/}
SelectUser
options={options}
visible={visible}
cursorPosition={cursorPosition}
onSelect={onSelect}
/
/div
实现思路:监听输入@,唤起选择框。截取@xxx的xxx作为搜素的关键字去查询接口选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了以上提到了监听@文本变化,通常绑定onChange事件就行,但是还有一种用户通过点击移动光标,这块需要绑定change,click两个时间,他们里边的逻辑基本一样,只需要额外处理点击选中输入框中用户时,整体选中g功能,那么代码如下:
constonObserveInput=()={
letcursorBeforeStr='';
constselection:any=window.getSelection();
if(selection?.focusNode?.data){
cursorBeforeStr=selection.focusNode?.data.slice(0,selection.focusOffset);
}
setFocusNode(selection.focusNode);
constlastAtIndex=cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if(lastAtIndex!==-1){
getCursorPosition();
constsearchStr=cursorBeforeStr.slice(lastAtIndex+1);
if(!StringTools.isIncludeSpacesOrLineBreak(searchStr)){
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
}else{
setVisible(false);
setSearchStr('');
}
}else{
setVisible(false);
}
constselectAtSpanTag=(target:Node)={
window.getSelection()?.getRangeAt(0).selectNode(target);
consteditorClick=async(event)={
onObserveInput();
//判断当前标签名是否为span 是的话选中当做一个整体
if(e.target.localName==='span'){
selectAtSpanTag(e.target);
}
consteditorChange=(event)={
const{innerText}=event.target;
setContent(innerText);
onObserveInput();
每次点击或者文本改变时都会去调用onObserveInput,以上onObserveInput该方法中主要做了以下逻辑:
在此之前需要先了解 Selection的一些方法通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。constgetCursorPosition=()={
//坐标相对浏览器的坐标
const{x,y}=window.getSelection()?.getRangeAt(0).getBoundingClientRect()as
//获取编辑器的坐标
consteditorDom=window.document.querySelector('#atInput');
const{x:eX,y:eY}=editorDom?.getBoundingClientRect()as
//光标所在位置
setCursorPosition({x:x-eX,y:y-eY
};
选择器弹出后,那么下面就到了选择用户之后的流程了,
/**
*@paramid唯一的id可以uid
*@paramname用户姓名
*@paramcolor回显颜色
*@returns
*/
constcreateAtSpanTag=(id:number|string,name:string,color='blue')={
constele=document.createElement('span');
ele.className='at-span';
ele.style.color=color;
ele.id=id.toString();
ele.contentEditable='false';
ele.innerText=`@${name}`;
return
/**
*选择用户时回调
*/
constonSelect=(item:Options)={
constselection=window.getSelection();
constrange=selection?.getRangeAt(0)asRange;
//选中输入的@关键字-@郑
range.setStart(focusNodeasNode,currentAtIdx!);
range.setEnd(focusNodeasNode,currentAtIdx!+1+searchStr.length);
//删除输入的@关键字
range.deleteContents();
//创建元素节点
constatEle=createAtSpanTag(item.id,item.name);
//插入元素节点
range.insertNode(atEle);
//光标移动到末尾
range.collapse();
//缓存已选中的用户
setSelected([...selected,item]);
//选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerTextasstring);
//关闭弹框
setVisible(false);
//输入框聚焦
atRef.current.focus();
选择用户的时候需要做的以下以下几点:
删除之前的@xxx字符插入不可编辑的span标签将当前选择的用户缓存起来重新获取输入框的内容关闭选择器将输入框重新聚焦最后在选择的用户或者内容发生改变时将数据抛给父组件
constgetAttrIds=()={
constspans=document.querySelectorAll('.at-span');
letids=newSet();
spans.forEach((span)=ids.add(span.id));
returnselected.filter((s)=ids.has(s.id));
/**@的用户列表发生改变时,将最新值暴露给父组件*/
useEffect(()={
constselectUsers=getAttrIds();
onChange(content,selectUsers);
},[selected,content]);
完整组件代码输入框主要逻辑代码:
lettimer:NodeJS.Timeout|null=null;
constAtInput=(props:AtInputProps)={
const{height=300,onRequest,onChange,value,onBlur}=props;
//输入框的内容=innerText
const[content,setContent]=useStatestring('');
//选择用户弹框
const[visible,setVisible]=useStateboolean(false);
//用户数据
const[options,setOptions]=useStateOptions[]
//@的索引
const[currentAtIdx,setCurrentAtIdx]=useStatenumber
//输入@之前的字符串
const[focusNode,setFocusNode]=useStateNode|string
//@后关键字@郑=郑
const[searchStr,setSearchStr]=useStatestring('');
//弹框的x,y轴的坐标
const[cursorPosition,setCursorPosition]=useStatePosition({
x:0,
y:0,
//选择的用户
const[selected,setSelected]=useStateOptions[]
constatRef=useRefany
/**获取选择器弹框坐标*/
constgetCursorPosition=()={
//坐标相对浏览器的坐标
const{x,y}=window.getSelection()?.getRangeAt(0).getBoundingClientRect()as
//获取编辑器的坐标
consteditorDom=window.document.querySelector('#atInput');
const{x:eX,y:eY}=editorDom?.getBoundingClientRect()as
//光标所在位置
setCursorPosition({x:x-eX,y:y-eY
/**获取用户下拉列表*/
constfetchOptions=(key?:string)={
if(timer){
clearTimeout(timer);
timer=null;
}
timer=setTimeout(async()={
const_options=awaitonRequest(key);
setOptions(_options);
},500);
useEffect(()={
fetchOptions();
//if(value){
///**判断value中是否有at用户*/
//constatUsers:any=StringTools.filterUsers(value);
//setSelected(atUsers);
//atRef.current.innerHTML=value;
//setContent(value.replace(/\/?.+?\/?/g,''));//全局匹配内html标签)
//}
},
constonObserveInput=()={
letcursorBeforeStr='';
constselection:any=window.getSelection();
if(selection?.focusNode?.data){
cursorBeforeStr=selection.focusNode?.data.slice(0,selection.focusOffset);
}
setFocusNode(selection.focusNode);
constlastAtIndex=cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if(lastAtIndex!==-1){
getCursorPosition();
constsearchStr=cursorBeforeStr.slice(lastAtIndex+1);
if(!StringTools.isIncludeSpacesOrLineBreak(searchStr)){
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
}else{
setVisible(false);
setSearchStr('');
}
}else{
setVisible(false);
}
constselectAtSpanTag=(target:Node)={
window.getSelection()?.getRangeAt(0).selectNode(target);
consteditorClick=async(e?:any)={
onObserveInput();
//判断当前标签名是否为span 是的话选中当做一个整体
if(e.target.localName==='span'){
selectAtSpanTag(e.target);
}
consteditorChange=(event:any)={
const{innerText}=event.target;
setContent(innerText);
onObserveInput();
/**
*@paramid唯一的id可以uid
*@paramname用户姓名
*@paramcolor回显颜色
*@returns
*/
constcreateAtSpanTag=(id:number|string,name:string,color='blue')={
constele=document.createElement('span');
ele.className='at-span';
ele.style.color=color;
ele.id=id.toString();
ele.contentEditable='false';
ele.innerText=`@${name}`;
return
/**
*选择用户时回调
*/
constonSelect=(item:Options)={
constselection=window.getSelection();
constrange=selection?.getRangeAt(0)asRange;
//选中输入的@关键字-@郑
range.setStart(focusNodeasNode,currentAtIdx!);
range.setEnd(focusNodeasNode,currentAtIdx!+1+searchStr.length);
//删除输入的@关键字
range.deleteContents();
//创建元素节点
constatEle=createAtSpanTag(item.id,item.name);
//插入元素节点
range.insertNode(atEle);
//光标移动到末尾
range.collapse();
//缓存已选中的用户
setSelected([...selected,item]);
//选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerTextasstring);
//关闭弹框
setVisible(false);
//输入框聚焦
atRef.current.focus();
constgetAttrIds=()={
constspans=document.querySelectorAll('.at-span');
letids=newSet();
spans.forEach((span)=ids.add(span.id));
returnselected.filter((s)=ids.has(s.id));
/**@的用户列表发生改变时,将最新值暴露给父组件*/
useEffect(()={
constselectUsers=getAttrIds();
onChange(content,selectUsers);
},[selected,content]);
return(
divstyle={{height,position:'relative'}}
{/*编辑器*/}
divid="atInput"ref={atRef}className={'editorDiv'}contentEditableonInput={editorChange}onClick={editorClick}/
{/*选择用户框*/}
SelectUseroptions={options}visible={visible}cursorPosition={cursorPosition}onSelect={onSelect}/
/div
};
选择器代码
constSelectUser=React.memo((props:SelectComProps)={
const{options,visible,cursorPosition,onSelect}=props;
const{x,y}=cursorPosition;
return(
div
className={'selectWrap'}
style={{
display:`${visible?'block':'none'}`,
position:'absolute',
left:x,
top:y+20,
}}
ul
{options.map((item)={
return(
li
key={item.id}
onClick={()={
onSelect(item);
}}
imgsrc={item.wechatAvatarUrl}alt=""/
span{item.name}/span
/li
})}
/ul
/div
});
exportdefaultSelectUser;
以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等。
未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线