欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

contenteditable实现编辑器,光标、输入法处理,emoji的显示和转换存储

程序员文章站 2022-07-14 13:29:20
...

需要开发一个移动端的富文本编辑器,但是不想用uedit等富文本编辑器,那就只能自己支持了。

1、contenteditable="true",对组件设置contenteditable="false",这俩是前提

<div id="content" contenteditable="true" class="content"></div>
<div class="feedback_mix_img" contenteditable="false" data-type="image">
    <img src="blob:http://wqs.jd.com/69573dec-eb6c-417c-b6f9-3124d071bfa8">
    <div class="operator_item">
        <div class="circle">
            <div class="up" data-action="up"></div>
        </div>
        <div class="circle">
            <div class="down" data-action="down"></div>
        </div>
        <div class="circle change changeImage">更改图片</div>
    </div>
    <div class="del_item" data-action="del">
        <div></div>
    </div>
</div>

2、placeholder,不要写在content里面,用样式empty来设置,否则插入组件之后,删除会自动清空。应该在外层使用div,用绝对定位,移动到你想要的位置。

 .content:empty::before{
     content: attr(placeholder);
     font-size: 14px;
     color: #CCC;
     line-height: 21px;
     padding-top: 10px;
 }
<div class="placeholder">请输入不少于150字正文,支持图文商品混排哦!</div>

contenteditable实现编辑器,光标、输入法处理,emoji的显示和转换存储

3、监听content的focus方法,当光标聚焦的时候,给元素插入default元素。这里开始琢磨了我一段时间,因为默认的插入文本都是在content里面,如输入aaaa:<div id="content">aaaa</div>,然后回车<div id="content">aaaa<div></div></div>,显然,如果是这种样式,不符合规定,且不好操作,之前一直想着替换,单问题很多:光标,回车不自动出现回车效果,而是里面嵌套。如下设置之后,则完全符合预期。

const defaultHtml = '<p class="feedback_mix_text citem"><br/></p>';

var dom = document.getElementById("content");

if(dom.innerHTML==""){
   dom.innerHTML=defaultHtml;
}

回车之后:

contenteditable实现编辑器,光标、输入法处理,emoji的显示和转换存储

4、监听input方法,如果用户,删除了,内容没数据,需要显示placeholder,查找p标签的内容,记录文本字数,还需要判断是否有插入组件,这里注意,如果用户点击删除,没内容之后,继续删除,那么可能会默认的defaultHtml都没有,且丢失焦点,这样子代理的问题是插入表情的时候就不符合预期了,表情应该是在p里面。

contenteditable实现编辑器,光标、输入法处理,emoji的显示和转换存储

let item = self.dom.getElementsByClassName("feedback_mix_text");
    let num = 0;
    for(let i = 0,len=item.length;i<len;i++){
        num+=item[i].innerText.length;
    }
    self.content_num = num;
    let style = self.placeholder.style;
    if(self.dom.innerHTML==""&&self.component_num==0){
        style.display="block";
    }else{
        style.display="none";
    }
if(!item||item.length==0){
        self.dom.innerHTML=defaultHtml;
        setTimeout(function(){
            moveRange(self,self.dom.querySelector("p"));
        },0);
    }

4、最核心的,需要记录最后的光标位置。这里还判断了光标位置,如果当前光标不再content里面的text标签里面,则不需要记录,我这边输入内容都会在<p class="feedback_mix_text citem"></p>里面。

document.addEventListener('selectionchange',function(){
             getCursor(self);
});
/**
 * 获取光标位置
 */
function getCursor(self){
    var sel = getSelection();
    if(!sel){
        return;
    }
    var node = sel.anchorNode;
    var isIn = false;
    while(node&&node.nodeType!=node.DOCUMENT_NODE){
        var cls = node.classList;
        if(cls&&cls.contains("feedback_mix_text")){
            isIn = true;
            break;
        }
        node=node.parentNode
    }
    if(!isIn) return;
    console.log("getCursor");
    self.select = sel;
    self.lastRange = sel.getRangeAt(0);
}

5、插入元素。分为插入表情,粘贴,插入其他正常商品,图片等。

