(More content, please be patient to read)

introduce

Slate.js provides the underlying capabilities of a rich text editor for the Web and does not come out of the box, requiring a lot of secondary development on your own.

It is this feature that makes it very extensible, and many people who want to customize the development editor will choose to do secondary development based on Slate.js.

Slate.js can meet the needs of users all over the world to customize development and expand functions, indicating that it has strong and perfect underlying capabilities and can highly abstract the common API of the editor. That’s where I’m going to use it, read it, and learn from it.

Using Slate.js requires a lot of secondary development, as you can see from looking directly at some of the demos and source code.

  • The demo source github.com/ianstormtay… All the files
  • The demo presentationwww.slatejs.org/examples/ri…, includingrichtextThat is, the file name in demo

PS: Slate. js is a L1 editor in terms of implementation principle (please refer to PPT sharing of Language editor for details). If it’s just the user, you don’t have to worry about this.

Link to original textJuejin. Cn/post / 691712…Reproduced without permission)

The basic use

Slate.js is a React based rendering mechanism that needs to be redeveloped for other frameworks.

The simplest editor

NPM install SLATE Slate-React and write the following code to generate a simple editor.

import React, { useState, useMemo } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

function BasicEditor() {
    // Create a Slate editor object that won't change across renders.
    // Editor is an object instance of the editor
    const editor = useMemo(() = > withReact(createEditor()) ,[])

    // Initializes value, the contents of the editor. The data format is similar to that of VNode, which is detailed below.
    const initialValue = [
        {
            type: 'paragraph'.children: [{text: 'I am a line of text'}}]]const [value, setValue] = useState(initialValue)

    return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <Slate
                editor={editor}
                onChange={newValue= > setValue(newValue)}
            >
                <Editable/>
            </Slate>
        </div>)}Copy the code

But this editor has nothing, you can type in text, and then you can get the content through onchange.

PS: The editor variable in the above code is important. It is an object instance of the editor, and you can use its API or continue to extend other plug-ins.

renderElement

In the demo above, type in a few lines of text and look at the DOM structure, and you’ll see that each line is represented by a div.

But text is best displayed using the P tag. Some semantic standards, so it is good to expand other types, such as UL OL table quote image.

Slate.js provides renderElement that lets us customize our rendering logic, but don’t worry. Rich text editors, of course, don’t just have text, they have a lot of data types that need to be rendered, so they rely on this renderElement.

For example, you need to render text and code blocks. The initialValue at this point should also contain data for the code block. The code is as follows, with comments.

import React, { useState, useMemo, useCallback } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

// First, define two basic components for rendering text and code blocks, respectively
// Default text paragraph
function DefaultElement(props) {
    return <p {. props.attributes} >{props.children}</p>
}
// Render the code block
function CodeElement(props) {
    return <pre {. props.attributes} >
        <code>{props.children}</code>
    </pre>
}

function BasicEditor() {
    // Create a Slate editor object that won't change across renders.
    const editor = useMemo(() = > withReact(createEditor()), [])

    // Initialize value
    const initialValue = [
        {
            type: 'paragraph'.children: [{ text: 'I am a line of text'}},// Second, the data contains code blocks
        {
            type: 'code'.children: [{ text: 'hello world'}}]]const [value, setValue] = useState(initialValue)

    // Third, define a function to determine how to render
    // Define a rendering function based on the element passed to `props`. We use
    // `useCallback` here to memoize the function for subsequent renders.
    const renderElement = useCallback(props= > {
        switch(props.element.type) {
            case 'code':
                return <CodeElement {. props} / >
            default:
                return <DefaultElement {. props} / >}}, [])return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <Slate
                editor={editor}
                value={value}
                onChange={newValue= > setValue(newValue)}
            >
                <Editable renderElement={renderElement}/>{/* renderElement */}</Slate>
        </div>)}Copy the code

Then, you can see the rendering effect. Of course, this is still a more basic editor, in addition to the ability to input text, what function is not.

renderLeaf

The most common text operations in rich text editors are bold, italic, underline, and stripeout. How does Slate.js implement this? Let’s ignore the manipulation and look at rendering first.

RenderElement is a rendering Element, but it does not turn off the lower-level Text Element, so Slate. js provides renderLeaf. Used to control text formatting.

import React, { useState, useMemo, useCallback } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

