实现一个简单的编辑器

2,591 阅读3分钟

转载请保留这部分内容,并注明出处。

关注微信公众号“头号前端”,每周几次新鲜的前端好文推送。

另外,头条号前端团队非常 期待你的加入

一、编辑器分类

1. 接管所有事件,有自己的排版引擎

  • Google Docs

    • 光标 kix-cursor-caret

    • 输入 docs-texteventtarget-iframe contenteditable

  • 金山文档

    • 光标 cursor-item

    • 输入virtual-input

  • Tbus

    • Tbus-selection

2. 接管渲染,监听/拦截 事件修正状态,有自己的模型层

  • Prosemirror

  • Slate

  • Draft

3. 依赖 document.execCommand

  • ueditor

  • kindeditor

二、实现一个简单的编辑器

1. 什么是 contenteditable

HTML中的 contenteditable 的属性可以打开某些元素的可编辑状态.也许你没用过 contenteditable 属性.甚至从未听说过. contenteditable 的作用相当神奇.可以让 div 或整个网页,以及 span 等等元素设置为可写。我们最常用的输入文本内容便是 inpu t与t extarea ,使用 contenteditable 属性后,可以在 div , table , p , span , body ,等等很多元素中输入内容。 即通过 contenteditable 可以让普通的元素实现可编辑状态。

2. 什么是 Selection

Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。要获取用于检查或修改的 Selection 对象,请调用 window.getSelection()

3. 通过execCommand实现编辑器

const formatBlock = 'formatBlock'
const appendChild = (parent, child) => parent.appendChild(child)
const createElement = tag => document.createElement(tag)
const queryCommandValue = command => document.queryCommandValue(command)
export const exec = (command, value = null) => document.execCommand(command, false, value)

const tools = {
  bold: {
    icon: 'B',
    title: 'Bold',
    handler: () => exec('bold')
  },
  heading1: {
    icon: 'H1',
    title: 'Heading 1',
    handler: () => {
      if (queryCommandValue(formatBlock) === 'h1') {
        exec(formatBlock, '<p>')
      } else {
        exec(formatBlock, '<h1>')
      }
    }
  },
  paragraph: {
    icon: 'P',
    title: 'Paragraph',
    handler: () => exec(formatBlock, '<p>')
  },
  quote: {
    icon: '“',
    title: 'Quote',
    handler: () => {
      exec(formatBlock, '<blockquote>')
      const { focusNode } = window.getSelection();
      const textBlock = createElement('p');
      const blockquote = focusNode.nodeType === 3 ? focusNode.parentElement : focusNode;
      
      textBlock.appendChild(focusNode.nodeType === 3 ? focusNode : focusNode.firstChild);
      blockquote.appendChild(textBlock)
        
    }
  },
  olist: {
    icon: '<small>1<small>—',
    title: 'Ordered List',
    handler: () => exec('insertOrderedList')
  },
  link: {
    icon: '🔗',
    title: 'Link',
    handler: () => {
      const url = window.prompt('Enter the link URL')
      if (url) exec('createLink', url)
    }
  },
  image: {
    icon: '&#128247;',
    title: 'Image',
    handler: () => {
      const url = window.prompt('Enter the image URL')
      if (url) exec('insertImage', url)
    }
  }
}
const editor = document.querySelector('#editor');
const toolbar = document.querySelector('#toolbar')
editor.focus();
const wrapParagraph = () => {
  if (!editor.firstChild || editor.firstChild.nodeType === 3) exec(formatBlock, `<p>`)
}
wrapParagraph();
editor.onkeydown = event => {
  if (event.key === 'Enter' && queryCommandValue(formatBlock) === 'blockquote') {
    setTimeout(() => exec(formatBlock, `<p>`), 0)
  }
}
Object.values(tools).forEach((tool) => {
  const button = createElement('button')
  button.innerHTML = tool.icon
  button.title = tool.title
  button.setAttribute('type', 'button')
  button.onclick = () => tool.handler() && editor.focus()
  appendChild(toolbar, button)
})

exec('defaultParagraphSeparator', 'p')

实现了一个完备的编辑器,但是存在一些问题

4. 问题

  1. 对内容的控制不足,只能满足基本的编辑需求

  2. contenteditable=false 的元素处理存在很大的问题

  3. 对历史状态的控制完全依赖浏览器

  4. 强依赖 document.execCommand 这个不稳定的功能

  5. 对选区位置缺少控制,依赖浏览器会导致行为不符合预期

  6. ...

核心的能力依赖的都是外部的不稳定的功能

5. 脱离execCommand实现编辑器

  1. execCommand 只在编辑器中渲染,完全可以通过使用 domapi 来实现渲染功能。

  2. 更重要的一个问题是拥有一个能描述出当前文档的数据结构,并拦截或者是监听用户的输入行为,把对 dom 的操作转换成对文档结构的操作。再把文档的数据映射到 dom

实现一个parser

class Node {
  constructor(name, data, children = []) {
    this.name = name;
    this.data = data;
    this.children = children;
  }
}
class TextNode extends Node {
  constructor(data) {
    super('text', data)
  }
}
class DOMNode extends Node {
  constructor(name, data) {
    super(name, data)
  }
}
class EDOMParser {
  constructor() {
    this.parser = new DOMParser();
    this.top = new DOMNode('body');
  }
  parse(html) {
    const dom = this.parser.parseFromString(html, 'text/html').body;
    for(let i = 0; i < dom.childNodes.length; i++) {
      const context = new ParseContext(dom.childNodes[i], '')
      if (context.content) {
        this.top.children.push(context.content);
      }
    }
    return this.top;
  }
}
class ParseContext {
  constructor(dom) {
    this.dom = dom;
    this.start();
  }
  start() {
    if (this.dom.nodeType === 1 && this.dom.nodeName === 'P') {
      this.content = new DOMNode('P');
      this.parseInner(this.dom)
    }
  }
  parseInner(dom) {
    for(let i = 0; i < dom.childNodes.length; i++) {
      this.addNode(dom.childNodes[i]);
    }
  }
  addNode(dom) {
    if (dom.nodeType === 3) {
      this.addTextNode(dom);
    }  else {
      this.parseInner(dom)
    }
  }
  addTextNode(dom) {
    this.content.children.push(new TextNode(dom.textContent))
  }
}
export { EDOMParser }

现在我们就实现了一个简单的编辑器,但还不成熟,我们还应补充:对输入的处理、对粘贴剪切的处理、对选区的处理...

三、总结

对于绝大多数的编辑需求,依赖于 contenteditable 去实现已经可以很好的满足。对于更高阶的需求,我们应该尽可能的抽象,屏蔽对外部的依赖对数据的影响,从而才能实现一个健壮的编辑器。