需求背景
- 类似微博评论功能 @ 用户的功能
- 列表中点击某项,将其插入文本框失焦处
实现
- 插件: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;
}