Background and Current situation

WangEditor is working on a new version that aims to be a more stable and concise open source rich text editor.

  • deprecateddocument.execCommandSeparate the View from the Model
  • Future support for collaborative editing
  • Consider extensibility and plug-in mechanisms to extend more complex functionality
  • The technical pursuit of programmers, has been walking on the road to torment themselves ~

I’ve done some work before

  • Develop a demo from scratch, and investigate practical questions from SLATE Quill proseMirror, documented in this article
  • Try using SLATE (independent of React) as a demo
  • Decided to use SLATE as the kernel (independent of React) and started trying to design a new version (WIP source is not open)

Although some functions have been implemented, but it is still in the process of technical design, API and code structure will continue to be adjusted.

Why slate.js?

I wanted to start kernel development from 0, but I changed my mind. Especially when you look at all the proseMirror code.

Why not build your own kernel?

  • Our core goal is to create a stable, easy-to-use, highly scalable open source product, not to be geeky and build wheels.
  • Self – research cost is very high, time-consuming, bug. And if you want to do, the basic pressure on me – and my personal energy is not guaranteed, now idle, maybe two days busy
  • PS: If you have deep technical pursuit, you can start from interpreting source code, writing demo, to see personal energy and ability.

Compare to other open source products

Quill inappropriate

  • It’s a full-fledged editor, the kernel, UI, plugins, etc are already in place, the ecosystem is big, and there’s not much we can do with it
  • The latest version of Quill was released two years ago
  • Quill’s Delta has a significant learning cost

ProseMirror inappropriate

  • It’s not a stripped-down core, it’s a lot of stuff, a lot of code, a lot of packages
  • The design is abstract and the code is complex and difficult to read

SLATE would be appropriate

  • The design is simple and easy to understand, less code, small package size (only 60KB GZIP 17KB), easy to read source code
  • Everything is extensible based on the plug-in mechanism
  • React by default, but you can customize the View layer through secondary development (already done)

Being based on Slate.js doesn’t mean it’s easy

  • Don’t rely on React, rewrite the View
  • Design various operation functions, such as tooltips, tooltips, and so on
  • Design extensibility, comprehensive plug-in
  • Develop various rich text editor features, especially complex features such as tables, code highlighting

For example, I design a whole car based on an off-the-shelf engine, which is not easy.

The overall design

Split multiple packages based on LERNA

The underlying dependence

  • Slate.js – Editor kernel
  • Snbbdom-view model separation, using VDOM render view
  • 【 Attention 】 Public dependencies, use peerDependencies properly, avoid repeated packaging!!

core

Technically, it’s called view Core. It is based on the slate.js kernel and completes the UI part of the editor.

  • Editor – Defines some of SLATE’s APIS for DOM UI
  • Text-area-input area DOM rendering, DOM events, selection synchronization,
  • Formats – Enter rendering rules for different data in an area, such as how to render bold, colors, images, lists, etc. Extensible registration.
  • Menus, including toolbar, hover menu, Tooltip, right-click menu, DropPanel, Modal, etc. Extensible registration.

Core itself doesn’t have any real function. Modules are used to extend formats, menus, plugins, etc., to define specific functions. Large and complex table functions, small and simple bold functions, need to be handled in this way.

basic modules

Summarized some common, simple, basic modules. Such as:

  • Simple-style – Bold, italic, underline, strikeout, inline code
  • Color – Text color and background color
  • Header – Sets the title
  • The other…

Some complex modules need to be separately split into a package, such as table and code-block. Anyway, it is built based on LerNA, and the extended package is relatively simple.

editor

You bring in core, you bring in modules, and then you build an editor based on the user configuration.

The design of the core

As I mentioned, core should technically be called view-core. Its core role:

  • Hijack user input and changes, using the Editor API to trigger Model changes (or selection changes)
  • Editor change updates to the DOM in real time, keeping the DOM and model (or Selection) in sync
  • Define an extension mechanism (which itself has no real function) that extends modules to implement specific functions

Hijack user input with beforeInput

