编写 Minecraft 玩家用小康文本编辑器的努力

2020 年 10 月 01 日

image.png

(不支持移动版浏览器)

这个编辑器在 这里,大家现在就可以使用了。

虽然说这玩意确实还没做到令我满意的程度,但是我把它写出来了,并达到了勉强可用的程度。

不过开发这玩意,我算是明白 HTML 的富文本编辑器(虽说我这是「小康文本编辑器」,因为屏蔽掉了所有 Minecraft 不支持的样式)有多难开发了。

顺便开发 Web 富文本编辑器的前端工程师们,你们都是好样的!

为什么要造轮子?

Minecraft 原生就支持那么几种样式和颜色,所以一开始记忆样式代码也不是太困难的事情。但是在 Minecraft 1.16,我们有了 RGB 文本支持;而 EssentialX(一种被各大服务器广泛使用的辅助性插件)也积极跟进支持,追加了玩家使用 RGB 颜色的样式代码;但要在脑海里演算 RGB 颜色数值发展的具体变化,感觉还是有点困难,况且我还希望在游戏中实现彩色渐变文本等酷炫的特效。

那么,头脑一热的我决定,干脆自己写个编辑器好了,顺便练练手。

contenteditable

这玩意 其实是个相当历史悠久的东西,IE 5.5 就支持了,而且设计上就是用来开发富文本编辑器的。

在前期开发阶段,我感觉要把编辑区的内容转换为 EssentialX 文本格式应该不是太困难的事情。我的做法是这样的:

  1. 读取 DOM 结构
  2. 获取各 HTML 元素计算过后的 CSS 样式
  3. 内部维护一个样式树(抽象的数据结构),记载每一段文本的样式。例如下面这段文本:

凤凰卷是一种奇怪的点心。

它的 HTML 代码是:

<b>凤凰卷</b>是一种<i>奇怪的</i>点心。

通过计算得出的样式树:

char color bold italic underline strickthrough
凤凰卷 #000000 true false false false
是一种 #000000 false false false false
奇怪的 #000000 false true false false
点心。 #000000 false false false false

然后,我依据这个数据结构,就可以非常轻松的计算出对应的 EssX 文本代码:

&l凤凰卷&r是一种&o奇怪的&r点心。

事情到这里,还是很简单的,毕竟富文本编辑器的操作,本质上不就是操作 HTML 结构嘛(虽然是这样)。

拉胯的 execCommand

这玩意 其实设计上是与 HTML contenteditable 配合使用的,目的就是用来在页面编辑中给选择的内容设置不同的样式。

于是我兴高采烈的用上了,然后发现一些问题:

  • MDN 显示,这玩意过时了。
  • 不同浏览器生成的 HTML 代码不一致,添加的标签五花八门,甚至连早已淘汰的 <font> 标签都出来了。不过还好对于我的解析方案不是太大的问题……
  • 不同浏览器的行为存在差异。例如给文本同时添加下划线和删除线,Chrome 浏览器肯定会把你选择的这段文本旁边的文本的下划线 / 删除线样式搞乱,但 Firefox 浏览器就不存在这个问题。(顺便最后的不完美之处还是跟下划线和删除线有关)

其实如果说 execCommand 这玩意,市面上倒是还有一大票编辑器还在用,而且大概浏览器厂商也不会那么轻易的干掉古董的 API(说的就是你,document.all)。不过考虑到下划线和删除线的问题,我还是决定:换掉它,避免未来更麻烦的事情发生。

用现代的 API 来解决吧!

我重新整理了一下思路,发现我需要的样式操作工作流其实是这样的:

  1. 将用户选择的文本片断剪下来
  2. 分析剪下来的片段的样式,并生成样式树
  3. 直接对样式树进行操作
  4. 从样式树生成相应的 HTML
  5. 将生成的 HTML 追加到文本被剪切的地方

我选择直接操作样式树的原因是因为我只关心最终呈现的效果,这样就可以保证 HTML 元素的低嵌套性(指一个 <span> 标签中包含了所有应有的样式,而不是一层套一层的标签),提升处理效率和鲁棒性。

那么,首先在进行一个样式操作以前,需要把选区剪切下来:

/**
 * 剪切用户选区
 * @returns 剪切下来的用户选区的文本数据结构
 */
cutSelection(): StringItem[] {
    // 获取用户选择的文本范围
    const selection = document.getSelection() as Selection;
    // 如果用户选择了至少一段文本,就取第一段被选中的文本(目前只有 FireFox 浏览器支持一次性选择多个选区?)
    if (selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        // 把选区剪切为 HTML 片段
        const extracted = range.extractContents();
        // 进行解析,返回解析得到的样式树
        return this.parseFromFragment(extracted, parent || undefined);
    }
    return [];
}

然后拿到样式树以后,再进行步骤 3-5 就可以了。

