做个编辑器

你遇到了多少“坑” ?

作者:徐沛文 (邮件产品部前端)


编辑器类型

WYSIWYG 编辑器

KindEditor UEditor Plus
Outlook (web) Gmail

竞品分析

类别 功能 Coremail 网易灵犀 Outlook
撤回 / 重做 撤回
  重做
  快捷键(Ctrl+Z,Ctrl+Y)
删除格式 删除单个文字格式(文本/表格中的文字格式)
  删除组合格式(包括有格式+无格式组合)
  删除段落格式(行距,对齐方式,编号,缩进)
格式刷 刷文字(文本/表格中的文字格式)
  刷段落格式(行距,缩进,对齐方式,编号)
  双击格式刷进行多处格式刷
格式刷 刷文字(文本/表格中的文字格式)
字体 初始值(注意收发信设置-默认字体设置)
  选择字体后字体框显示相应字体
  支持更换字体(选择字体,不选择字体,选择多种字体的段落)
类别 功能 Coremail 网易灵犀 Outlook
文字大小 初始值(注意收发信设置-默认字体设置)
  文字大小下拉列表
  选择文字后文字大小框显示相应字体大小
  更换文字大小(选择文字,未选择字体,选择多种文字的段落)
粗体 选择字体(未加粗,未加粗+加粗,加粗+未加粗,加粗)
  未选择字体后加粗输入文字,跨行
  快捷键(Ctrl+B)
斜体 选择字体(未倾斜,未倾斜+倾斜,倾斜+未倾斜,倾斜)
  未选择字体后设置斜体输入文字,跨行
  快捷键(Ctrl+I)
类别 功能 Coremail 网易灵犀 Outlook
下划线 选择字体(没有下划线,没有下划线+下划线,下划线+没有下划线,下划线)
  未选择字体后设置下划线输入文字,跨行
  快捷键(Ctrl+U)
删除线 选择字体(未倾斜,未倾斜+倾斜,倾斜+未倾斜,倾斜)
  未选择字体后设置斜体输入文字,跨行
上标下标 选择字体(无上标,无上标+有上标,有上标+无上标,有上标),下标情况一致 /
  未选择字体后设置上标/下标输入文字,跨行 /
文字颜色 选择字体(无上标,无上标+有上标,有上标+无上标,有上标),下标情况一致
  颜色悬浮提示
  未选择字体后选择颜色,换行
  字体颜色更改(无颜色-设置颜色1,无颜色+颜色1-设置颜色1,颜色2,颜色1+无颜色,设置颜色1,颜色2)
类别 功能 Coremail 网易灵犀 Outlook
文字背景 选择字体(没有下划线,没有下划线+下划线,下划线+没有下划线,下划线)
  未选择字体后选择颜色,换行
  字体颜色更改(无背景颜色-设置背景颜色1,无背景颜色+背景颜色1-设置背景颜色1,背景颜色2,背景颜色1+无背景颜色,设置背景颜色1,背景颜色2)
对齐方式 选择字体(未倾斜,未倾斜+倾斜,倾斜+未倾斜,倾斜)
  左对齐
  居中对齐
  右对齐
  对齐方式切换
类别 功能 Coremail 网易灵犀 Outlook
项目编号 回车自动添加编号
  未选择文字/段落添加、取消编号
  选择文字/段落后添加、取消编号
  选择段落(含编号和未编号)后点击编号为统一添加编号
  支持特殊 Unicode 字符 /
数字编号 回车自动添加编号
  未选择文字/段落添加、取消编号
  选择文字后/段落添加、取消编号
  选择段落(含编号和未编号)后点击编号为统一添加编号
  中文编号
类别 功能 Coremail 网易灵犀 Outlook
缩进 增加缩进
  减少缩进
  未输入文字选择缩进
  输入文字后选择缩进
  选中文字/段落后选择缩进
行距 选择文字后选择行距大小
  未选择文字后选择行距大小
  光标定位文字/段落查看行距大小
插入 水平线
  引用 /
  附件
  块引用 / /
  链接
类别 子类别 功能 Coremail 网易灵犀 Outlook
表格 表格属性 单元格数 /
    大小(宽度、高度) /
    边距 /
    间距 /
    对齐方式
    边框粗细 /
    边框颜色 /
    背景颜色
  单元格属性 大小 /
    对齐方式
    边框 /
    背景颜色
