從零實現富文本編輯器#5-編輯器選區模型的狀態結構表達

先前我們總結了瀏覽器選區模型的交互策略,并且實現了基本的選區操作,還調研了自繪選區的實現。那么相對的,我們還需要設計編輯器的選區表達,也可以稱為模型選區。編輯器中應用變更時的操作范圍,就是以模型選區為基準來實現的。在這里我們就以編輯器狀態為基礎,來設計模型選區的結構表達。

  • 開源地址: https://github.com/WindRunnerMax/BlockKit
  • 在線編輯: https://windrunnermax.github.io/BlockKit/
  • 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md

從零實現富文本編輯器項目的相關文章:

  • 深感一無所長,準備試著從零開始寫個富文本編輯器
  • 從零實現富文本編輯器#2-基于MVC模式的編輯器架構設計
  • 從零實現富文本編輯器#3-基于Delta的線性數據結構模型
  • 從零實現富文本編輯器#4-瀏覽器選區模型的核心交互策略
  • 從零實現富文本編輯器#5-編輯器選區模型的狀態結構表達

編輯器選區模型

在編輯器中的選區模型設計是涉及面比較廣的命題,因為其作為應用變更或者稱為執行命令的基礎范圍,涉及到了廣泛的模塊交互。雖然本質上是需要其他模塊需要適配選區模型的表達,但明顯的如果選區模型不夠清晰的話,其他模塊的適配工作就會變得復雜,交互自然是需要相互配合來實現的。

而選區模型最直接依賴的就是編輯器的狀態模型,而狀態模型的設計又非常依賴數據結構的設計。因此,在這里我們會從數據結構以及狀態模型的角度出發,先來調研一下當前主流編輯器的選區模型設計。

Quill

Quill是一個現代富文本編輯器,具備良好的兼容性及強大的可擴展性,還提供了部分開箱即用的功能。Quill的出現給富文本編輯器帶了很多新的東西,也是目前開源編輯器里面受眾非常大的一款編輯器,至今為止的生態已經非常的豐富,目前也推出了2.0版本。

Quill的數據結構表達是基于Delta實現的,當然既然其名為Delta,那么數據的變更也可以基于Delta來實現。那么使用Delta表達基本的富文本數據結構如下所示,可以觀察到其不會存在嵌套的數據結構表達,所有內容以及格式表達都是線性的。

{ops: [{ insert: "這樣" },{ insert: "一段文本", attributes: { italic: true } },{ insert: "的" },{ insert: "數據結構", attributes: { bold: true } },{ insert: "如下所示。\n" },],
};

既然數據結構是扁平的表達,那么選區的表達也不需要復雜的結構來實現。直觀感受上來說,扁平化結構要特殊處理的Case會更少,狀態結構會更好維護,編輯器架構會更加輕量。通常編輯器的選區也會構造Range對象,那么在Quill中的選區結構如下所示:

{index: 5, // 光標位置length: 10, // 選區長度
}

選區作為編輯器變更的應用范圍,不可避免地需要提及應用變更的操作。在應用變更時,自然需要進行狀態結構的遍歷,以取出需要變更的節點來實際應用變更。那么自然而然地,扁平的結構本身的順序就是渲染的順序,不需要嵌套的結構來進行遞歸地查找,查找的效率自然會更高。

此外,Quill的視圖層是重新設計了一套模型parchment,維護了視圖和狀態模型,雖然在倉庫中通過繼承的方式重寫了部分類結構,例如ScrollBlot類。但是在閱讀源碼的時候難以確定很多視圖模塊設計意圖,所以對于DOM事件與狀態模型的交互暫時還沒有很好的理解。

Slate

Slate是一個高度靈活、完全可定制的富文本編輯器框架,其提供了一套核心模型和API,讓開發者能夠深度定制編輯器的行為,其更像是富文本編輯器的引擎,而不是開箱即用的組件。Slate經歷過一次大版本重構,雖然目前仍然處于Beta狀態,但是已經有相當多的線上服務使用。

Slate的數據結構表達就不是扁平的內容表示的,依據其TS類型的定義,Node類型是Editor | Element | Text三種類型的元組。當然拋開Editor本身不談,我們從下面的內容描述上可以很容易看出來ElementText類型的定義。

{type: "paragraph",children: [{ text: "這樣" },{ text: "一段文本", italic: true },{ text: "的" },{ text: "數據結構", bold: true },{ text: "如下所示。" },]
}

其中children屬性可以認為是Element類型的節點,而text屬性則是Text類型的節點,Element類型的節點可以無限嵌套Element類型以及Text類型節點。那么這種情況下,扁平的選區表達就無法承載這樣的結構,因此Slate的選區表達是用端點來實現的。

