输入框@选人功能实现

本项目完整代码地址:https://github.com/MrHGJ/at-mentions

一. 前言

在项目中有这样一个场景,除了普通的文本输入外,还需要支持@选人功能,类似于微博、QQ空间。自己造轮子前,我调研并尝试使用了市面上较为流行的组件去实现:

国外的一个支持@功能组件,github 1.7k start,功能和api比较丰富。下层textarea输入,上层覆盖div展示,但实际使用后存在以下问题:

  1. 某些电脑上打字会变成粘贴。
  2. 复制粘贴后,光标会跳到最后位置。
  3. 删除@人名,只能删掉某个字符,破坏@结构,而且光标会定位错误。

Ant Design提及组件,直接使用textarea,无法满足业务需求:

  1. @人员不支持高亮展示。
  2. 删除@人名,只能删掉某个字符,破坏@结构。

综上,现有的mentions组件存在不支持@人员高亮、数据结构不合适、可定制化程度低等问题,不能满足业务需求。因此,自己动手实现了一个支持@选人功能的编辑器,记录一下实现过程。

二. 明确功能

动手前,我们应明确@编辑器应具备的基本功能:

编辑器部分:
  1. 用户可以在输入框中输入任意普通字符。
  2. 用户可以通过输入一个“@”字符来调起一个选人浮层,选人浮层出现在光标位置右下侧。
  3. 选人浮层出现后,用户可以鼠标点击选人或者通过键盘的上下键选人,将人选填入到输入框中。
  4. 输入框中的人名要高亮显示并且是一个整体。即光标不能移动到人名中间,在人名后方按退格键要将当前人名整体删除。
  5. 支持模糊搜索。当用户输入“@”字符后,弹出相关匹配的推荐人员浮层。
  6. 用户输入完后,可以将输入框中的普通文本和人名转成对应的数据结构存储到后端。该数据类型要方便后端存储,也要能在多端(Web、小程序)方便展示。在这里插入图片描述
@文本展示部分
  1. @人员高亮展示
  2. @人员支持点击,并能携带部分数据。点击后弹出个人卡片,如图所示。
    在这里插入图片描述

三. 实现

1. 基于contentEditable的编辑器

contentEditable: 一个枚举属性,表示元素是否可被用户编辑。如果可以,浏览器会修改元素的部件以允许编辑。
个人感受:很强大,坑也多。

编辑器构成分为以下三个部分:

  1. 输入部分。一个设置了contentEditable属性的div标签。
  2. 选人浮层。
  3. 模拟的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. 定义数据结构

为了方便数据处理,我们定义了两种数据结构和对应的转换方法:

  1. 方便后台存储、兼容小程序的原始数据类型。
  2. 方便渲染的节点数据类型。
    暂时无法在文档外展示此内容
    例如编辑器中的一段数据: “测试@李大钊 测试哈哈的@李元芳”,在两种模型下的表示:
// 存储数据模型
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. 实现流程

在这里插入图片描述

步骤一:初始化

初始化主要是将用户传入的原始数据渲染到编辑器中:

  1. 将原始数据转换成节点数据模型。
  2. 遍历节点数据,若是普通文本则直接插入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])
步骤五:支持上下键选人
  1. 拦截键盘按下事件,默认选中第一个人员
  2. 如果用户按的是上、下键,则上、下调整选中的人员,保持高亮
  3. 手动计算并更新滚动条的位置
  4. 如果用户按了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])
    }
  }
}
步骤六:选择人员
  1. 关闭选人浮层,重置搜索关键词
  2. 删除原来检索文案
  3. 插入新的@人员节点
// 选择@的人。替换原来的检索文案,并插入新的@标签<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)
    }
  }
}
步骤七:输入回调
  1. 当输入框的值发生改变时,获取当前输入框所有子节点,遍历转换为节点数据类型。
  2. 将节点数据转换成后端方便处理的存储类型数据。
  3. 将数据通过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+@的渲染展示组件,功能已经完成,文档后续补充。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
