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
$container
Append to the edit area - perform
embedInstance.render($container)
Render the Embed card to$container
inside
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 be
contenteditable="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. id
It must correspond to the Embed instance, so that it can be found immediately by the Embed instance$container
display
Divided intoinline-block
和block
Two 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.