类别 子类别 功能 Coremail 网易灵犀 Outlook
表格 合并拆分 向下、右合并单格
    拆分行、列
  其他 左、右侧插入列
    上、下方插入行
    删除行、列
    插入表格(显示行列数)
    删除表格
    垂直对齐
    伸缩
    表格插入文字图片
    表格粘贴
    表格带格式粘贴
类别 功能 Coremail 网易灵犀 Outlook
其他 打印 /
  HTML代码 / /
  拼写检查 /
  文字拖动
  更多 /

框架选型

项目 / 基础功能 (REF) 历史记录
(撤回、重做)
清除格式
格式刷 文字
(段落、字体、字体大小)
基本样式
(粗体、斜体、下划线、删除线、上下标)
对齐 编号 表格
(合并、拆分、单元格选取,光标移动)
UEditor (Archived)
KindEditor /
Adiptal Editor / / /
CKEditor 4
CKEditor 5 /
Content Tools / / /
Jodit /
Medium Editor / / / / /
Pell / / / / /
Quill / / / /
SunEditor / /
wangEditor /
               
项目 / 兼容性 (及 License) (REF) Internet Explorer Chrome Opera Safari Firefox License
UEditor (Archived) 9+? MIT
KindEditor 8+ LGPL 2.1
Adiptal Editor ? GPL 2.1
CKEditor 4 8+ ~ ~ ~ ~ Custom
CKEditor 5 11? ~ ? ~ ~ GPL 2.x
Content Tools 9+ ~ ? ? ~ MIT
Jodit 11 ~ ? ~ ~ MIT
Medium Editor 9+ ~ ~ ~ ~ Custom
Pell 9+? 5+ 11.6+ 5+ 4+ MIT
Quill ? 79+ ? 12+ 73+ BSD 3-Clause
SunEditor 11 ~ ~ ~ ~ MIT
wangEditor 8+ MIT
           

功能改造


格式刷

如何从零开始实现?

把一段带格式的文本,“覆盖”“粘贴” 到另一段文本

// 注册格式刷按钮
CMDS['formatPainter'] = function () {
    // 处理重复点击或双击的逻辑 ...
    
    // 复制当前选择文字的格式
    const copied = getFormats();

    // 鼠标释放时开始覆盖并粘贴格式到对应的文本
    document.addEventListener('mouseup', function () {
        // 清除当前选择文本的格式
        removeFormats();
        // 应用复制的格式
        applyFormats(copied);
    });
};
// 复制当前选择文字的格式
function getFormats() {
    // 文本基本格式
    const _STATED_CMDS = [
        'fontname', 'fontsize',
        'justifyleft', 'justifycenter', 'justifyright', 'justifyfull',
        'insertorderedlist', 'insertunorderedlist',
        'subscript', 'superscript', 'bold', 'italic', 'underline', 'strikethrough',
        'lineheight', 'indent', 'formatblock', 'forecolor', 'hilitecolor',
    ];
    
    return Object.fromEntries(_STATED_CMDS.map(name =>
        [name, hasValue(name) ? cmd.val(name) : cmd.state(name)]));
}

The queryCommandState() method will tell you if the current selection has a certain Document.execCommand() command applied.

// bold, italic, underline, subscript, superscript
// insertorderedlist, insertunorderedlist
document.queryCommandState(command);

This is a paragraph for instance.

// 清除当前选择文本的格式
function removeFormats() {
    const bookmark = range.createBookmark();

    // 从 bookmark 开始点切 DOM tree
    breakParent(bookmark.start);
    // 从 bookmark 结束点切 DOM tree
    bookmark.end && breakParent(bookmark.end);

    // 清除 HTML 格式
    doRemove();
    
    function breakParent(node, parent) {
        while ((parent = node.parentNode) && !K(parent).isBlockElem()) {
            K(node).breakParent(parent);
        }
    }
}

This is a paragraph for instance.

This is a p|ar|agraph for instance.

<p>
  This is a
  <span style="color: red; background: yellow">
    <b>
      <i>
        <u>
          p
          <span class="boomark start">|</span>
          ar
          <span class="boomark end">|</span>
          agraph
        </u>
      </i>
    </b>
  </span>
  for instance.
</p>
<p>
  This is a
  <span style="color: red; background: yellow">
    <b><i><u>p</u></i></b>
  </span>
  <span style="color: red; background: yellow">
    <b>
      <i>
        <u>
          <span class="boomark start">|</span>
          ar
          <span class="boomark end">|</span>
        </u>
      </i>
    </b>
  </span>
  <span style="color: red; background: yellow">
    <b><i><u>agraph</u></i></b>
  </span>
  for instance.
