react-mentions 实例

需求背景

  • 类似微博评论功能 @ 用户的功能
  • 列表中点击某项,将其插入文本框失焦处

实现

  • 插件:https://github.com/signavio/react-mentions
  • 已实现功能
    • plainText、rawText 格式均可自定义
    • 唤起字符,可自定义,默认 @
    • 文本框高度自适应
    • 整体高亮,中间不可插入,删除时为一个整体
    • 外部列表点击时,由于 plainText 和 rawText 不一致,重新计算实际的插入位置

npm install react-mentions --save

import React, { Component } from 'react';
import { render } from 'react-dom';
import './style.css';
import { MentionsInput, Mention } from 'react-mentions';

class App extends Component {
  constructor() {
    super();
    this.state = {
      caretPos: 0,
      value: '',
      mentions: null,
      users: [
        {
          _id: 1000,
          name: { first: 'John', last: 'Reynolds' },
        },
        {
          _id: 10001,
          name: { first: 'Holly', last: 'Reynolds' },
        },
        {
          _id: 100002,
          name: { first: 'Ryan', last: 'Williams' },
        },
      ],
    };

    this.expInputRef = React.createRef()
  }

  handleChange = (event, newValue, newPlainTextValue, mentions) => {
    this.setState({
      value: newValue,
      mentions,
    });
  };

  handleBlur = event => {
    event.persist()
    this.setState({ caretPos: event?.target?.selectionStart || 0 })
  }

  // 判断光标是否在复合指标之间,以及光标之前复合指标的个数
  getCursorInfo = (caretPos = 0, mentions = []) => {
    // 光标之前,复合指标,markup 比 displayTransform 多的字节数
    let byteNum = 0
    // 光标之前,复合指标个数
    let num = 0
    // 光标是否在复合指标之间
    let isMiddle = false

    mentions.some(({ plainTextIndex, display, id }) => {
      if (plainTextIndex < caretPos) {
        const strEndIndex = plainTextIndex + display.length
        if (strEndIndex < caretPos) {
          byteNum += String(id).length + 6
          num++
        }
        if (strEndIndex === caretPos) {
          byteNum += String(id).length + 6
          num++
          return true
        }
        if (strEndIndex > caretPos) {
          isMiddle = true
          return true
        }
      }
      if (plainTextIndex === caretPos) {
        return true
      }
    })

    return {
      byteNum,
      num,
      isMiddle,
    }
  }

  // `[${display}]`, id, `{{[${display}(${id})}}`)
  handleIndexSelect(display, id, str) {
    const { value = '', caretPos = 0, mentions = [] } = this.state
    const mentionObj = {
      display,
      id,
      index: caretPos,
      plainTextIndex: caretPos,
    }
    const plainTextCaretPos = caretPos + display.length

    if (!value?.trim() || !mentions?.length) {
      this.doInserIndex(str, value, caretPos, plainTextCaretPos)
      this.setState({
        mentions: [mentionObj],
      })
      return
    }

    const { byteNum, num, isMiddle } = this.getCursorInfo(caretPos, mentions)

    if (isMiddle) {
      alert('指标中间不能插入指标')
      return
    }
    const rawTextCaretPos = caretPos + byteNum
    mentionObj.index = rawTextCaretPos
    mentions.splice(num, 0, mentionObj)
    // 如果插入的指标,不是最后一个复合指标,需更新该指标之后的指标的 mention
    if (num + 1 < mentions.length) {
      for (let index = num + 1; index < mentions.length; index++) {
        const mention = mentions[index]
        mention.plainTextIndex += display.length
        mention.index += str.length
      }
    }
    this.doInserIndex(str, value, rawTextCaretPos, plainTextCaretPos)
    this.setState({
      mentions,
    })
  }

