富文本編輯器 quill.js 開發(二): 光標和選區

術語表

首先我們需要知道一些術語, 才能更好地理解, 如果您已經瞭解, 可以跳過這一段

錨點 (anchor)

錨指的是一個選區的起始點(不同於 HTML 中的錨點鏈接)。當我們使用鼠標框選一個區域的時候,錨點就是我們鼠標按下瞬間的那個點。在用戶拖動鼠標時,錨點是不會變的。

焦點 (focus)

選區的焦點是該選區的終點,當您用鼠標框選一個選區的時候,焦點是你的鼠標鬆開瞬間所記錄的那個點。隨着用戶拖動鼠標,焦點的位置會隨着改變。

範圍 (range)

範圍指的是文檔中連續的一部分。一個範圍包括整個節點,也可以包含節點的一部分,例如文本節點的一部分。用戶通常下只能選擇一個範圍,但是有的時候用戶也有可能選擇多個範圍(例如當用戶按下 Control 按鍵並框選多個區域時,Chrome 中禁止了這個操作)。“範圍”會被作爲 Range 對象返回。Range 對象也能通過 DOM 創建、增加、刪減。

本術語表來源於 MDN

contenteditable

contenteditable全局屬性是一個枚舉屬性,表示該元素是否應該由用戶編輯。如果是的話,瀏覽器就會修改其小部件以允許編輯。

簡單的來說, 如果要讓一個 div 變得可編輯, 我們加上這個屬性就能實現了

這就是富文本編輯器的最基礎的構造了, 想要完整的富文本, 首先我們要控制他的光標

而瀏覽器提供了 selection 對象和 range 對象來操作光標。

Selection

Selection 對象表示用戶選擇的文本範圍或插入符號的當前位置。它代表頁面中的文本選區,可能橫跨多個元素。文本選區由用戶拖拽鼠標經過文字而產生。

我們可以通過 API window.getSelection() 來獲取當前用戶選中了哪些文本

這是調用後的返回結果:

image

部分屬性說明

anchorNode 只讀

返回該選區起點所在的節點(Node)。

anchorOffset 只讀

返回一個數字,其表示的是選區起點在 anchorNode 中的位置偏移量。

  1. 如果 anchorNode 是文本節點,那麼返回的就是從該文字節點的第一個字開始,直到被選中的第一個字之間的字數(如果第一個字就被選中,那麼偏移量爲零)。
  2. 如果 anchorNode 是一個元素,那麼返回的就是在選區第一個節點之前的同級節點總數。(這些節點都是 anchorNode 的子節點)

isCollapsed 只讀

返回一個布爾值,用於判斷選區的起始點和終點是否在同一個位置。

rangeCount 只讀

返回該選區所包含的連續範圍的數量。

方法

這裏只闡述幾個重要的方法

getRangeAt

var selObj = window.getSelection();
range = sel.getRangeAt(index)

例子:

let ranges = [];

sel = window.getSelection();

for(var i = 0; i < sel.rangeCount; i++) {
 ranges[i] = sel.getRangeAt(i);
}
/* 在 ranges 數組的每一個元素都是一個 range 對象,
 * 對象的內容是當前選區中的一個。 */

在很多情況下, rangeCount 的數量都是 1

他的返回值是一個 Range, 具體在本文的 Range 部分講解

addRange

向選區(Selection)中添加一個區域(Range)。

這裏舉一個小栗子就能快速理解:

<strong id="foo">這是一段話巴拉巴拉</strong>
<strong id="bar">這是另一段話</strong>
var s = window.getSelection();

// 一開始我們讓他選中 foo 節點
var range = document.createRange();
range.selectNode(foo);
s.addRange(range);

// 在一秒鐘後我們取消foo 節點的選中, 選擇所有body節點
setTimeout(()=>{
    s.removeAllRanges();
    var range2 = document.createRange();
    range2.selectNode(document.body);
    s.addRange(range2);
}, 1000)

效果展示:
image

遇到 contenteditable 元素時

如果 strong#foo 元素是一個 contenteditable 元素: <strong id="foo" contenteditable="true">這是一段話巴拉巴拉</strong>
那麼我們不能直接用 range.selectNode(foo);, 而是應該這樣做:

    var range = document.createRange();
    range.setStart(foo, 0)
    range.setEnd(foo, 1)
    // 其中 0, 1 代表子節點數量
    s.addRange(range);

其中 setStartsetEnd 第二個參數:

如果起始節點類型是 Text、Comment 或 CDATASection之一,那麼 startOffset 指的是從起始節點算起字符的偏移量。對於其他 Node 類型節點,startOffset 是指從起始結點開始算起子節點的偏移量。

或者使用 selectNodeContents API:

    var range = document.createRange();
    range.selectNodeContents(foo)
    s.addRange(range);

collapse

collapse 方法可以收起當前選區到一個點。文檔不會發生改變。如果選區的內容是可編輯的並且焦點落在上面,則光標會在該處閃爍。

同樣地, 這裏也創建一個例子

<p id="foo">這是一段話巴拉巴拉</p>
var s = window.getSelection();

var range = document.createRange();
range.selectNode(foo);
s.addRange(range);

setTimeout(()=>{
    s.collapse(foo, 0);
}, 1000)

效果是, 在 1 秒之後, 選區消失了
image

我們再在 p 標籤上添加 contenteditable 嘗試下:

<p contenteditable="true" id="foo">這是一段話巴拉巴拉</p>
var s = window.getSelection();

var range = document.createRange();
range.selectNodeContents(foo)
s.addRange(range);

setTimeout(()=>{
    s.collapse(foo, 1);
}, 1000)

效果展示:
image

Range

Range 接口表示一個包含節點與文本節點的一部分的文檔片段。

在上述的例子中, 我們已經嘗試過使用 Document.createRange 方法創建 Range
也可以通過 Selection 對象的 getRangeAt() 方法或者 Document 對象的 caretRangeFromPoint() 方法獲取 Range 對象。

Range (通過 document.createRange(); 創建)擁有這些屬性:

{
    collapsed:true // 表示 Range 的起始位置和終止位置是否相同的布爾值
    commonAncestorContainer:document // 返回完整包含 startContainer 和 endContainer 的、最深一級的節點
    endContainer:document // 包含 Range 終點的節點。
    endOffset:0 // 一個表示 Range 終點在 endContainer 中的位置的數字。
    startContainer:document // 包含 Range 開始的節點。
    startOffset:0 // 一個數字,表示 Range 在 startContainer 中的起始位置。
}

collapse

Range.collapse() 方法向邊界點摺疊該 Range

語法:

range.collapse(toStart);

toStart 可選

一個布爾值: true 摺疊到 Range 的 start 節點,false 摺疊到 end 節點。如果省略,則默認爲 false

在之前的 Selection - collapse 例子中, 我們也可以通過此 API 來操作, 達到相同的效果:

<p contenteditable="true" id="foo">這是一段話巴拉巴拉</p>
var s = window.getSelection();

var range = document.createRange();
range.selectNodeContents(foo)
s.addRange(range);

setTimeout(()=>{
    range.collapse()
    // s.collapse(foo, 1);
}, 1000)

在前文中已經嘗試過使用 selectNode() , selectNodeContents() , setEnd(), setStart() 等方法, 這裏就不在多贅述

quill 中的 Selection

在 quill 中, 會基於原生 API 獲取信息, 幷包裝出一個自己的對象:

  getRange() {
    const root = this.scroll.domNode;
    // 省略空值判斷
    const normalized = this.getNativeRange(); // 我們先看這個函數
    if (normalized == null) return [null, null];
    // 後續暫時忽略
  }

getRange 函數就是 quill 中, 獲取選區的方法, 而 normalized 是基於原生的api, 並通過一定的包裝, 來獲取數據:

  getNativeRange() {
    const selection = document.getSelection();
    if (selection == null || selection.rangeCount <= 0) return null;
    const nativeRange = selection.getRangeAt(0);
    if (nativeRange == null) return null;
    // 上面四句都是通過原生 api, 來判斷當前是否有選區
    // 因爲基本上 rangeCount 都是 1, 所以直接通過 getRangeAt(0) 即可獲取選區
    
    // 這裏的 normalizeNative 纔是對原生真正的操作
    // nativeRange 是當前
    const range = this.normalizeNative(nativeRange);
    return range;
}