{anchor: { path: [0, 1], offset: 2 }, // 起始位置focus: { path: [0, 3], offset: 4 }, // 結束位置
}

這種選區的表達是不是非常眼熟,沒錯Slate的選區表達是完全與瀏覽器選區對齊的,因為其從最開始的設計原則就是與DOM對齊的。說句題外的話,Slate對于數據結構的表現也是完全與選區對齊的,例如表達圖片時必須要放置一個空的Text作為零寬字符。

{type: "image",url: "https://example.com/image.png",children: [{ text: "" }]
}
<div data-slate-node="element" data-slate-void="true"><div data-slate-spacer="true"><span data-slate-zero-width="z" data-slate-length="0">\u200B</span></div><div contenteditable="false"><img src="https://example.com/image.png"></div>
</div>

除了類似于圖片的Void節點表達,針對于行內的Void節點,更能夠表現其內建維護的數據結構是完全對齊DOM的。例如下面的Mention結構表達,由于需要在兩側放置光標,因此在DOM中引入了兩個零寬字符,此時內建數據結構也維護了兩個Text節點。

[{ text: "" },{type: "mention",user: "Mace",children: [{ text: "" }],},{ text: "" },
];
<p data-slate-node="element"><span data-slate-node="text"><span data-slate-zero-width="z" data-slate-length="0">\u200B</span></span><span contenteditable="false"><span data-slate-spacer="true"><span data-slate-zero-width="z" data-slate-length="0">\u200B</span></span>@Mace</span><span data-slate-node="text"><span data-slate-zero-width="z" data-slate-length="0">\u200B</span></span>
</p>

說回選區模型,Slate在應用數據變更時,同樣需要依賴兩個端點來進行遍歷查找,特別是進行諸如setNode等操作時。那么查找就非常依賴渲染順序來決定,由于文檔整體結構還是二維的,因此通過path + offset對比找到起始/結束的節點,然后從首節點開始遞歸遍歷查找到尾節點就可以了。

嵌套的數據結構針對于批量的變更整體會變得更復雜,因為Op之間的索引關系需要維護,這就依賴transform的實現。但是,針對于單個Op的變更實際上是更清晰的,類似于Delta的變更是不容易非常針對性地處理單個操作變更,而JSON結構下的單次變更非常清晰。

transform這部分也是核心實現,先前基于OT-JSON以及Immer實現的低代碼場景的狀態管理方案也提到過。單個Op變更時通常不會影響Path,批量的Op變更時是需要考慮到其間相互影響的關系的,例如在Slateremove-nodes實現中,通過ref維護的影響關系。

// packages/slate/src/transforms-node/remove-nodes.ts
const depths = Editor.nodes(editor, { at, match, mode, voids })
const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p))for (const pathRef of pathRefs) {const path = pathRef.unref()!if (path) {const [node] = Editor.node(editor, path)editor.apply({ type: 'remove_node', path, node })}
}

Lexical

LexicalMeta開源的現代化富文本編輯器框架,專注于提供極致的性能、可訪問性和可靠性。其作為Draft.js的繼任者,提供了更好的可擴展性和靈活性。特別的,Lexical還提供了IOS的原生支持,基于Swift/TextKit編寫的可擴展文本編輯器,共享Lexical設計理念與API

Lexical的數據結構與Slate類似,都是基于樹形的嵌套結構來表達內容。但是其整體設計上增加了很多的預設內容,并沒有像Slate設計為足夠靈活的編輯器引擎,從下面的數據結構表達中也可以看出并沒有那么簡潔,這已經是精簡過后的內容,原本還存在諸如detail等屬性。

[{children: [{ format: 0, mode: "normal", text: "這樣", type: "text" },{ format: 2, mode: "normal", text: "一段文本", type: "text" },{ format: 0, mode: "normal", text: "的", type: "text" },{ format: 1, mode: "normal", text: "數據結構", type: "text" },{ format: 0, mode: "normal", text: "如下所示。", type: "text" },],direction: "ltr",indent: 0,type: "paragraph",version: 1,textFormat: 0,},
]

由于其本身是嵌套的數據結構,那么選區的表達也類似于Slate的實現。只不過Lexical的選區類似于ProseMirror的實現,將選區分為了RangeSelectionNodeSelectionTableSelectionnull選區類型。

其中Table以及null的選區類型比較特殊,我們就暫且不論。而針對于RangeNode實際上是可以同構表達的,Node是針對于Void類型如圖片、分割線的表達,而類似于Quill以及Slate是將其直接融入Range的表達,以零寬字符文本作為選區落點。