var sel = this.select;
            var range = this.lastRange;
            if(!sel||!range) return;
            var el;
            if(type=="emoji"){
                el = document.createElement("img");
                el.className="quan_icon_emoji";
                el.src=opt.url;
            }else if(type=="paste"){
                el = document.createElement("p");
                el.className="feedback_mix_text citem";
                el.innerText = opt.tpl;
            }else{
                el = document.createElement('div');
                el.innerHTML = opt.tpl;
                el.className="citem";
            }
            range.insertNode(el);
            
            afterInserDom(this,el,type);

6、处理元素,插入的节点,会在p标签里面,但是实际上应该和P标签并列。所以需要处理。如果是emoji的话,不应该换行。这里插入了元素

function afterInserDom(self,lastNode,type){
    if(type=="emoji"){
        domUtil.deleteBr(lastNode);
    }else{
        domUtil.breakParent(lastNode,lastNode.parentNode);
    }
    self.component_num++;
    if(self.content_num==0){
        textChange(self);
    }
    
}
function breakParent(node, parent) {
    var tmpNode,
      parentClone = node,
      clone = node,
      leftNodes,
      rightNodes;
    do {
      parentClone = parentClone.parentNode;
      //保护,防止出现插入内容不是在<p></p>里面,那么则不需要breank,否则会跑到content之外
      if(parentClone.id=="content"){
        return;
      }
        leftNodes = parentClone.cloneNode(false);
        rightNodes = leftNodes.cloneNode(false);
      
      while ((tmpNode = clone.previousSibling)) {
        leftNodes.insertBefore(tmpNode, leftNodes.firstChild);
      }
      while ((tmpNode = clone.nextSibling)) {
              rightNodes.appendChild(tmpNode);
      }
      //如果右边没有数据了,则需要插入br,否则会获取不了焦点。
      if(rightNodes&&rightNodes.nodeName=="P"&&rightNodes.innerHTML==""){
        rightNodes.appendChild(document.createElement("br"));
      }
      //删除左边的空p标签
      if(leftNodes&&leftNodes.nodeName=="P"&&leftNodes.innerHTML==""){
        leftNodes="";
      }
      clone = parentClone;
    } while (parent !== parentClone);
    tmpNode = parent.parentNode;
    leftNodes&&tmpNode.insertBefore(leftNodes, parent);
    tmpNode.insertBefore(rightNodes, parent);
    tmpNode.insertBefore(node, rightNodes);
    remove(parent);
    return node;
  }
  function remove(node) {
    var parent = node.parentNode;
    if (parent) {
      parent.removeChild(node);
    }
    return node;
  }
function deleteBr(node){
  var next = node.nextSibling;
  if(next&&next.nodeName=="BR"&&next.parentNode.nodeName=="P"){
    remove(next);
  }
}

7、移动光标。插入元素之后,有把节点调整了位置,则已经失去光标了,需要把光标移动插入元素之后。

function moveRange(self,el,range){
    var sel = self.select;
    if(!sel){
        console.log(sel);
        return;
    }
    range = (range||self.lastRange).cloneRange();
    if(el){
        if(!el.nextSibling&&el.nodeName=="P"){
            range.setStart(el,0);
        }else if(el.nextSibling){
            range.setStart(el.nextSibling,0);
        }else{
            range.setStartAfter(el);
        }
    }
    range.collapse(true);
    sel.removeAllRanges();
    sel.addRange(range);
}

8、富文本可以粘贴,所以需要处理用户粘贴的情况。这次暂时不处理图片,文章的粘贴,如有需要,可以data.items[0].getAsFile()来获取图片。

wrap.addEventListener("paste",function (event) {
        var data = event.clipboardData;
        if(!data||(data.files&&data.files.length>0)){//not support or copy file
            event.returnValue = false;
            return false;
        }
        //如果当前已经有update,且时间是100ms以内,则认为先textchange,再paste,这不是标准的paste,需要拦截。
        var update = store.state.flag.update;
        if(update&&Date.now()-update<100){
            return;
        }
        handlePaster();
    });

