1. 程式人生 > >一文入門富文字編輯器

一文入門富文字編輯器

簡介

富文字編輯器,能夠使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>

參考資料

  • Document.execCommand
  • Selection
  • Range