實際上特殊的表達自然是有特殊的含義,Quill以及Slate通過零寬字符同構了選區的文本表達,而Lexical的設計是不存在零寬字符占位的,所以無法直接同構文本選區表達。那么對于Lexical的選區,基礎的文本RangeSelection選區如下所示:

{anchor: { key: "51", offset: 2, type: "text" },focus: { key: "51", offset: 3, type: "text" }
}

這里可以看出來雖然數據結構與Slate類似,但是path這部分被替換成了key,而需要注意的是,在我們先前展示的數據結構中是沒有key這個標識的。也就是說這個key值是臨時的狀態值而不是維護在數據結構內的,雖然看起來這個key很像是數字,實際上是字符串來表達唯一id

// packages/lexical/src/LexicalUtils.ts
let keyCounter = 1;export function generateRandomKey(): string {return '' + keyCounter++;
}

實際上這種id生成的方式在很多地方都存在,包括Slate以及我們的BlockKit中,而恰好我們都是通過這種方式來生成key值的。因為我們的視圖層是通過React來渲染的,因此不可避免地需要維護唯一的key值,而在Lexicalkey值還維護了狀態映射的作用。

那么這里的key值本質上是維護了idpath的關系,我們應該可以直接通過key值取得渲染的節點狀態。并且通過這種方式實際上就相當于借助了臨時的path做到了任意key字符串可比較,并且可以沿著相對應的nextSibling剪枝查找到尾節點,其實這里讓我想到了偏序關系的維護。

// packages/lexical/src/LexicalSelection.ts
const cachedNodes = this._cachedNodes;
if (cachedNodes !== null) {return cachedNodes;
}
const range = $getCaretRangeInDirection($caretRangeFromSelection(this),'next',
);
const nodes = $getNodesFromCaretRangeCompat(range);
return nodes;

在這里比較重要的是key值變更時的狀態保持,因為編輯器的內容實際上是需要編輯的。然而如果做到immutable話,很明顯直接根據狀態對象的引用來映射key會導致整個編輯器DOM無效的重建。例如調整標題的等級,就由于整個行key的變化導致整行重建。

那么如何盡可能地復用key值就成了需要研究的問題,我們的編輯器行級別的key是被特殊維護的,即實現了immutable以及key值復用。而葉子狀態的key依賴了index值,因此如果調研Lexical的實現,同樣可以將其應用到我們的key值維護中。

通過在playground中調試可以發現,即使我們不能得知其是否為immutable的實現,依然可以發現Lexicalkey是以一種偏左的方式維護。因此在我們的編輯器實現中,也可以借助同樣的方式,合并直接以左值為準復用,拆分時若以0起始直接復用,起始非0則創建新key

  1. [123456(key1)][789(bold-key2)]文本,將789的加粗取消,整段文本的key值保持為key1
  2. [123456789(key1)]]文本,將789這段文本加粗,左側123456文本的key值保持為key1789則是新的key
  3. [123456789(key1)]]文本,將123這段文本加粗,左側123文本的key值保持為key1456789則是新的key
  4. [123456789(key1)]]文本,將456這段文本加粗,左側123文本的key值保持為key1456789分別是新的key

說起來,Lexical其實并不是完全由React作為視圖層的,其僅僅是可以支持React組件節點的掛載。而主要的DOM節點,例如加粗、標題等格式還是自行實現的視圖層,判斷是否是通過React渲染的方式很簡單,通過控制臺查看是否存在類似__reactFiber$的屬性即可。

// Slate
__reactFiber$lercf2nvv2a: {}
__reactProps$lercf2nvv2a: {}// Lexical
__lexicalDir: "ltr"
__lexicalDirTextContent: "這樣一段文本的數據結構如下所示。"
__lexicalKey_siggg: "47"
__lexicalTextContent: "這樣一段文本的數據結構如下所示。\n\n"

飛書文檔

飛書文檔是基于Blocks的設計思想實現的富文本編輯器,其作為飛書的核心應用之一,提供了強大的文檔編輯功能。飛書文檔深度融合文檔、表格、思維筆記等組件,支持多人云端實時協作、深度集成飛書套件、高度移動端適配,是非常不錯的商業產品。

飛書文檔是商業化的產品,并非開源的編輯器,因此數據結構只能從接口來查閱。如果直接查閱飛書的SSR數據結構,可以發現其基本內容如下,直接在控制臺上輸出DATA即可。當然由于文本數據是EtherPadEasySync協同算法的數據,這部分在Delta數據結構的文章也介紹過。