9、获取要粘贴的内容。之前还傻傻的想直接获取文件内容,event.clipboardData.items[0].getAsString()  没有用,没有用。

/**
 * 处理复制内容
 */
function handlePaster() {
    var sel = getSelection();
    var range = sel.getRangeAt(0).cloneRange();
    var div = document.createElement("div");
    div.id = "gwq_paste";
    div.setAttribute("contenteditable","true");
    div.style.cssText ="position:absolute;width:1px;height:1px;overflow:hidden;left:-1000px;white-space:nowrap;top:"+window.pageYOffset+"px";
    div.innerHTML = "<br/>";
    document.body.appendChild(div);
    range.setStart(div,0);
    range.collapse(true);
    sel.removeAllRanges();
    sel.addRange(range);
    setTimeout(function () {
        var pastedom = document.querySelector("#gwq_paste");
        var text = pastedom.innerText;
        pastedom.remove();
        JD.events.trigger("afterpaste",text);
    },0);
    
}

10、禁止拖动和移动,

//prevent drag
    wrap.addEventListener('dragover', function(event){
        event.preventDefault();
        return false;
    });
    //prevent drop
    wrap.addEventListener('drop', function(event){
        event.preventDefault();
        return false;
    });

11、将输入法输入的表情转换成unicode

/**
 * emoji转换成unicode存储,\ud83c\udf4f
 * 然后innerHTMl="\ud83c\udf4f"即可显示表情
 * @param {*} emoji 
 */
function emoji2Unicode(emoji) {
  var backStr = '';
  if (emoji && emoji.length > 0) {
      for (var char of emoji) {
          var index = char.codePointAt(0);
          if (index > 65535) {
              var h =
                  '\\u' +
                  (Math.floor((index - 0x10000) / 0x400) + 0xd800).toString(
                      16
                  );
              var c =
                  '\\u' + ((index - 0x10000) % 0x400 + 0xdc00).toString(16);
              backStr = backStr + h + c;
          } else {
              backStr = backStr + char;
          }
      }
  }
  return backStr;
}
/**
 * //unicode 转换为实体字符以供后台存储
 * unicode2Enti("\ud83c\udf4f")  ---》"&#127823;"
 * 然后innerHTMl="&#127823;"即可显示表情
 * @param {*} str 
 */
function unicode2Enti(str) {
  var patt = /[\ud800-\udbff][\udc00-\udfff]/g;
  str = str.replace(patt, function(char) {
      var H, L, code;
      if (char.length === 2) {
          //辅助平面字符(我们需要做处理的一类)
          H = char.charCodeAt(0); // 取出高位
          L = char.charCodeAt(1); // 取出低位
          code = (H - 0xd800) * 0x400 + 0x10000 + L - 0xdc00; // 转换算法
          return '&#' + code + ';';
      } else {
          return char;
      }
  });
  return str;
}
function isEmoji(substring) {  
  for ( var i = 0; i < substring.length; i++) {  
      var hs = substring.charCodeAt(i);  
      if (0xd800 <= hs && hs <= 0xdbff) {  
          if (substring.length > 1) {  
              var ls = substring.charCodeAt(i + 1);  
              var uc = ((hs - 0xd800) * 0x400) + (ls - 0xdc00) + 0x10000;  
              if (0x1d000 <= uc && uc <= 0x1f77f) {  
                  return true;  
              }  
          }  
      } else if (substring.length > 1) {  
          var ls = substring.charCodeAt(i + 1);  
          if (ls == 0x20e3) {  
              return true;  
          }  
      } else {  
          if (0x2100 <= hs && hs <= 0x27ff) {  
              return true;  
          } else if (0x2B05 <= hs && hs <= 0x2b07) {  
              return true;  
          } else if (0x2934 <= hs && hs <= 0x2935) {  
              return true;  
          } else if (0x3297 <= hs && hs <= 0x3299) {  
              return true;  
          } else if (hs == 0xa9 || hs == 0xae || hs == 0x303d || hs == 0x3030  
                  || hs == 0x2b55 || hs == 0x2b1c || hs == 0x2b1b  
                  || hs == 0x2b50) {  
              return true;  
          }  
      }  
  }  
}