Photo credit: github.com/ianstormtay…

Introduction of Slate

Slate is a rich text editor development framework developed in TypeScript by Ian Storm Taylor in 2016. Slate is a fully customizable framework for rich text editors. Slate lets you build rich, intuitive editors like Medium, Dropbox Paper, or Google Docs. You can think of it as a pluggable contenteditable implementation of React. It takes inspiration from such libraries as draft.js, Prosemirror, and Quill. Some of SLATE’s more well-known users include GitBook and Wordbird.

Slate online Demo

The characteristics of

  • As first-class citizens, plug-ins can completely modify editor behavior;
  • The data layer is separated from the rendering layer, and updating the data triggers the rendering.
  • Document data is similar to A DOM tree and can be nested;
  • With atomic operation API, support collaborative editing;
  • Use React as the render layer;

Introduction to the SLATE Architecture

Architecture diagram

There are four packages in the SLATE repository:

  1. Slate History: History plugin with undo/redo support
  2. Slate-hyperscript: Ability to create slates using JSX syntax
  3. Slate-react: View layer;
  4. SLATE: Core Editor abstract, defined Editor, Path, Node, Text, Operation and other basic classes, Transforms operations;

slate (model)

The SLATE Package is the heart of SLATE, defining the editor’s data models, the basic operations to manipulate those models, and methods to create editor instance objects.

Interfaces

In the intefaces directory is the data model defined by Slate, which defines editor, Element, text, path, point, range, Operation, location, and so on.

export type Node = Editor | Element | Text

export interface Element {
  children: Node[]
  [key: string]: unknown
}

export interface Text {
  text: string
  [key: string]: unknown
}
Copy the code
  • EditorSlate’s top level node isEditor. It encapsulates all the rich text content of the document, but the most important part to the node is itschildrenProperty, which contains aNodeThe object tree.
  • The Element type contains the children attribute.
  • Text The Text node is the lowest level node in the tree and contains the Text content of the document as well as any format.

Users can extend Node attributes by adding a type field to identify the Node type (Paragraph, ordered list, heading, etc.) or text attributes (italic, bold, etc.). To describe words and paragraphs in rich text.

Path (Path)

A path is the lowest level way to refer to a location. Each path is a simple array of numbers that references a node through the index of each ancestor node in the document tree:

type Path = number[]
Copy the code

Point (Point)

Point contains an offset attribute (offset) for a particular text node:

interface Point {
  path: Path
  offset: number
  [key: string]: unknown
}
Copy the code

Document Range

Document scope is not just a point in a document, it is the content between two points.

interface Range {
  anchor: Point
  focus: Point
  [key: string]: unknown
}
Copy the code

Anchors and focus are established through user interaction. The anchor point is not necessarily in front of the focal point. Just like in the DOM, the ordering of anchors and focal points depends on the direction (forward or backward) of the selection.

Operation

The Operation object is a low-level instruction that Slate uses to change the internal state. As the minimal abstraction of the document, Slate represents all changes as Operation. You can see the source code here.

export interface OperationInterface {
  isNodeOperation: (value: any) = > value is NodeOperation
  isOperation: (value: any) = > value is Operation
  isOperationList: (value: any) = > value is Operation[]
  isSelectionOperation: (value: any) = > value is SelectionOperation
  isTextOperation: (value: any) = > value is TextOperation
  inverse: (op: Operation) = > Operation
}