Beforeinput is a relatively new DOM event that was not universally supported last year. For now, it seems to be supported by major browsers, especially since FireFox 87 was released, see Caniuse. For browsers that do not yet support keyDown /keypress compatibility, it will have some impact, but fortunately, the percentage of users is small. (PS: IE11 is no longer supported in the new version)

Listen for beforeInput events, and then execute different Editor apis based on different InputTypes.

  // Prevent default behavior, hijacking all rich text input
  event.preventDefault()

  // according to event.inputtype beforeInput
  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) {
        // This handles pasting of impure text (such as HTML image files). For pasting plain text, use the paste event
        DomEditor.insertData(editor, data)
      } else if (typeof data === 'string') {
        Editor.insertText(editor, data)
      }
      break}}Copy the code

Selection synchronization

DOM Selection changes trigger document.addEvenListener(‘selectionchange’, fn) Editor Selection changes trigger the editor.onChange event. So you can synchronize with each other.

UpdateView Synchronizes views

Editor onChange triggers a view update to keep the View and model in sync in real time. There are two steps:

  • Generate vNodes based on the model
  • patch vnode

The second step is simple, we use snabbdom.js to do vDOM rendering. Vue 2.x used this library, older, more stable. Also, it supports JSX with simple configuration, making it very, very easy to write.

The key is the first step, generating a VNode. The following code is simplified to make it easier to read by splitting the logic into two pieces: renderElement and renderText

/** * Snabbdom vnode * is generated from SLATE node@param node slate node
 * @param index node index in parent.children
 * @param parent parent slate node
 * @param editor editor
 */
function node2Vnode(node: SlateNode, index: number, parent: SlateAncestor, editor: IDomEditor) :VNode {
  if (node.type && node.text) {
    throw new Error(` no node can not have both 'type' and 'text' prop! A node cannot have both type and text attributes!The ${JSON.stringify(node)}
         `)}let vnode: VNode
  if (Element.isElement(node)) {
    // element
    vnode = renderElement(node as Element, editor)
  } else {
    // text
    vnode = renderText(node as Text, parent, editor)
  }

  return vnode
}
Copy the code

renderElment

The renderElement simplifies the code as follows

// renderElement simplifies code
function renderElement(elemNode: SlateElement, editor: IDomEditor) :VNode {
  // Generate a vNode function based on type
  const { type, children = [] } = elemNode
  let genVnodeFn = getRenderFn(type)

  const childrenVnode = isVoid
    ? null // void render elem without passing children
    : children.map((child: Node, index: number) = > {
        return node2Vnode(child, index, elemNode, editor)
      })

  / / create a vnode
  let vnode = genVnodeFn(elemNode, childrenVnode, editor)

  return vnode
}
Copy the code

In this code, node.type is used to get genVnodeFn, which is the function used by the current node to generate a VNode. This function is coded as follows, which means that by default the node is rendered using either

or
.
/** * default render elem *@param elemNode elem
 * @param editor editor
 * @param children children vnode
 * @returns vnode* /
function defaultRender(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
) :VNode {
  const Tag = editor.isInline(elemNode) ? 'span' : 'div'
  const vnode = <Tag>{children}</Tag>
  return vnode
}

/** * Get renderElement * from elemnode. type@param type elemNode.type
 */
function getRenderFn(type: string) :RenderElemFnType {
  const fn = RENDER_ELEM_CONF[type]
  return fn || defaultRender
}
Copy the code

Of course, with defaults, there are custom extensions (important). For example, the simplest case, where type is ‘paragraph’, can be extended as follows:

function renderParagraph(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
) :VNode {
  const vnode = <p>{children}</p>
  return vnode
}

export const renderParagraphConf = {
  type: 'paragraph'.renderFn: renderParagraph,
}
Copy the code

Finally, the vNode of the current node can be generated according to genVnodeFn. The child nodes, whether element or text, are assigned to the node2Vnode function above.

renderText

RenderText simplifies the code as follows

