Web 富文本编辑器 embed 卡片机制的设计与实践

2,167 阅读6分钟

背景

富文本编辑器不仅仅是图文,需要扩展更多类型的信息。像比较出名的 notion 编辑器直接就是一个“大杂烩”,啥都能塞进去。

wangEditor 正在考虑做全面的插件化,也是近期我就以“公式”和“代码块”为例子,探索了一下 embed 的设计和实践。正好,这俩是不同的显示类型,前者是 inline 后者是 block 。

image.png

PS:embed 卡片机制,并不是什么新鲜东西,一些优秀的开源编辑器(slate.js Quill 等)早就支持。当前的一些知识库产品(腾讯文档、石墨等)也都有很成熟的应用。

扩展性

embed 卡片,需要被设计为自由可扩展的,才能发挥他的最大价值。它可以随着扩展菜单和插件,同时被注册到编辑器,从而扩展格式。

例如,默认情况下编辑器只支持基础的文本编辑(这样轻量化,代码体积小),不支持数学公式。
你可以开发一个第三方的数学公式菜单,注册到编辑器中,这样就有了公式菜单。同时,还要注册一个公式的 embed 卡片,这样编辑区域就可以显示公式卡片。

同理,你可以继续扩展其他的菜单、embed、插件……

菜单可扩展的,插件可扩展,embed 当然也需要可扩展。

应用场景

embed 应用场景非常多,除了普通文字编辑之外,剩下的都可以用 embed 来实现。而且有些交互性复杂的(如表格、代码块)必须用 embed 才能做出好的用户体验。

下面列出一些常见的使用场景:

  • 链接、链接卡片
  • 图片
  • 公式
  • 代码块
  • 视频
  • 表格
  • 附件
  • 各种可嵌入的文档和服务,如思维导图、ppt、地图等
  • 可根据业务,高度定制自定义的组件,如常见的 logo 、时间轴、图文混排的小卡片等

有了 embed ,可以让编辑器区域为所欲为!

设计和实现

设计调整了很多次,一边调整一边改代码。最终有了目前的阶段性成果,虽然还不完善,但我感觉已经可以照着这个思路来继续进行了。

注册 embed 卡片

image.png

还是以数学公式为例子,embed 随着菜单一起被注册。菜单被注册到菜单栏,embed 被注册到编辑器,等待使用。

其中,KaTex 是渲染 LaTeX 语法公式的 lib ,可以直接 npm 安装使用。

【注意】这里关于 KaTex 有一个很关键的话题 —— 如何保证编辑器基础部分代码体积小?
有一个很重要的因素就是:把这些第三方的 lib ,放在第三方扩展的扩展代码(插件、菜单、embed)里,谁用谁安装。这也是做插件化拆分的

核心代码如下。首次,编辑器需要定义必要的接口

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
}

公式 embed 的核心 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}-`) // id 会对应到 embed 容器的 DOM 节点
        this.data = data
    }
    public get $container(): DomElement {
        return $(`#${this.id}`)
    }
    /**
     * 渲染公式
     * @param $container embed 容器
     */
    public render($container: DomElement): void {
        const data = this.data as string
        katex.render(data, $container.getNode(0) as HTMLElement, {
            throwOnError: false,
        })
    }
    /**
     * 获取 result html ,执行 txt.html() 时触发
     * @returns html 代码
     */
    public genResultHtml(): string {
        const embedKey = this.embedKey
        const data = this.data

        // 要和 selector getData() 对应好
        return `<span data-embed-key="${embedKey}" data-embed-value="${data}"></span>`
    }
}

export default FormulaEmbed

插入公式

image.png

插入公式时,需要输入 LaTeX 语法的字符,然后最终于 KaTeX 渲染成为数学公式。

对插入 embed 的操作,编辑器做了统一的命令模式处理。即执行 insert(embedKey, data) 即可插入 embed 。插入的过程是:

  • 创建对应的 embed 实例
  • 创建一个 $container ,append 到编辑区域
  • 执行 embedInstance.render($container) 把 embed 卡片渲染到 $container 里面