export const Operation: OperationInterface = {

  .....
  
  isOperation(value: any): value is Operation {
    if(! isPlainObject(value)) {return false
    }

    switch (value.type) {
      case 'insert_node':
        return Path.isPath(value.path) && Node.isNode(value.node)
      case 'insert_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
      case 'merge_node':
        return (
          typeof value.position === 'number' &&
          Path.isPath(value.path) &&
          isPlainObject(value.properties)
        )
      case 'move_node':
        return Path.isPath(value.path) && Path.isPath(value.newPath)
      case 'remove_node':
        return Path.isPath(value.path) && Node.isNode(value.node)
      case 'remove_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
      case 'set_node':
        return (
          Path.isPath(value.path) &&
          isPlainObject(value.properties) &&
          isPlainObject(value.newProperties)
        )
      case 'set_selection':
        return (
          (value.properties === null && Range.isRange(value.newProperties)) ||
          (value.newProperties === null && Range.isRange(value.properties)) ||
          (isPlainObject(value.properties) &&
            isPlainObject(value.newProperties))
        )
      case 'split_node':
        return (
          Path.isPath(value.path) &&
          typeof value.position === 'number' &&
          isPlainObject(value.properties)
        )
      default:
        return false}},... }Copy the code

As you can see from the code above, there are nine Operation types:

  • Insert_node: inserts a Node. containsInsert the location(path),Insert the node(node) information
 case 'insert_node':
        return Path.isPath(value.path) && Node.isNode(value.node)
Copy the code
  • Insert_text: inserts a piece of text, wherenode(path),Insert content(text),The offset(offset)
case 'insert_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
Copy the code
  • Merge_node: Combines two nodes into one, including the Node to be mergednodePath, position, and properties of merged nodes.
case 'merge_node':
        return (
          typeof value.position === 'number' &&
          Path.isPath(value.path) &&
          isPlainObject(value.properties)
        )
Copy the code
  • Move_node: indicates a move_node, which contains move_location (PATH) and move_destination (newPath) information
case 'move_node':
 return Path.isPath(value.path) && Path.isPath(value.newPath)
Copy the code
  • Remove_node: indicates that the Node is to be removed, including the path and Node information
 case 'remove_node':
  return Path.isPath(value.path) && Node.isNode(value.node)
Copy the code
  • Remove_text: removes text, including the node (path), content (text), and offset (offset)
case 'remove_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
Copy the code
  • Set_node: Sets Node properties, including the Node path, Node to be set, and Node properties
case 'set_node':
        return (
          Path.isPath(value.path) &&
          isPlainObject(value.properties) &&
          isPlainObject(value.newProperties)
        )
Copy the code
  • Set_selection: Sets the selection location, including new and old node properties (properties, newProperties)
case 'set_selection':
        return (
          (value.properties === null && Range.isRange(value.newProperties)) ||
          (value.newProperties === null && Range.isRange(value.properties)) ||
          (isPlainObject(value.properties) &&
            isPlainObject(value.newProperties))
        )
Copy the code
  • Split_node: split Node, including the Node path, Node position, and Node properties
case 'split_node':
        return (
          Path.isPath(value.path) &&
          typeof value.position === 'number' &&
          isPlainObject(value.properties)
        )
Copy the code

Transforms

Transforms are auxiliary functions that operate on documents, including selection Transforms, node Transforms, text Transforms, and general Transforms. You can see the source code here.

export constTransforms: GeneralTransforms & NodeTransforms & SelectionTransforms & TextTransforms = { ... GeneralTransforms,// Operate the Operation command. NodeTransforms,// Operate the node instruction. SelectionTransforms,// Operate selection command. TextTransforms,// Manipulate text commands
}

Copy the code

GeneralTransforms is special in that it does not generate Operation, but processes the Operation. Only it can directly modify the model, and other transforms will eventually be transformed into one of the GeneralTransforms.

createEditor

Method to create an Editor instance, returning an Editor instance object that implements the Editor interface. You can see the source code here.

export const createEditor = (): Editor= > {
  const editor: Editor = {
    .....
  }
 
  return editor
}
Copy the code

Update the model

The process of making changes to the Model is divided into the following two steps:

  1. Operations are generated through a series of methods provided by Transforms
  2. Operation Enters the apply process

There are four main steps in the Operation Apply process:

  1. Record the changed dirty area
  2. Transform Operation
  3. Verify the correctness of the model
  4. Trigger the change callback

In the case of Transforms.inserttext, you can see the source here.

