background

Rich text editors need to extend more types of information than graphics. The notion editor, for example, is simply a hodgepodge that can fit anything.

WangEditor is considering a full plug-in, and I recently explored embed design and practice using “formulas” and “code blocks” as examples. As it happens, the two display types are different, with the former inline and the latter block.

PS: Embed card mechanisms are nothing new, and some excellent open source editors (slate.js Quill, etc.) have long supported them. Some current knowledge base products (Tencent documents, graphite, etc.) are also very mature applications.

scalability

Embed cards need to be freely extensible to get the most out of them. It can be registered with the editor along with the extension menu and plug-in, thus extending the format.

For example, by default the editor only supports basic text editing (which is lightweight and small code) and does not support mathematical formulas. You can develop a third party math formula menu, register it in the editor, and you have the formula menu. Also, register a formula Embed card so that the edit area displays the formula card.

Similarly, you can continue to extend other menus, embed, plugins…

Menus are extensible, plug-ins are extensible, and embed also needs to be extensible.

Application scenarios

There are many embed application scenarios, except for ordinary text editing, the rest can be implemented with embed. And some interactive complex (such as tables, code blocks) must use embed to make good user experience.

Here are some common usage scenarios:

  • Link, link card
  • The picture
  • The formula
  • The code block
  • video
  • form
  • The attachment
  • Embeddable documents and services such as mind maps, powerpoint, maps, etc
  • You can customize customized components, such as logos, timelines, and small cards

With Embed, you can let the editor area do whatever you want!

Design and implementation

The design was tweaked many times, changing the code as it went along. Finally, I have the current stage results. Although it is not perfect, I feel THAT I can continue to follow this train of thought.

Embed Card Registration

Again using mathematical formulas, Embed is registered with menus. The menu is registered to the menu bar, and the Embed is registered to the editor, waiting to be used.

KaTex is a lib for rendering LaTeX syntax formulas that can be used directly by NPM installations.

[Note] There is a key topic here about KaTex — how to keep the basic part of the editor code small? One important factor is to put these third-party lib’s in the extension code of the third-party extension (plug-ins, menus, embed), and install them by whoever uses them. This is also a plug-in split

The core code is as follows. For the first time, the editor needs to define the necessary interfaces

export interface IEmbed {
    id: string
    embedKey: string
    isBlock: boolean
    data: any
    readonly $container: DomElement // getter
    render($container: DomElement): void
    genResultHtml(): string
}

export interface IEmbedConf {
    key: string
    isEmbedElem($elem: DomElement): boolean
    getDataFromElem($elem: DomElement): any
    createEmbedInstance(data: any): IEmbed
}
Copy the code

Formula embed core class

import katex from 'katex'
import 'katex/dist/katex.min.css'

import { IEmbed } from '.. /.. /.. /embed/IEmbed'
import { getRandom } from '.. /.. /.. /utils/util'
import $, { DomElement } from '.. /.. /.. /utils/dom-core'
import { EMBED_KEY } from './const'

class FormulaEmbed implements IEmbed {
    id: string
    public embedKey: string = EMBED_KEY
    public isBlock: boolean = false // display: inline-block
    public data: string = ' '