</p>
<p>
  This is a
  <span style="color: red; background: yellow">
    <b><i><u>p</u></i></b>
  </span>
  <span class="boomark start">|</span>
  ar
  <span class="boomark end">|</span>
  <span style="color: red; background: yellow">
    <b><i><u>agraph</u></i></b>
  </span>
  for instance.
</p>

This is a paragraph for instance.

<p>
<span style="text-decoration: line-through">
    abc
    <span style="text-decoration: line-through; font-size: 2em">123</span>
    efg
</span>
</p>

abc 123 efg

<p>
<span style="text-decoration: line-through">abc</span>
<span style="text-decoration: line-through; font-size: 2em">123</span>
<span style="text-decoration: line-through">efg</span>
</p>

abc 123 efg

// 应用复制的格式
function applyFormats(copied) {
    // copied 的格式一定保留 DOM tree 顺序
    // ...
}

This is a paragraph for instance.

<p>
  This is a
  <span style="color: red; background: yellow">
    <b><i><u>paragraph</u></i></b>
  </span>
  for instance.
</p>
<p>
  This is a
  <u>
  <span style="color: red; background: yellow">
    <b><i>paragraph</i></b>
  </span>
  </u>
  for instance.
</p>

This is a paragraph for instance.

This is a paragraph for instance.

<p>
  This is a paragraph <span style="font-size: 0.8em"><sup>[0]</sup></span> for instance.
</p>
<p>
  This is a paragraph <sup><span style="font-size: 0.8em">[0]</span></sup> for instance.
</p>

This is a paragraph [0] for instance.

This is a paragraph [0] for instance.


表格

拆分 / 合并

<table>
    <tr><td /><td /><td /><td /><td /><td /></tr>
    <tr><td /><td colspan="3" /><td /></tr>
    <tr><td /><td rowspan="2" /><td /><td /><td /><td /></tr>
    <tr><td /><td /><td /><td rowspan="3" colspan="2" /></tr>
    <tr><td /><td /><td /><td /></tr>
    <tr><td /><td /><td /><td /></tr>
</table>
<table>
    <tr><td /><td /><td /><td /><td /><td /></tr>
    <tr><td /><td colspan="3" /><td /><td /><td /><td /></tr>
    <tr><td /><td rowspan="2" /><td /><td /><td /><td /></tr>
    <tr><td /><td /><td /><td rowspan="3" colspan="2" /><td /><td /></tr>
    <tr><td /><td /><td /><td /><td /><td /></tr>
    <tr><td /><td /><td /><td /><td /><td /></tr>
</table>
colspan=3
rowspan=2
colspan=2, rowspan=3
colspan=3fakefake
rowspan=2
colspan=2, rowspan=3
colspan=3fakefake
rowspan=2
fakecolspan=2, rowspan=3
colspan=3fakefake
rowspan=2
fakecolspan=2, rowspan=3fake
fakefake
fakefake
colspan=3fakefake
rowspan=2
fakecolspan=2, rowspan=3fake
fakefake
fakefake
[2, 2]
[1, 3]
[2, 2]
[2, 3]
1fake1fake1
2
fake23fake3
fake3fake3
fake3fake3

“不规则”“规则”


文本操作

Most commands affect the document's selection (bold, italics, etc.), while others insert new elements (adding a link), or affect an entire line (indenting). When using contentEditable, execCommand() affects the currently active editable element.

// redo, undo
// bold, italic, underline, strikethrough, subscript, superscript
// insertorderedlist, insertunorderedlist, indent, outdent
document.execCommand(command);

粗体

<strong>粗体</strong>
<b>粗体</b>
<span style="font-weight: bold">粗体</span>

撤销 (undo) / 重做 (redo)

堆栈

 
状态1
 
状态2
状态1
 
状态3
状态2
状态1

粘贴

document.addEventListener('paste', event => {
    // 从粘贴事件中获取粘贴板数据
    const clipboardData = event.clipboardData || window.clipboardData;
    // 粘贴的文本数据
    const rawHTML = clipboardData.getData('text/html');
    // 粘贴的文件数据
    const rawFiles = clipboardData.files;
});

总结

魔鬼在于 细节

在各框架实现中,取


感谢