{doxcnTYlsMboJlTMcxwXerq6wqc: {id: "doxcnTYlsMboJlTMcxwXerq6wqc",data: {children: ["doxcnXFzVSkuH3XoiT3mqXvOx4b"],},},doxcnXFzVSkuH3XoiT3mqXvOx4b: {id: "doxcnXFzVSkuH3XoiT3mqXvOx4b",data: {type: "text",parent_id: "doxcnTYlsMboJlTMcxwXerq6wqc",text: {apool: {nextNum: 3,numToAttrib: { "1": ["italic", "true"], "2": ["bold", "true"] },},initialAttributedTexts: {attribs: { "0": "*0+2*0*1+4*0+1*0*2+4*0+5" },text: { "0": "這樣一段文本的數據結構如下所示。" },},},},},
}

當然這個數據結構看起來比較復雜,我們也可以直接從飛書開放平臺的服務端API的獲取文檔所有塊中,響應示例中得到整個數據結構的概覽。當然看起來這部分數據結構是經歷過一次數據轉換的,并非直接將數據結構直接響應。其實這部分結構可以認為跟Slate的樹結構類似,但每行都是獨立實例。

當然看起來飛書文檔是扁平化的結構,但是實際上構建出來的狀態值還是樹形的結構,只不過是使用id來實現類似鏈式結構,從而可以構建整個樹結構。那么對于飛書文檔的選區結構來說,自然也是需要適配數據結構狀態的模型。對于文本選區而言,飛書文檔的結構如下:

// PageMain.editor.selectionAPI.getSelection()
[{ id: 2, type: "text", selection: { start: 3, end: 16 } },{ id: 6, type: "text", selection: { start: 0, end: 2 } },{ id: 7, type: "text", selection: { start: 0, end: 1 } },
];

既然飛書文檔是Blocks的塊結構設計,那么僅僅是純文本的選區維護自然是不夠的。那么在塊結構的選區表達,飛書的選區表達如下所示。說起來,飛書文檔的結構選區如果執行跨級別的塊選區操作,那么飛書文檔會在抬起鼠標的時候會將更深層級的塊選區合并為同層級的選區。

// PageMain.editor.selectionAPI.getSelection()
[{ id: 2, type: "block" },{ id: 6, type: "block" },{ id: 7, type: "block" },
];

針對深度層級的Blocks級別文本選區,類似Editor.js完全不支持文本的跨節點選中;飛書文檔支持跨行的文本選區,但是在跨層級的文本選區會提升為同級塊選區;Slate/Lexical等編輯器則是支持跨層級的文本選區,當然其本身并非Blocks設計的編輯器,主要是支持節點嵌套。

基于Blocks思想設計的編輯器其實更類似于低代碼的設計,相當于實現了一套受限的低代碼引擎,準確來說是無代碼引擎。這里的受限主要是指的不會像圖形畫板那么靈活的拖拽操作,而是基于某些規則設計下的編輯器形式,例如塊組件需要獨占一行、結構不能任意嵌套等。

其實在這種嵌套的數據結構下,選區的表達方式自然有很多可行的表達。飛書文檔的同層級提升方法應該是更加合適的,因為這種情況下處理相關的數據會更簡單,而且也并非不支持跨節點的文本選區,也不需要像深層次嵌套結構那樣在取得相關節點時需要遞歸查找,屬于數據表達與性能的折中取舍。

選區結構設計

那么在調研了當前主流編輯器的選區模型設計后,我們就需要依照類似的原則來設計我們的編輯器選區模型。首先是針對數據結構設計選區對象,也就是編輯器中通常存在的Range對象,其次是針對狀態模型的表達,并且需要考慮以此為基準設計編輯器的狀態選區結構表達。

RawRange

在瀏覽器中Range對象是瀏覽器選區的基礎表達,而對于編輯器而言,通常都會實現編輯器本身的Range對象。當然在這種情況下,編輯器Range對象和瀏覽器Range對象,特別是可能實現IOC DI的情況下,我們可以對瀏覽器的Range對象獨立分配對象名。

import DOMNode = globalThis.Node;
import DOMText = globalThis.Text;
import DOMElement = globalThis.Element;

那么先前已經提到了我們的編輯器數據結構設計,是基于Delta的實現改造而來的。因此Range對象的設計同樣可以與Quill編輯器的選區設計保持一致,畢竟選區設計的直接依賴便是數據結構的設計。為了區分我們后邊的設計方案,我們這里命名為RawRange

export class RawRange {constructor(/** 起始點 */public start: number,/** 長度 */public len: number) {}
}

