【译】自己实现 document.execCommand 富文本编辑器核心 API

6,870 阅读7分钟

本文是中文翻译,英文原文链接 medium.com/swlh/reimpl… 或者 dev.to/daviddalbus… (这个可以访问)

译者说

我是 wangEditor 的作者,目前正和开发团队做 V4.0 的重构。V4.0 发布之前的代码在 we-next ,发布之后可访问官网

了解富文本编辑器的同学都知道,document.execCommand 是实现网页富文本的核心 API 。但遗憾的是 document.execCommand 已经被 MDN 给废弃了。而且,一直以来,各个浏览器对于它的实现,细节上也没有完全统一,浏览器兼容性一直比较头疼。

为了解决以上问题,各个富文本编辑器早就有了自己的探索。兵强马壮的 Google Doc 就最早实现了自己的富文本编辑 API ,后来随着在线 office 的推广使用,国内国外的富文本编辑器也都迅速发展状态,有些已经自研 document.execCommandwangEditor 接下来(待 V4.0 发布且功能稳定之后)也难免要做这样的自研和升级。

那么如何自己实现 document.execCommand 呢?中文资料目前还比较少,因此找了一个英文的文章翻译一下。如翻译有误,可回复评论指正。

(原文地址 juejin.cn/post/686491… ,转载需经过作者同意!)


下面正式开始翻译!!!

下面正式开始翻译!!!

下面正式开始翻译!!!

这个功能(即 document.execCommand)是被废弃的,即便在很多浏览器中它依然可用,但它随时都有可能会被移除。所以,尽量避免使用 —— MDN web docs

不知道从何时起,因为什么原因,document.execCommand() 在 MDN 里已经被标注为废弃。有趣的是,它并没有在所有的语言(如中文、英文,而非编程语言,译者注)中标注为废弃,例如法语和西班牙语里,就没有提及此事😜。

为了 DeckDeckGo ,一个开源的幻灯片编辑器,我们开发并发布了一个自定义的 WYSIWYG 编辑器,它就用到了 document.execCommand() 这个功能。

我想,通过自定义的实现 document.execCommand() 可能是未来的一种开发方式,所以我花了相当长的时间去重新实现它😄。

尽管我的实现方式,看起来并不是那么糟糕(希望如此),我还是觉得,我必须要重新造这个轮子。这也是我写这篇文章的原因,希望大家能多提一些改进意见,让它更加成熟完善🙏。

介绍

WYSIWYG 编辑器,它跨设备的兼容是比较好的。它可以在 PC 浏览器工作,也可以在移动设备工作。它可以根据键盘的行为,被附加在试图的顶部(iOS)或者底部(Android)。

它可以修改文本的样式(加粗、斜体、下划线和删除线),字体颜色和背景色,对齐方式,列表。甚至还支持一些自定义操作的 slot

局限

我自己实现的 document.execCommand() 运行起来没啥问题,不过它目前还不支持撤销,有点遗憾😕。 大家可以对此提出自己的好建议。

目标

本文接下来要自己实现的就是如下这个 API ,可以参考 MDN 的文档

document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

要实现的功能有

  • bold 加粗/取消加粗
  • italic 斜体/取消斜体
  • underline 设置/取消下划线
  • strikeThrough 设置/取消删除线
  • foreColor 修改字体颜色
  • backColor 修改背景色

实现

我喜欢用 ts 来开发,所以接下来的代码会是强类型的,并且还会用到 interface

export interface ExecCommandStyle {
  style: 'color' | 
         'background-color' | 
         'font-size' | 
         'font-weight' | 
         'font-style' | 
         'text-decoration';
  value: string;
  initial: (element: HTMLElement | null) => Promise<boolean>;
}

默认情况下,执行 document.execCommand 时会创建新的元素(如加粗时会创建 <b> 元素,译者注),而我决定通过修改 CSS 来达到效果。例如 value: 'bold' 就可以表示 style: 'font-weight',如果 style: "color"value 可以是 #ccc,这样子。这个接口中还包含一个 initial 函数,我用来确认:该设置样式,还是删除样式。

接口定义了,接下来开始具体实现,看如何应用样式。首先通过 selection 获取用户选中的文字,然后识别它的 container。注意,这个 container 有可能是文本节点,也有可能是选区的父元素。

另外,函数中的第二个参数 containers ,它定义了一个函数可以应用的元素列表,默认为 h1,h2,h3,h4,h5,h6,div 。引入这个限制,是为了在搜索时不用遍历整个 DOM 树。

