React实现简单编辑器,代码其实一点也不简单

776 阅读2分钟

编辑器能想到设置contentEditable就行,这次产品的需求也简单,就是添加1个标签,例如,插入顾客昵称替换成

image.png

本来想写简单,就直接设置自定义属性,控制样式等,并且设置不能编辑

<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…