一文入门富文本编辑器

1,782 阅读7分钟

简介

富文本编辑器,能够使web页面像word一样,实现对文本的编辑,通常应用在一些文本处理比较多的系统中。现在业界有很多成熟的富文本编辑器,比如功能齐全啊TinyMCE、轻量高效的wangEditor、百度出品的UEditor等。富文本编辑器很多,但是却很少思考如何从零开始,实现一个富文本编辑器。本文主要简述如何从零开始,实现一个简易的富文本编辑器。

基本使用

普通的HTML标签,能够输入的通常只是表单,表单输入的是纯文本,不带格式的内容。富文本相对于表单,能够给输入文本内容增加一些自定义内容样式,比如加粗、字体颜色、背景...。富文本的实现,主要是给HTML标签,比如div增加一个contenteditable属性,拥有该属性的HTML标签,就能够对该标签里的内容,实现自定义的编辑。最简单的富文本编辑器如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
     
</head>
<body>
    <div id="app" style="width: 200px;height: 200px;background-color: antiquewhite;" contenteditable='true'></div>
</body>
</html>

基本操作

富文本类似于Word,有很多操作文本选项,比如文本的加粗、添加背景颜色、段落缩进等,使用方式是命令式的,只需要执行document.execCommand(aCommandName, aShowDefaultUI, aValueArgument),其中aCommandName命令名称aShowDefaultUI 一个 Boolean是否展示用户界面,一般为 false。Mozilla 没有实现。aValueArgument额外参数,一般为null

基本操作命令

以下简单列举一些富文本操作命令,下面给出一些例子的简单使用

命令 说明
backcolor 颜色字符串 设置文档的背景颜色
bold null 将选择的文本加粗
createlink URL字符串 将选择的文本转换成一个链接,指向指定的URL
indent null 缩进文本
copy null 将选择的文本复制到剪切板
cut null 将选择文本剪切到剪切板
inserthorizontalrule null 在插入字符处插入一个hr元素

