術語表
首先我們需要知道一些術語, 才能更好地理解, 如果您已經瞭解, 可以跳過這一段
錨點 (anchor)
錨指的是一個選區的起始點(不同於 HTML 中的錨點連結)。當我們使用滑鼠框選一個區域的時候,錨點就是我們滑鼠按下瞬間的那個點。在使用者拖動滑鼠時,錨點是不會變的。
焦點 (focus)
選區的焦點是該選區的終點,當您用滑鼠框選一個選區的時候,焦點是你的滑鼠鬆開瞬間所記錄的那個點。隨著使用者拖動滑鼠,焦點的位置會隨著改變。
範圍 (range)
範圍指的是文件中連續的一部分。一個範圍包括整個節點,也可以包含節點的一部分,例如文字節點的一部分。使用者通常下只能選擇一個範圍,但是有的時候使用者也有可能選擇多個範圍(例如當使用者按下 Control 按鍵並框選多個區域時,Chrome 中禁止了這個操作)。“範圍”會被作為 Range
物件返回。Range 物件也能透過 DOM 建立、增加、刪減。
本術語表來源於 MDN
contenteditable
contenteditable全域性屬性是一個列舉屬性,表示該元素是否應該由使用者編輯。如果是的話,瀏覽器就會修改其小部件以允許編輯。
簡單的來說, 如果要讓一個 div 變得可編輯, 我們加上這個屬性就能實現了
這就是富文字編輯器的最基礎的構造了, 想要完整的富文字, 首先我們要控制他的游標
而瀏覽器提供了 selection 物件和 range 物件來操作游標。
Selection
Selection 物件表示使用者選擇的文字範圍或插入符號的當前位置。它代表頁面中的文字選區,可能橫跨多個元素。文字選區由使用者拖拽滑鼠經過文字而產生。
我們可以透過 API window.getSelection()
來獲取當前使用者選中了哪些文字
這是呼叫後的返回結果:
部分屬性說明
anchorNode 只讀
返回該選區起點所在的節點(Node)。
anchorOffset 只讀
返回一個數字,其表示的是選區起點在
anchorNode
中的位置偏移量。
- 如果
anchorNode
是文字節點,那麼返回的就是從該文位元組點的第一個字開始,直到被選中的第一個字之間的字數(如果第一個字就被選中,那麼偏移量為零)。- 如果
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)
效果展示:
遇到 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);
其中 setStart
和 setEnd
第二個引數:
如果起始節點型別是 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 秒之後, 選區消失了
我們再在 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)
效果展示:
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);
}
執行順序
檢視 selection
透過官方 API, 我們即可檢視到之前計算的資料:
const editorRef = useRef<any>()
editorRef.current?.getEditor()?.selection
結果如圖:
其中 lastRange
對應 normalizedToRange
的結果,
而 lastNative
則是 getNativeRange
的返回(包裝的原生資料)
總結
本文主要介紹了 原生 API: Selection 和 Range 的作用和他的屬性、方法的說明,
並透過這兩API 介紹在 quill 中, API 會有什麼影響, 我們又需要採用哪些判斷
總的來說, 這兩 API 在除富文字功能中, 基本不會遇見, 所以大多數情況下, 只需要瞭解即可