但是……接下来的不可靠因素出现在剪下来的 HTML 片段(DocumentFragment)。当用户选择一段文本,有这么几种特别需要注意的情况:

  1. 用户只选中了一个元素里的文本,则整个片断里只有一个文本节点(Node)。
<b>Phoenix Roll</b> is <i>awesome</i>
   | ----+--- |
         |
         +-------------------- [0] [Text Node] Phoenix Roll
  1. 用户选中了嵌套在多层元素中的文本,则:
<u><b>Phoenix Roll</b> is <i>awesome</i></u>
          | ----+------ |
                |
                +-------------------- [0] [Element Node] <b>nix Roll</b>
                                      [1] [Text Node]     is

没错,外面一层的 <u>(下划线)标签就被完全无视了。虽说这样的操作其实合情合理,毕竟浏览器哪里知道你想要的根元素是哪个啊。

下划线和删除线

我一开始的考虑是:让顶层的 CSS 样式直接覆盖父级元素的 CSS 样式,这样不仅处理容易,而且展示效果也会是正确的。但是,下划线和删除线样式的处理问题又向我泼了一盆冷水。

CSS 的下划线和删除线的样式继承偏偏和别的不!一!样!

简而言之,CSS 的 text-decoration 实际渲染样式是从父级元素向下叠加,就像这样:

<s>
    <!-- 追加了删除线样式 -->
    <u>
        <!-- 追加了下划线线样式 -->
        <span style="text-decoration: none">
            <!--
            期望效果:这段文字什么样式都没有
            实际效果:这段文字有下划线和删除线样式
            -->
            为什么我的下划线和删除线没有被消除?
        </span>
    </u>
</s>

那么,如果要正确的获取到一段文本的下划线和删除线样式,就只有向下递归搜索了:

/**
 * 检查某个元素是否会渲染出下划线或删除线样式
 * @param el 要开始搜索的节点
 * @param root 根节点(停止搜索的地方)
 * @param name 搜索的关键词
 * @returns 是否会渲染
 */
static searchLineStyle(el: HTMLElement, root: HTMLElement, name: string): boolean {
    // 如果被搜索的节点没有有效的根节点,停止搜索
    if (el === null) {
        return false;
    }
    // 获取该元素实际计算出的样式。直接通过 `el.style` 未必能够获取到实际计算出的样式。
    const styles = window.getComputedStyle(el);
    // 如果找到了搜索的关键词,停止搜索,返回搜索成功
    if (styles.textDecorationLine.includes(name)) {
        return true;
    }
    // 如果要开始搜索的节点已经是根节点,停止搜索
    if (el.isEqualNode(root)) {
        return false;
    }
    // 递归,继续搜索上一层元素
    return TextEditor.searchLineStyle(el.parentNode as HTMLElement, root, name);
}

至于编辑框里的文本,我写了一个函数,在编辑框失焦以后进行自动「标准化操作」:

  1. 获取编辑框里的 HTML
  2. 解析样式树
  3. 清空编辑框,用从样式树渲染得到的 HTML 替换原始内容

渲染完成的 HTML 为扁平的,意味着不会出现任何嵌套元素。为了避免出现「用户只选中了一个元素里的文本」带来的麻烦,我想了个办法,强迫浏览器每次剪切出的文档片段首层只有元素:把每一个字单独放在一个 <span> 里。以第一个例子的文本为例,最后渲染出的 HTML 是这样的:

<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255); font-weight: 700;">凤</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255); font-weight: 700;">凰</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255); font-weight: 700;">卷</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255);">是</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255);">一</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255);">种</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255); font-style: italic;">奇</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255); font-style: italic;">怪</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255); font-style: italic;">的</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255);">点</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255);">心</span>
<span style="color: rgb(255, 255, 255); text-decoration-color: rgb(255, 255, 255);">。</span>

(实际上各个 <span> 之间并不包含空格或换行,因为行内元素之间的空格/换行在浏览器中会被渲染为一个空格。)

那如果用户只选择了一个字怎么办?对于这种情况,我会读取和操作它的父级元素的样式。

不过,这种情况仅适用于对编辑框内已有文本进行操作的情况。如果用户一直输入文本,没有让编辑框失去焦点,然后选中了一段特定的文本并试图添加/删除样式,编辑器就崩坏了。

对于这种情况,我又补充了一些比较难看的 hack,但是目前并没有在本质上解决这问题。所以这就是为什么我提醒大家输入完文本以后先点一下编辑器外面了(

更新:现在,每当你应用一个样式以后,编辑器就会自动失焦并优化 HTML 结构,所以这种不可靠情况应该是暂且这么解决了。

结论

富文本编辑器是一种很让用户和开发者难过的东西,即使我降低标准,屏蔽除 Minecraft 支持的所有特殊样式,我依然没有把它做到令我完美的程度;至少目前而言,这是让我感到很遗憾的事情了。

这告诉我们:

  • 作为合格的 Minecraft 玩家,熟练背诵样式代码和一大堆常用颜色还是很重要的
  • 还是不要随便碰富文本编辑器坑了

就酱。