preface

In the first article Slate source analysis (a) I have analyzed the implementation of Slate core package Slate from the source point of View, that is, Controller and Model design, so this article will take you to learn the implementation of View layer, And summarize some excellent design of Slate, let me feel bright, finally learn how to use Slate for editor development through several examples on the official website.

slate-react

The reason why SLATE chose React as the rendering layer is that the data design concepts between them are actually identical. We know that the core concept of React data streams is immutable. In addition, React, as one of the three most popular frameworks in front end, also has a good ecology. In fact, according to the data in recent years, React enjoys a better market share and is favored by more developers.

To understand Slate-React, you need to have some knowledge of the React API. If you have experience with react projects, you should feel free to read the source code here. And my advice is, if you have a hard time reading SLATE Core, take a good look at the SLATE react source code. Why? Because what Slate-React does is what core in our editor is actually going to do.

Let’s look directly at the source code!

First look at the directory structure:

Directory structure is also relatively simple, more core isHooks, components, pluginThe other directories are the utils and TS type definitions, which you can refer to when talking about specific source code.

Look at the Components directory first.

components

In the previous content, we mentioned that Slate Document abstracts Node, Element, Text and other data models. If we use React to correspond to these models, it must be in the form of components. So files in the Components directory do just that. To better abstract these models, the components directory encapsulates the following components:

  • The Children component is a set of Element and Text components that correspond to the Children of the React parent (props. Children), which is a jsx. Eelment array.
  • The Editable component, which is the component abstraction of the Editable part, is the most core component. Basically, the core event listening and operation of the editor are concentrated on the Editable component.
  • The Element component basically corresponds to the abstraction of Slate Element nodes;
  • The Leaf component, the next layer of the Text component, the Slate module does not have this layer of abstraction, I understand that this layer is added to make it easier to expand the Text;
  • The Slate component is an abstraction of SlateContext, EditorContext, FocusedContext, and other Context providers. This layer was added to make it easier to share common data.
  • StringThe slate-React component, the lowest level component that renders the final text content, is usedspanPackage, if it has bold functions, actually use bold labels for examplestrongThe package isStringComponents;
  • The Text component basically corresponds to the abstraction of the Slate Text node.

React: View editor: View editor: View Editor: View Editor: View Editor: View Editor: View Editor: View Editor: View Editor: View Editor: React

So why Leaf and String abstractions? In fact, as our boss mentioned in another article, if you are interested, you can click on the following link to detail: rich text editor L1 ability research record. It mentioned Slate’s flattening of Model design, so what is flattening? For example, if the content of a text contains multiple effects at the same time: bold, italic, color, etc., if the flattened data looks like this:

[
  {
    type: 'p',
    attrs: {},
    children: [
      {
        type: 'text',
        text: 'editable'
      },
      {
        type: 'text',
        text: 'rich',
        marks: { bold: true }
      },
      {
        type: 'text',
        text: 'rich',
        marks: { bold: true, em: true},},]}]Copy the code

Look directly at two real DOM node examples:Multiple effects look like this:Components in bold and italics. The nice thing about this flattening is that no matter how many layers there are, we just have to focus on the outer layers when we’re working with them. Their structure is fixed, and it’s not difficult to manage because the DOM level gets out of control if you add more effects. It is important to understand this, and in fact our V5 design should be based on this.

Now that you understand the component-level relationships and flat design, let’s focus on the Editable component, which is the core component for implementing the editor. The rest of the components are relatively simple and you can see for yourself.

Let’s look at some of the core type definitions in Editable:

interface RenderElementProps {
  children: any
  element: Element
  attributes: {
    'data-slate-node': 'element'
    'data-slate-inline'? :true
    'data-slate-void'? :truedir? :'rtl'
    ref: any}}interface RenderLeafProps {
  children: any
  leaf: Text
  text: Text
  attributes: {
    'data-slate-leaf': true}}// Editable props type
typeEditableProps = { decorate? :(entry: NodeEntry) = >Range[] onDOMBeforeInput? :(event: Event) = > voidplaceholder? :stringreadOnly? :booleanrole? :stringstyle? : React.CSSProperties renderElement? :(props: RenderElementProps) = >JSX.Element renderLeaf? :(props: RenderLeafProps) = > JSX.Element
  as? : React.ElementType } & React.TextareaHTMLAttributes<HTMLDivElement>Copy the code