export async function execCommandStyle(action: ExecCommandStyle, containers: string) {
  const selection: Selection | null = await getSelection();

  if (!selection) {
    return;
  }

  const anchorNode: Node = selection.anchorNode;

  if (!anchorNode) {
    return;
  }

  const container: HTMLElement =
    anchorNode.nodeType !== Node.TEXT_NODE 
    && anchorNode.nodeType !== Node.COMMENT_NODE ? 
      (anchorNode as HTMLElement) : anchorNode.parentElement;
  // TODO: next chapter
}

async function getSelection(): Promise<Selection | null> {
  if (window && window.getSelection) {
    return window.getSelection();
  } else if (document && document.getSelection) {
    return document.getSelection();
  } else if (document && (document as any).selection) {
    return (document as any).selection.createRange().text;
  }

  return null;
}

要给设置 CSS 样式,所以要把用户选中的文字转换为 span 标签。

而且,我觉得设置 CSS 样式比总是新增标签要好一些。例如,用户选中了同一段文字,先设置背景色红色,再设置背景色绿色,那肯定是直接修改 CSS 样式比较好,而不是为了同样的设置(都是背景色)生成新的元素。所以,我在实现时,会判断是 updateSelection 还是 replaceSelection

const sameSelection: boolean = container && container.innerText === selection.toString();

if (sameSelection && 
  !isContainer(containers, container) 
  && container.style[action.style] !== undefined) {

    await updateSelection(container, action, containers);

    return;
}

await replaceSelection(container, action, selection, containers);

Update Selection 更新选区

简单来说,就是对于已有的元素设置新的 CSS 样式。例如用户修改背景色时,把 <span style="background-color: red;"/> 设置为 <span style="background-color: green;"/>

另外,当设置了样式之后,其子元素应该继承新的样式,例如在微软 word 中。所以,当设置了样式之后,我又加了另外一个函数,来清除子元素的样式。

async function updateSelection(container: HTMLElement, action: ExecCommandStyle, containers: string) {
  container.style[action.style] = await getStyleValue(container, action, containers);

  await cleanChildren(action, container);
}

修改样式比新增样式要更加麻烦一些。例如针对 bolditalic,用户可能想要设置,然后取消,然后又设置,然后又取消……

async function getStyleValue(container: HTMLElement, action: ExecCommandStyle, containers: string): Promise<string> {
  if (!container) {
    return action.value;
  }

  if (await action.initial(container)) {
    return 'initial';
  }

  const style: Node | null = 
        await findStyleNode(container, action.style, containers);

  if (await action.initial(style as HTMLElement)) {
    return 'initial';
  }

  return action.value;
}

对于 boldinitial 函数就是一个简单的属性检查。

{
  style: 'font-weight',
  value: 'bold',
  initial: (element: HTMLElement | null) => 
           Promise.resolve(element && 
                           element.style['font-weight'] === 'bold')
}

对于颜色,就比较麻烦一点,因为色值可能是 hexrbg 格式,两个都要支持。

