专栏首页前端皮小蛋实现一个简单的编辑器

实现一个简单的编辑器

一、编辑器分类

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 去实现已经可以很好的满足。

对于更高阶的需求,我们应该尽可能的抽象,屏蔽对外部的依赖对数据的影响,从而才能实现一个健壮的编辑器。

本文分享自微信公众号 - 前端皮小蛋(gh_e69260c16440),作者:头号前端

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2021-01-21

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 编写一个非常简单的 JavaScript 编辑器

    当然,我们已经有可以使用的很好的Web编辑器:你只需下载,并插入页面即可。我以前习惯于使用CodeMirror和ACE。例如,我为CodeMirror写了一个插...

    哲洛不闹
  • 使用 LLVM 实现一个简单编译器

    ? 作者:tomoyazhang,腾讯 PCG 后台开发工程师 1. 目标 这个系列来自 LLVM 的Kaleidoscope 教程,增加了我对代码的注释以及...

    腾讯技术工程官方号
  • 实现一个简单的表单校验器

    最近笔者在用React+antd做管理后台系统需求的时候,碰到了一个问题,就是在同一个antd的FormItem下面有多个子数据,那么在表单校验的时候某个数据一...

    IMWeb前端团队
  • LLVM极简教程:9个步骤!实现一个简单编译器

    ? 导语 | 本文将从目标及详细的步骤教学来介绍使用LLVM实现一个简单编译器,希望带领大家去理解使用LLVM实现一个编译器的完整代码运行。 一、目标 这个...

    腾小云
  • Java | ​实现一个简单的 IOC 容器 (一)

    这篇文章主要讲一下如何使用 Java 实现一个简单的 IOC 容器,这里该系列的第一篇,要实现的内容的也相对简单,主要介绍一下 B 依赖 A 这种简单的关系是怎...

    双鬼带单
  • Java | 实现一个简单的 IOC 容器 (一)

    这篇文章主要讲一下如何使用 Java 实现一个简单的 IOC 容器,这里该系列的第一篇,要实现的内容的也相对简单,主要介绍一下 B 依赖 A 这种简单的关系是怎...

    双鬼带单
  • 一个简单的多机器人编队算法实现--PID

    用PID进行领航跟随法机器人编队控制 课题2:多机器人编队控制 研究对象:两轮差动的移动机器人或车式移动机器人 研究内容:平坦地形,编队的保持和避障,以及避障和...

    机器人网
  • 一个简单的多机器人编队算法实现--PID

    课题2:多机器人编队控制 研究对象:两轮差动的移动机器人或车式移动机器人 研究内容:平坦地形,编队的保持和避障,以及避障和队形切换算法等;起伏地形,还要考虑地形...

    zhangrelay
  • 实现一个简单的redux

    上面的代码虽然实现了修改, 但是却没有通知到所有使用到name的地方上,我们通过发布订阅来实践一下

    JianLiang
  • 手把手教你实现一个简易的Vue组件在线编辑器

    vue-cli使用过vue的我想大家都知道,那么xxx.vue组件是怎么运行的呢?怎么把template,script,style渲染到页面上的呢?今天我们手动...

    小丑同学
  • 实现一个简单音乐播放器

    1、写静态页面 2、通过getMusicList函数用Ajax获取歌曲数据列表musiclist 3、通过loadMusic函数实现歌曲的播放功能 4、根据获取...

    bamboo
  • 简单实现一个Promise

    static:静态属性指的是 Class 本身的属性,即Class.propName,而不是定义在实例对象(this)上的属性。

    木子星兮
  • [Java定时器]用Spring Task实现一个简单的定时器.

    一枝花算不算浪漫
  • [Java定时器]用Spring Task实现一个简单的定时器.

    今天做一个项目的的时候需要用到定时器功能. 具体需求是: 每个月一号触发一次某个类中的方法去拉取别人的接口获取上一个月份车险过期的用户. 如若转载请附上原文链接...

    一枝花算不算浪漫
  • php + redis + lua 实现一个简单的发号器

    很多地方我们都需要一个全局唯一的编号,也就是uuid。举一个常见的场景,电商系统产生订单的时候,需要有一个对应的订单编号。在composer上我们也可以看到有很...

    猿哥
  • Spring AOP的一个简单实现

    首先配置XML:service采用和之前一样的代码,只是没有通过实现接口来实现,而是直接一个实现类。transactionManager依旧为之前的事务管理器。

    Rekent
  • python实现一个简单的dnspod

    dnspod api地址:https://www.dnspod.cn/docs/records.html#record-create

    py3study
  • Netty实现一个简单的 RPC

    众所周知,dubbo 底层使用了 Netty 作为网络通讯框架,而 Netty 的高性能我们之前也分析过源码,对他也算还是比较了解了。今天我们就自己用 Nett...

    用户5224393
  • 一个新的markdown编辑器

    最近一段时间没有更新文章,因为业余时间都在写一个markdown编辑器。市面上有很多各种各样的编辑器,但是或多或少存在一些问题,对于一个轻度强迫症患者而言,在使...

    ACM算法日常

扫码关注云+社区

领取腾讯云代金券