// renderText simplifies code
function renderText(textNode: SlateText, parent: Ancestor, editor: IDomEditor) :VNode {
  // Generate leaves vnodes - each text node can be split into several leaf nodes
  const leavesVnode = leaves.map((leafNode, index) = > {
    // Text and style
    const isLast = index === leaves.length - 1
    let strVnode = genTextVnode(leafNode, isLast, textNode, parent, editor)
    strVnode = addTextVnodeStyle(leafNode, strVnode)
    // Generate each leaf node
    return <span data-slate-leaf>{strVnode}</span>
  })

  // Generate a text vnode
  const textId = `w-e-text-${key.id}`
  const vnode = (
    <span data-slate-node="text" id={textId} key={key.id}>{leavesVnode /* a text may contain multiple leaves */}</span>
  )

  return vnode
}
Copy the code

The most critical of these is the rendering of text styles, the addTextVnodeStyle function, which is also extensible. For example,

function addTextStyle(node: SlateText | SlateElement, vnode: VNode) :VNode {
  const { bold, italic, underline, code, through } = node
  let styleVnode: VNode = vnode

  if (bold) {
    styleVnode = <strong>{styleVnode}</strong>
  }
  if (code) {
    styleVnode = <code>{styleVnode}</code>
  }
  if (italic) {
    styleVnode = <em>{styleVnode}</em>
  }
  if (underline) {
    styleVnode = <u>{styleVnode}</u>
  }
  if (through) {
    styleVnode = <s>{styleVnode}</s>
  }

  return styleVnode
}
Copy the code

In short, both element and text rendering can be extended through modules. This not only ensures support for multiple formats and functions, but also separates the code logic into modules.

Menus support a variety of menus

Menu should be an abstraction based on which to generate various types of menus:

  • Traditional toolbar
  • Select the hover menu after text and elements
  • Right-click menu, etc.
  • Also support various types: Button Select, etc

The current definition of menu is as follows:

interface IOption {
  value: string
  text: stringselected? :booleanstyleForRenderMenuList? : { [key:string] :string } // Render menu list style
}

export interface IMenuItem {
  title: string
  iconSvg: string

  tag: string // 'button' / 'select'showDropPanel? :boolean // Click 'button' to display dropPaneloptions? : IOption[]// select -> optionswidth? :number // Set button width

  getValue: (editor: IDomEditor) = > string | boolean
  isDisabled: (editor: IDomEditor) = > booleanexec? :(editor: IDomEditor, value: string | boolean) = > void // Button click or select changegetPanelContentElem? :(editor: IDomEditor) = > Dom7Array // In the showDropPanel case, get content elem
  
  // Other capabilities may continue to be expanded, but try to keep them concise and readable
}
Copy the code

The following menus are supported based on the preceding definitions. Others are still being designed and developed.

The editor API and plugins

Refer to the Slate-React source code to define some global commands that are useful when rendering the DOM.

Encapsulates a SLATE plugin to add/rewrite apis

This is a plugin that comes with Core. You can also continue to extend other plug-ins, that is, within The Module.

The design of the module

Core doesn’t have any basic functions. All functions are extended by modules. Module can be expanded by:

  • menu
  • formats
    • renderElement
    • addTextStyle
  • Plugin (SLATE plugin)

Eventually, each module can output such a data format to register with Core

interface IRenderElemConf {
  type: string
  renderFn: RenderElemFnType
}
interface IMenuConf {
  key: string
  factory: () = >IMenuItem config? : { [key:string] :any}}// module Data format