{
  style: this.action,
  value: $event.detail.hex, // 用户选中的颜色
  initial: (element: HTMLElement | null) => {
    return new Promise<boolean>(async (resolve) => {
      const rgb: string = await hexToRgb($event.detail.hex);
      resolve(element && (element.style[this.action] === 
              $event.detail.hex || 
              element.style[this.action] === `rgb(${rgb})`));
  });
}

借助上述的 initial ,我就可以判断应该新增样式、还是移除样式。

但这些还不够。因为选区的样式有可能会继承它父元素的,例如 <div style="font-weight: bold"><span/></div>。所以我新建了一个函数 findStyleNode ,它会递归查找,直到找到相同样式的元素或者容器。

async function findStyleNode(
    node: Node, 
    style: string, 
    containers: string
): Promise<Node | null> {
  // Just in case
  if (node.nodeName.toUpperCase() === 'HTML' || node.nodeName.toUpperCase() === 'BODY') {
    return null;
  }

  if (!node.parentNode) {
    return null;
  }

  if (DeckdeckgoInlineEditorUtils.isContainer(containers, node)) {
    return null;
  }

  const hasStyle: boolean =
    (node as HTMLElement).style[style] !== null && 
    (node as HTMLElement).style[style] !== undefined && 
    (node as HTMLElement).style[style] !== '';

  if (hasStyle) {
    return node;
  }

  return await findStyleNode(node.parentNode, style, containers);
}

最后,样式会被设置好,cleanChildren 也会被执行。cleanChildren 也是一个递归方法,但并不是往 DOM 树的上级去递归,而是去递归容器的所有子元素,直到处理完。

async function cleanChildren(action: ExecCommandStyle, span: HTMLSpanElement) {
  if (!span.hasChildNodes()) {
    return;
  }

  // Clean direct (> *) children with same style
  const children: HTMLElement[] = 
        Array.from(span.children)
             .filter((element: HTMLElement) => {
                return element.style[action.style] !== undefined && 
                       element.style[action.style] !== '';
              }) as HTMLElement[];

  if (children && children.length > 0) {
    children.forEach((element: HTMLElement) => {
      element.style[action.style] = '';

      if (element.getAttribute('style') === '' || element.style === null) {
        element.removeAttribute('style');
      }
    });
  }

  // Direct children (> *) may have children (*) to be clean too
  const cleanChildrenChildren: Promise<void>[] = 
    Array.from(span.children).map((element: HTMLElement) => {
      return cleanChildren(action, element);
  	});

  if (!cleanChildrenChildren || cleanChildrenChildren.length <= 0) {
    return;
  }

  await Promise.all(cleanChildrenChildren);
}

Replace Selection 替换选区

替换选区的设置样式会稍微简单一点(相比于 Update Selection),使用 range.extractContents 可以得到一个 fragment ,它可以作为内容被添加到新的 span 中。

async function replaceSelection(container: HTMLElement, 
                                action: ExecCommandStyle, 
                                selection: Selection, 
                                containers: string) {
  const range: Range = selection.getRangeAt(0);

  const fragment: DocumentFragment = range.extractContents();

  const span: HTMLSpanElement = await createSpan(container, action, containers);
  span.appendChild(fragment);

  await cleanChildren(action, span);
  await flattenChildren(action, span);

  range.insertNode(span);
  selection.selectAllChildren(span);
}

对新的 span 设置样式,我可以直接复用上文的 getStyleValue 函数。

async function createSpan(
	container: HTMLElement, 
    action: ExecCommandStyle, 
    containers: string
): Promise<HTMLSpanElement> {
  const span: HTMLSpanElement = document.createElement('span');
  span.style[action.style] = await getStyleValue(container, action, containers);

  return span;
}

创建了新 span 并且加入了 fragment 之后,还得去 cleanChildren 以便样式对所有的子元素都能生效。cleanChildren 也和上文介绍的一样。

最后,我会尽量避免没有任何样式的 span 标签,因此新建了一个 flattenChildren 函数。它会检查子元素中的 span,如果没有了任何样式,那就把 span 变成普通文本节点。即,清理无用的空 span 元素。

async function flattenChildren(
	action: ExecCommandStyle, 
    span: HTMLSpanElement
) {
  if (!span.hasChildNodes()) {
    return;
  }

  // Flatten direct (> *) children with no style
  const children: HTMLElement[] =    
      Array.from(span.children).filter((element: HTMLElement) => {
         const style: string | null = element.getAttribute('style');
         return !style || style === '';
      }) as HTMLElement[];

  if (children && children.length > 0) {
    children.forEach((element: HTMLElement) => {
      const styledChildren: NodeListOf<HTMLElement> = element.querySelectorAll('[style]');
      if (!styledChildren || styledChildren.length === 0) {
        const text: Text = document.createTextNode(element.textContent);
        element.parentElement.replaceChild(text, element);
      }
    });

    return;
  }

  // Direct children (> *) may have children (*) to flatten too
  const flattenChildrenChildren: Promise<void>[] =  
    Array.from(span.children).map((element: HTMLElement) => {
       return flattenChildren(action, element);
    });

  if (!flattenChildrenChildren || flattenChildrenChildren.length <= 0) {
    return;
  }

  await Promise.all(flattenChildrenChildren);
}

总而言之

本文相关的详细代码可以从这里获取

如果你想要下载到本地,你需要去 clone mono-repo

结论

文章快写完了,自己又回顾了一下,老实说,我不确定是否有人能明白我写的内容😅。我希望通过本文至少能激起你对 WYSIWYG 项目的好奇心

下次做幻灯片时欢迎试一下 DeckDeckGo,也可以给我们提一些建议和好想法。

飞向无限!

David(本文作者,译者注)