export const TextTransforms: TextTransforms = {
  .....
  
   insertText(
    editor: Editor,
    text: string,
    options: { at? : Location voids? : boolean } = {} ):void {
     Editor.withoutNormalizing(editor, () = > {
       const { voids = false } = options
      let { at = editor.selection } = options
      .....
      
      const { path, offset } = at
      if (text.length > 0)
        editor.apply({ type: 'insert_text', path, offset, text })
     })
  }
}
Copy the code

The end of the Transforms. InsertText module generates an Operation of type insert_text and calls the apply method of the Editor instance.

Editor. The apply method

You can see the source code here.

export const createEditor = (): Editor= > {
  const editor: Editor ={
    children: [].operations: [].selection: null.marks: null.isInline: () = > false.isVoid: () = > false.onChange: () = > {},
     apply: (op: Operation) = > {
      for (const ref of Editor.pathRefs(editor)) {
        PathRef.transform(ref, op)
      }

      for (const ref of Editor.pointRefs(editor)) {
        PointRef.transform(ref, op)
      }

      for (const ref of Editor.rangeRefs(editor)) {
        RangeRef.transform(ref, op)
      }

      const set = new Set(a)const dirtyPaths: Path[] = []

      const add = (path: Path | null) = > {
        if (path) {
          const key = path.join(', ')

          if(! set.has(key)) { set.add(key) dirtyPaths.push(path) } } }const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
      const newDirtyPaths = getDirtyPaths(op)

      for (const path of oldDirtyPaths) {
        const newPath = Path.transform(path, op)
        add(newPath)
      }

      for (const path of newDirtyPaths) {
        add(path)
      }

      DIRTY_PATHS.set(editor, dirtyPaths)
      Transforms.transform(editor, op)
      editor.operations.push(op)
      Editor.normalize(editor)

      // Clear any formats applied to the cursor if the selection changes.
      if (op.type === 'set_selection') {
        editor.marks = null
      }

      if(! FLUSHING.get(editor)) { FLUSHING.set(editor,true)

        Promise.resolve().then(() = > {
          FLUSHING.set(editor, false)
          editor.onChange()
          editor.operations = []
        })
      }
    },
    ......
  }
  return editor
}
Copy the code
Conversion coordinates
for (const ref of Editor.pathRefs(editor)) {
    PathRef.transform(ref, op)
  }

  for (const ref of Editor.pointRefs(editor)) {
    PointRef.transform(ref, op)
  }

  for (const ref of Editor.rangeRefs(editor)) {
    RangeRef.transform(ref, op)
  }
Copy the code
dirtyPaths

There are two generation mechanisms for dirtyPaths:

  1. One is before Operation ApplyoldDirtypath
  2. By agetDirthPathsMethods to obtain
const set = new Set(a)const dirtyPaths: Path[] = []
const add = (path: Path | null) = > {
        if (path) {
          const key = path.join(', ')

          if(! set.has(key)) { set.add(key) dirtyPaths.push(path) } } }const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)

for (const path of oldDirtyPaths) {
        const newPath = Path.transform(path, op)
        add(newPath)
}

for (const path of newDirtyPaths) {
        add(path)
}
Copy the code
Perform a change operation
Transforms.transform(editor, op)
Copy the code

Transforms (Editor, OP) Transforms an Operation.

const applyToDraft = (editor: Editor, selection: Selection, op: Operation) = > {
 switch (op.type) {
  ...
  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] ofRange.points(selection)) { selection[key] = Point.transform(point, op)! }}break}... }}Copy the code
Record the operation
editor.operations.push(op)
Copy the code
Data validation
Editor.normalize(editor)
Copy the code
Trigger the change callback
if(! FLUSHING.get(editor)) {// To clear operations
        FLUSHING.set(editor, true)

        Promise.resolve().then(() = > {
           // Clean up
          FLUSHING.set(editor, false)
          // Notify the change callback function
          editor.onChange()
          / / clearing operations
          editor.operations = []
        })
      }
