可编辑div

630 阅读2分钟

需求

1、文案可编辑,能实时将输入的{{xx}}转化为tag

2、用户点击位置即光标定位处

3、可插入标签,需要在插入前记录光标位置

4、支持选中区域(即蓝标)直接替换为tag

看到需求第一眼,想到用div和inpput进行切换,但这个满足不了2,所以首先这个需求肯定不会是两个 标签切换,只能一个标签承担展示和编辑的功能,标签既能展示也能编辑,想到了html属性contentEditable,这可以实现1,2,需求3,4涉及光标和选区,利用selection对象和splitText api可实现3,4

contentEditable

contenteditable是一个枚举属性,表示元素是否可被用户编辑,如果没给出该属性或设置了无效的属性值,则其默认值继承自父元素:即,如果父元素可编辑,该子元素也可编辑

Selection

Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生,Selection 对象所对应的是用户所选择的 ranges,俗称拖蓝,检查或修改的 Selection 对象,使用window.getSelection()

选区、范围

在 Web 中,选区指Selection,范围指Range,选区概念更大一点,一个选区中可能有多个范围,也就是可以从Selection中获取Range,Selection和Range都提供了很多属性和方法,可以让我们对选区进行增删改等操作

// 我是范围
const selection = document.getSelection();
// 我是选区
const range = selection.getRangeAt(0);
// 我是元素
const container = document.querySelector('.xxx');

遇到的一些问题及解决

匹配以xx开头,xx结束的正则表达式,正则表达式中插入动态数据

/开头.*?结尾/g

需求需要匹配以{{开头,}}结尾的字符串,正则表达式为/{{.*?}}/g

const edit = () => {
    const currentValue = textRef.current.innerText;
    let matchReg = /{{.*?}}/g;
    const matchText = currentValue.match(matchReg);
    if (matchText) {
      const tagText = matchText[0].replace("{{", "").replace("}}", "");
      textRef.current.innerHTML = textRef.current.innerHTML.replace(
        matchText,
        `<span contentEditable='false' style="margin-right:10px;padding:10px;border:1px solid #ccc;cursor: pointer;">${tagText}</span>`
      );
      textRef.current.blur();
    }
  };

有时正则表达式中的数据是动态的,在js中不能直接使用常规的字符串拼接方式来拼接,它必须使用eval来进行拼接,需要注意的是,用eval拼接字符时,对于 “\” 符号需要多加一个转义符,例如“\s” 需要改成 “\\s”,多加一个 “\” 字符

let matchReg = eval('/'+startContainer.textContent+'.*?'+endContainer.textContent+'/g')

原生js向后添加兄弟元素

原生js只有insertBefore api,可以利用insertBefore和nextSibling实现insertAfter

const insertAfter = (newNode, curNode) => {
    curNode.parentNode.insertBefore(newNode, curNode.nextSibling);
 };
当前元素为最后一个元素时,nextSibling为null,insertBefore会在父元素的最后添加新元素,仍旧生效

nextSibling和nextElementSibling的区别,nextSibling() 返回元素节点之后的兄弟节点(包括文本节点、注释节点),nextElementSibling() 只返回元素节点之后的兄弟元素节点(不包括文本节点、注释节点)

下述代码中first的nextSibling是“节点2”,nextElementSibling是<p>元素3</p>

<div>
    <p id="first">元素1</p>
    节点2
    <p>元素3</p>
</div>

替换dom

function replaceWith(node, node2) {
    var parent = node.parentNode;
    if (parent) {
      parent.replaceChild(node2, node);
    }
 }

range对象中属性详解

  • startContainer:返回包含 Range 开始的节点
  • endContainer:返回包含 Range 结束的节点
  • startOffset:返回一个数字,表示 Range 在 startContainer 中的起始位置
  • endOffset:返回一个表示 Range 终点在 endContainer 中的位置的数字
  • commonAncestorContainer:返回完整包含 startContainer 和 endContainer 的、最深一级的节点 注意:startContainer、endContainer遇标签中断

image.png

image.png

image.png

splitText(offset)

将节点分成指定偏移量处的两个节点,将树中的两个节点保持为兄弟节点,拆分后,当前节点包含到指定偏移点的所有内容,新创建的同类型节点包含剩余文本。

此方法将节点按偏移量分为左右两个节点,注意此方法会改变节点,调用后,节点变为左侧部分,如果调用同时赋值给变量,此变量值为右侧部分

node文本节点3456
let a = node.splitText(2)
console.log(node)//内容为34的文本节点
console.log(a)//内容为56的文本节点

取文本节点的内容

nodeValue属性获取该文本节点的文本内容

实现(存在相同文本替换问题)

import logo from "./logo.svg";
import "./App.css";
import { Select, Input, Tag } from "antd";
import { useRef, useState } from "react";

