解决方案调研-Web富文本编辑器 ( 一 )

816 阅读5分钟

前言

相信各位都听过【富文本编辑器是天坑】这一说法,或许使用document.execCommond是很不错,但遗憾的是它已经被MDN废弃了。

确切的说这个API本身就是非标准API,而是在IE9时被引入的私有API,后面才陆续被Chrome/Firefox等浏览器做了支持,但始终没有一个标准导细节及兼容一直没实现统一。
而被废弃的另一个原因就是安全问题,在这个越来越注重安全的年代,未经用户授权的情况下进行敏感操作是件非常恐怖的事情,虽然各浏览器都进行了调整但权限是宽松定义的在不同的浏览器中有不同定义。并且因为得到了广泛的支持,在IE支持时还没promise所以它只能是同步方法,操作DOM对象亦或是读取剪切板时都会因为同步导致阻塞页面渲染和脚本执行,包括剪切板的读写它只能是DOM内容

最重要的一点: 它被MDN废弃,不仅不会继续维护甚至可能随时被删除

不用document.execCommond该如何实现富文本编辑器

要在web页面上实现富文本格式的编辑功能,这不是一件很容易的事情,基于contenteditable,selection,range和document.execCommond来实现编辑器是目前较为广泛流传的contenteditable is terrible由来。

这种方案目前存在以下已知问题 (及前言中隐性问题)

  • 输入处理不一致,产生的HTML不一致
    • document.execCommond按键标签结构等处理在不同浏览器之间具有差异
    • 同步剪贴板且只能读写DOM,这会导致阻塞及DOM产生不可预料的丢失
  • range光标会在输入区域表现为一个`闪动的竖线`,让用户感知到`当前正在输入的位置`,但这个表现并`非准确`他会因为DOM结构产生不可预料的结果,这和用户的点击也有关
    列如下面两种结构(其中 "**|**" 为光标位置):
    <!-- 第一种 -->
    <span><strong>|text</strong></span>
    <!-- 第二种 -->
    <span>|<strong>text</strong></span>
假设我们输入了一些内容,那么可能产生以下效果<br>

123text -> <span><strong>123text</strong></span>
123text -> <span>123<strong>text</strong></span>

或许这是较为理想的`不可预料`甚至某种场景下他应该出现,但想象一下当他的结构更加复杂时,会产生哪种复杂的不可预料?

我们理想中的编辑器应当存在哪些因素:

  • 有专业团队长期维护
  • 较高的 可拓展、定制性、配置化
  • 响应式图像和媒体嵌入
  • 自定义输出输入格式 HTML、Markdown
  • 可自动格式化来提高生产力
  • 良好及强大的API设计
  • 可导入导出文档
  • RDFa、Annotations
  • 如果能在线协同编辑最好了 :) 以上为举例说明

下面就简单的说一下实现的初步思路

  • 基于contenteditable良好的输入,它能较好的兼容IME、CJK输入,但它只作为input viewdocument view,只接受 转换器 写入的dom data和在用户输入时劫持数据抛给 转换器,转换器转换成 model对树进行拆分转化并央射可调用的command,修正定位光标位置确保每次的 selection change 内容是可预测的,不会出现 上述range光标定位问题
    input view -> model -> data view update其中需要实现的包括NodeControllerOBSOP...
  • 数据结构方面可以采用immerimmutable
  • undo和redo的实现可以参考一位大佬的实现 链接 Demo
  • Range 是范围,Selection 是选区,范围没有方向,选区才有方向。从前往后拖选文字,和从后往前拖选文字,selection 不同,但 range 是相同的。
  • 借助Schema我们可以实现自动校验内容
  • 通过Clipboard实现复制、粘贴、剪切

用图表进行描述: image.png

场景一: 用户在内容区域输入任意内容
流程:contenteditable dom劫持到输入的内容传到model经过FEATUREOBS...生成出新NODE结构immer在Node改变期间对节点的变动进行记录,让用户可对本次操作进行undo和redo,然后将Node传回给input view渲染DOM。

场景二: 用户点击MENUS VIEW(假设为菜单栏)中的加粗
流程:出发点从contenteditable dom变更为MENUS VIEW,如果有Selection Range则将选区内容进行加粗处理,再将内容视为输入内容传入到model,走 场景一 劫持后的流程。

如果 以下内容随便选一段进行加粗、取消加粗处理过程

<p>
    欢迎<span style="color: red;">使用</span><a href="xxx">wangEditor</a>
    富文本
    <b>编辑器</b>
</p>

首先他会被我们解析为Node树大致为:

    const node = {
        tagName: 'p',
        // TODO: 此处el为上面的p,为解析时所得主要用于在selection Range时和startContainer或endContainer作比对,来确定选中的节点亦或是子节点的文本对比
        el: p, 
        props: {},
        children: [
            '欢迎',
            { tagName: 'span', props: { style: 'color: red;' }, children: [ '使用' ], el: span },
            { tagName: 'a', props: { href: 'xxx' }, children: [ 'wangEditor' ], el: a },
            '富文本',
            { tagName: 'b', props: {}, children: [ '编辑器' ], el: b },
        ],
    }

随便选一段有以下可能性:

    // TODO: 以下为最直接的方式展示 操作过程 和 数据变化
    // 选中内容 “ 欢迎 ” 加粗
    node.children[0] = { tagName: 'b', props: {}, children: [ '欢迎' ], el: CREATE_GET };
    // 渲染结果:<b>欢迎</b>
    
    // 选中内容 “ 迎<span style="color: red;">使 ”加粗
    node.children[0] = '欢';
    node.splice(1, 0, { tagName: 'b', props: {}, children: [ '迎' ], el: CREATE_GET });
    node.children[2].children[0] = { tagName: 'b', props: {}, children: [ '使' ], el: CREATE_GET };
    node.children[2].children[1] = '用';
    // 渲染结果: 欢<b>迎</b><span style="color: red;"><b>使</b>用</span>
    
    // 选中内容 “ 文本<b>编辑 ”加粗,这种场景需要判断标签,如果像“富文本”为纯字符串且至包含了tagName=b则可以进行转移内容 或 直接合并标签
    node.children[3] = '富';
    node.children[4].children[0] = '文本' + node.children[4].children[0];
    // 渲染结果 富<b>文本编辑器</b>