Copy the code

Model data verification

After making changes to the Model, the data of the model needs to be verified to avoid content errors. The data calibration mechanism has two main points, one is the management of dirtyPaths, the other is the withoutNormalizing mechanism.

withoutNormalizing

You can see the source code here.

export const Editor: EditorInterface = {
  .....
  withoutNormalizing(editor: Editor, fn: () = > void) :void {
    const value = Editor.isNormalizing(editor)
    NORMALIZING.set(editor, false)
    try {
      fn()
    } finally {
      NORMALIZING.set(editor, value)
    }
    Editor.normalize(editor)
  }
}
Copy the code
export const NORMALIZING: WeakMap<Editor, boolean> = new WeakMap(a)Copy the code

You can see that this code saves the state of whether data verification is required through WeakMap.

dirtyPaths

DirtyPaths is formed using the editor.apply method

const set = new Set(a)const dirtyPaths: Path[] = []
const add = (path: Path | null) = > {
        if (path) {
          const key = path.join(', ')

          if(! set.has(key)) { set.add(key) dirtyPaths.push(path) } } }const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)

for (const path of oldDirtyPaths) {
        const newPath = Path.transform(path, op)
        add(newPath)
}

for (const path of newDirtyPaths) {
        add(path)
}
Copy the code

The Normalize method of editor.normalize (Editor), which creates a loop that retrieves dirty paths from the leaves of the model tree from the bottom up and calls nomalizeNode to verify that the nodes corresponding to the paths are valid.

export const Editor: EditorInterface = {
  .....
  
  normalize(
    editor: Editor,
    options: { force? : boolean } = {} ):void{... Editor.withoutNormalizing(editor,() = >{...const max = getDirtyPaths(editor).length * 42 // HACK: better way?
      let m = 0

      while(getDirtyPaths(editor).length ! = =0) {
        if (m > max) {
          throw new Error(`
            Could not completely normalize the editor after ${max} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.
          `)}const dirtyPath = getDirtyPaths(editor).pop()!

        // If the node doesn't exist in the tree, it does not need to be normalized.
        if (Node.has(editor, dirtyPath)) {
          const entry = Editor.node(editor, dirtyPath)
          editor.normalizeNode(entry)
        }
        m++
      }
    })
  },
  .....
}
Copy the code

brief

Slate.js plugin architecture

Slate’s plug-in is simply a function that returns an editor instance and modifies the editor behavior by overriding the editor instance method. Just call the plug-in function when the editor instance is created.

const withImages = editor= > {
  const { isVoid } = editor

  editor.isVoid = element= > {
    return element.type === 'image' ? true : isVoid(element)
  }

  return editor
}
Copy the code

You can then use it like this:

import { createEditor } from 'slate'

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

slate-history

Slate-history tracks changes to the state of the SLATE value over time and enables undo and redo capabilities.

withHistory

You can see the source code here.

export const withHistory = <T extends Editor>(editor: T) => { const e = editor as T & HistoryEditor const { apply } = e e.history = { undos: [], redos: [] } e.redo = () => { const { history } = e const { redos } = history if (redos.length > 0) { const batch = redos[redos.length - 1] HistoryEditor.withoutSaving(e, () => { Editor.withoutNormalizing(e, () => { for (const op of batch) { e.apply(op) } }) }) history.redos.pop() history.undos.push(batch) } } e.undo = () => {  const { history } = e const { undos } = history if (undos.length > 0) { const batch = undos[undos.length - 1] HistoryEditor.withoutSaving(e, () => { Editor.withoutNormalizing(e, () => { const inverseOps = batch.map(Operation.inverse).reverse() for (const op of inverseOps) { e.apply(op) } }) }) history.redos.push(batch) history.undos.pop() } } e.apply = (op: Operation) => { const { operations, history } = e const { undos } = history const lastBatch = undos[undos.length - 1] const lastOp = lastBatch && lastBatch[lastBatch.length - 1] const overwrite = shouldOverwrite(op, lastOp) let save = HistoryEditor.isSaving(e) let merge = HistoryEditor.isMerging(e) if (save == null) { save = shouldSave(op, lastOp) } if (save) { if (merge == null) { if (lastBatch == null) { merge = false } else if (operations.length ! == 0) { merge = true } else { merge = shouldMerge(op, lastOp) || overwrite } } if (lastBatch && merge) { if (overwrite) { lastBatch.pop() } lastBatch.push(op) } else { const batch = [op] undos.push(batch) } while (undos.length > 100) { undos.shift() } if (shouldClear(op)) { history.redos = [] } } apply(op) } return e }Copy the code

