从零开始写一个富文本编辑器(一)

7,558 阅读4分钟

前言

富文本编辑器技术的演进

Level 0(不知道为啥从零开始)是编辑器的起始阶段,代表旧一代的编辑器的实现

Level 1 第二阶段,是在第一阶段发展过来的,有一定的先进性,也引入了主流的一些编程思想,对于富文本内容有一定的抽象

Level 2 第三阶段,完全不依赖浏览器的编辑能力,独立的实现光标和排版

搜了一下掘金,发现富文本编辑器的文章实在是太少了,于是一时兴起,准备写一个系列,从Level 0的编辑器写到Level 2的编辑器。

从Level 0到Level 2,个人理解就是将富文本编辑器的控制权一步步由浏览器控制,变成由开发者控制。

最简单的富文本编辑器

<div contenteditable></div>

利用浏览器的能力,我们便拥有了一个最简单的富文本编辑器,而且可以通过浏览器自带的能力,如document.execCommand,达到一些加粗、斜体的目的。

本章节的内容就是去写一个简单的Level 0的富文本编辑器

一个简单的编辑器

由于简单,我们先按如下的图来划分模块

编辑器核心

首先我们来定义主类Editor,思考一个编辑器需要哪些基础api,初步定义了【获取编辑器内容】和【设置编辑器内容】两个api。

// editor.ts

interface IEditorProps {
  container: HTMLElement;
  editable: boolean; // 编辑器是否可编辑
  createToolbar?: (editor: Editor) => HTMLElement[];
}

class Editor {
    // 编辑器主体编辑区域
    private body: HTMLELement;

    constructor(props: IEditorProps) {
    	// ...
    }
    
    // 获取富文本内容
    getContent() {
    	return this.body.innerHTML;
    }
    
    // 设置富文本内容
    setContent(html: string) {
    	this.body.innerHTML = html;
    }
   
    // 焦点是否在编辑器内
    isFocus() {
      	return (
            document.activeElement === this.body ||
            this.body!.contains(document.activeElement)
      	);
    }
    
    // 对document.execCommand封装一层,使得如果window selection在编辑器外,调用execCommand仍然有效
    execCommand(commandId, showUI?, value?) {
    	if (!this.isFocus()) {
      	    this.selection.setSelection();
    	}
    	document.execCommand(commandId, showUI, value);
    }
}

编辑器选区

选区表达了当前编辑器选中的文本范围或者光标所在位置,选区的表达是Range,目前采用的是浏览器原生的表达 developer.mozilla.org/zh-CN/docs/…

// selection.ts
interface ISelectionProps {
  body: HTMLElement;
}
export class Selection {
  private range: Range | null = null;
  
  constructor (private props: ISelectionProps) {
    document.addEventListener('selectionchange', this._handleSelectionChange);
  }

  private _handleSelectionChange = () => {
    // ...
  }

  setSelection = (range?: Range) => {
    // ...
  }
  
  destroy() {
    document.removeEventListener('selectionchange', this._handleSelectionChange);
  }
}

工具栏

目前采用的方式是传入一个createToolbar方法来创建工具栏,但是这可能并不是一个非常合适的方式,更好的工具栏模块设计还在进一步思考中。

当前的设计中,createToolbar会返回一个HTMLElement数组,那么可能有人会问,为什么不是直接传入这个HTMLElement数组,而且传入一个函数,反而多了一层。最主要的原因是为了扩展,虽然现在工具栏使用的api基本上是document.execCommand,但并不是完全与当前的Editor实例无关了,在某些时候,可能需要调用到编辑器内部的api。因此需要把当前的Editor实例传递进去。

目前内置了这三个工具栏按钮

工具栏按钮的坑

举个例子,加粗,当我们选中文字时,点击加粗按钮,预期是选中的文字加粗了。

我们的做法是在加粗按钮上绑定click事件,然而这样做之后,就会发现文字并没有加粗。

  boldBtn.addEventListener('click', () => {
  	document.execCommand('bold');
  });

原因是当我们点击事件执行时,windows的选区已经不在选中的文本上了,此时选区如图所示

解决方案1

editor内保存的选区重新设置回去。但这种方案有个缺点,即选中的蓝色区域会闪一下。

    const selection = window.getSelection();
    if (!selection) {
      return;
    }

    selection.removeAllRanges();

    // range即原先的选区
    selection.addRange(range);

解决方案2

我们录一个profile,发现在mousedown事件之后,触发了编辑区域的blur事件,那问题就简单了,我们阻止掉mousedown事件的默认行为即可。

  boldBtn.addEventListener('mousedown', (e) => {
  	e.preventDefault();
  });

源码 github.com/TGuoW/T-edi…

结语

第一篇文章到此就结束了,可以看到这个简单编辑器还是存在很多缺点。

举一些例子

  1. 编辑器内容完全是由浏览器控制的,不同浏览器可能还不一样,容易发生不符合预期的行为

  2. 选区的描述还是原生的Range,不符合直觉。比如说 我们正常的思维可能是我的光标在某一行的某一列,而不是现在的某个元素的某个偏移位置

  3. 缺少了很多功能,比如插入图片、插入视频等。

  4. 难以扩展,假设开发者想要在 编辑器内插入一个iframe,那么他就必须去改动底层的代码。