编辑器能想到设置contentEditable就行,这次产品的需求也简单,就是添加1个标签,例如,插入顾客昵称替换成
本来想写简单,就直接设置自定义属性,控制样式等,并且设置不能编辑
<div contentEditable="false">顾客昵称</div>
碰到的坑
- 点击中心内容光标怎么跑到最后
- 如果这个标签最后,后续没有其他节点,光标在safari飘到最后行中 --- 网上说插入空白节点也不靠谱
最后觉得修改成图片
- 出现受控组件光标往前飘
查看react-contenteditable内部做了判断处理就是外部和内部值不一致才emit
整体代码如下
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'
import './index.css'
const BaseEditor = React.forwardRef(({
value = '',
onChange,
leftBottom,
showWordLimit = true,
maxLength = 1000
}, ref) => {
const editor = useRef(null)
const [len, setLen] = useState(0)
const tagMap = {
'%NICKNAME%': 'https://flashmallfs.qiwangcheng.com/upload/202305/2023052614235795366.png'
}
const checkIsUneditable = (target) => {
return target && target.dataset && target.dataset.editable === 'false'
}
// 光标到元素最后
const cursorToTargetEnd = (target) => {
const range = document.createRange();
const selection = window.getSelection();
range.setStartAfter(target);
// range.setEndAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
// 修复contentEditable=false 元素点击,光标到之后
const handleClick = function (event) {
const target = event.target
if (checkIsUneditable(target)) {
cursorToTargetEnd(target)
}
}
// 插入节点
const insertText = (textToInsert, contentEditable = true) => {
let newNode
if (contentEditable === false) {
newNode = document.createElement('img');
newNode.dataset.editable = 'false'
newNode.setAttribute('src', tagMap[textToInsert])
newNode.setAttribute('value', textToInsert)
} else {
newNode = document.createTextNode(textToInsert)
}
try {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// 在光标位置插入文本节点
range.insertNode(newNode);
// 将光标移动到插入的文本节点之后
range.setStartAfter(newNode);
range.setEndAfter(newNode);
range.collapse(true);
// 清空选区并重新设置选区
selection.removeAllRanges();
selection.addRange(range);
// 使编辑器保持焦点
editor.current.focus();
} catch (ex) {
const lastDiv = editor.current.childNodes[editor.current.childNodes.length - 1]
if (lastDiv && lastDiv.nodeType !== 3) {
if (lastDiv.innerHTML === '<br>') {
lastDiv.innerHTML = ''
}
lastDiv.append(newNode)
} else {
editor.current.append(newNode)
}
}
handleChange()
}
// 插入标签
const insertTag = (textToInsert) => {
insertText(textToInsert, false)
}
// 把html转换value
const htmlToValue = (html = '') => {
return html.replace(/<div><br><\/div>/g, '\n')
.replace(/<img([^>]*?)%NICKNAME%([^>]*?)>/g, '%NICKNAME%')
.replace(/<\w([^>]*?)>/g, '\n')
.replace(/<\/\w([^>]*?)>/g, '')
}
// 把value转换html
const valueToHtml = (value = '') => {
return value
.replaceAll('%NICKNAME%', `<img data-editable="false" src="${tagMap['%NICKNAME%']}" value="%NICKNAME%" />`)
.replaceAll('\n', '<br>')
}
const handleChange = () => {
onChange && onChange(htmlToValue(editor.current.innerHTML))
}
const handlePaste = (event) => {
// IE
if (window.clipboardData) {
if (window.clipboardData.getData('Text').length + editor.current.textContent.length > maxLength) {
event.preventDefault();
}
}
// Chrome , Firefox
if (event.clipboardData) {
if (event.clipboardData.getData('Text').length + editor.current.textContent.length > maxLength && event.keyCode !== 8) {
event.preventDefault();
}
}
}
/**
* Check if a keycode is allowed when max limit is reached
* 8 : Backspace
* 37: LeftKey
* 38: UpKey
* 39: RightKey
* 40: DownKey
* ctrlKey for control key
* metakey for command key on mac keyboard
* @param {any} eventKeycode
* @returns boolean
*/
const isAllowedKeyCode = (event) => {
return event.keyCode === 8 ||
event.keyCode === 38 ||
event.keyCode === 39 ||
event.keyCode === 37 ||
event.keyCode === 40 ||
event.ctrlKey ||
event.metaKey;
}
const handleKeyDown = (event) => {
if (maxLength && editor.current.textContent.length === maxLength &&
!isAllowedKeyCode(event)
) {
event.preventDefault();
}
}
// 当输入的时候向外出发onChange,TODO手动添加也需要
const handleInput = (e) => {
handleChange()
}
useImperativeHandle(ref, () => {
return {
insertText,
insertTag,
blur () {
editor.current.blur()
}
}
})
useEffect(() => {
// 这部判断很重要,不然会闪光标乱飘,chrome 和 safari 表现不一致
if (value !== htmlToValue(editor.current.innerHTML)) {
editor.current.innerHTML = valueToHtml(value)
handleChange()
}
setLen(editor.current?.textContent.length || 0)
}, [value])
return <><div
ref={editor}
className="editor"
contentEditable
onClick={handleClick}
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
></div>
<div className="editor__footer">
<div className="editor__toolbar">{leftBottom}</div>
{showWordLimit && (
<div className="editor__word-limit">{len}/{maxLength}</div>
)}
</div>
</>
});
function Editor(props) {
const baseEditor = useRef(null)
const wrapEditor = useRef(null)
const customer = typeof props.customer === 'undefined' ? true : props.customer
return <div ref={wrapEditor} style={{'user-select': 'none'}}>
<BaseEditor ref={baseEditor} {...props} leftBottom={<>
<button onClick={() => baseEditor.current.insertText('$')}>插入文本</button>
{customer && (
<button onClick={() => baseEditor.current.insertTag('%NICKNAME%')}>插入标签</button>
)}
</>}/>
</div>
}
export default Editor
运行代码
import React, { useState, useEffect } from 'react';
import Editor from './components/Editor'
import './App.css';
function App() {
const [value, setValue] = useState('')
useEffect(() => {
// 模拟请求
setTimeout(() => {
setValue('aaa%NICKNAME%bb')
}, 200)
}, [])
return (
<div className="App">
<Editor value={value} onChange={setValue} />
</div>
);
}
export default App;
查看在线效果 codesandbox.io/s/react-edi…