    constructor(data: string) {
        this.id = getRandom(`${EMBED_KEY}- `) // The ID corresponds to the DOM node of the Embed container
        this.data = data
    }
    public get $container() :DomElement {
        return $(` #The ${this.id}`)}/** * render formula *@param $container Embed Container */
    public render($container: DomElement): void {
        const data = this.data as string
        katex.render(data, $container.getNode(0) as HTMLElement, {
            throwOnError: false})},/** * get result HTML, triggered when txt.html() is executed@returns * / HTML code
    public genResultHtml(): string {
        const embedKey = this.embedKey
        const data = this.data

        // To match selector getData()
        return `<span data-embed-key="${embedKey}" data-embed-value="${data}"></span>`}}export default FormulaEmbed
Copy the code

Insert the formula

To insert a formula, you need to enter the characters of the LaTeX syntax, and then KaTeX renders it into a mathematical formula.

For the operation of embed embed, the editor makes a unified command mode processing. That is, execute insert(data) to insert embed. The procedure for inserting is:

  • Create the corresponding Embed instance
  • To create a$containerAppend to the edit area
  • performembedInstance.render($container)Render the Embed card to$containerinside

The key code

    /** * Insert embed card *@param key embed key
     * @param data embed data
     * @returns void* /
    public insertEmbed(key: string.data: any) :void {
        const editor = this.editor
        const embed = editor.embed.createEmbedInstance(key, data)
        if (embed == null) return

        const $container = genEmbedContainerElem(embed)
        this.insertElem($container)
        embed.render($container)
    }
Copy the code

The $container here has a few very important details

  • It must becontenteditable="false"Embed is a black box that is not editable by default. This is important!This ensures the degree of freedom for embed and thus scalability.
  • idIt must correspond to the Embed instance, so that it can be found immediately by the Embed instance$container
  • displayDivided intoinline-blockblockTwo kinds of

The core code for generating $Container is as follows

/** * Generates the embed container ELEm *@param EmbedInstance Embed instance *@returns elem* /
export function genEmbedContainerElem(embedInstance: IEmbed) :DomElement {
    const id = embedInstance.id
    const isBlock = embedInstance.isBlock

    // block
    let tag = 'div'
    let className = 'we-embed-card-block'
    // inline
    if (isBlock === false) {
        tag = 'span'
        className = 'we-embed-card-inline'
    }

    $container is generated. Note that id must be written like this otherwise you will not find embedInstance.$container
    const containerHtml = ` <${tag} id="${id}" data-we-embed-card class="${className}" contenteditable="false"></${tag}> `
    const $container = $(containerHtml)

    // TODO allows you to extend many events and actions, such as delete, copy, full screen, drag and drop, etc

    return $container
}
Copy the code

To get the results

The HTML in the edit area is different and completely different from the HTML retrieved by the end user. This is very important to understand!

For example, editing areas use KaTeX to render mathematical formulas, and DOM structures are very complex and rely on a lot of CSS. The user gets a very simple HTML result that is . For example, code blocks are edited using CodeMirror, and the DOM structure rendered by CodeMirror is also very complex. The user gets the normal HTML result

 XXXX 

.

To do this, each embed needs to do its own parsing. We need to write this parsing logic into the Embed instance’s genResultHtml. For example, the embed formula is written like this:

    /** * get result HTML, triggered when txt.html() is executed@returns * / HTML code
    public genResultHtml(): string {
        const embedKey = this.embedKey
        const data = this.data

        // To match selector getData()
        return `<span data-embed-key="${embedKey}" data-embed-value="${data}"></span>`
    }
Copy the code

Finally, a unified renderHtml2ResultHtml method is used to summarize the genResultHtml of all embed instances and parse the entire HTML. HtmlParser is used to parse HTML strings:

/**
 * renderHtml --> resultHtml
 * @param renderHtml renderHtml
 * @param editor editor
 */
export function renderHtml2ResultHtml(renderHtml: string, editor: Editor) :string {
    let resultHtmlArr: string[] = []

    let inEmbedFlag = 0 // Whether to start entering the embed embed

    const htmlParser = new HtmlParser()
    htmlParser.parse(renderHtml, {
        startElement(tag: string, attrs: IAttr[]) {
            const idEmbed = hasEmbedMarkAttr(attrs)
            if (idEmbed) {
                // Start to embed
                inEmbedFlag = inEmbedFlag + 1

                // Get the Embed instance, get resultHtml, and concatenate
                const embedId = getAttrValue(attrs, 'id')
                const embedInstance = editor.embed.getEmbedInstance(embedId)
                if (embedInstance == null) return
                const resultHtml = embedInstance.genResultHtml() // Parse out resultHtml
                resultHtmlArr.push(resultHtml)
                return
            }

            // Normally, if it is not embed, the HTML is splice
            if (inEmbedFlag === 0) {
                const html = genStartHtml(tag, attrs)
                resultHtmlArr.push(html)
                return
            }

            // Embed embed embed embed embed embed embed embed No stitching HTML
            if (inEmbedFlag > 0 && EMPTY_TAGS.has(tag) === false) {
                inEmbedFlag = inEmbedFlag + 1}},characters(str: string) {
            // In normal case, not embed, splice
            if (inEmbedFlag === 0) {
                resultHtmlArr.push(str)
            }
        },
        endElement(tag: string) {
            // Normally, if it is not embed, the HTML is splice
            if (inEmbedFlag === 0) {
                const html = genEndHtml(tag)
                resultHtmlArr.push(html)
            }

            // Embed embed inside, reduce one layer. No stitching HTML
            if (inEmbedFlag > 0) inEmbedFlag = inEmbedFlag - 1
        },
        comment(str: string) {}, // comments are not processed
    })

    return resultHtmlArr.join(' ')}Copy the code

Results the echo

I just got the HTML, converted from renderHtml to resultHtml. The result, in turn, is converted from resultHtml to renderHtml. It’s reverse engineering.

However, this step cannot be used to parse the HTML, as it requires rendering the DOM of the editor area. So the design steps are:

  • First, assign a resultHtml value to the edit area
  • Immediately do a transformation that advertises renderHtml based on existing resultHtml
function renderEmbed(editor: Editor) :void {
    // ------------ disable change listener ------------

    // Walk through the edit area
    const $textElem = editor.$textElem
    traversal($textElem, ($elem: DomElement) = > {
        // Determine if it is embed, generate embed instance
        const embedConf = editor.embed.getEmbedConfByElem($elem)
        if (embedConf == null) return
        const data = embedConf.getDataFromElem($elem)
        const embedInstance = editor.embed.createEmbedInstance(embedConf.key, data)
        if (embedInstance == null) return

        // Generate $container and append it to the current element
        const $container = genEmbedContainerElem(embedInstance)
        $container.insertAfter($elem)

        / / call embed. Render
        embedInstance.render($container)

        // Delete the current element
        $elem.remove()
    })

    // ------------ finally enable change listener ------------
}
Copy the code

TODO

At present, a general framework has been implemented, and there are still many details to be done, such as

  • Embed lifecycle: create, update, destroy, etc
  • Embed events: click, mouseEnter, etc
  • Other API changes in the edit area, such as getting Text, getting and setting JSON, Append ELEm, paste handling, etc
  • Embed internal and external interactions, such as focus to codeMirror, require all editor menus to be disabled.

conclusion

Embed I made several days, but also continue to make go down, the feeling is still very troublesome. After all the necessary embed has been extended and stabilized, I will write articles to share again.