### 回答1: 以下是一个使用 React 实现的三个输入框搜索功能的示例代码: ```jsx import React, { useState } from "react"; const Search = () => { const [input1, setInput1] = useState(""); const [input2, setInput2] = useState(""); const [input3, setInput3] = useState(""); const [results, setResults] = useState([]); const handleSearch = () => { // 在这里编写搜索逻辑 // 这里简单示范只是把所有输入框的值拼接在一起作为搜索关键字 const query = input1 + " " + input2 + " " + input3; setResults(["结果1", "结果2", "结果3"]); // 搜索结果存储在 results 状态中 }; return ( <div> <input type="text" placeholder="输入关键字" value={input1} onChange={(e) => setInput1(e.target.value)} /> <input type="text" placeholder="输入关键字" value={input2} onChange={(e) => setInput2(e.target.value)} /> <input type="text" placeholder="输入关键字" value={input3} onChange={(e) => setInput3(e.target.value)} /> <button onClick={handleSearch}>搜索</button> {results.map((result) => ( <div key={result}>{result}</div> ))} </div> ); }; export default Search; ``` 在这个例子中,我们定义了三个输入框和一个搜索按钮。当用户点击搜索按钮时,我们拼接三个输入框的值作为搜索关键字,并执行搜索逻辑。这里只是简单地将结果存储在 `results` 状态中,并在页面上展示出来,实际应用中需要根据具体需求进行修改。 ### 回答2: React是一种用于构建用户界面的JavaScript库,它有很多强大的功能,其中包括实现三个输入框的搜索功能。要实现这个功能,我们可以按照以下步骤操作: 首先,我们需要在React中创建一个组件实现搜索功能。可以使用类组件或函数组件实现,根据个人喜好选择。假设我们使用函数组件。 然后,我们需要在组件的状态中定义三个变量,用于存储输入框中的值。可以使用useState钩子函数来定义这些变量,并使用对应的set函数来更新它们。 接下来,我们需要在组件的渲染函数中添加三个输入框,用于接收用户的输入。可以使用input元素来创建这些输入框,并将其与之前定义的变量绑定。这样,当用户输入内容时,这些变量的值会自动更新。 然后,我们可以在组件中添加一个按钮,用于触发搜索功能。可以使用button元素来创建这个按钮,并为其添加一个点击事件处理函数。在这个事件处理函数中,我们可以获取输入框中的值,并进行搜索操作(例如,发送请求到服务器,过滤本地数据等)。 最后,我们可以在组件的渲染函数中添加一个结果区域,用于显示搜索结果。可以使用div元素来创建这个结果区域,并根据搜索结果的内容进行动态渲染。例如,可以使用map函数遍历搜索结果数组,并为每个结果创建一个显示元素。 总的来说,实现三个输入框的搜索功能需要创建一个组件,定义输入框的值变量,将输入框与这些变量绑定,添加一个触发搜索的按钮以及显示搜索结果的区域。这样,当用户输入内容并点击搜索按钮时,就可以根据输入框的值执行相应的搜索操作,并在结果区域显示搜索结果。 ### 回答3: React是一个用于构建用户界面的JavaScript库,可以用于创建交互式和可复用的组件。如果要实现三个输入框的搜索功能,可以按照以下步骤进行: 1. 创建React组件:首先,创建一个React组件,用于容纳三个输入框和搜索功能。可以使用React的类组件或函数组件来定义组件。 2. 状态管理:在组件中定义三个输入框的状态,以便在用户输入时进行更新。可以使用React的useState钩子或类组件的state来管理状态值。 3. 输入框事件处理:为每个输入框添加事件处理函数,以便在用户输入时更新相应的状态。可以使用React的onChange事件监听器来监听输入框的变化。 4. 搜索功能:为搜索按钮或回车键添加点击或按键事件处理函数,以触发搜索功能。在事件处理函数中,可以获取三个输入框的当前值,并根据需要进行搜索操作。 5. 展示搜索结果:根据搜索操作的结果,可以将结果显示在页面上的某个区域中,例如一个列表或表格。可以使用React的条件渲染功能,根据搜索结果的状态来决定是否显示结果。 6. 样式美化:可以根据需要对输入框、搜索按钮、搜索结果等进行样式设置,以实现更好的用户界面效果。可以使用CSS或CSS框架(如Bootstrap)来进行样式美化。 7. 测试和优化:最后,对搜索功能进行测试,并根据用户反馈和需求进行优化。可以使用React的测试工具来进行单元测试或端到端测试,以确保搜索功能的正常工作。 总之,使用React可以很方便地实现三个输入框的搜索功能。通过状态管理、事件处理和条件渲染等React的特性,可以使搜索功能更加实用和用户友好。
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值