The first thing to know is that while Slate-React builds in the default way of rendering Elements and Leaf components, it also exposes the options left for user customization. You can customize renderings with renderElement and renderLeaf as prop properties.

In addition to the renderElement and renderLeaf properties mentioned above, other properties are general, such as readOnly, placeholder, style, etc. In addition to these properties, there are two properties that need to be mentioned. One is the AS property. You can customize the tag that you want to render in the editor area. The default is div. You can change it to an element like textarea.

The other property is decorate. What does that property do? Let’s start with its type. Its type is a function that takes NodeEntry data and returns a Range array. This NodeEntry is actually a type defined by Slate Core in SRC /interfaces/node.ts:

type NodeEntry<T extends Node = Node> = [T, Path]
Copy the code

Is an array of nodes and paths.

As we mentioned earlier, SLATE renders leaf nodes flat, no matter how many effects they have, by “word segmentation,” or splitting strings. For general effects, such as bold, italic scenes, we can easily do the rendering of a custom Leaf. See the examples on our website:

const Leaf = ({ attributes, children, leaf }) = > {
  if (leaf.bold) {
    children = <strong>{children}</strong>
  }

  if (leaf.code) {
    children = <code>{children}</code>
  }

  if (leaf.italic) {
    children = <em>{children}</em>
  }

  if (leaf.underline) {
    children = <u>{children}</u>
  }

  return <span {. attributes} >{children}</span>
}
Copy the code

But in some special scenarios, such as code highlighting, markdown and other special scenarios, how to split, flat effect? The answer is Slate doesn’t care, Slate has no way of knowing how to divide your business in a particular situation, so it exposes a function for you, and you tell Slate how you want to divide it. Having said that, it’s a bit abstract, so let’s look at a concrete example:

const MarkdownPreviewExample = () = > {
  const [value, setValue] = useState<Node[]>(initialValue)
  const renderLeaf = useCallback(props= > <Leaf {. props} / >[]),const editor = useMemo(() = > withHistory(withReact(createEditor())), [])
  const decorate = useCallback(([node, path]) = > {
    const ranges = []

    if(! Text.isText(node)) {return ranges
    }

    const getLength = token= > {
      if (typeof token === 'string') {
        return token.length
      } else if (typeof token.content === 'string') {
        return token.content.length
      } else {
        return token.content.reduce((l, t) = > l + getLength(t), 0)}}const tokens = Prism.tokenize(node.text, Prism.languages.markdown)
    let start = 0

    for (const token of tokens) {
      const length = getLength(token)
      const end = start + length

      if (typeoftoken ! = ='string') {
        ranges.push({
          [token.type]: true.anchor: { path, offset: start },
          focus: { path, offset: end },
        })
      }

      start = end
    }

    return ranges
  }, [])

  return (
    <Slate editor={editor} value={value} onChange={value= > setValue(value)}>
      <Editable
        decorate={decorate}
        renderLeaf={renderLeaf}
        placeholder="Write some markdown..."
      />
    </Slate>)}Copy the code

The above example is from an official markdown example. In this example, Prism is used to segment the strings under markdown syntax, and then the tokens are generated to generate the final Range array return. So how do you use this generated class in SLATE, and actually pass it through layers, and really use it in the Text component:

// This is the line
const leaves = SlateText.decorations(text, decorations)
 
 const key = ReactEditor.findKey(editor, text)
 const children = []

 for (let i = 0; i < leaves.length; i++) {
   const leaf = leaves[i]

   children.push(
      <Leaf
        isLast={isLast && i= = =leaves.length - 1}
        key={` ${key.id}-The ${i} `}leaf={leaf}
        text={text}
        parent={parent}
        renderLeaf={renderLeaf}
      />)}Copy the code

Now come down to the slatetext. exe method, where the SlateText is actually the Text interface defined in Slate Core, and the SRC /interfaces/text.ts file in Slate package:

/** * Get the leaves for a text node given decorations. */

  decorations(node: Text, decorations: Range[]): Text[] {
    let leaves: Text[] = [{ ...node }]

    for (const dec of decorations) {
      const{ anchor, focus, ... rest } = decconst [start, end] = Range.edges(dec)
      const next = []
      let o = 0

      for (const leaf of leaves) {
        const { length } = leaf.text
        const offset = o
        o += length

        // If the range encompases the entire leaf, add the range.
        if (start.offset <= offset && end.offset >= offset + length) {
          Object.assign(leaf, rest)
          next.push(leaf)
          continue
        }

        // If the range starts after the leaf, or ends before it, continue.
        if( start.offset > offset + length || end.offset < offset || (end.offset === offset && offset ! = =0)
        ) {
          next.push(leaf)
          continue
        }

        // Otherwise we need to split the leaf, at the start, end, or both,
        // and add the range to the middle intersecting section. Do the end
        // split first since we don't need to update the offset that way.
        let middle = leaf
        let before
        let after

        if (end.offset < offset + length) {
          constoff = end.offset - offset after = { ... middle,text: middle.text.slice(off) } middle = { ... middle,text: middle.text.slice(0, off) }
        }

        if (start.offset > offset) {
          constoff = start.offset - offset before = { ... middle,text: middle.text.slice(0, off) } middle = { ... middle,text: middle.text.slice(off) }
        }

        Object.assign(middle, rest)

        if (before) {
          next.push(before)
        }

        next.push(middle)

        if (after) {
          next.push(after)
        }
      }

      leaves = next
    }

    return leaves
  },
Copy the code

The function is to generate String nodes for Leaf rendering by means of Text content and corresponding decorations. Decoration can be interpreted as a format, that is, bold, italic, code, etc. It’s just that in a special situation like Markdown, it’s a little peculiar.

For example, if the markdown editor input is “Slate is flexible enough to add that can format text based on its content”, come on down.

Then the final rendering display effect of leaf is: For the text with bold and code effect, it will separate into its own leaf node, and then the bold and code effect will be displayed according to the customized style.

After defining Editable props, let’s take a look at some of the core methods that are implemented internally.

The first is to listen to the beforeInput method:

// Listen on the native `beforeinput` event to get real "Level 2" events. This
  // is required because React's `beforeinput` is fake and never really attaches
  // to the real event sadly. (2019/11/01)
  // https://github.com/facebook/react/issues/11211
  const onDOMBeforeInput = useCallback(
    (
      event: Event & {
        data: string | null
        dataTransfer: DataTransfer | null
        getTargetRanges(): DOMStaticRange[]
        inputType: string
        isComposing: boolean
      }
    ) = > {
      if(! readOnly && hasEditableTarget(editor, event.target) && ! isDOMEventHandled(event, propsOnDOMBeforeInput) ) {const { selection } = editor
        const { inputType: type } = event
        const data = event.dataTransfer || event.data || undefined

        // Omit some code

        switch (type) {
          case 'deleteByComposition':
          case 'deleteByCut':
          case 'deleteByDrag': {
            Editor.deleteFragment(editor)
            break
          }

          case 'deleteContent':
          case 'deleteContentForward': {
            Editor.deleteForward(editor)
            break
          }

          case 'deleteContentBackward': {
            Editor.deleteBackward(editor)
            break
          }

          case 'deleteEntireSoftLine': {
            Editor.deleteBackward(editor, { unit: 'line' })
            Editor.deleteForward(editor, { unit: 'line' })
            break
          }

          case 'deleteHardLineBackward': {
            Editor.deleteBackward(editor, { unit: 'block' })
            break
          }

          case 'deleteSoftLineBackward': {
            Editor.deleteBackward(editor, { unit: 'line' })
            break
          }

          case 'deleteHardLineForward': {
            Editor.deleteForward(editor, { unit: 'block' })
            break
          }

          case 'deleteSoftLineForward': {
            Editor.deleteForward(editor, { unit: 'line' })
            break
          }

          case 'deleteWordBackward': {
            Editor.deleteBackward(editor, { unit: 'word' })
            break
          }

          case 'deleteWordForward': {
            Editor.deleteForward(editor, { unit: 'word' })
            break
          }

          case 'insertLineBreak':
          case 'insertParagraph': {
            Editor.insertBreak(editor)
            break
          }

          case 'insertFromComposition':
          case 'insertFromDrop':
          case 'insertFromPaste':
          case 'insertFromYank':
          case 'insertReplacementText':
          case 'insertText': {
            if (data instanceof DataTransfer) {
              ReactEditor.insertData(editor, data)
            } else if (typeof data === 'string') {
              Editor.insertText(editor, data)
            }

            break
          }
        }
      }
    },
    [readOnly, propsOnDOMBeforeInput]
  )

  // Attach a native DOM event handler for `beforeinput` events, because React's
  // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
  // real `beforeinput` events sadly... (2019/11/04)
  // https://github.com/facebook/react/issues/11211
  useIsomorphicLayoutEffect(() = > {
    if (ref.current && HAS_BEFORE_INPUT_SUPPORT) {
      // @ts-ignore The `beforeinput` event isn't recognized.
      ref.current.addEventListener('beforeinput', onDOMBeforeInput)
    }

    return () = > {
      if (ref.current && HAS_BEFORE_INPUT_SUPPORT) {
        // @ts-ignore The `beforeinput` event isn't recognized.
        ref.current.removeEventListener('beforeinput', onDOMBeforeInput)
      }
    }
  }, [onDOMBeforeInput])