normalizeNative

  normalizeNative(nativeRange) {
    // 判斷選區是否在當前的編輯器根元素中, 是否是選中狀態
    if (
      !contains(this.root, nativeRange.startContainer) ||
      (!nativeRange.collapsed && !contains(this.root, nativeRange.endContainer))
    ) {
      return null;
    }
    // 構造一個自定義對象, 存儲原生數據
    const range = {
      start: {
        node: nativeRange.startContainer,
        offset: nativeRange.startOffset, // 開始元素的偏移, 但是並不代表是從視覺看上去的偏移, 具體看 nativeRange.startContainer.data
      },
      end: { node: nativeRange.endContainer, offset: nativeRange.endOffset },
      native: nativeRange,
    };
    // 開始遍歷 [range.start, range.end] 
    [range.start, range.end].forEach(position => {
      // 從原生處取值: node = range.startContainer  offset = range.startOffset
      let { node, offset } = position;
      
      // 當某一節點不是 text, 且有子節點時
      // 因爲在 quill, 中會有一些特殊的格式, 比如圖片, 視頻, emoji 等等
      // 這些特殊格式在選區中的佔位是不同的, 舉個例子:  一張看着很大的圖片, 但其實偏移量只有 1
      // 同時, 如果我們需要一些定製的功能的話, 這裏的判斷可能會影響選區 , 所以我們需要對這裏做出一些特殊的判斷
      while (!(node instanceof Text) && node.childNodes.length > 0) {
        if (node.childNodes.length > offset) { // 超出的情況判斷
          node = node.childNodes[offset];
          offset = 0;
        } else if (node.childNodes.length === offset) {
          node = node.lastChild;
          if (node instanceof Text) {
            offset = node.data.length;
          } else if (node.childNodes.length > 0) {
            // Container case
            offset = node.childNodes.length;
          } else {
            // Embed case
            offset = node.childNodes.length + 1;
          }
        } else {
          break;
        }
      }
      position.node = node;
      position.offset = offset; // 賦值
    });
    return range;
  }

最後返回一個自定義 range 對象

normalizedToRange

而在自定義對象包裝結束之後, 還會經歷一次計算 normalizedToRange 方法

  getRange() {
    const root = this.scroll.domNode;
    // 省略空值判斷
    const normalized = this.getNativeRange(); // 返回的自定義 range
    if (normalized == null) return [null, null];
    
    const range = this.normalizedToRange(normalized);
    return [range, normalized];
  }

normalizedToRange:

    // range 的結構
    // const range = {
    //  start: {
    //      node: nativeRange.startContainer,
    //          offset: nativeRange.startOffset, // 開始元素的偏移, 但是並不代表是從視覺看上去的偏移, 具體看 nativeRange.startContainer.data
    //  },
    //  end: { node: nativeRange.endContainer, offset: nativeRange.endOffset },
    //  native: nativeRange,
    // };
    // 
    normalizedToRange(range) {
    const positions = [[range.start.node, range.start.offset]];
    // 如果不是閉合的即光標狀態, 則新增 末尾的數據到數組中
    if (!range.native.collapsed) {
      positions.push([range.end.node, range.end.offset]);
    }
    // 遍歷數據, 獲取索引(從編輯框的第 0 個開始算)
    const indexes = positions.map(position => {
        // 經過 normalizeNative 修改的取值,和原生相比,  node 和 offset 可能發生了修改
        const [node, offset] = position;
        // 搜索到對應的 dom
        const blot = this.scroll.find(node, true);
        // 通過他的 api 來獲取偏移量, 可以查看 https://github.com/quilljs/parchment
        const index = blot.offset(this.scroll);
        // 如果在某一個 dom 上的偏移量爲 0, 那麼當前索引就是 dom 的索引
        if (offset === 0) {
            return index;
        }
        // LeafBlot屬於特殊情況, 屬於子節點, 屬於 parchment 庫
        if (blot instanceof LeafBlot) {
            return index + blot.index(node, offset);
        }
        // 最後加上當前節點的長度
        return index + blot.length();
    });
    
    // 比較當前的索引和, 編輯器的長度, 不讓他超出
    const end = Math.min(Math.max(...indexes), this.scroll.length() - 1);
    // 比較結尾和索引, 獲取最小值, 作爲開始值
    const start = Math.min(end, ...indexes);
    // 通過原生 api 重新 new 一個新的 Range 對象, 傳參爲 start 和 length
    return new Range(start, end - start);
    }

執行順序

image

查看 selection

通過官方 API, 我們即可查看到之前計算的數據:

    const editorRef = useRef<any>()
    editorRef.current?.getEditor()?.selection

結果如圖:
image

其中 lastRange 對應 normalizedToRange 的結果,
lastNative 則是 getNativeRange 的返回(包裝的原生數據)

總結

本文主要介紹了 原生 API: Selection 和 Range 的作用和他的屬性、方法的說明,
並通過這兩API 介紹在 quill 中, API 會有什麼影響, 我們又需要採用哪些判斷

總的來說, 這兩 API 在除富文本功能中, 基本不會遇見, 所以大多數情況下, 只需要瞭解即可

引用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章