最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Web 富文本编辑器 embed 卡片机制的设计与实践

    正文概述 掘金(王福朋)   2021-03-15   997

    背景

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

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

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

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

    扩展性

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

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

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

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

    应用场景

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

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

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

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

    设计和实现

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

    注册 embed 卡片

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

    还是以数学公式为例子,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
    

    插入公式

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

    插入公式时,需要输入 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
    }
    

    获取结果

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

    编辑区域内的 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 都扩展完,稳定之后,我还会再来写文章分享。


    起源地下载网 » Web 富文本编辑器 embed 卡片机制的设计与实践

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元