关键代码

    /**
     * 插入 embed 卡片
     * @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)
    }

这里的 $container 有几个非常重要的细节

  • 必须是 contenteditable="false"embed 是一个默认不可编辑的黑盒,这一点非常重要! 这样才能保证 embed 的自由度,从而保证扩展性。
  • id 必须要和 embed 实例对应起来,这样通过 embed 实例可以一下子找到它的 $container
  • display 要分为 inline-blockblock 两种

生成 $container 的核心代码如下

/**
 * 生成 embed 容器 elem
 * @param embedInstance embed 实例
 * @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 。注意 id 必须这样写,否则找不到 embedInstance.$container
    const containerHtml = `<${tag} id="${id}" data-we-embed-card class="${className}" contenteditable="false"></${tag}>`
    const $container = $(containerHtml)

    // TODO 这里可以扩展很多事件和操作,例如删除、复制、全屏、拖拽等

    return $container
}

获取结果

image.png

编辑区域内的 html ,和最终用户获取的 html 是不一样的,而且完全不一样。理解这一点非常重要!

例如,编辑区域使用 KaTeX 渲染数学公式,DOM 结构是非常复杂的,还要依赖于大量的 css 。而用户得到的 html 结果非常简单,就是 <span data-embed-key="${embedKey}" data-embed-value="${data}"></span>
再例如,代码块我们是借助 CodeMirror 来实现的代码编辑,CodeMirror 渲染出来的 DOM 结构也是非常复杂的。而用户得到的 html 结果就是普通的 <pre><code>xxxx</code></pre>

要做到这一步,就需要每个 embed 都分别做各自的解析。我们需要把这个解析逻辑写到 embed 实例的 genResultHtml 中。例如公式的 embed 这样写:

    /**
     * 获取 result html ,执行 txt.html() 时触发
     * @returns html 代码
     */
    public genResultHtml(): string {
        const embedKey = this.embedKey
        const data = this.data

        // 要和 selector getData() 对应好
        return `<span data-embed-key="${embedKey}" data-embed-value="${data}"></span>`
    }

最终,通过一个统一的 renderHtml2ResultHtml 方法,来汇总所有 embed 实例的 genResultHtml,把整个 html 解析完。这里借助了 htmlParser 来解析 html 字符串:

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

    let inEmbedFlag = 0 // 是否开始进入 embed 内部

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

                // 获取 embed 实例,获取 resultHtml ,并拼接
                const embedId = getAttrValue(attrs, 'id')
                const embedInstance = editor.embed.getEmbedInstance(embedId)
                if (embedInstance == null) return
                const resultHtml = embedInstance.genResultHtml() // 解析出 resultHtml
                resultHtmlArr.push(resultHtml)
                return
            }

            // 正常情况下,不是 embed ,则拼接 html
            if (inEmbedFlag === 0) {
                const html = genStartHtml(tag, attrs)
                resultHtmlArr.push(html)
                return
            }

            // embed 内部,继续深入一层。不拼接 html
            if (inEmbedFlag > 0 && EMPTY_TAGS.has(tag) === false) {
                inEmbedFlag = inEmbedFlag + 1
            }
        },
        characters(str: string) {
            // 正常情况下,不是 embed ,则拼接
            if (inEmbedFlag === 0) {
                resultHtmlArr.push(str)
            }
        },
        endElement(tag: string) {
            // 正常情况下,不是 embed ,则拼接 html
            if (inEmbedFlag === 0) {
                const html = genEndHtml(tag)
                resultHtmlArr.push(html)
            }

            // embed 内部,减少一层。不拼接 html
            if (inEmbedFlag > 0) inEmbedFlag = inEmbedFlag - 1
        },
        comment(str: string) {}, // 注释,不做处理
    })

    return resultHtmlArr.join('')
}

回显结果

刚刚获取 html ,是由 renderHtml 转换为 resultHtml 。而回显结果,就是由 resultHtml 转换为 renderHtml 。这是一个逆向工程。

但这一步不能再向上文一样,去解析 html ,因为这需要渲染编辑器区域的 DOM 。所以设计的步骤是:

  • 先把 resultHtml 赋值给编辑区域
  • 立马做一个转换,根据现有的 resultHtml 宣传出 renderHtml
function renderEmbed(editor: Editor): void {
    // ------------ 先关闭 change 监听 ------------

    // 遍历编辑区域
    const $textElem = editor.$textElem
    traversal($textElem, ($elem: DomElement) => {
        // 判断是不是 embed,生成 embed 实例
        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

        // 生成 $container ,添加到当前元素后面
        const $container = genEmbedContainerElem(embedInstance)
        $container.insertAfter($elem)

        // 调用 embed.render
        embedInstance.render($container)

        // 删除当前元素
        $elem.remove()
    })

    // ------------ 最后再开启 change 监听 ------------
}

TODO

目前实现了一个大概的框架,还有很多细节需要做,例如

  • embed 生命周期:创建、更新、销毁等
  • embed 的事件:如 click、mouseEnter 等
  • 编辑区域的其他 API 的改动,例如获取 text 、获取和设置 JSON 、append elem 、粘贴处理等
  • embed 内部和外部的交互,例如 focus 到 codeMirror 时需要禁用编辑器的所有菜单。

总结

embed 我搞了好几天,还要继续再搞下去,感觉还是挺麻烦的。
等把所有的必要 embed 都扩展完,稳定之后,我还会再来写文章分享。