Example:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
    <style>
      html, body{
          width: 100%;
          height: 100%;
          padding: 0;
          margin: 0;
      }
      #app{
          display: flex;
          flex-direction: column;
          justify-content: flex-start;
          width: calc(100% - 100px);
          height: calc(100% - 100px);
          padding: 50px;
      }

      .operator-menu{
          display: flex;
          justify-content: flex-start;
          align-items: center;
          width: 100%;
          min-height: 50px;
          background-color: beige;
          padding: 0 10px;
      }
      .edit-area{
          width: 100%;
          min-height: 600px;
          background-color: blanchedalmond;
          padding: 20px;
      }
      .operator-menu-item{
          padding: 5px 10px;
          background-color: cyan;
          border-radius: 10px;
          cursor: pointer;
          margin: 0 5px;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="operator-menu">
        <div class="operator-menu-item" data-fun='fontBold'>加粗</div>
        <div class="operator-menu-item" data-fun='textIndent'>缩进</div>
        <div class="operator-menu-item" data-fun='inserthorizontalrule'>插入分隔符</div>
        <div class="operator-menu-item" data-fun='linkUrl'>链接百度</div>
      </div>
      <div class="edit-area" contenteditable="true"></div>
    </div>
    <script>
      let operationItems = document.querySelector('.operator-menu')
      // 事件监听采用mousedown,click事件会导致富文本编辑框失去焦点
      operationItems.addEventListener('mousedown', function(e) {
        let target = e.target
        let funName = target.getAttribute('data-fun')
        if (!window[funName]) return
        window[funName]()
        // 要阻止默认事件,否则富文本编辑框的选中区域会消失
        e.preventDefault()
      })

      function fontBold () {
        document.execCommand('bold')
      }
      function textIndent () {
        document.execCommand('indent')
      }
      function inserthorizontalrule () {
        document.execCommand('inserthorizontalrule')
      }
      function linkUrl () {
        document.execCommand('createlink', null, 'www.baidu.com')
      }
    </script>
  </body>
</html>

文本范围与选区

  • 富文本中,文本范围和选区是一个非常强大的功能,借助于文本选区,我们可以对选中文本做一些自定义设置。核心是两个对象,SelectionRange对象。用比较官方的说法是,Selection对象,表示用户选择的文本范围或光标的当前位置Range对象表示一个包含节点与文本节点的一部分的文档片段。简单来说,Selection是指页面中,我们鼠标选中的所有区域,Range是指页面中我们鼠标选中的单个区域,属于一对多的关系。比如,我们要获取当前页面的选区对象,可以调用var selection = window.getSelection(),如果想要获取到第一个文本选区信息,可以调用var rang = selection.getRangeAt(0),获取到选区文本信息,采用range.toString()
  • 文本范围与选区,一个比较经典的用法就是,富文本粘贴格式过滤。在我们往富文本编辑器中复制文本时,会保留原文本的格式,如果我们要去除复制的默认格式,只保留纯文本,该如何操作呢?
  • 博主在处理这个问题时,首先想到的是,能不能监听粘贴事件(paste),在粘贴文本时,将剪切板内容替换掉。这一个里面也是有坑的,粘贴时操作剪切板是不生效的。在实现功能需求时,最初采用的是正则匹配,去除HTML标签。奈何文本格式五花八门,经常出现各种奇奇怪怪的字符,问题比较多,而且复制大文本时,页面存在性能问题,这并不是一种好的处理方式,直到后来真正理解了文本范围与选区,才发现这个设置,真香。 富文本选区的处理逻辑大致思路如下:
  1. 监听文本粘贴事件
  2. 阻止默认事件(阻止浏览器默认复制操作)
  3. 获取复制纯文本
  4. 获取页面文本选区
  5. 删除已选中文本选区
  6. 创建文本节点
  7. 将文本节点插入到选区中
  8. 将焦点移动到复制文本结尾

获取粘贴纯文本

let $editArea = document.querySelector('.edit-area')
$editArea.addEventListener('paste', e => {
    // 阻止默认的复制事件
    e.preventDefault()
    let txt = ''
    let range = null
    // 获取复制的文本
    txt = e.clipboardData.getData('text/plain')
    // 获取页面文本选区
    range = window.getSelection().getRangeAt(0)
    // 删除默认选中文本
    range.deleteContents()
    // 创建一个文本节点,用于替换选区文本
    let pasteTxt = document.createTextNode(txt)
    // 插入文本节点
    range.insertNode(pasteTxt)
    // 将焦点移动到复制文本结尾
    range.collapse(false)
})

除此之外,还有很多操作可以借助于选区来实现,比如光标的定位选中区域内容包裹其他样式等。

实现手动将光标定位到最后一个字符


function keepLastIndex(element) {
    if (element && element.focus){
        element.focus();
    } else {
        return
    }
    let range = document.createRange();
    range.selectNodeContents(element);
    range.collapse(false);
    let sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
}

选中区域包裹其他样式

function addCode () {
    let selection = window.getSelection()
    // 暂时处理第一个选区
    let range = selection.getRangeAt(0)
    // 拷贝一份原始选中数据
    let cloneNodes = range.cloneContents()
    // 移除选区
    range.deleteContents()
    // 创建内容容器
    let codeContainer = document.createElement('code')
    codeContainer.appendChild(cloneNodes)
    // 往选区内添加文本
    range.insertNode(codeContainer)
}

附件

以下为测试代码

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
    <!-- https://electronjs.org/docs/tutorial/security#csp-meta-tag -->
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
    <style>
      html, body{
          width: 100%;
          height: 100%;
          padding: 0;
          margin: 0;
      }
      #app{
          display: flex;
          flex-direction: column;
          justify-content: flex-start;
          width: calc(100% - 100px);
          height: calc(100% - 100px);
          padding: 50px;
      }

      .operator-menu{
          display: flex;
          justify-content: flex-start;
          align-items: center;
          width: 100%;
          min-height: 50px;
          background-color: beige;
          padding: 0 10px;
      }
      .edit-area{
          width: 100%;
          min-height: 600px;
          background-color: blanchedalmond;
          padding: 20px;
      }
      .operator-menu-item{
          padding: 5px 10px;
          background-color: cyan;
          border-radius: 10px;
          cursor: pointer;
          margin: 0 5px;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="operator-menu">
        <div class="operator-menu-item" data-fun='fontBold'>加粗</div>
        <div class="operator-menu-item" data-fun='textIndent'>缩进</div>
        <div class="operator-menu-item" data-fun='inserthorizontalrule'>插入分隔符</div>
        <div class="operator-menu-item" data-fun='linkUrl'>链接百度</div>
        <div class="operator-menu-item" data-fun='addCode'>code</div>
      </div>
      <div class="edit-area" contenteditable="true"></div>
    </div>
    <script>
      let operationItems = document.querySelector('.operator-menu')
      // 事件监听采用mousedown,click事件会导致富文本编辑框失去焦点
      operationItems.addEventListener('mousedown', function(e) {
        let target = e.target
        let funName = target.getAttribute('data-fun')
        if (!funName) return
        window[funName]()
        // 要阻止默认事件,否则富文本编辑框的选中区域会消失
        e.preventDefault()
      })
      let $editArea = document.querySelector('.edit-area')
      $editArea.addEventListener('paste', e => {
        // 阻止默认的复制事件
        e.preventDefault()
        let txt = ''
        let range = null
        // 获取复制的文本
        txt = e.clipboardData.getData('text/plain')
        // 获取页面文本选区
        range = window.getSelection().getRangeAt(0)
        // 删除默认选中文本
        range.deleteContents()
        // 创建一个文本节点,用于替换选区文本
        let pasteTxt = document.createTextNode(txt)
        // 插入文本节点
        range.insertNode(pasteTxt)
        // 将焦点移动到复制文本结尾
        range.collapse(false)
        keepLastIndex($editArea)
      })

      function fontBold () {
        document.execCommand('bold')
      }
      function textIndent () {
        document.execCommand('indent')
      }
      function inserthorizontalrule () {
        document.execCommand('inserthorizontalrule')
      }
      function linkUrl () {
        document.execCommand('createlink', null, 'www.baidu.com')
      }

      function addCode () {
        let selection = window.getSelection()
        // 暂时处理第一个选区
        let range = selection.getRangeAt(0)
        // 拷贝一份原始选中数据
        let cloneNodes = range.cloneContents()
        // 移除选区
        range.deleteContents()
        // 创建内容容器
        let codeContainer = document.createElement('code')
        codeContainer.appendChild(cloneNodes)
        // 往选区内添加文本
        range.insertNode(codeContainer)
      }

      function keepLastIndex(element) {
        if (element && element.focus){
          element.focus();
        } else {
          return
        }
        let range = document.createRange();
        range.selectNodeContents(element);
        range.collapse(false);
        let sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
      }
    </script>
  </body>
</html>

参考资料