function App() {
  const textRef = useRef(null);
  const [lastEditRange, setLastEditRange] = useState(null);

  const edit = () => {
    const currentValue = textRef.current.innerText;
    let matchReg = /{{.*?}}/g;
    const matchText = currentValue.match(matchReg);
    if (matchText) {
      const tagText = matchText[0].replace("{{", "").replace("}}", "");
      textRef.current.innerHTML = textRef.current.innerHTML.replace(
        matchText,
        `<span id="${
          Math.random() * 1000000
        }" contentEditable='false' style="margin-right:10px;padding:10px;border:1px solid #ccc;cursor: pointer;">${tagText}</span>`
      );
      textRef.current.blur();
    }
  };
  const insertAfter = (newNode, curNode) => {
    curNode.parentNode.insertBefore(newNode, curNode.nextSibling);
  };
  const getCuscor = () => {
    const range = getSelection().getRangeAt(0);
    setLastEditRange(range);
    console.log("object", range);
  };
  function replaceWith(node, node2) {
    var parent = node.parentNode;
    if (parent) {
      parent.replaceChild(node2, node);
    }
  }
  function insert(value) {
    const tagEle = `<span id='${value}' contentEditable='false' style="padding:10px;border:1px solid #ccc";cursor: pointer;>${value}</span>`;
    const tagEle1 = document.createElement("span");
    tagEle1.style.cssText =
      "padding:10px;border:1px solid #ccc;cursor: pointer;";
    tagEle1.innerHTML = value;
    tagEle1.contentEditable = false;
    const range = lastEditRange.cloneRange();
    let startContainer = range.startContainer,
      endContainer = range.endContainer,
      endOffset = range.endOffset,
      startOffset = range.startOffset,
      commonAncestorContainer = range.commonAncestorContainer;

    let node = startContainer;
    if (node.nodeType === 1) {
      startOffset === 0 ? node.insertBefore(tagEle1, node.firstChild) : node.appendChild(tagEle1)
    }
    (node === endContainer) && (node.length > endOffset) && (node.splitText(endOffset))

    if (startOffset) {
      node = node.splitText(startOffset);
      if (endContainer === startContainer) {
        endContainer = node;
        endOffset -= startOffset;
      }
      startContainer = node;
      startOffset = 0;
    }
    // 蓝标之间有标签
    if(startContainer!==endContainer&&commonAncestorContainer.nodeName==='DIV'){
      let matchReg = eval('/'+startContainer.textContent+'.*?'+endContainer.textContent+'/g')
      const con = textRef.current.innerHTML.match(matchReg)
      // 取startContainer和endContainer蓝标部分
      startContainer.splitText(startOffset)
      endContainer = endContainer.splitText(endOffset)
      const t1 = startContainer.nodeValue + tagEle + endContainer.nodeValue
      textRef.current.innerHTML = textRef.current.innerHTML.replace(con[0],t1)
    }
    replaceWith(node, tagEle1);
  }
  return (
    <div className="App">
      <Select
        defaultValue="lucy"
        style={{
          width: 120,
        }}
        onChange={insert}
        options={[
          {
            value: "jack",
            label: "Jack",
          },
          {
            value: "lucy",
            label: "Lucy",
          },
          {
            value: "disabled",
            label: "Disabled",
          },
          {
            value: "Yiminghe",
            label: "yiminghe",
          },
        ]}
      />
      <div
        contentEditable="true"
        style={{
          border: "1px solid #000",
          width: "800px",
          height: "400px",
          padding: "20px",
        }}
        onInput={edit}
        onBlur={getCuscor}
        ref={textRef}
        id="contentEditableDiv"
      ></div>
    </div>
  );
}

export default App;

execCommand解决方案

import logo from "./logo.svg";
import "./App.css";
import { Select, Input, Tag } from "antd";
import { useRef, useState } from "react";
import Slot from './components/Slot'

function App() {
  const textRef = useRef(null);
  const [lastEditRange, setLastEditRange] = useState(null);

  const edit = () => {
    const currentValue = textRef.current.innerText;
    let matchReg = /{{.*?}}/g;
    const matchText = currentValue.match(matchReg);
    console.log('dfdf',currentValue);
    if (matchText) {
      const tagText = matchText[0].replace("{{", "").replace("}}", "");
      textRef.current.innerHTML = textRef.current.innerHTML.replace(
        matchText,
        `<span id="${
          Math.random() * 1000000
        }" contentEditable='false' style="margin-right:10px;padding:10px;border:1px solid #ccc;cursor: pointer;">${tagText}</span>`
      );
      // textRef.current.blur();
    }
  };
  
  function test(value){
    textRef.current.focus()
    const selection = getSelection()
    if(lastEditRange){
    selection.removeAllRanges()
    selection.addRange(lastEditRange)
    }
    const tagEle = `{{${value}}}`;
    document.execCommand('insertText',false,tagEle)
  }
  return (
    <div className="App">
      <Select
        defaultValue="lucy"
        style={{
          width: 120,
        }}
        onChange={test}
        options={[
          {
            value: "jack",
            label: "Jack",
          },
          {
            value: "lucy",
            label: "Lucy",
          },
          {
            value: "disabled",
            label: "Disabled",
          },
          {
            value: "Yiminghe",
            label: "yiminghe",
          },
        ]}
      />
      <div
        contentEditable="true"
        style={{
          border: "1px solid #000",
          width: "800px",
          height: "400px",
          padding: "20px",
        }}
        onInput={edit}
        onBlur={getCuscor}
        ref={textRef}
        id="contentEditableDiv"
      >
    </div>
  );
}

export default App;