// First, define a component for rendering text styles
function Leaf(props) {
    return (
        <span
            {. props.attributes}
            style={{
                fontWeight: props.leaf.bold ? 'bold' : 'normal',
                textDecoration: props.leaf.underline ? 'underline': null/* Other styles can be added... * /}} >{props.children}</span>)}function BasicEditor() {
    // Create a Slate editor object that won't change across renders.
    const editor = useMemo(() = > withReact(createEditor()), [])

    // Initialize value
    const initialValue = [
        {
            type: 'paragraph'.// Second, the style of the text is stored here
            children: [{text: 'I am' }, { text: 'row'.bold: true }, { text: 'text'.underline: true}}]]const [value, setValue] = useState(initialValue)

    const renderLeaf = useCallback(props= > {
        return <Leaf {. props} / >
    }, [])

    return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <Slate
                editor={editor}
                value={value}
                onChange={newValue= > setValue(newValue)}
            >
                <Editable renderLeaf={renderLeaf}/>{/* Reader */}</Slate>
        </div>)}Copy the code

RenderElement and renderLeaf do not conflict. They can be used together, and usually should be. RenderElement is not used here for simplicity.

Rich text operation

[Warning] This part is a bit troublesome and involves a lot of slate.js apis. This article will only demonstrate one or two, leaving the rest to the documentation and demo.

This is just how to render, not how to style. Using bold and code blocks as an example, customize a command set as follows: Some of the apis designed into the Editor and Transforms can be guessed just by looking at their names.

import { Transforms, Text, Editor } from 'slate'

// Define our own custom set of helpers.
const CustomCommand = {
    // Is the current cursor text bold?
    isBoldMarkActive(editor) {
        const [ match ] = Editor.nodes(editor, {
            match: n= > n.bold === true.universal: true
        })
        return!!!!! match },// Is the current cursor text a code block?
    isCodeBlockActive(editor) {
        const [ match ] = Editor.nodes(editor, {
            match: n= > n.type === 'code'
        })
        return!!!!! match },// Set/unbold
    toggleBoldMark(editor) {
        const isActive = CustomCommand.isBoldMarkActive(editor)
        Transforms.setNodes(
            editor,
            { bold: isActive ? null : true },
            {
                match: n= > Text.isText(n),
                split: true})},// Set/cancel the code block
    toggleCodeBlock(editor) {
        const isActive = CustomCommand.isCodeBlockActive(editor)
        Transforms.setNodes(
            editor,
            { type: isActive ? null : 'code' },
            { match: n= > Editor.isBlock(editor, n) }
        )
    }
}

export default CustomCommand
Copy the code

Then write your own menu bar, define bold and code block buttons.

return (<div>
  <div style={{ background: '#f1f1f1', padding: '3px 5px' }}>
      <button
          onMouseDown={event= > {
              event.preventDefault()
              CustomCommand.toggleBoldMark(editor)
          }}
      >B</button>
      <button
          onMouseDown={event= > {
              event.preventDefault()
              CustomCommand.toggleCodeBlock(editor)
          }}
      >code</button>
  </div>
  <Slate editor={editor} value={value} onChange={changeHandler}>
      <Editable
          renderElement={renderElement}
          renderLeaf={renderLeaf}
      />
  </Slate>
</div>)
Copy the code

After all this time, we can only implement a very simple demo, which is really urgent. But it is what it is. It can’t be helped.

Custom event listening

Simply listen for DOM events in the

component. Some shortcuts can be set in this way.

    <Slate editor={editor} value={value} onChange={value= > setValue(value)}>
      <Editable
        onKeyDown={event= >{// 'CTRL + B' bold shortcut key if (! event.ctrlKey) return if (event.key === 'b') { event.preventDefault() CustomCommand.toggleBoldMark(editor) } }} />
    </Slate>
Copy the code

Insert the picture

Rich text editor, the most basic is graphic editing, picture is the most basic content. But pictures are something completely different from text.

Text is editable, selectable, typeable, and very flexible to edit, whereas images are not expected to be as flexible as text and are best handled the way we want them to be.

Not just images, but code blocks (which are also word-processed, but not in real life), tables, videos, etc. These are commonly called “cards”. If an editor is an ocean, text is the ocean and cards are islands. The water is flexible, but the island is closed and does not mix with the water.

You can refer to the demo directly to insert images. As you can see, images cannot be edited like text after being inserted. As you can see from the source, the images are rendered with contentEditable={false} set

const ImageElement = ({ attributes, children, element }) = > {
  const selected = useSelected()
  const focused = useFocused()
  return (
    <div {. attributes} >
      <div contentEditable={false}>
        <img
          src={element.url}
          className={css`
            display: block;
            max-width: 100%;
            max-height: 20em;
            box-shadow:The ${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'}; `} / >
      </div>
      {children}
    </div>)}Copy the code

Slate.js has also created an Embeds demo, which uses video as an example and is a bit more complex than the image demo.

The plug-in

Slate.js provides the basic capabilities of the editor, and if not, it provides a plug-in mechanism for users to extend themselves. In addition, with the standard plug-in mechanism, you can also form your own community, you can directly download and use third-party plug-ins. WangEditor, our open source rich text editor, will soon extend the plugin mechanism to include more features.

Developing a plug-in

Plug-in development is simply an extension and decoration to the Editor. You can fully return to your imagination about what you want to do. The demo provided by Slate.js is also implemented by plug-ins, with very powerful functions.

const withImages = editor= > {
  const { isVoid } = editor
  editor.isVoid = element= > {
    return element.type === 'image' ? true : isVoid(element)
  }
  return editor
}
Copy the code

A single plug-in is very convenient to use, the code is as follows. But if there are many plug-ins, want to use together, it will be a little ugly, such as a(b(c(d(e(editor))))))

import { createEditor } from 'slate'

const editor = withImages(createEditor())
Copy the code

Third party plug-ins available

You can find some results by searching “SLATE plugins” on Github or a search engine. For example,

  • Github.com/udecode/sla…
  • Github.com/ianstormtay…

The core concept

The data model

Review the initialization data in the code above.

const initialValue = [
    {
        type: 'paragraph'.children: [{ text: 'I am'.bold: true }, { text: 'row'.underline: true }, {text: 'words'}]}, {type: 'code'.children: [{ text: 'hello world'}]}, {type: 'image'.children: [].url: 'xxx.png'
    }
    // Other extensions continue
]
Copy the code

Data types and relationships

The slate.js data model emulates the DOM constructor, and its structure is very simple and easy to understand.

The entire edit area is an Editor instance, and below it is a single-layer list of Element instances. The children of Element are the list of Text instances. Element can be rendered using renderElement, and Text can be rendered using renderLeaf.

An Element does not have to be a single layer. You can extend an Element under an Element instance.

Block and inline

Elements are block by default, and Text is inline. However, there are times when an Element needs to be inline, such as text links.

As you can see from the demo source, you can change link to inline Element by extending the plugin.

const withLinks = editor= > {
  const { isInline } = editor

  editor.isInline = element= > {
    return element.type === 'link' ? true : isInline(element)
  }
  
  // Other code omitted......
}
Copy the code

The tag is then output directly during rendering.

const renderElement = ({ attributes, children, element }) = > {
  switch (element.type) {
    case 'link':
      return (
        <a {. attributes} href={element.url}>
          {children}
        </a>
      )
    default:
      return <p {. attributes} >{children}</p>}}Copy the code

The render tag isInline, which is the browser’s default rendering logic, so why override the editor.isInline method? isInline by default in the browser. This isInline for the view, and then you need to synchronize it to the model, so you need to change editor.isInline.

Selection and the Range

Selection and Range are the core apis of L1 Rich text editors. Used to find the selection range, which DOM nodes it contains, where it starts, and where it ends.

Slate.js encapsulates the native API and provides its own API for users to use for secondary development.

Slate.js breaks down the native API in more detail into Path Point Range Selection, which is documented here. They are layered, simple and easy to understand, and cleverly designed.

Path is an array used to find a specific node in the component tree. For example, in the tree below, red nodes can be represented as [0, 1, 0] if found.

On the basis of Path, Point further determines the offset corresponding to the selection, that is, the specific text selected. Offset is also available in the native API, and the concept is the same.

const start = {
  path: [0.0].offset: 0,}const end = {
  path: [0.0].offset: 15,}Copy the code

Range refers to a Range, which is not the same as selection. It only refers to a Range and has no functional significance. Since it is a range, it can be represented by two points, one beginning and one ending.

interface Range {
  anchor: Point
  focus: Point
}
Copy the code

Finally, Selection is really just a Range object, denoting the Selection as follows.

Selection can contain multiple ranges in the native API, but Slate.js does not support a one-to-one relationship. Selection and Range are fine in most cases, except for special situations, such as CTRL +d multiple Selection in vscode.

const editor = {
  selection: {
    anchor: { path: [0.0].offset: 0 },
    focus: { path: [0.0].offset: 15}},// Other attributes...
}
Copy the code

Slate.js is an important API for Selection and Range as an underlying capability provider for a rich text editor, and it also provides detailed API documentation for users to refer to.

Commands and operations

  • Commands high-level are extensible and implemented internally using Transforms APIS
  • Operations Low-level The atom cannot be extended
  • A command can contain multiple operations

commands

Commands are a rewrite of the native execCommand API. Because native apis have been declared obsolete in MDN, and the API is really not very friendly, see the old blog Why ContentEditable is Terrible.

Commands are commands that operate on rich text, such as inserting text, deleting text, etc. Slate.js has some common commands built in. See the Editor API.

Editor.insertText(editor, 'A new string of text to be inserted.')
Editor.deleteBackward(editor, { unit: 'word' })
Editor.insertBreak(editor)
Copy the code

Slate.js strongly recommends that you install your own commands, which extend your own helper API to the Editor. This can be done internally using powerful Transforms APIS.

constMyEditor = { ... Editor,insertParagraph(editor) {
    // ...}},Copy the code

operations

To understand the significance of Operations, it is also necessary to understand the OT algorithm to achieve collaborative editing by multiple people.

Executing command generates operations, which take effect via editor.apply(operation).

Operation is not extensible, all types refer here, and is atomic. With Operation, it is easy to support undo and multi-person collaborative editing.

editor.apply({
  type: 'insert_text'.path: [0.0].offset: 15.text: 'A new string of text to be inserted.',
})

editor.apply({
  type: 'remove_node'.path: [0.0].node: {
    text: 'A line of text! ',
  },
})

editor.apply({
  type: 'set_selection'.properties: {
    anchor: { path: [0.0].offset: 0}},newProperties: {
    anchor: { path: [0.0].offset: 15}},})Copy the code

The type of operation is basically the same as that of the Quill editor Delt, which conforms to the basic type of the OT algorithm. But operation here is better suited to a rich text tree structure.

PS: If you don’t consider collaborative editing, don’t worry about this part, it’s all packaged inside Slate.js.

Normalizing data verification

Rich text editor, content is complex, nested, and not enumerable. Therefore, some rules are needed to ensure that the data format is standardized, and this is data validation.

There are many ways in which data formats can be messed up, and the most common are

  • Complex, repetitive, continuous text formatting operations, such as bold, italic, set color, set links, line breaks, etc… It makes the data format very complicated
  • Paste. Copy from various web pages, copy from Word, copy from Excel, copy from wechat QQ… That is, the source of the pasted data cannot be determined, so the pasted data cannot be guaranteed to be in uniform format, and confusion is very normal.

Slate.js has some built-in validation rules to ensure the most basic data formats

  • Each Element must contain at least one descendant Text node. That is, if an Element is empty, an empty Text child node is given by default.
  • Two consecutive texts are merged into one Text if they have the same property.
  • A block node can only contain another block node, or an inline node and a Text. That is, a block node cannot contain both a block node and an inline node.
  • Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array. If this is the case, An empty text node will be added to correct this to be in complience with the constraint.
  • The top-level Element can only be a block

Slate. js also allows users to customize validation rules, such as writing a plugin to validate: the Element from paragraph can only be Text or inline Element.

import { Transforms, Element, Node } from 'slate'

const withParagraphs = editor= > {
  const { normalizeNode } = editor

  editor.normalizeNode = entry= > {
    const [node, path] = entry

    // If the element is a paragraph, ensure its children are valid.
    if (Element.isElement(node) && node.type === 'paragraph') {
      for (const [child, childPath] of Node.children(editor, path)) {
        if(Element.isElement(child) && ! editor.isInline(child)) { Transforms.unwrapNodes(editor, {at: childPath })
          return}}}// Fall back to the original `normalizeNode` to enforce other constraints.
    normalizeNode(entry)
  }

  return editor
}
Copy the code

conclusion

Slate.js is a powerful rich text editor framework that needs a lot of secondary development.

There is much more to Web rich text editors and slate.js. These include those mentioned in this paper, such as historical records and collaborative editing; It also includes what is not mentioned, such as internal implementation procedures, immutable data, etc. There is breadth and depth. I will write articles to share in the future.

There was a time when the “Rich text editor for the Web is the ceiling of front-end technology” joke was made, with some absolute, but with some truth.