本项目完整代码地址:https://github.com/MrHGJ/at-mentions
一. 前言
在项目中有这样一个场景,除了普通的文本输入外,还需要支持@选人功能,类似于微博、QQ空间。自己造轮子前,我调研并尝试使用了市面上较为流行的组件去实现:
国外的一个支持@功能组件,github 1.7k start,功能和api比较丰富。下层textarea输入,上层覆盖div展示,但实际使用后存在以下问题:
- 某些电脑上打字会变成粘贴。
- 复制粘贴后,光标会跳到最后位置。
- 删除@人名,只能删掉某个字符,破坏@结构,而且光标会定位错误。
Ant Design提及组件,直接使用textarea,无法满足业务需求:
- @人员不支持高亮展示。
- 删除@人名,只能删掉某个字符,破坏@结构。
综上,现有的mentions组件存在不支持@人员高亮、数据结构不合适、可定制化程度低等问题,不能满足业务需求。因此,自己动手实现了一个支持@选人功能的编辑器,记录一下实现过程。
二. 明确功能
动手前,我们应明确@编辑器应具备的基本功能:
编辑器部分:
- 用户可以在输入框中输入任意普通字符。
- 用户可以通过输入一个“@”字符来调起一个选人浮层,选人浮层出现在光标位置右下侧。
- 选人浮层出现后,用户可以鼠标点击选人或者通过键盘的上下键选人,将人选填入到输入框中。
- 输入框中的人名要高亮显示并且是一个整体。即光标不能移动到人名中间,在人名后方按退格键要将当前人名整体删除。
- 支持模糊搜索。当用户输入“@”字符后,弹出相关匹配的推荐人员浮层。
- 用户输入完后,可以将输入框中的普通文本和人名转成对应的数据结构存储到后端。该数据类型要方便后端存储,也要能在多端(Web、小程序)方便展示。
@文本展示部分
- @人员高亮展示
- @人员支持点击,并能携带部分数据。点击后弹出个人卡片,如图所示。
三. 实现
1. 基于contentEditable的编辑器
contentEditable: 一个枚举属性,表示元素是否可被用户编辑。如果可以,浏览器会修改元素的部件以允许编辑。
个人感受:很强大,坑也多。
编辑器构成分为以下三个部分:
- 输入部分。一个设置了contentEditable属性的div标签。
- 选人浮层。
- 模拟的placeholder部分。
<div>
{/* 真正的输入框 */}
<div contentEditable ref={editorRef} />
{/* 人员选择浮层 */}
<PersonSelcectDialog visible={showDialog} />
{/* placeholder展示 */}
{showPlaceholder && <div>{placeholder}</div>}
</div>
2. API设计
整个api设计部分参照textarea,追求简单,方便使用。
interface Props {
className?: string
placeholder?: string
// 允许输入的最大长度
maxLength?: number
// 初始化内容
value: string
// 初始化提及人员
mentions: IMention[]
// 输入框内容改变时的回调
onChange: (value: string, mentions: IMention[]) => void
onFocus?: () => void
onBlur?: () => void
}
3. 定义数据结构
为了方便数据处理,我们定义了两种数据结构和对应的转换方法:
- 方便后台存储、兼容小程序的原始数据类型。
- 方便渲染的节点数据类型。
例如编辑器中的一段数据: “测试@李大钊 测试哈哈的@李元芳”,在两种模型下的表示:
// 存储数据模型
const mentionData = {
pureString: '测试@李大钊 测试哈哈的@李元芳',
mentionList: [
{
userId: 'lidazhao',
userName: '李大钊',
length: 4,
offset: 2,
},
{
userId: 'liyuanfang',
userName: '李元芳',
length: 4,
offset: 12,
},
],
}
// 节点数据模型
const NodeList = [
{
type: 'text',
data: '测试',
},
{
type: 'at',
data: {
userId: 'lidazhao',
userName: '李大钊',
},
},
{
type: 'text',
data: ' 测试哈哈的',
},
{
type: 'at',
data: {
userId: 'liyuanfang',
userName: '李元芳',
},
},
]
4. 实现流程
步骤一:初始化
初始化主要是将用户传入的原始数据渲染到编辑器中:
- 将原始数据转换成节点数据模型。
- 遍历节点数据,若是普通文本则直接插入TextNode,若是@人员,则插入一个节点,这样人员既支持高亮,也支持整体删除。
// 根据传入的数据初始化editor内容显示
const init = () => {
// 转换成节点数据类型
const nodeList = transformMentionDataToNodeList(value, mentions)
renderNode(nodeList)
}
const renderNode = (nodeList: INode[]) => {
nodeList.forEach((item) => {
if (item.type === NodeType.text) {
const textNode = document.createTextNode(item.data)
editorRef.current.appendChild(textNode)
}
if (item.type === NodeType.at) {
const btn = document.createElement('button')
// 携带数据
btn.dataset.person = JSON.stringify(item.data)
btn.textContent = `@${item.data.userName}`
// 高亮人名
btn.setAttribute('style', 'color:#4387f4;')
editorRef.current.appendChild(btn)
}
})
}
步骤二:监听@输入
在用户进行输入时,判断光标位置前方是否有“@”,除了“@”还有关键字则展示选人浮层,并记下当前光标位置。
// @字符输入检测 是否展示选人弹窗
const checkIsShowSelectDialog = () => {
const rangeInfo = getEditorRange()
if (!rangeInfo || !rangeInfo.range || !rangeInfo.selection) return
const curNode = rangeInfo.range.endContainer
if (!curNode || !curNode.textContent || curNode.nodeName !== '#text') return
const searchStr = curNode.textContent.slice(0, rangeInfo.selection.focusOffset)
// 判断光标位置前方是否有at,除了at还有关键字则展示选人浮层
const words = /@([^@]*)$/.exec(searchStr)
if (words && words.length >= 2) {
const [atChart, keyWord] = words
// 搜索关键字不超过20个字符
if (keyWord && keyWord.length > 20) {
return
}
if (atChart === '@') {
setShowDialog(true)
setSearchKey(keyWord)
// 记下弹窗前光标位置range
editorRange.current = rangeInfo
}
} else {
// 关掉选人
setShowDialog(false)
}
}
步骤三:展示选人浮层,并根据关键字模糊搜索人员列表
根据具体业务而定,以下代码是模拟搜索。
// 通过关键词搜索用户列表。模拟搜索人员。
export const fetchUsers = async (
searchKey: string,
callback: { (data: any): void; (arg0: IPerson[]): void },
) => {
// 随机头像api,https://api.sunweihu.com/api/sjtx/api.php?lx=c1
const testData: IPerson[] = [
{
userId: 'asan',
userName: '阿三',
avatar: 'https://tva4.sinaimg.cn/large/9bd9b167ly1fzjxzbgbllj20b40b4mxw.jpg',
},
{
userId: 'baobo',
userName: '鲍勃',
avatar: 'https://tva1.sinaimg.cn/large/9bd9b167ly1fzjxz5x8zcj20b40b4wf4.jpg',
},
....
]
const userList: IPerson[] = testData.filter(
(item) => item.userId.startsWith(searchKey) || item.userName.startsWith(searchKey),
)
callback(userList)
}
// @后关键词发生改变时触发
useEffect(() => {
fetchUsers(searchKey, (data) => {
setPersonList(data)
})
}, [searchKey])
步骤四: 选人浮层定位
根据光标位置,在右下方展示选人浮层,对超出输入框右边界情况进行手动处理。
// 弹窗展示时,根据光标更新位置
useEffect(() => {
if (showDialog) {
// 获取光标的位置
const { x: cursorX, y: cursorY } = getSelectionCoords()
if (editorRef.current) {
const editorWidth = editorRef.current.offsetWidth
const editorLeft = editorRef.current.getBoundingClientRect().left
const editorRight = editorLeft + editorWidth
const dialogWidth = 300
// 弹窗超出右边界处理
if (cursorX + dialogWidth > editorRight) {
setDialogPosition({ x: editorRight - dialogWidth, y: cursorY })
} else {
setDialogPosition({ x: cursorX, y: cursorY })
}
} else {
setDialogPosition({ x: cursorX, y: cursorY })
}
}
}, [showDialog])
步骤五:支持上下键选人
- 拦截键盘按下事件,默认选中第一个人员
- 如果用户按的是上、下键,则上、下调整选中的人员,保持高亮
- 手动计算并更新滚动条的位置
- 如果用户按了Enter键,则选中当前高亮人员
// 键盘按下
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (showDialog && personList.length > 0) {
const dialogHeight = 250
const itemHeight = 46
// 向下移动光标,调整dialog选中的人
if (e.keyCode === 40) {
e.preventDefault()
let newIndex = activeIndex + 1
if (newIndex === personList.length) {
newIndex = personList.length - 1
}
setActiveIndex(newIndex)
const nowScrollTop = document.getElementById('at-editor-dialog').scrollTop
// 调整滚动条的位置
if ((newIndex + 1) * itemHeight > dialogHeight + nowScrollTop) {
document.getElementById('at-editor-dialog').scrollTop =
(newIndex + 1) * itemHeight - dialogHeight
}
}
// 向上移动光标,调整dialog选中的人
if (e.keyCode === 38) {
e.preventDefault()
let newIndex = activeIndex - 1
if (newIndex < 0) {
newIndex = 0
}
setActiveIndex(newIndex)
const nowScrollTop = document.getElementById('at-editor-dialog').scrollTop
if (newIndex * itemHeight < nowScrollTop) {
document.getElementById('at-editor-dialog').scrollTop =
newIndex === 0 ? 0 : newIndex * itemHeight
}
}
// 按Enter键,确认选择当前人
if (e.keyCode === 13) {
e.preventDefault()
onSelectPerson(personList[activeIndex])
}
}
}
步骤六:选择人员
- 关闭选人浮层,重置搜索关键词
- 删除原来检索文案
- 插入新的@人员节点
// 选择@的人。替换原来的检索文案,并插入新的@标签<button>
const onSelectPerson = (personItem: IPerson) => {
// 选择人员后关闭并重置选人框,重置搜索词
setShowDialog(false)
setPersonList([])
const editor = editorRef.current
if (editor) {
const myEditorRange = editorRange?.current?.range
if (!myEditorRange) return
const textNode = myEditorRange.endContainer // 拿到末尾文本节点
const endOffset = myEditorRange.endOffset // 光标位置
// 找出光标前的@符号位置
const textNodeValue = textNode.nodeValue
const expRes = /@([^@]*)$/.exec(textNodeValue)
if (expRes && expRes.length > 1) {
myEditorRange.setStart(textNode, expRes.index)
myEditorRange.setEnd(textNode, endOffset)
// 删除之前的检索文案
myEditorRange.deleteContents()
const btn = document.createElement('button')
btn.dataset.person = JSON.stringify(personItem)
btn.textContent = `@${personItem.userName}`
btn.setAttribute('style', 'color:#4387f4;')
// 插入空格字符,为了放光标方便
const bSpaceNode = document.createTextNode('\u00A0')
insertHtmlAtCaret([btn, bSpaceNode], editorRange.current.selection, editorRange.current.range)
}
}
}
步骤七:输入回调
- 当输入框的值发生改变时,获取当前输入框所有子节点,遍历转换为节点数据类型。
- 将节点数据转换成后端方便处理的存储类型数据。
- 将数据通过API暴露的onChange事件回调。
// 当输入框值发生变化时,解析它的数据,并回传
const onDataChangeCallBack = () => {
if (editorRef.current) {
const nodeList: INode[] = []
// 获取当前输入框内节点
const editorChildNodes = [].slice.call(editorRef.current.childNodes)
// 转换成节点数据类型
if (editorChildNodes.length > 0) {
editorChildNodes.forEach((element) => {
// 文本
if (element.nodeName === '#text') {
if (element.data && element.data.length > 0) {
nodeList.push({
type: NodeType.text,
data: element.data,
})
}
}
// br换行
if (element.nodeName === 'BR') {
nodeList.push({
type: NodeType.br,
data: '\n',
})
}
// button
if (element.nodeName === 'BUTTON') {
const personInfo = JSON.parse(element.dataset.person)
nodeList.push({
type: NodeType.at,
data: {
openId: personInfo?.openId || '',
userId: personInfo.userId,
userName: personInfo.userName,
},
})
}
})
}
// 转换成存储数据类型
const { pureString, mentionList } = transformNodeListToMentionData(nodeList)
// 文本末尾换行出现两个换行符处理
if (pureString.length > 0 && pureString.charAt(pureString.length - 1) === '\n') {
onChange(pureString.substring(0, pureString.length - 1), mentionList)
} else {
onChange(pureString, mentionList)
}
}
}
粘贴处理
由于输入框使用了contentEditable,在复制粘贴时,会将整个dom粘贴进去。因此在粘贴时,要拦截粘贴动作,获取粘贴dom中的纯文本部分,手动放置到当前光标位置。
// 拦截粘贴,只允许粘贴文本
const onPaste = (e) => {
e.preventDefault()
let pastedText
if (window.clipboardData && window.clipboardData.getData) {
// IE
pastedText = window.clipboardData.getData('Text')
} else{
pastedText = e.clipboardData.getData('text/plain')
}
document.execCommand('insertHTML', false, pastedText)
return false
}
四. 使用
// 组件使用demo
const [pureString, setPureString] = useState('')
const [mentionList, setMentionList] = useState<IMention[]>([])
{/* @编辑器部分 */}
<AtMentions
value={pureString}
mentions={mentionList}
onChange={(value, mentions) => {
setPureString(value)
setMentionList(mentions)
}}
minHeight={200}
placeholder='请输入文本'
/>
{/* 结果展示部分 */}
<AtMentionsPreview
pureString={pureString}
mentions={mentionList}
minHeight={200}
/>
这样,一个简单的@功能就完成了。最后说明一下,文中代码是核心步骤,有一些过程比如两套数据格式转换的具体方法、光标位置计算等都省略了,如有需要,可以查看项目源码,地址:https://github.com/MrHGJ/at-mentions。
由于业务需要,在此@编辑器基础上又嵌入了Markdown语法,也就是Markdown+支持@的编辑器。同时,也有与之匹配的Markdown+@的渲染展示组件,功能已经完成,文档后续补充。