Copy the code

Beforeinput This event is triggered before , < SELECT > or

The second is to listen for the selectionChange method:


  // Listen on the native `selectionchange` event to be able to update any time
  // the selection changes. This is required because React's `onSelect` is leaky
  // and non-standard so it doesn't fire until after a selection has been
  // released. This causes issues in situations where another change happens
  // while a selection is being dragged.
  const onDOMSelectionChange = useCallback(
    throttle(() = > {
      if(! readOnly && ! state.isComposing && ! state.isUpdatingSelection) {const { activeElement } = window.document
        const el = ReactEditor.toDOMNode(editor, editor)
        const domSelection = window.getSelection()

        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)

        if (anchorNodeSelectable && focusNodeSelectable) {
          const range = ReactEditor.toSlateRange(editor, domSelection)
          Transforms.select(editor, range)
        } else {
          Transforms.deselect(editor)
        }
      }
    }, 100),
    [readOnly]
  )

  // Attach a native DOM event handler for `selectionchange`, because React's
  // built-in `onSelect` handler doesn't fire for all selection changes. It's a
  // leaky polyfill that only fires on keypresses or clicks. Instead, we want to
  // fire for any change to the selection inside the editor. (2019/11/04)
  // https://github.com/facebook/react/issues/5785
  useIsomorphicLayoutEffect(() = > {
    window.document.addEventListener('selectionchange', onDOMSelectionChange)

    return () = > {
      window.document.removeEventListener(
        'selectionchange',
        onDOMSelectionChange
      )
    }
  }, [onDOMSelectionChange])
Copy the code

For some Selection operations in the edit area, this method is used to hijack the Selection changes, first get the native Selection, then call toSlateRange to convert the native Selection to SLATE’s custom Range format. Then call the corresponding method to update SLATE’s own selection.

These two methods are more core, we can go to carefully read this part of the source. Of course, there are other methods to listen, such as copy, paste, click, blur, focus, keydown, etc. These are to add more complete functions to the editor, such as shortcut keys, Chinese input method compatibility processing and so on.

In fact, strictly speaking, this part of the source code is relatively easy to read, even if you do not look at the specific implementation of the code, only look inside the method can probably know some of the core design, our new editor development, whether it is listening to beForInput event or selectionchange event, So slate-React encapsulation actually gives us some insight.

hooks

There is not much to say about the hooks section, because it is simply designed to be a wrapper around several Context definitions and hooks that use the Context.

A brief introduction to the role of each hook:

  • UseFocused, as the name suggests, is a hook to get whether the editor is focused.
  • useSelectedThis hook is used to determine if an Element is in the selected statementionSuch a scenario, to choosementionHighlight, there’s an example on the website;
  • UseReadOnly, a hook to get whether the editor’s current state is reabOnly;
  • UseIsomorphicLayoutEffect for compatible SSR cases, from the use of useLayoutEffecct fallback to useEffect a hook, the difference between the two hooks is call time is different, UseLayoutEffect is called faster than useLayoutEffect.
  • UseEditor, useSlate, and useSlateStatic are the three hooks that return an editor instance.

plugin

In fact, methods on the Slate Editor instance can be overridden or added. When we first introduced Slate’s data model definition, we knew that the Editor defined the following types:


export type Editor = ExtendedType<'Editor', BaseEditor>
Copy the code

By wrapping ExtendedType, we can extend the type of Editor. In fact, the Slate plugin design is a special kind of “higher-order function”. We know that higher-order functions take a function argument and return a new function, but the Slate plugin takes an edit instance argument and returns an edit instance object. It is a kind of alternative “higher-order function”.

