本文是中文翻译,英文原文链接 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.execCommand
。wangEditor 接下来(待 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);
}
修改样式比新增样式要更加麻烦一些。例如针对 bold
或 italic
,用户可能想要设置,然后取消,然后又设置,然后又取消……
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;
}
对于 bold
,initial
函数就是一个简单的属性检查。
{
style: 'font-weight',
value: 'bold',
initial: (element: HTMLElement | null) =>
Promise.resolve(element &&
element.style['font-weight'] === 'bold')
}
对于颜色,就比较麻烦一点,因为色值可能是 hex
和 rbg
格式,两个都要支持。
{
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);
}
总而言之
本文相关的详细代码可以从这里获取
- the WYSIWYG Web Component
ExecCommandStyle
接口- 函数的实现
如果你想要下载到本地,你需要去 clone mono-repo。
结论
文章快写完了,自己又回顾了一下,老实说,我不确定是否有人能明白我写的内容😅。我希望通过本文至少能激起你对 WYSIWYG 项目的好奇心
下次做幻灯片时欢迎试一下 DeckDeckGo,也可以给我们提一些建议和好想法。
飞向无限!
David(本文作者,译者注)