Introduction of SLATE

To quote the official documentation: Slate is a lightweight, fully customizable (by implementing a series of plug-ins) framework for controlled development of rich text editors

What to make of this sentence? After a period of study, I tried to summarize my understanding of Slate

First of all, Slate is based on contenteditable. I have worked on a simple rich text editor based on native Contenteditable. From my experience in the development of rich text editor based on Contenteditable, I feel that the development of rich text editor based on Contenteditable has the following pain points:

1. Uncontrollability of documents

Document. execommand, range. insertNode, range. surroundContents, deletions, newlines, input characters, etc., are implemented by browsers, so it is difficult to expect or control documents to render uniformly within the same operation/command

2. Uncontrollability of selection/range and complexity of boundary judgment

In rich text editor developers, we often use selection and range to determine the cursor position or change the content. However, due to the uncontrollability of the document, the results of obtaining the selection and range of the cursor in the same position may be very different, and a selection may contain multiple nodes. It is very troublesome to judge the node boundary

Slate is designed to address these native development pain points. Slate is a controllable rich text editor development framework, and its controllable features depend on two main points (i.e., the above two points)

1. Controllable documents

In Slate, a document tree object is maintained, and our real DOM is rendered by this tree. This document tree object must conform to Slate’s rules, or constraints, for documents to achieve predictability, uniformity, and control

2. Controllable constituency/range

In Slate, it also maintains a selection object, which is processed and converted from the native Selection object. Similarly, to make the selection controlled, Slate will process the Selection object into a selection object that complies with Slate constraints

So in summary, Slate is a rich text editor development framework that maintains a mapping object of the real DOM, a selection object that simulates the real selection, and defines a set of constraint rules to keep documents and selections under control

So you can understand why Slate is a lightweight, customizable, plug-in rich text editor framework, because Slate only implements the most basic and core content of text editing (DOM tree, selection), with these basic features, developers need rich text manipulation functions. You can customize it as a plug-in (because at the end of the day, all rich text does is manipulate selection and modify the DOM tree)

Slate architecture

First let’s take a look at the Slate source structure

As mentioned earlier, Slate is a lightweight framework. Slate code contains very few modules. In fact, there are only two core modules in Slate code: Slate and Slate-React

1. The SLATE module

You can see that the SLATE is divided into two parts: Interface and Transform

(1) interface

The interface folder defines a few interface functions (the public API exposed by SLATE). These functions can be function functions, such as fetching a node by path, iterating over document nodes, or checking functions, such as checking whether a node is of type Node or text. As with any framework or language, familiarity with these apis is essential if you want to develop on Slate. For full details, see the documentation on the official website

(2) the transform

All changes to the Editor will be called by the transform method. There are three separate files in the transform, which correspond to node. The change to text Selection confirms that all text operations are actually dom and Selection operations, and in SLATE, dom objects are mapped by node and Text nodes, except for those three files. There is also a general file in which the editor object is actually modified by calling the transform function

2. The SLATE – react module

React is SLATE’s view layer. Here are the components and hooks modules

1. com ponents module

This folder contains all of the components used in the view layer. In fact, from this folder we can probably see the overall view layer architecture of Slate. I will analyze it in detail below

2. The hooks module

This folder contains the React ContextProvider component, which is convenient for transferring data, with the exception of the use-children file, which is used to dynamically render DOM objects and uses the breadth-first traversal method to build components. The dom document tree object maintained internally in the Editor needs to be rerendered with each update

The overall architecture

According to Slate source code here, I first roughly divide Slate application into view layer and core layer, see the following figure

The transform method in the general. Ts file of the transform module will be used to modify all operations on the editor. As a text editor, undo/redo is essential. To preserve snapshots before modifying them, Slate uses immer as a data persistence library. See transform for example

CreateDraft Creates a snapshot, applyToDraft modifies the object (newProperties in the OP object stores the data to be modified), and finishDraft completes the modification and generates a new object

The view layer

The view layer above is fairly straightforward, but there are three more important functions in the view layer, which are used to dynamically render the chidren of the document root object editor, respectively, UseChildren renderElement, renderLeaf, then we take a look at these three functions is how to work with

First we look at the concrete implementation of useChildren

If it’s an Element, add the ElementComponent to the array. If it’s an Element, add the TextCompoenent to the array. Then look at the ElementComponent

As you can see, ElementComponent will call useChildren as soon as it comes in. This is a recursive nested rendering process that returns TextCompoenent. You can see that renderElement is called in the ElementComponent