Let’s look at the slate-history wrapper withHistory:

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) { // 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() } } 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

We added redo and undo methods to the editor instance, and finally overrode Apply to add additional logic to save history data.

Inspired by this example, we can package some complex functions separately in the form of plug-ins, and then introduce corresponding plug-ins when users need them, so as to ensure that the volume of the editor is small enough. In this way, users can also develop their own plug-ins to customize special editor functions.

The withReact plugin is packaged in slate-React:

export const withReact = <T extends Editor>(editor: T) => { const e = editor as T & ReactEditor const { apply, onChange } = e e.apply = (op: Operation) => { const matches: [Path, Key][] = [] switch (op.type) { case 'insert_text': case 'remove_text': case 'set_node': { for (const [node, path] of Editor.levels(e, { at: op.path })) { const key = ReactEditor.findKey(e, node) matches.push([path, key]) } break } case 'insert_node': case 'remove_node': case 'merge_node': case 'split_node': { for (const [node, path] of Editor.levels(e, { at: Path.parent(op.path), })) { const key = ReactEditor.findKey(e, node) matches.push([path, key]) } break } case 'move_node': { // TODO break } } apply(op) for (const [path, key] of matches) { const [node] = Editor.node(e, Path) node_to_key. set(node, key)}} // Set the content copied from the node to the stickboard. E. setFragmentData = (data: DataTransfer) => {// omit the core code} // To get the content from the stickboard and insert it into the editor. DataTransfer) => { const fragment = data.getData('application/x-slate-fragment') if (fragment) { const decoded = decodeURIComponent(window.atob(fragment)) const parsed = JSON.parse(decoded) as Node[] e.insertFragment(parsed) return }  const text = data.getData('text/plain') if (text) { const lines = text.split(/\r\n|\r|\n/) let split = false for (const  line of lines) { if (split) { Transforms.splitNodes(e, { always: True})} e.iserttext (line) split = true}}} // re-onchange, Do batch setState with react methods e.onchange = () => {// COMPAT: React doesn't batch `setState` hook calls, which means that the // children and selection can get out of sync for one render pass. So we // have to use this unstable API to ensure it batches them. (2019/12/03) // https://github.com/facebook/react/issues/14259#issuecomment-439702367 ReactDOM.unstable_batchedUpdates(() => { const onContextChange = EDITOR_TO_ON_CHANGE.get(e) if (onContextChange) { onContextChange() } onChange() }) } return e }Copy the code

Simple and crude, it doesn’t cost much to write a plug-in.

summary

That’s it for the Slate-React section. This part of the source code is relatively easy to understand and read, and I think it will be a great help in learning about Slate and developing an editor based on Slate. Let’s make a simple summary:

  • Since it is developed based on React, the hierarchical abstraction of all document trees in Slate can be encapsulated in the form of components, except for components such as Element and Text, through Leaf and String abstractions with finer degree. The entire data model design and rendering of Slate can be flattened;
  • The core editing area, the Editable component, listens for the key beforeInput and SelecctionChange events for content and selection processing, and then calls Slate’s API for final processing.
  • React Context API and Hook are used to make state management of the editor easier and more convenient.
  • Slate’s plug-in relies on the extensibility of functions and editor instances to reduce the development cost, while the design of the plug-in mechanism allows us to extract complex functions according to our own needs and keep the core package size not too large.

other

In addition to the core source code, Slate has a few other designs that stand out to me, listed here separately.

test

The Slate test is quite unique, but when I first looked at it anyway, my reaction was that it could have been written like this.

Let’s look at a few examples, SLATE/test/interfaces/Text/decorations / *

// end.tsx
import { Text } from 'slate'

export const input = [
  {
    anchor: {
      path: [0].offset: 2,},focus: {
      path: [0].offset: 3,},decoration: 'decoration',},]export const test = decorations= > {
  return Text.decorations({ text: 'abc'.mark: 'mark' }, decorations)
}
export const output = [
  {
    text: 'ab'.mark: 'mark'}, {text: 'c'.mark: 'mark'.decoration: 'decoration',},]// middle.tsx
import { Text } from 'slate'

export const input = [
  {
    anchor: {
      path: [0].offset: 1,},focus: {
      path: [0].offset: 2,},decoration: 'decoration',},]export const test = decorations= > {
  return Text.decorations({ text: 'abc'.mark: 'mark' }, decorations)
}
export const output = [
  {
    text: 'a'.mark: 'mark'}, {text: 'b'.mark: 'mark'.decoration: 'decoration'}, {text: 'c'.mark: 'mark',},]Copy the code

Notice that there are no calls to any assertions related to the test in these files. Actually, all fixtures in the test directory are just fixtures. A: So what is fixtures? Fixtures in the testing domain, the purpose of a test fixture is to ensure that a well-known, fixed environment is used to run tests so that the results are reproducible. Also known as fixtures is the test context. Here are some examples of fixtures:

  • Load the database with a specific set of known data;
  • Copy a specific set of known files;
  • Write input data and setup/creation of hypothetical or simulated objects.

The scenario here in Slate is copying a specific set of known files, so how does that work? Fixtures. Js in the Slate directory with a support folder:

import fs from 'fs'
import { basename, extname, resolve } from 'path'

export const fixtures = (. args) = > {
  let fn = args.pop()
  let options = { skip: false }

  if (typeoffn ! = ='function') {
    options = fn
    fn = args.pop()
  }

  constpath = resolve(... args)const files = fs.readdirSync(path)
  const dir = basename(path)
  const d = options.skip ? describe.skip : describe

  d(dir, () = > {
    for (const file of files) {
      const p = resolve(path, file)
      const stat = fs.statSync(p)

      if (stat.isDirectory()) {
        fixtures(path, file, fn)
      }
      if (
        stat.isFile() &&
        (file.endsWith('.js') ||
          file.endsWith('.tsx') ||
          file.endsWith('.ts')) &&
        !file.endsWith('custom-types.ts') &&
        !file.endsWith('type-guards.ts') &&
        !file.startsWith('. ') &&
        // Ignoring `index.js` files allows us to use the fixtures directly
        // from the top-level directory itself, instead of only children.file ! = ='index.js'
      ) {
        const name = basename(file, extname(file))

        // This needs to be a non-arrow function to use `this.skip()`.
        it(`${name} `.function() {
          const module = require(p)

          if (module.skip) {
            this.skip()
            return
          }

          fn({ name, path, module })
        })
      }
    }
  })
}

fixtures.skip = (. args) = >{ fixtures(... args, {skip: true})}Copy the code

The fixtures file is used to read the fixtures file for a particular module and is then called in the index.js file in the test directory of each package, as shown in SLATE PKG:

import assert from 'assert'
import { fixtures } from '.. /.. /.. /support/fixtures'
import { Editor } from 'slate'
import { createHyperscript } from 'slate-hyperscript'

describe('slate'.() = > {
  fixtures(__dirname, 'interfaces'.({ module }) = > {
    let { input, test, output } = module
    if (Editor.isEditor(input)) {
      input = withTest(input)
    }
    const result = test(input)
    assert.deepEqual(result, output)
  })
  fixtures(__dirname, 'operations'.({ module }) = > {
    const { input, operations, output } = module
    const editor = withTest(input)
    Editor.withoutNormalizing(editor, () = > {
      for (const op of operations) {
        editor.apply(op)
      }
    })
    assert.deepEqual(editor.children, output.children)
    assert.deepEqual(editor.selection, output.selection)
  })
  fixtures(__dirname, 'normalization'.({ module }) = > {
    const { input, output } = module
    const editor = withTest(input)
    Editor.normalize(editor, { force: true })
    assert.deepEqual(editor.children, output.children)
    assert.deepEqual(editor.selection, output.selection)
  })
  fixtures(__dirname, 'transforms'.({ module }) = > {
    const { input, run, output } = module
    const editor = withTest(input)
    run(editor)
    assert.deepEqual(editor.children, output.children)
    assert.deepEqual(editor.selection, output.selection)
  })
})
const withTest = editor= > {
  const { isInline, isVoid } = editor
  editor.isInline = element= > {
    return element.inline === true ? true : isInline(element)
  }
  editor.isVoid = element= > {
    return element.void === true ? true : isVoid(element)
  }
  return editor
}
export const jsx = createHyperscript({
  elements: {
    block: {},
    inline: { inline: true}},})Copy the code

This is where the tests are actually written, and it takes only 50 lines of code to do all the tests in the SLATE package, which I call “data-driven testing.”

WeakMap

There is another thing worth mentioning that slate-React uses a large number of WeakMap data structures. If you have seen the reactivity source code of Vue3.0, you should be familiar with this data structure. The advantage of using WeakMap is that its key value object is a kind of weak reference, so its key cannot be enumerated, which can save memory to some extent and prevent memory leakage.

Slate-react/SRC /utils/weak-maps.ts < WeakMap > slate-react/ SRC /utils/weak-maps.ts < WeakMap > slate-react/ SRC /utils/weak-maps.ts < WeakMap >

/** * Two weak maps that allow us rebuild a path given a node. They are populated * at render time such that after a Render occurs we can always backtrack. * Mapping between a storage Node and an index, a Node and its parent */

export const NODE_TO_INDEX: WeakMap<Node, number> = new WeakMap(a)export const NODE_TO_PARENT: WeakMap<Node, Ancestor> = new WeakMap(a)/** * Weak maps that allow us to go between Slate nodes and DOM nodes. These * are used to resolve DOM event-related Logic into Slate Actions. * Stores mappings between Slate node nodes and real DOM nodes */

export const EDITOR_TO_ELEMENT: WeakMap<Editor, HTMLElement> = new WeakMap(a)export const EDITOR_TO_PLACEHOLDER: WeakMap<Editor, string> = new WeakMap(a)export const ELEMENT_TO_NODE: WeakMap<HTMLElement, Node> = new WeakMap(a)export const KEY_TO_ELEMENT: WeakMap<Key, HTMLElement> = new WeakMap(a)export const NODE_TO_ELEMENT: WeakMap<Node, HTMLElement> = new WeakMap(a)export const NODE_TO_KEY: WeakMap<Node, Key> = new WeakMap(a)/** * Weak maps for storing editor-related state. * Store some state of the editor, such as readOnly */

export const IS_READ_ONLY: WeakMap<Editor, boolean> = new WeakMap(a)export const IS_FOCUSED: WeakMap<Editor, boolean> = new WeakMap(a)export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap(a)export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap(a)/** * Weak map for associating the context 'onChange' context with the plugin

export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () = > void> ()Copy the code

There are four main types of WeakMap, and I have added general annotations for each type, so you can have a look yourself. To learn more about WeakMap, you can click on the following link: WeakMap.

A few examples from the official website

After looking at the source code, we can take a look at creating an editor using SLATE with a few examples from the official website.

richtext

A classic example is to create a simple rich text editor using SLATE. If you want to create a rich text using SLATE, the most important thing is that you need to customize renderElement, renderLeaf, Since Slate-React only provides the default Element(div) and Leaf(span) renderings, if you want to implement lists, bold, titles, etc., you’ll have to customize the special effects layer:

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>}}const Leaf = ({ attributes, children, leaf }) = > {
  if (leaf.bold) {
    children = <strong>{children}</strong>
  }

  if (leaf.code) {
    children = <code>{children}</code>
  }

  if (leaf.italic) {
    children = <em>{children}</em>
  }

  if (leaf.underline) {
    children = <u>{children}</u>
  }

  return <span {. attributes} >{children}</span>
}
Copy the code

The rest of the functionality can be integrated using SLATE’s API depending on your needs. For example, the example on the official website also implements functions such as switching bold:

const toggleMark = (editor, format) = > {
  const isActive = isMarkActive(editor, format)

  if (isActive) {
    Editor.removeMark(editor, format)
  } else {
    Editor.addMark(editor, format, true)}}Copy the code

It’s handy to call SLATE’s wrapped removeMark and addMark methods directly. The example on the official website uses about 200 lines of code to implement bold, italic, code, underline, list, quote, title, etc. for rich text, so overall it’s pretty simple.

Markdown editor

Another example of this is the Markdown editor, which uses Editable’s decorate prop to parse markdown syntax:

const decorate = useCallback(([node, path]) = > {
    const ranges = []

    if(! Text.isText(node)) {return ranges
    }

    const getLength = token= > {
      if (typeof token === 'string') {
        return token.length
      } else if (typeof token.content === 'string') {
        return token.content.length
      } else {
        return token.content.reduce((l, t) = > l + getLength(t), 0)}}const tokens = Prism.tokenize(node.text, Prism.languages.markdown)
    let start = 0

    for (const token of tokens) {
      const length = getLength(token)
      const end = start + length

      if (typeoftoken ! = ='string') {
        ranges.push({
          [token.type]: true.anchor: { path, offset: start },
          focus: { path, offset: end },
        })
      }

      start = end
    }

    return ranges
  }, [])
Copy the code

This is the core of implementing the Markdown editor, which I won’t cover here because I’ve already covered it. Then, according to the effect of the word segmentation, such as title, bold, code block implementation of the specified style:

const Leaf = ({ attributes, children, leaf }) = > {
  return (
    <span
      {. attributes}
      className={css`
        font-weight:The ${leaf.bold && 'bold'};
        font-style:The ${leaf.italic && 'italic'};
        text-decoration:The ${leaf.underlined && 'underline'}; The ${leaf.title &&
          css`
            display: inline-block;
            font-weight: bold;
            font-size: 20px;
            margin: 20px 0 10px 0;
          `}
        ${leaf.list &&
          css`
            padding-left: 10px;
            font-size: 20px;
            line-height: 10px;
          `}
        ${leaf.hr &&
          css`
            display: block;
            text-align: center;
            border-bottom: 2px solid #ddd;
          `}
        ${leaf.blockquote &&
          css`
            display: inline-block;
            border-left: 2px solid #ddd;
            padding-left: 10px;
            color: #aaa;
            font-style: italic;
          `}
        ${leaf.code &&
          css`
            font-family: monospace;
            background-color: #eee;
            padding: 3px;
          `}
      `}
    >
      {children}
    </span>)}Copy the code

That is, to implement the Leaf layer ourselves, and in fact to implement the code highlighting, the key step is word segmentation.

The image editor

When it comes to content editors, images are definitely a must, and the official website also provides a demo to insert images. First, the image is a void element. In short, the content area is not editable, so we need to rewrite the isVoid method of the editor instance. Second, we need to deal with the paste situation when copying the image content. ReactEditor extends insertData, which is used to manipulate pasted content, with a withImage plugin that extends both methods:

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

  // Nodes such as images and videos are void elements, that is, their regions are not editable. We can think of such elements as black boxes
  editor.isVoid = element= > {
    return element.type === 'image' ? true : isVoid(element)
  }

  // If the file contains the image file, it needs to be processed separately
  editor.insertData = data= > {
    const text = data.getData('text/plain')
    const { files } = data

    if (files && files.length > 0) {
      for (const file of files) {
        const reader = new FileReader()
        const [mime] = file.type.split('/')

        if (mime === 'image') {
          reader.addEventListener('load'.() = > {
            const url = reader.result
            insertImage(editor, url)
          })

          reader.readAsDataURL(file)
        }
      }
    } else if (isImageUrl(text)) {
      insertImage(editor, text)
    } else {
      insertData(data)
    }
  }

  return editor
}
Copy the code

The other is the implementation of the custom render Element and insertImage methods, which can be looked at briefly:

const insertImage = (editor, url) = > {
  const text = { text: ' ' }
  // Image is treated as an element, so it must have children, and children must have a child element
  const image = { type: 'image', url, children: [text] }
  Transforms.insertNodes(editor, image)
}

const Element = props= > {
  const { attributes, children, element } = props

  switch (element.type) {
    case 'image':
      return <ImageElement {. props} / >
    default:
      return <p {. attributes} >{children}</p>}}Copy the code

Isn’t it easy? After reading the examples on the official website, it will be much clearer to actually implement the menu functions we want to do in V5.

At the end

At this point, the Slate source code parsing is basically over. Of course, this article does not introduce too much detail source code details, or from a macro point of view to select some important design analysis, but also hope to give you to learn Slate source code and understand some help Slate design.

Finally, a conclusion is made. From the content of this paper, we can get the following conclusions and inspiration:

  • The whole design architecture of Slate can be regarded as MVC architecture. Model and Controller are encapsulated in Slate, the core package of Slate, and Slate-React is encapsulated in View layer by React.
  • In the definition of its data model, it contains core Node nodes such as Editor, Element and Text, which together form Slate Document tree. Through Path, Point, Range and other data structures to form the editor’s internal selection of the abstract, so that we can more conveniently according to the selection of Operation;
  • Operations defines the lowest level operation for Slate to modify the content of the editor. There are altogether 9 types of Operations, of which 6 types correspond in pairs to facilitate undo Operations. For ease of use, Operations based encapsulates higher-level Operations such as Transforms and Commands;
  • React is used as the rendering layer in View layer. The React component and powerful API make it easier to customize the business editor.
  • Slate’s powerful plug-in mechanism makes it easy for us to extend the functions of the editor, or rewrite some instance methods according to specific scenarios to customize our own editor. The official Slate-Histroy function is split out through the plug-in mechanism.
  • In addition to its powerful functional design, the writing of its tests is a data-driven way of thinking, by defining the input and output of each test file and the test logic to be executed, and finally running through a unified Runnner, greatly provides the efficiency of writing tests, which is refreshing. The introduction of WeakMap also helps prevent unexpected memory leaks.