In the withHistory method, slate-history creates two arrays in the Editor to store the history operations:

e.history = { undos: [].redos: []}Copy the code

They are of type Operation[][], a two-dimensional array of operations, each of which represents a batch of operations (called batch in code) that can contain more than one Operation.

Slate-history inserts undo/redo logic before Operation’s apply process by overwriting the apply method, and then calls the original apply method.

 e.apply = (op: Operation) = > {
    const { operations, history } = e
    const { undos } = history
    const lastBatch = undos[undos.length - 1]
    const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
    const overwrite = shouldOverwrite(op, lastOp)
    let save = HistoryEditor.isSaving(e)
    let merge = HistoryEditor.isMerging(e)

    if (save == null) {
      save = shouldSave(op, lastOp)
    }

    if (save) {
      if (merge == null) {
        if (lastBatch == null) {
          merge = false
        } else if(operations.length ! = =0) {
          merge = true
        } else {
          merge = shouldMerge(op, lastOp) || overwrite
        }
      }

      if (lastBatch && merge) {
        if (overwrite) {
          lastBatch.pop()
        }

        lastBatch.push(op)
      } else {
        const batch = [op]
        undos.push(batch)
      }

      while (undos.length > 100) {
        undos.shift()
      }

      if (shouldClear(op)) {
        history.redos = []
      }
    }

    apply(op)
  }
Copy the code

Undo method

e.undo = () = > {
  const { history } = e
  const { undos } = history

  if (undos.length > 0) {
    const batch = undos[undos.length - 1]

    HistoryEditor.withoutSaving(e, () = > {
      Editor.withoutNormalizing(e, () = > {
        const inverseOps = batch.map(Operation.inverse).reverse()

        for (const op of inverseOps) {
          // If the final operation is deselecting the editor, skip it. This is
          if (
            op === inverseOps[inverseOps.length - 1] &&
            op.type === 'set_selection' &&
            op.newProperties == null
          ) {
            continue
          } else {
            e.apply(op)
          }
        }
      })
    })

    history.redos.push(batch)
    history.undos.pop()
  }
}
Copy the code

Redo method

e.redo = () = > {
    const { history } = e
    const { redos } = history

    if (redos.length > 0) {
      const batch = redos[redos.length - 1]

      HistoryEditor.withoutSaving(e, () = > {
        Editor.withoutNormalizing(e, () = > {
          for (const op of batch) {
            e.apply(op)
          }
        })
      })

      history.redos.pop()
      history.undos.push(batch)
    }
  }
Copy the code

slate-react

The React component of the Slate-React editor renders document data.

Rendering principle

The document data of Slate is a node tree structure similar to DOM. Slate-react generates the children array through recursion of this tree, and finally React renders the components in the children array to the page.

  • Sets the children property of the editor instance
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/slate.tsx#L17

export const Slate = (props: {
  editor: ReactEditor
  value: Descendant[]
  children: React.ReactNode
  onChange: (value: Descendant[]) => void
}) = >{...const context: [ReactEditor] = useMemo(() = > {
    // Set the children attribute of the editor instance to value
    editor.children = value
    .....
  }, [key, value, ...Object.values(rest)])
  
  .....
}
Copy the code
  • The Editable component passes an editor instance to the useChildren Hooks component.
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/editable.tsx#L100

