记录使用 hooks 实现 mention 组件的历程

September 20, 2019

记录使用 hooks 实现 mention 组件的历程 💻

State

measuring // 测量
measureLocation // 需要定位的位置
measureText // 用于搜索的本文
measurePrefix // 呼出 List 的符号 例如 '@'
isESC // 使用 ESC 关闭
activeIndex // 用于键盘事件中上下移动搜索的列表

Props

type Props = {
  setValue: (value: string) => void
  value: string
  trigger: string // '@'
  onSearch: (text: string) => void // 传递 MeasureText
  textareaRef?: React.RefObject<HTMLTextAreaElement> | null
  style?: React.CSSProperties
  className?: string
  onSelect?: (params: ItemType) => void
  MenuClassName?: string
  mountIn?: () => HTMLElement
  loading?: boolean
} & Omit<HTMLTextareaProps, 'onSelect'>

MeasureList 何时出现

MeasureList 在这仅代表呼出的搜索结果列表,很多人可能首先想到的是应该要确定 measureList 出现的位置,但其实我们只需要在 measureList 要出现的时候再计算其位置就可以,所以首先必须要确定 measureList 何时出现

规则:

  • 当光标左侧有 measurePrefix 存在并且之间没有空格时, 也就是 measureText 不含有空格
  • isESCfalse 时「下文详细解释」

获取 MeasureText

// 获取光标左侧的文本
textareaValue.slice(0, textAreaRef.current!.selectionStart)
// 获取距离光标最近的 MeasurePrefix 的位置, 也就是需要定位的位置
const lastMeasurePrefixLocation = selectionStartText.lastIndexOf(measurePrefix)
// 如果 lastMeasurePrefixLocation 不等于 -1 的情况下
// 此时 mesurePrefix 位置与光标位置之间的文本即为 measurePrefix
const measureText = selectionStartText.slice(lastMeasurePrefixLocation + measurePrefix.length)

当满足以上规则时 measuring 为 true

MeasurePrefix 的定位

measureList 出现时,需要用 measurePrefix 此时的位置来对 measureList 进行定位

textarea 中做定位相对困难,因此我们采用 div 来模拟 textarea 中的文本情况去实现 measurePrefix 的定位

首先需要将 textarea 中影响文本样式的 style 应用到 div 中去

useEffect(() => {
  if (textAreaRef.current) {
    const textareaCssProperties = getComputedStyle(textAreaRef.current!)
    const style = {
      lineHeight: textareaCssProperties.lineHeight!,
      fontSize: textareaCssProperties.fontSize!,
    }
    setTextAreaStyle(style)
  }
}, [textAreaRef.current && textAreaRef.current.style, props.style])

模拟的 div 如下

const measureContent = `
	<div style={textAreaStyle} className=${classnames(props.className, Styles['measure-content'])}>
		<span dangerouslySetInnerHTML={{ __html: escape(value.slice(0, measureLocation)) }} />
		<span ref={measurePrefixRef}>@</span>
		<span dangerouslySetInnerHTML={{ __html: escape(value.slice( measureLocation)) }} />
  </div>`

// escape 函数主要是用来处理回车造成的文本换行,如果不转换成 <br> 的话,在 div 中文本会显示成一行,定位就会有错误。
const escape = function(text: string) {
  return text.replace(/<|>|`|"|&/g, '?').replace(/\r\n|\r|\n/g, '<br>')
}

模拟 div 的样式需要额外添加一些属性来使 div 完全模拟 textarea 的行为,当然 text-area-autosize 的行为也支持

.measure-content
  position absolute
  top 0
  right 0
  bottom 0
  left 0
  z-index -1
  overflow scroll
  white-space pre-wrap
  word-break break-all
  opacity 0

注意其共同的父 div 需要添加 position: relative

MeasureList 何时隐藏

  • measureText 中包含空格时
  • 当选中 MeasureList 中的某一个 item 时「点击或 Enter」
  • 当按下 ESC 键时
  • 当光标的左侧无 measurePrefix

注意

按下 ESC 键时,此时 measureText 中不会包含空格,因此依然满足 measuringtrue 的情况,所以需要对 ESC 进行特殊处理:当 measuringtrue 时按下 ESC 键时我会将 isESC 置为 true,而当 isESCtruemeasuring 则至为 falseisESC 会在下次普通的 keyUp 事件中重新置为 false

原文链接