由于實際上RawRange對象相當于是表達的一個線性范圍,本身僅需要兩個number即可以表達。但是為了更好地表達其語義,以及相關的調用方法,例如isBefore以及isAfter等方法,因此我們還有其內部的Point對象的表達。

export class RawPoint {constructor(/** 起始偏移 */public offset: number) {}/*** 判斷 Point1 是否在 Point2 之前* - 即 < (p1 p2), 反之則 >= (p2 p1)*/public static isBefore(point1: RawPoint | null, point2: RawPoint | null): boolean {if (!point1 || !point2) return false;return point1.offset < point2.offset;}
}

在此基礎上就可以實現諸如intersectsincludes等方法,對于選區的各種操作還是比較重要的。例如intersects方法可以用來判斷選區塊級節點的選中狀態,因為void節點本身是非文本的內容,瀏覽器本身是沒有選區狀態的。

export class RawRange {public static intersects(range1: Range | null, range2: Range | null) {if (!range1 || !range2) return false;const { start: start1, end: end1 } = range1;const { start: start2, end: end2 } = range2;// --start1--end1--start2--end2--// => --end1--start2--// --start1--start2--end1--end2--  ?// => --start2--end1--const start = Point.isBefore(start1, start2) ? start2 : start1;const end = Point.isBefore(end1, end2) ? end1 : end2;return !Point.isAfter(start, end);}
}
export class SelectionHOC extends React.PureComponent<Props, State> {public onSelectionChange(range: Range | null) {const leaf = this.props.leaf;const leafRange = leaf.toRange();const nextState = range ? Range.intersects(leafRange, range) : false;if (this.state.selected !== nextState) {this.setState({ selected: nextState });}}public render() {const selected = this.state.selected;return (<divclassName={cs(this.props.className, selected && "block-kit-embed-selected")}data-selection>{React.Children.map(this.props.children, child => {if (React.isValidElement(child)) {const { props } = child;return React.cloneElement(child, { ...props, selected: selected });} return child;})}</div>);}
}

Range

既然有RawRange選區對象的設計,那么相對應的自然是Range對象的設計。在這里我們Range對象的設計直接基于編輯器狀態的實現,因此其實可以認為,我們的RawRange對象是基于數據結構的實現,Range對象則是基于編輯器狀態模型的實現。

先來看Range對象的聲明,實際上這里的實現是相對更精細的表達。在Point對象中,我們維護了行索引和行內偏移,而在Range對象中,我們維護了選區的起始點和結束點,此時的Range對象中區間永遠是從start指向end,通過isBackward來標記此時是否反選狀態。


export class Point {constructor(/** 行索引 */public line: number,/** 行內偏移 */public offset: number) {}
}export class Range {/** 選區起始點 */public readonly start: Point;/** 選區結束點 */public readonly end: Point;/** 選區方向反選 */public isBackward: boolean;/** 選區折疊狀態 */public isCollapsed: boolean;
}

其中,選區區間永遠是從start指向end這點非常重要,在我們后續的瀏覽器選區與編輯器選區狀態同步中會非常有用。因為瀏覽器的Selection對象得到的anchorfocus并非總是由start指向end,此時維護我們的Range對象需要從Selection取得相關節點。

那么從先前的數據結構上來看,Delta數據結構是不存在任何行相關的數據信息,因此我們需要從編輯器維護的狀態上來獲取行索引和行內偏移。維護獨立的狀態變更本身也是一件復雜的事情,這件事我們需要后續再看,此時我們先來看下各個渲染節點狀態維護的數據。

export class LeafState {/** Op 所屬 Line 的索引 */public index: number;/** Op 起始偏移量 */public offset: number;/** Op 長度 */public readonly length: number;
}export class LineState {/** 行 Leaf 數量 */public size: number;/** 行起始偏移 */public start: number;/** 行號索引 */public index: number;/** 行文本總寬度 */public length: number;/** Leaf 節點 */protected leaves: LeafState[] = [];
}

因此,瀏覽器選區實現的Selection對象都是基于DOM來實現的,那么通過DOM節點來同步編輯器的選區模型同樣需要需要處理。不過在這里,我們先以從狀態模型中獲取選區的方式來構建Range對象。而由于上述實現中我們是基于點Point來實現的,那么自然可以分離點來處理區間。

const leafNode = getLeafNode(node);
let lineIndex = 0;
let leafOffset = 0;const lineNode = getLineNode(leafNode);
const lineModel = editor.model.getLineState(lineNode);
// 在沒有 LineModel 的情況, 選區會置于 BlockState 最前
if (lineModel) {lineIndex = lineModel.index;
}const leafModel = editor.model.getLeafState(leafNode);
// 在沒有 LeafModel 的情況, 選區會置于 Line 最前
if (leafModel) {leafOffset = leafModel.offset + offset;
}
return new Point(lineIndex, leafOffset);

這樣看起來,我們分離了LineStateLeafState的狀態,然后直接從相關狀態中就可以取出Point對象的行索引和行內偏移。注意這里我們得到的是行內偏移而不是葉子結點的偏移。類似Slate計算得到的偏移是Text節點的偏移,這也是對于選區模型的設計相關。

換句話說,當前我們的選區實現是L-O的實現,也就是LineOffset索引級別的實現,也就是說這里的Offset是會跨越多個實際的LeafState節點的。那么這里的Offset就會導致我們在實現選區查找的時候需要額外的迭代,實際實現很比較靈活的。

const lineNode = editor.model.getLineNode(lineState);
const selector = `[${LEAF_STRING}], [${ZERO_SPACE_KEY}]`;
const leaves = Array.from(lineNode.querySelectorAll(selector));
let start = 0;
for (let i = 0; i < leaves.length; i++) {const leaf = leaves[i];let len = leaf.textContent.length;const end = start + len;if (offset <= end) {return { node: leaf, offset: Math.max(offset - start, 0) };}
}
return { node: null, offset: 0 };

其實我也思考過使用L-I-O選區的實現,也就是說像是Slate的數據結構,只不過我們將其簡化為3級,而不是像Slate一樣可以無限層即嵌套下去。這樣的好處是,選區模型會更加清晰,因為不需要在行的基礎上循環查找,但是缺點是增加了選區復雜度,L-O模型屬于靈活復雜的折中實現。

export class Point {constructor(/** 行索引 */public line: number,/** 節點索引 */public index: number,/** 節點內偏移 */public offset: number) {}
}

那么最后,我們實際上還需要實現RawRangeRange對象的轉換方法。即使是編輯器內部也需要經常相互轉換,例如在執行換行、刪除行等操作時,為了方便處理都是用Range對象構造的,而基于Delta實際執行選區變換時,則需要使用RawRange對象處理。

RawRangeRange對象的轉換相當于從線性范圍到二維范圍的轉換,因此我們需要對選區的行狀態進行一次檢索。那么由于我們的LineState對象索引是線性增長的,那么針對有序序列查找的方式,最常見的方式就是二分查找。

export class Point {public static fromRaw(editor: Editor, rawPoint: RawPoint): Point | null {const block = editor.state.block;const lines = block.getLines();const line = binarySearch(lines, rawPoint.offset);if (!line) return null;return new Point(line.index, rawPoint.offset - line.start);}
}export class Range {public static fromRaw(editor: Editor, raw: RawRange): Range | null {const start = Point.fromRaw(editor, new RawPoint(raw.start));if (!start) return null;const end = !raw.len ? start.clone() : Point.fromRaw(editor, new RawPoint(raw.start + raw.len));if (!end) return null;return new Range(start, end, false, raw.len === 0);}
}

Range對象到RawRange對象的轉換相對簡單,因為我們只需要將行索引和行內偏移轉換為線性偏移即可。而由于此時我們針對行的偏移信息都是記錄在行對象上的,因此我們直接取相關的值相加即可。

export class RawPoint {public static fromPoint(editor: Editor, point: Point | null): RawPoint | null {if (!point) return null;const block = editor.state.block;const line = block.getLine(point.line);if (!line || point.offset > line.length) {editor.logger.warning("Line Offset Error", point.line);return null;}return new RawPoint(line.start + point.offset);}
}export class RawRange {public static fromRange(editor: Editor, range: Range | null): RawRange | null {if (!range) return null;const start = RawPoint.fromPoint(editor, range.start);const end = range.isCollapsed ? start : RawPoint.fromPoint(editor, range.end);if (start && end) {// 此處保證 start 指向 endreturn new RawRange(start.offset, Math.max(end.offset - start.offset, 0));}return null;}
}

總結

在先前我們基于Range對象與Selection對象實現了基本的選區操作,并且舉了相關的應用具體場景和示例。與之相對應的,在這里我們總結了調研了現代富文本編輯器的選區模型設計,并且基于數據模型設計了RawRangeRange對象兩種選區模型。

接下來我們需要基于編輯器選區模型的表示,然后在瀏覽器選區相關的API基礎上,實現編輯器選區模型與瀏覽器選區的同步。通過選區模型作為編輯器操作的范圍目標,來實現編輯器的基礎操作,例如插入、刪除、格式化等操作,以及諸多選區相關的邊界操作問題。

每日一題

  • https://github.com/WindRunnerMax/EveryDay

參考

  • https://quilljs.com/docs/api#selection
  • https://lexical.dev/docs/concepts/selection
  • https://prosemirror.net/docs/ref/#state.Selection
  • https://tiptap.dev/docs/editor/api/commands/selection
  • https://docs.slatejs.org/concepts/03-locations#selection
  • https://open.larkoffice.com/document/server-docs/docs/docs/docx-v1/document/list

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/bicheng/84460.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/84460.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/84460.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

一個小小的 flask app, 幾個小工具,拼湊一下

1. 起因&#xff0c; 目的: 自己的工具&#xff0c;為自己服務。給大家做參考。項目地址&#xff1a; https://github.com/buxuele/flask_utils 2. 先看效果 3. 過程: 一個有趣的 Flask 工具集&#xff1a;從無到有的開發歷程 緣起&#xff1a;為什么要做這個項目&#xff…

織夢dedecms怎樣用標簽調用隨機數?

?在使用織夢模板建站中&#xff0c;隨機數作為一個偶爾使用到的參數&#xff0c;在具體使用中雖然用的少&#xff0c;但是今天跟版網小編給大家介紹下&#xff0c;大家可以參考下&#xff1a; 實現隨機數的調用可以使用下面的js&#xff1a; 方法一&#xff1a;js代碼 Math…

訪問服務器項目,服務器可以ping通,但是端口訪問不到

原因&#xff1a;端口未開放 假設項目部署服務器為205&#xff0c;在90服務器訪問205項目 1、首先在205確定項目啟動&#xff0c;看端口是否占用 # Windows&#xff08;檢查端口占用&#xff09; netstat -ano | findstr "8103"期望輸出&#xff1a; TCP 0.0.…

云原生核心技術 (7/12): K8s 核心概念白話解讀(上):Pod 和 Deployment 究竟是什么?

大家好&#xff0c;歡迎來到《云原生核心技術》系列的第七篇&#xff01; 在上一篇&#xff0c;我們成功地使用 Minikube 或 kind 在自己的電腦上搭建起了一個迷你但功能完備的 Kubernetes 集群。現在&#xff0c;我們就像一個擁有了一塊嶄新數字土地的農場主&#xff0c;是時…

華為云Flexus+DeepSeek征文 | 基于ModelArts Studio、DeepSeek大模型和Dify搭建網站智能客服助手

目錄 一、前言 二、ModelArts Studio&#xff08;MaaS&#xff09;介紹與應用場景 2.1ModelArts Studio&#xff08;MaaS&#xff09;介紹 2.2 ModelArts Studio&#xff08;MaaS&#xff09;使用場景 2.3 開通MaaS服務 2.4 開通DeepSeek-V3商用服務 三、華為云Flexus簡介 3.1 …

『uniapp』url攔截屏蔽 避免webview中打開淘寶店鋪自動跳轉淘寶

目錄 分析1. wv.overrideUrlLoading2. 參數 `mode: allow`3. 參數 `match: ^(http|https)://.*`4. 回調函數 `function(e) { console.warn(allow url:, e.url); }`作用:可能的應用場景:核心代碼總結歡迎關注 『uniapp』 專欄,持續更新中 歡迎關注 『uniapp』 專欄,持續更新…

將對透視變換后的圖像使用Otsu進行閾值化,來分離黑色和白色像素。這句話中的Otsu是什么意思?

Otsu 是一種自動閾值化方法&#xff0c;用于將圖像分割為前景和背景。它通過最小化圖像的類內方差或等價地最大化類間方差來選擇最佳閾值。這種方法特別適用于圖像的二值化處理&#xff0c;能夠自動確定一個閾值&#xff0c;將圖像中的像素分為黑色和白色兩類。 Otsu 方法的原…

Zookeeper 和 Kafka 版本與 JDK 要求

Apache Zookeeper 和 Apache Kafka 在不同版本中對 JDK 的要求如下表所示(基于官方文檔和歷史版本記錄整理): 1. Zookeeper 版本與 JDK 要求 Zookeeper 版本要求的最低 JDK 版本說明3.4.x 系列JDK 6生產環境建議用 JDK 8(舊版兼容性強)。3.5.x 系列(3.5.5+)JDK 83.5.0 …

V837s-SDK Telnetd服務連接不上異常解決

目錄 前言 一、檢查 Telnetd 服務是否啟動 二、問題解決 總結 前言 在基于 V837s-SDK 進行開發的過程中,Telnetd 服務連接不上是一個較為常見且棘手的問題。Telnet 作為一種遠程登錄協議,在開發調試時為我們提供了便捷的遠程操作方式。若其連接出現異常,將嚴重影響開發進度…

滑動窗口最大值和最小值

題目&#xff1a; 思路&#xff1a; 窗口進行滑動時&#xff0c;需要快速獲取min和max&#xff0c;因此需要一個結構來保存最值&#xff0c;而不是臨時計算。動態的最值更新容易聯想到單調棧&#xff0c;但是這里需要頻繁增刪元素&#xff0c;因此用雙端隊列&#xff0c;front…

JVM——對象創建全家桶:JVM中對象創建的模式及最佳實踐

引入 在 Java 應用開發中&#xff0c;對象創建是最基礎且高頻的操作&#xff0c;但往往也是性能優化的關鍵切入點。想象一個在線閱讀平臺&#xff0c;每天需要創建數百萬個 Book 對象來統計閱讀數據。如果每個對象的創建過程存在內存浪費或性能瓶頸&#xff0c;累積效應將導致…

VSCode中PHP使用Xdebug

本地環境 windows10php8.2 ntsxdebug v3thinkphp v8 下載Xdebug Xdebug下載地址 從xdebug下載地址,下載最新的xdebug,解壓后將php_xdebug.dll放入php目錄的ext目錄下 配置php.ini [Xdebug] zend_extension php_xdebug xdebug.client_host 127.0.0.1 xdebug.client_port…

金融系統滲透測試

金融系統滲透測試是保障金融機構網絡安全的核心環節&#xff0c;它的核心目標是通過模擬攻擊手段主動發現系統漏洞&#xff0c;防范數據泄露、資金盜取等重大風險。 一、金融系統滲透測試的核心框架 合規性驅動 需嚴格遵循《網絡安全法》《數據安全法》及金融行業監管要求&am…

高考志愿填報管理系統---開發介紹

高考志愿填報管理系統是一款專為教育機構、學校和教師設計的學生信息管理和志愿填報輔助平臺。系統基于Django框架開發&#xff0c;采用現代化的Web技術&#xff0c;為教育工作者提供高效、安全、便捷的學生管理解決方案。 ## &#x1f4cb; 系統概述 ### &#x1f3af; 系統定…

PHP 項目中新增定時任務類型的詳細步驟(以 CRMEB 為例)

1.首先需要在下面文件中增加定時任務類型 2.在app\services\system\crontab\CrontabRunServices類中增加第一步中與定時任務類型同名的方法&#xff0c;注意需要下劃線轉小駝峰 例如定時任務的類型為&#xff1a;order_tick,而在CrontabRunServices類中的方法名稱為&#xff1…

Day27 函數專題2:裝飾器

1.裝飾器的思想&#xff1a;進一步復用 裝飾器&#xff08;Decorator&#xff09;是 Python 中一種強大的編程工具&#xff0c;核心作用是在不修改原函數代碼的前提下&#xff0c;為函數添加額外功能&#xff08;如日志記錄、性能統計、權限校驗等&#xff09;。它充分利用了 …

Qt進階開發:動畫框架的介紹和使用

文章目錄 一、QPropertyAnimation 簡介二、基本用法三、常用屬性和方法四、支持的屬性&#xff08;部分常用&#xff09;五、多個動畫組合六、使用緩和曲線七、狀態機框架 一、QPropertyAnimation 簡介 #include <QPropertyAnimation>QPropertyAnimation 可以讓你在一段…

IP選擇注意事項

IP選擇注意事項 MTP、FTP、EFUSE、EMEMORY選擇時&#xff0c;需要考慮以下參數&#xff0c;然后確定后選擇IP。 容量工作電壓范圍溫度范圍擦除、燒寫速度/耗時讀取所有bit的時間待機功耗擦寫、燒寫功耗面積所需要的mask layer

Flask RESTful 示例

目錄 1. 環境準備2. 安裝依賴3. 修改main.py4. 運行應用5. API使用示例獲取所有任務獲取單個任務創建新任務更新任務刪除任務 中文亂碼問題&#xff1a; 下面創建一個簡單的Flask RESTful API示例。首先&#xff0c;我們需要創建環境&#xff0c;安裝必要的依賴&#xff0c;然后…

filebeat原理架構

Filebeat 是基于 Golang 開發的輕量級日志采集 Agent&#xff0c;其核心架構設計圍繞高效、可靠地采集與轉發日志數據&#xff0c;主要組件和工作流程如下&#xff1a; ?一、核心架構組件? ?輸入 (Inputs)? 負責監控指定的日志源&#xff08;如文件路徑、日志文件&#x…