export const Editable = (props: EditableProps) = > {
 const editor = useSlate()
 ....
 return (
  <ReadOnlyContext.Provider value={readOnly}>
   <DecorateContext.Provider value={decorate}>
    <Component
    .
    >
    {useChildren({
            decorations,
            node: editor,
            renderElement,
            renderPlaceholder,
            renderLeaf,
            selection: editor.selection,
          })}
     </Component>
    </DecorateContext.Provider>
   </ReadOnlyContext.Provider>)}Copy the code
  • UseChildren generates the render array and gives it to the React render component.

The useChildren component generates the corresponding ElementComponent or TextComponent according to the type of each Node in the children.

const useChildren = (props: { decorations: Range[] node: Ancestor renderElement? : (props: RenderElementProps) => JSX.Element renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element renderLeaf? : (props: RenderLeafProps) => JSX.Element selection: Range |null
}) = > {
  const decorate = useDecorate()
  const editor = useSlateStatic()
  const path = ReactEditor.findPath(editor, node)
  const children = []
  constisLeafBlock = Element.isElement(node) && ! editor.isInline(node) && Editor.hasInlines(editor, node)for (let i = 0; i < node.children.length; i++) {
    const p = path.concat(i)
    const n = node.children[i] as Descendant
    const key = ReactEditor.findKey(editor, n)
    const range = Editor.range(editor, p)
    const sel = selection && Range.intersection(range, selection)
    const ds = decorate([n, p])

    for (const dec of decorations) {
      const d = Range.intersection(dec, range)

      if (d) {
        ds.push(d)
      }
    }

    if (Element.isElement(n)) {
      children.push(
        <ElementComponent
          decorations={ds}
          element={n}
          key={key.id}
          renderElement={renderElement}
          renderPlaceholder={renderPlaceholder}
          renderLeaf={renderLeaf}
          selection={sel}
        />)}else {
      children.push(
        <TextComponent
          decorations={ds}
          key={key.id}
          isLast={isLeafBlock && i= = =node.children.length - 1}
          parent={node}
          renderPlaceholder={renderPlaceholder}
          renderLeaf={renderLeaf}
          text={n}
        />
      )
    }

    NODE_TO_INDEX.set(n, i)
    NODE_TO_PARENT.set(n, node)
  }

  return children
}
Copy the code

Website example

Custom rendering

Rendering functions renderElement and renderLeaf are passed to the Editable component. The user can decide how to render a Node in the Model by providing these two parameters. Let’s take the richText demo website as an example.

const RichTextExample = () = >{...const renderElement = useCallback(props= > <Element {. props} / >[]),const renderLeaf = useCallback(props= > <Leaf {. props} / >[]),return (
    <Slate editor={editor} value={value} onChange={value= > setValue(value)}>
       ....
      <Editable
        renderElement={renderElement}
        renderLeaf={renderLeaf}
        .
      />
    </Slate>)}Copy the code
 const Element = ({ attributes, children, element }) = > {
  switch (element.type) {
    case 'block-quote':
      return <blockquote {. attributes} >{children}</blockquote>
    case 'bulleted-list':
      return <ul {. attributes} >{children}</ul>
    case 'heading-one':
      return <h1 {. attributes} >{children}</h1>
    case 'heading-two':
      return <h2 {. attributes} >{children}</h2>
    case 'list-item':
      return <li {. attributes} >{children}</li>
    case 'numbered-list':
      return <ol {. attributes} >{children}</ol>
    default:
      return <p {. attributes} >{children}</p>}}Copy the code

This demo extends the Element node’s Type attribute to render the Element as a different tag.

slate-hyperscript

Slate-hyperscript A HyperScript tool for writing SLATE documents using JSX.

conclusion

  1. Slate is currently in beta and some of its APIs are not “finalised” yet;
  2. Partial selection and input events cannot be processed because of the use of ContentedItable;

The resources

Slate Chinese Document

SLATE Architecture design analysis