To get an idea of the ElementComponent rendering structure, take a look at TextCompoenent’s source code

Iterate through all the Leaf nodes, render them in an array, which is the Leaf component here, and renderLeaf is called from the Leaf component

The children of renderLeaf is a String component, and the String component will render either a TextString or a ZeroWidthString, and as you can see from the name, return a TextString if there is text

The summary is as follows:

(The layers highlighted in red are controlled by renderElement or renderLeaf, we can pass in custom functions to render, and don’t forget to render children to ensure the integrity of Slate documentation.)

Core layer

For studying kernel code, I prefer to make a simple sample, and then find a point I want to study, and follow up from there to test and research. Fortunately, Slate has a rich sample, from clone project on Github to local, after installing it, To see this, run YARN Run start,

InsertBreak and setSelection source code interpretation

At the beginning, I chose the simplest plain text sample for study. In this sample, I studied two points: one is how SLATE deals with text newlines; Second, how does SLATE handle when we change focus (cursor position)

1. Wrap text

Because it is a text editor, so our various logical entry functions are basically callback button events and mouse events, find the source code, you can see in the editor listening to various events

To wrap text, press The Enter key, trigger keyDown, and then you can trace from there, summarising the process as follows:

  • Press the Enter key

  • Event check

    Almost all events are event-checked at the time they are raised to determine whether the event should be handled or discarded (three conditions)

    • 1. ReadOnly // The current editing area cannot be readOnly
    • 2. HasEditableTarget // Event trigger elements should be editable
    • 3. IsEventHandled // If there are more than one event handler, execute the other function first. Finally determine the return event. IsDefaultPrevented () | | event. IsPropagationStopped () the results to decide whether to process events in the current function
  • The KeyDown event is triggered

    Splitblock in KeyDown is a fallback option when beforeInput events are not supported

  • Triggers the beforeInput event

    InsertLineBreak: insertLineBreak: insertLineBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak: insertBreak We call the static insertBreak method of the Editor, pass in the current instance of Editor, and finally call the insertBreak method of the Editor instance

  • Transforms.splitNodes

    Transforms.splitNodes(editor, { always: True}) cut the node (call the specific conversion type (node, text, selection, splitNode), Transforms (editor, op). The key logic is as follows:

    For (const [node, path] of editor. levels(Editor, {// true, voids, })) { let split = false if (path.length < highestPath.length || path.length === 0 || (! voids && Editor.isVoid(editor, node))) { break } const point = beforeRef.current! const isEnd = Editor.isEnd(editor, point, path) if (always || ! beforeRef || ! Editor.isEdge(editor, point, path)) { split = true const properties = Node.extractProps(node) editor.apply({ type: 'split_node', path, position, properties, }) } position = path[path.length - 1] + (split || isEnd ? 1:0)}Copy the code
  • Editor. Apply (split_node)

    Once passed in from Transforms. SplitNodes, editor.apply is called. This passes an important argument, op, that stores the information needed to change the editor’s state, such as path, offset, properties, newProperties, etc. This parameter will be passed until the state is changed, and this will queue our onchange call.

    if (! FLUSHING.get(editor)) { FLUSHING.set(editor, true) Promise.resolve().then(() => { FLUSHING.set(editor, false) editor.onChange() editor.operations = [] }) }Copy the code
  • Transforms. Transform (Editor, op) It’s time to start executing the real status update

    • CreateDraft (editor.children) editor.selection && createDraft(editor.Selection) // Saves a snapshot of the document and selection
    • ApplyToDraft (Editor, Selection, op) // Update status
    • FinishDraft (editor.children) finishDraft(Selection)// A new Editor object is generated after the status update is completed
  • ApplyToDraft (split_node)

    Transforms The main logic is for applyToDraft, which sets up nine action types: Insert_node, merge_node, move_node, remove_node, set_node, split_node, insert_text, remove_text, set_selection, All of our operations are converted to one of these nine. The newline in this case is split_node. Let’s look at the implementation of split_node

    Const {path, position, properties} = op // Const {path, position, properties} = op The inside of the path is an array, each element is an index, an empty array said root element, the first element (index) which is under the root node elements, the second element (index), said the child element under which child, so on), for example, for example, have so a document object tree

    {
        type: 'paragraph',
        children: [
        	{ text: 'This is editable ' },
        	{ text: 'rich', bold: true },
        ],
    },
    {
    	type: 'paragraph',
    	children: [
    		{ text: 'bold', bold: true },
    	],
    },
    Copy the code

    Path[0,1] {text: ‘rich’, bold: true},[1,0] {text: ‘bold’, bold: true}, Path[0] {type: ‘paragraph’,children: [{text: ‘This is editable ‘}, {text: ‘rich’, bold: true}]}, the special path[] represents the root node of the editor instance

    Const node = node. get(editor, path) // Find the node corresponding to the path const parent = node.parent (editor, Const index = path[path.length-1] // Get the index of the current node. Let newNode: Retrieve if (text.istext (node)) {const before = node.text.slice(0, Descendant); Position) const after = Node.text.slice (position) //position is the offset of the current cursor, Node. text = before // Change the current point of text to the segment before the cursor newNode = {// the segment after the cursor will form a new text node... (properties as Partial<Text>), text: After,}} else {// For non-text nodes, the logic is the same, except that this is an Element node. Const before = Node.children. Slice (0, position) const after = node.children.slice(position) node.children = before newNode = { ... (properties as Partial<Element>), children: After,}} parent.children.splice(index + 1, 0, newNode) // // We are done modifying the document tree, If (selection) {for (const [point, key] of Range.points(selection)) { selection[key] = Point.transform(point, op)! }}Copy the code
  • finishDraft

    editor.children = finishDraft(editor.children)
    if (selection) {
    	editor.selection = isDraft(selection) ? (finishDraft(selection) as Range) : selection
    } else {
    	editor.selection = null
    }
    Copy the code

    You can see that both children and Selection are applied to the Editor instance at this point

  • Onchange callback

    After finishDraft is executed, the logic of the main thread is finished. The onchange callback is put into the event queue under Apply, and the browser will fetch the onchange event from the event loop queue and re-render react

    1. Dom re-rendering:

    Bind the document tree value to the component when creating the editor, and set the callback function to update the setValue document book

    2. The selection of updates

    The LayoutEffect hooks are used on the Editable component to synchronize updates to selection, which are updated with each rendering

Key algorithm diagram:

The key algorithm here is how to cut nodes and form a new tree of document objects. Here is a simple example to sort out the algorithm idea, as shown below:

The document object tree is as follows:

{
    type: 'paragraph',
    children: [
    	{ text: 'This' }
    ],
},
Copy the code

The view is as follows:

Press Enter at Path=[0,0], offset=3

(Green boxes indicate new ones and red boxes indicate deleted ones)

The key point is, how do you find the text node from the last split and put it into the next new paragraph, the key line at the end of the loop

After each cycle, it is followed by a new position, which is the position of the new node generated after the last cut

Focus of 2.

I’m not going to expand on what I’ve already said, but I’m going to add the concept of Location

Type the Location Path = | Point | Range (the Location can be a Path, Point, any of the Range)

  1. Path: is an indexed array, as mentioned above
  2. Point: is the focal position, consisting of Path and offset. To determine a position, you need to know not only which node it is in, but also the offset position within this node
  3. A Range of two points, one starting Point, one ending Point

Let’s jump right into the source code analysis

  • Operate the mouse to switch the focus

  • The onSelectionChange event is raised, where the logic is throttled

    throttle(() => { if ( ! readOnly && ! state.isComposing && ! state.isUpdatingSelection && ! State. IsDraggingInternally) {const root = ReactEditor. FindDocumentOrShadowRoot (editor) / / to get original document object const { activeElement } = root const el = ReactEditor.toDOMNode(editor, Editor) // Editor instance converted to dom object const domSelection = root.getSelection() // Get native selection if (activeElement === el) { state.latestElement = activeElement IS_FOCUSED.set(editor, true) } else { IS_FOCUSED.delete(editor) } if (! domSelection) { return Transforms.deselect(editor) } const { anchorNode, FocusNode} = domSelection const anchorNodeSelectable = hasEditableTarget(Editor, anchorNode) || isTargetInsideVoid(editor, anchorNode) const focusNodeSelectable = hasEditableTarget(editor, focusNode) || isTargetInsideVoid(editor, FocusNode) // Determine the editable selection if (anchorNodeSelectable && focusNodeSelectable) {const range = ReactEditor. ToSlateRange (editor, domSelection, {exactMatch: Select (editor, range) // Set the selection to else {Transforms. Deselect (Editor)}}}, 100)Copy the code

    The key logic here is to convert ReactEditor. ToSlateRange, the real native selection into SLATE’s range object, and the whole setSelection logic

    const { exactMatch } = options const el = isDOMSelection(domRange) ? domRange.anchorNode : Domrange.startcontainer // There are two objects for natively locating the cursor: Selection objects and range objects, Let anchorNode let anchorOffset let focusNode let focusOffset let isCollapsed if (EL) {if (isDOMSelection(domRange)) { anchorNode = domRange.anchorNode anchorOffset = domRange.anchorOffset focusNode = domRange.focusNode focusOffset = domRange.focusOffset if (IS_CHROME && hasShadowRoot()) { isCollapsed = domRange.anchorNode === domRange.focusNode && domRange.anchorOffset === domRange.focusOffset } else { isCollapsed = domRange.isCollapsed } } else { anchorNode = domRange.startContainer anchorOffset = domRange.startOffset focusNode = Domrange.endcontainer focusOffset = domrange.endoffset isCollapsed = domrange.collapsed} // Obtain the start node of the current focus, the offset within the end node, Terminating node, the offset position within the terminating node, District whether fold} if (anchorNode = = null | | focusNode = = null | | anchorOffset = = null | | focusOffset = = null) {throw new Error( `Cannot resolve a Slate range from DOM range: ${domRange}` ) } const anchor = ReactEditor.toSlatePoint( editor, [anchorNode, anchorOffset], ExactMatch) //toSlatePoint converts native point objects toSlatePoint objects if (! anchor) { return null as T extends true ? Range | null : Range } const focus = isCollapsed ? anchor : ReactEditor. ToSlatePoint (Editor, [focusNode, focusOffset], exactMatch) // If the selection is collapsed, then the end node and the start node overlap, assign the start node to the end node if (! focus) { return null as T extends true ? Range | null : Range } return ({ anchor, focus } as unknown) as T extends true ? Range | null : RangeCopy the code

    In fact, the main logic of toSlateRange is in toSlatePoint, because range is composed of points. When two PONits are calculated and combined, they become range

    const [nearestNode, nearestOffset] = exactMatch ? domPoint : NormalizeDOMPoint (domPoint)// Determine the matching pattern. If it is an exact match, use domPoint directly. If it is not, serialize domPoint. Normalize a DOM point so that it always refers to a text node. The serialized node points to a text node. The serialized node points to a text node until the node is not an Element node. Return while (isDOMElement(node) && node.childNodes.length) {const I = isLast? node.childNodes.length - 1 : 0 node = getEditableChild(node, i, isLast ? 'backward' : Closest ('[data-slate-leaf]')} Closest to usps ('[data-slate-node="text"]) Const window = ReactEditor. GetWindow (Editor) const window = ReactEditor. GetWindow (Editor) const window = ReactEditor. Don't need to render to the view const range = window. The document. The createRange () range. The setStart (textNode, 0) range. SetEnd (nearestNode, nearestOffset) const contents = range.cloneContents() const removals = [ ...Array.prototype.slice.call( contents.querySelectorAll('[data-slate-zero-width]') ), ... Array. Prototype. Slice. Call (contents. QuerySelectorAll (' [contenteditable = false] ')),] / / zero wide character and an edit characters are not in the inside of the offset, Removals. ForEach (el => {el! .parentNode! .removeChild(el) }) // COMPAT: Edge has a bug where Range.prototype.toString() will // convert \n into \r\n. The bug causes a loop when slate-react // attempts to reposition its cursor to match the native position. Use // textContent.length instead. // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/ offset = contents.textContent! Const slateNode = ReactEditor. ToSlateNode (Editor, textNode!) const slateNode = ReactEditor. ELEMENT_TO_NODE is a map object that uses ref to reference the real DOM node in the rendering process. Set each node in the tree and the real DOM into the map as you traverse the SLATE document tree), Const path = ReactEditor. FindPath (Editor, slateNode) // Pathreturn {path, offset } as T extends true ? Point | null : PointCopy the code
  • Transforms.select(editor, range)

    select(editor: Editor, target: Location): void { const { selection } = editor target = Editor.range(editor, target) if (selection) { Transforms.setSelection(editor, target) return } if (! Range.isRange(target)) { throw new Error( `When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${JSON.stringify(target)}` ) } editor.apply({ type: 'set_selection', properties: selection, newProperties: target, }) },Copy the code
  • Editor. Apply (set_selection)

  • Transforms.transform(editor, op)

  • ApplyToDraft (set_selection)

    case 'set_selection': { // debugger; const { newProperties } = op if (newProperties == null) { selection = newProperties } else { if (selection == null) { if (! Range.isRange(newProperties)) { throw new Error( `Cannot apply an incomplete "set_selection" operation properties ${JSON.stringify( newProperties )} when there is no current selection.` ) } selection = { ... newProperties } } for (const key in newProperties) { const value = newProperties[key] if (value == null) { if (key === 'anchor' || key === 'focus') { throw new Error(`Cannot remove the "${key}" selection property`) } delete selection[key] } else {selection[key] = value}} else {selection[key] = value}}Copy the code
  • finishDraft

  • Onchange callback

InsertText source code interpretation

I realized the function of a code block with a rich text editor written natively before, and now I realize it with SLATE

The functions are as follows:

1. Insert code block (Pre tag)

2. Line newlines inside code blocks do not cut off nodes, but insert \n

3. Press CTRL + Q to exit the code block. If the Pre tag does not have a sibling node, insert a new node

The main implementation logic and API are as follows

1. Customize listening events

2. Customize renderElement

2. Insert code block editor.setnode ()

2. A new line inside the code block editor.inserttext

Instead of executing a default event if the current focus is within a code block, the insertText API inserts a newline character after the current text

3. Determine whether the current focus is within the code block

5. Exit the code block

Reverse can control the direction of the traversal. In this case, we need to start from the leaf nodes, set reverse to true, and use the match function to specify which nodes we want to match. Transforms. Select (Editor,nextSilbingNode[1])) using the Transforms API for the next sibling of the node. Transforms. Collapse (Editor,{edge:’end’})

If there are no sibling elements, line wrap is used directly with editor.insertBreak (Editor), changing the node tag to the normal Paragraph tag

In this sample, I looked at the execution flow of insertText

  • insertText

    if (marks) { const node = { text, ... marks } Transforms.insertNodes(editor, node)} else { Transforms.insertText(editor, Text)}// Call the insertNodes method if there is a marks state in the editor, because marks is a style for marking text (blod, italic, etc.), and these styles require new tags to wrap the textCopy the code
  • Transforms.insertText(editor, text)

  • editor.apply({ type: ‘insert_text’, path, offset, text })

  • Transforms.transform(editor, op)

  • ApplyToDraft (insert_text)

    case 'insert_text': { const { path, offset, text } = op if (text.length === 0) break const node = Node.leaf(editor, path) const before = node.text.slice(0, offset) const after = node.text.slice(offset) node.text = before + text + after if (selection) { for (const [point, key] of Range.points(selection)) { selection[key] = Point.transform(point, op)! }} break}// The logic here is simple, the original text is cut and splice with the new textCopy the code
  • finishDraft

  • Onchange callback

Summary and Feeling

In the three examples above, one command is selected from each of the three parts of the Transform module. In fact, a set of workflow or template of SLATE can be roughly summarized:

​ command–editor.command—Transforms.command—editor.apply(command)—Transforms.transform—applyToDraft—finishDraft

Plug-ins are SLATE’s first-class citizens, because plug-ins are the encapsulation of various commands, and the core workflow of SLATE is delivered through commands

At the same time, the API exposed by SLATE is very rich, clean and fine grained, which can meet most of our needs for adding, deleting, changing and searching nodes and selection

Slate plugin mechanism

Editor can be regarded as a view layer and core into a bridge of communication, of course, we also can call directly the core API, but this is not a good method, on the one hand, will reduce the maintainability of the project, at the same time not good reuse repeated logic, good method is: will a set of operating logic encapsulated into a function as command mounted to the editor

All plug-ins manipulate the Editor object. Slate provides a createEditor function to create a basic Editor object. It says that you can encapsulate a set of operation logic into a function and mount it as a command in the Editor. This is an extension of the Editor object

So the best practice for Slate development is to package a set of operation logic into a function and mount it in the Editor as a command, using Slate’s plug-in mechanism instead of calling the core layer API all over the code

The example analysis

Because the createEditor function returns an Editor object, our plug-in can nest multiple extensions to The Editor by combining functions as long as it also returns an Editor object

So what does createEditor, withReact, withHistory do

In Slate, the editor object represents the root object of the document, using Path: [] The objects retrieved are the Editor object, children and Selection are the two key objects, one describing the mapping of the real DOM, one holding information about the current cursor position, and some other basic command functions

As you can see, withReact simply overwrites or adds command functions in the Editor

Also, withHistory adds a history state object, two redo and undo commands, and finally returns an Editor object, so we can create an Editor with both withHistory, withReact, CreateEditor Command assigned to editor by the three functions

At the end

The above is my summary of a simple introduction to SLATE learning. I hope I can join the research and development team of this open source project, continue to learn and communicate with you, and do a challenging thing with a group of like-minded people