  doInserIndex = (str, value, rawTextCaretPos, plainTextCaretPos) => {
    this._expFocus()
    const newValue = this._insertStr(str, value, rawTextCaretPos)
    this.setState({
      value: newValue,
    })
    if (!this.expInputRef.current) {
      return
    }
    const $node = this.expInputRef.current
    this._setCaretPos($node, plainTextCaretPos)
  }

  _insertStr(source = '', target = '', pos) {
    const startPart = target.substring(0, pos)
    const endPart = target.substring(pos)
    return `${startPart}${source}${endPart}`
  }

  _setCaretPos($input, pos) {
    if (!$input) {
      return
    }
    setTimeout(() => {
      if ($input.createTextRange) {
        const range = $input.createTextRange()
        range.collapse(true)
        range.moveEnd('character', pos)
        range.moveStart('character', pos)
        range.select()
      } else if ($input.setSelectionRange) {
        $input.setSelectionRange(pos, pos)
      }
    }, 200)
  }

  _expFocus() {
    if (!this.expInputRef.current) {
      return
    }
    setTimeout(() => {
      const node = this.expInputRef.current
      node.focus()
    }, 200)
  }

  render() {
    const userMentionData = this.state.users.map((myUser) => ({
      id: myUser._id,
      display: `${myUser.name.first} ${myUser.name.last}`,
    }));

    return (
      <div>
        <p>Start editing to see some magic happen :)</p>
        <MentionsInput
          className="mentions"
          placeholder={`Type anything, use the @ symbol to tag other users.`}
          value={this.state.value}
          markup="{{[__display__](__id__)}}"
          allowSpaceInQuery
          displayTransform={(id, display) => `[${display}]`}
          inputRef={event => this.expInputRef.current = event}
          onChange={this.handleChange}
          onBlur={this.handleBlur}
        >
          <Mention
            type="index"
            trigger={/(?:^|.)(@([^.@]*))$/}
            data={userMentionData}
            className="mentions__mention"
          />
        </MentionsInput>

        <h3>The raw text is: {this.state.value}</h3>
        <ul className="index-list">
          {userMentionData.map(({ id, display }) => (
            <li key={id} onClick={() => this.handleIndexSelect(`[${display}]`, id, `{{[${display}](${id})}}`)}>
              {display}
            </li>
          ))}
        </ul>
      </div>
    )
  }
}

render(<App />, document.getElementById('root'));
.mentions {
  margin: 0;
  padding: 0;
  font-size: 14px;
  color: #60626b;
}

.mentions .mentions__control {
  min-height: 120px;
}

.mentions:focus-within .mentions__input {
  border-color: #5d95fc;
  outline: 0;
  box-shadow: 0 0 0 2px rgb(50 109 240 / 20%); 
}

.mentions .mentions__highlighter {
  padding: 4px 11px;
  line-height: 32px;
  border: 1px solid transparent;
  height: auto!important;
}

.mentions .mentions__input {
  padding: 4px 11px;
  min-height: 120px;
  line-height: 32px;
  outline: 0;
  border: 1px solid #dee0e8;
}

.mentions__mention {
  background-color: #d9e4ff;
}

.mentions__suggestions__list {
  width: 140px;
  line-height: 20px;
  color: #60626b;
  font-size: 12px;
  border: 1px solid #e8eaf2;
  box-shadow: 0px 2px 8px rgba(61, 67, 102, 0.148055);
  border-radius: 2px;
  background-color: #fff;
}

.mentions__suggestions__item {
  padding: 0 8px;
}

.mentions__suggestions__item:hover {
  background: #f4f6fc;
  color: #507ff2;
}

.mentions__suggestions__item--focused {
  background: #f4f6fc;
  color: #507ff2;
}

.index-list {
  padding: 0;
  margin: 0;
  width: 300px;
  border: 1px solid #e8eaf2;
  border-radius: 2px;
}

.index-list li {
  padding: 0 20px;
  margin: 0;
  list-style: none;
  line-height: 30px;
  cursor: pointer;
}

.index-list li:hover {
  background-color: #f4f6fc;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容