export interfaceIModuleConf { addTextStyle? : TextStyleFnType renderElems? :Array<IRenderElemConf> menus? :Array<IMenuConf> editorPlugin? :<T extends Editor>(Editor: T) => T // Some changes may be made to the format later, but the overall scope will not change much}Copy the code

Extension addTextStyle

As mentioned above, this is to style the text node. The code for bold, italic, underline, etc. Here is the code for font color and background color:

/** * Text style - font color/background color *@param node slate node
 * @param vnode vnode
 * @returns vnode* /
export function addTextStyle(node: SlateText | SlateElement, vnode: VNode) :VNode {
  const { color, bgColor } = node
  let styleVnode: VNode = vnode

  if (color) {
    addVnodeStyle(styleVnode, { color }) // Add styles to vNode
  }
  if (bgColor) {
    addVnodeStyle(styleVnode, { backgroundColor: bgColor }) // Add styles to vNode
  }

  return styleVnode
}
Copy the code

PS: The tricky part here is code highlighting.

Extension renderElement

Define a function for Node. type that enters SLATE node and outputs vNode.

// render h1
function renderHeader1(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
) :VNode {
  const vnode = <h1>{children}</h1>
  return vnode
}
export const renderHeader1Conf = {
  type: 'header1'.renderFn: renderHeader1,
}
Copy the code

To expand the menu

Menu is an abstraction whose interface format IMenuItem is defined above.

button menu

Such as bold, underline, etc

A quick explanation:

  • getValueFunction determines the current state, such as bold.
  • isDisabledCheck whether menu is currently available. For example, bold is not available in a code block.
  • execThat is, the method executed when the Menu button is clicked. Pay attention totag = 'button'It’s a button type.

select menu

To set a title, it needs to define options.

showDropPanel

To set colors, dropPanel is required. The menu requires a getPanelContentElem to define the dropPanel content DOM.

Currently, there are only three types, with the possibility of extending other types later.

menu config

Some menus require some configuration, such as color, font, line height, and so on. In V4, all configuration is centralized globally in editor.config. The new version will be split:

  • The default configuration is defined when menu is extended, not globally
  • Configure unified storage ineditor.getConfig().menuConf[key]Can be modified by users
// Define the default configuration when extending menu in module
Editor.getconfig ().menuconf [key
// Users can use editor.getconfig ().menuconf [key] = {... } Modifies the configuration of a menu

{
  key: 'color'.factory() {
    return new ColorMenu('color'.'Text color'.'
      
       ... 
      ')},config: {
    colors: ['# 000000'.'# 262626'.'# 595959'.'#8c8c8c'.'#bfbfbf'.'#d9d9d9',}}Copy the code

Extend the plugin

A number of features require plug-in functionality to override the Editor API, such as:

  • Header – When the end is newline, the next line is inserted<p>Instead of the default header
  • List – Two consecutive line breaks at the end, jumps out of list, inserts<p>
  • Code-block – Two consecutive line breaks at the end, out of code-block, insert<p>
  • Table – line break in cell; Insert a blank line between two tables if they are next to each other. Etc.
  • Paste – After pasting text, insert text
  • And a lot more… (The more complex the function, the more need plugin support)

The following is a simple plug-in in the Header Module

import { Editor, Transforms } from 'slate'

function withHeader<T extends Editor> (editor: T) :T {
  const { insertBreak } = editor
  const newEditor = editor

  // Rewrite insertbreak-header to insert paragraph at the end of the carriage return
  newEditor.insertBreak = () = > {
    const [match] = Editor.nodes(newEditor, {
      match: n= > {
        const { type = ' ' } = n
        return type.startsWith('header') // Matches node.type with a header
      },
      universal: true,})if(! match) {// No match was found
      insertBreak()
      return
    }

    // Insert a null p
    const p = { type: 'paragraph'.children: [{ text: ' ' }] }
    Transforms.insertNodes(newEditor, p, { mode: 'highest'})}// Return editor, important!
  return newEditor
}
Copy the code

The follow-up plan

There are many things that need to be done and integrated into the design. Such as:

  • Other basic functions
  • paste
  • upload
  • Hover menu, ToolTip, right-click menu, Modal
  • Combing user Configurations
  • Combing the API
  • Unit testing/E2E testing
  • CI/CD
  • i18n
  • Write development documents, user usage documents

It will take about 3 weeks to complete the basic functions and finalize the technical solution and expansion form. The rest can be replenished slowly.

In addition, there is a lot of testing to be done. I plan to test all 3000+ issues accumulated in the current Github Issues in the new version before releasing. These issues are accumulated wealth. Rich text editors are known as sinkholes, so I’ll at least step on the 3000+ sinkholes first.