JavaScript 简单富文本编辑器

1,736 阅读8分钟

用JavaScript制作一个简单的富文本编辑器

该富文本编辑器只实现了加粗、添加字体颜色、添加标题等功能,其他的功能实现可以查看document.execCommand API

1、核心API

  • 1、contenteditable 该属性指定元素内容是否可编辑,当可编辑的时候HTML文档模式将会切换到设计模式。
  • 2、document.execCommand 当HTML文档为设计模式的时候,document 暴露 execCommand 方法,该方法允许运行命令来操纵可编辑内容区域的元素。

2、方案设计

简单的划分后可以得到:

  • 1、主容器
  • 2、工具栏 - 功能按钮
  • 3、编辑区域 - 段落

所以我们需要一个挂载点作为主容器
需要在主容器内生成一个工具栏区域和一个编辑区域
工具栏区域里面需要生成功能按钮
编辑区域里面需要生成段落

3、准备工作

这里说明一下,这篇文章主要写的是开发思路和实现过程,项目搭建我就不具体去写了。
因为我最近有在去了解webpack,所以项目搭建我用了webpack,没用过的同学,可以不需要使用webpack,并且把es 代码改写成es5就可以了,本文章的源码也会放在文章末尾,你也可以选择拉取代码。

  • 1、index.html 页面是必须要有的233
  • 2、index.js js代码,富文本编辑器的实现
  • 3、index.css 富文本编辑器的样式,这里我就不去写,大家按照自己喜欢的来

index.html 这里再解释一下,因为我用webpack打包,所以啊,我这里的代码是没有引入js和css的

<!DOCTYPE html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>富文本编辑器</title>
        <meta name="description" content="富文本,编辑器,富文本编辑器">
        <meta name="viewport" content="width=device-width, initial-scale=1">
    </head>
    <body>
        <!-- 外层容器 -->
        <div id="container"></div>
    </body>
</html>

index.scss

html,
body {
    margin: 0;
    padding: 0;
    height: 100%;
}

p { margin: 0;}

.th-container {

    position: relative;
    width: 500px;
    height: 500px;
    margin: 20px;
    background-color: #eee;
    box-sizing: border-box;
    overflow: hidden;

    .th-toolbar {

        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 20px;
        font-size: 0;
        border-bottom: 1px solid #999;
        background-color: white;
        box-sizing: border-box;

        > button {
            
            height: 20px;
            line-height: 20px;
            padding: 0 6px;
            font-size: 12px;
            border: 1px solid #999;
            background-color: #FFF;
            box-sizing: border-box;

            > b {
                
                font-size: 12px;
            }

            &:hover {
                background-color: #eee;
            }

            &._active {
                color: #008af0;
            }
        }
    }

    .th-edit-box {

        height: calc(100% - 20px);
        margin-top: 20px;
        padding: 10px;
        border: 1px solid #999;
        border-top: none;
        box-sizing: border-box;
        outline: none;
    }
}

4、工具栏 和 功能按钮

首先我们需要一个能存放功能按钮的工具栏容器。
接着我们需要为它添加上class。

    const toolbarDiv = document.createElement(div)
    toolbarDiv.className = 'th-toolbar'

接下来我们需要生成功能按钮。
首先功能按钮肯定是一个可以轻易增减的。
所以它必定是一个配置项或者说是一类东西的集合。
其次我要怎么去实现对应的功能,相信大部分同学都看了开始的document.execCommand API
我们可以通过document.execCommand(command)来控制编辑区域。

const defaultConfig = {
    bold: {
        icon: '<b>B</b>', // 按钮显示的内容
        title: '加粗', // 按钮title属性的值
        state: () => document.queryCommandState('bold'), // 当前命令的状态
        result: () => document.execCommand('bold', false) // 点击按钮时所作的操作
    },
    res: {
        icon: '<b>红色</b>',
        title: '红色',
        state: () => document.queryCommandState('foreColor'),
        result: () => document.execCommand('foreColor', false, '#F00')
    },
    h1: {
        icon: '<b>H<sub>1</sub></b>',
        title: '标题1',
        result: () => document.execCommand(configs.formatBlock, false, '<h1>')
    },
}

通过上面代码我们会发现有几个地方有重复的部分,这里我们可以简单做一个封装。
封装完了后就可以把对应部分的代码给替换掉,这里我就不做演示了。

const utils = {
    exec(key, val) {
        return document.execCommand(key, false, val)
    },

    queryCommandState(type) {
        return document.queryCommandState(type)
    },
}

const { exec, queryCommandState } = utils

有了功能按钮的配置项后,我们就可以通过配置项去生成对应的按钮了。

// 将对象,转换成数组
const configs = Object.keys(defaultConfig).map(item => defaultConfig[item])

configs.forEach( item => {
    
    const button = createElement('button') // 对 document.createElement() 进行了封装
    button.innerHTML = item.icon // 设置html内容
    button.title = item.title // 设置标题
    button.setAttribute('type', 'button') // 设置type属性
    button.onclick = () => { // 设置点击事件
        item.result()
    }
    
    toolbarDiv.appendChild(button) // 添加到工具栏容器里面
} )

到这里工具栏这一部分就基本上已经完成了,只剩下把工具栏容器添加到挂载的外层容器里面了

5、编辑区域

编辑区域的创建就比工具栏部分的简单一些,大体上一致。

const editDiv = createElement('div') // 创建容器
editDiv.setAttribute('contenteditable', true)  // 设置容器为可编辑
editDiv.className = 'th-edit-box'  // 添加样式

这里基本上就完成了,但是实际查看效果的时候会发现,编辑区域的段落用的div标签作为分隔,我们期望是分隔应该是p标签,所以我们要对编辑区域做一些处理

// 当编辑区域的内容发生了变化的时候
editDiv.oninput = (el) => {
    const firstChild = el.target.firstChild // 获取编辑区域的内容
    // 当编辑区域的内容是文本的时候生成一个p标签
    // 当编辑区域的内容是<div><br></div><div><br></div>的时候设置编辑区域内容为空
    if (firstChild && firstChild.nodeType === 3) exec('formatBlock', `<p>`) 
    else if (editDiv.innerHTML === '<div><br></div><div><br></div>') editDiv.innerHTML = ''
}

// 当编辑区域有按键按下的时候
editDiv.onkeydown = event => {
    // 当按下的按键是回车键的时候 并且 获取当前命令的返回值是 '' 或者 'div'的时候,我们用p标签覆盖它。
    if (event.key === 'Enter' &&  (['', 'div'].indexOf(document.queryCommandValue('formatBlock')) != -1) ) {
        setTimeout(() => exec('formatBlock', `<p>`), 0)
    }
}

上面的代码中的 ['', 'div'] 、 p 、 document.queryCommandValue都可以封装一下,或者做成一个可配置的对象文件。

6、将 工具栏 和 编辑区域添加到挂载点

当我们生成了工具栏和编辑区域后,就需要把他添加到挂载的外层容器里面是把

class RichText {
    
    constructor(selector, config) {
    
        // 获取挂载点的元属对象 querySelector: document.querySelector()
        if(typeof selector === 'string') 
            this.el = querySelector(selector)
        else 
            this.el = querySelector('body')
        
        this.el.classList.add('th-container') // 添加样式

        this.defaultParagraph = 'p' // 设置默认段落分隔标签

        this.createEditNode() // 创建编辑区域
        this.createToolbarNode(config) // 创建工具栏
    }
    
    createEditNode() {
        //...
    }
    
    createToolbarNode() {
        //...
    }
}

window.onload = () => {
    const richText = new RichText('#container')
}

7、Selection 和 Range

Selection

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

Range

Range 接口表示一个包含节点与文本节点的一部分的文档片段。
可以用 Document 对象的 Document.createRange 方法创建 Range,也可以用 Selection 对象的 getRangeAt 方法获取 Range。另外,还可以通过 Document 对象的构造函数 Range() 来得到 Range。

这两个API对于富文本编辑器来说是主要的核心API。
所以在本文章的项目里面加了点功能,需要用到 Selection 和 Range。

当对富文本编辑器中的某一段落中的某几个字进行选中(鼠标按住不动拖动后,会有蓝色底色)后,点击编辑器外后,再点击功能按钮例如加粗后,依旧可以对之前选中的字进行操作。

分析:

首先我们需要对 文本选区 进行保存是把?
这里我们可以用 window.getSelection() 获取用户所以的 文本选区
然后通过 getRangeAt(0) 方法拿到第一条文本选区(这里不做过多的深入,需要了解更多的同学可以去MDN上查看)

其次我们要考虑怎么样才能在 文本选区 变动的时候拿到它?
没错,是鼠标抬起按键的事件,因为选区文字需要鼠标按下不动并且拖拽才能进行选区操作的。

最后我们要考虑怎么样去判断,是否有缓存的文本选区?
这里我们要用到 window.getSelection().isCollapsed 它用于判断选区的起始点和终点是否在同一个位置。
意思就是,我当前只有一个光标,没有选区,这个时候我们就去判断一下我们保存的range对象是否是一个选区了 myRange.collapsed

分析完了就开始上代码了

class RichText {
    constructor(selector, config) {
        //... 之前的代码我就不在写了
        this.range = ''
    }
    
    // 创建 编辑区域
    createEditNode() {
        //... 之前的代码我就不在写了
        editDiv.onmouseup = () => {
            this.range = window.getSelection().getRangeAt(0)
        }
    }
    
    // 创建 工具栏
    createToolbarNode() {
        //... 之前的代码我就不在写了
        button.onclick = () => {
            
            const sel = window.getSelection() // 获取当前的用户选区对象
            
            // 判断当前用户选区是否只是光标,并判断range是否存在,存在的话,判断是否有选区
            if(sel.isCollapsed && (this.range && !this.range.collapsed) ) {
            
                // 点击按钮的时候会生成一个光标选区,干掉它。
                // 这里不干掉它的话,新增进去的选区就并没有什么用
                sel.removeAllRanges() 
                
                // 把缓存的选区添加到用户选区里面去
                sel.addRange(this.range)
            }
            
            // 正常执行功能按钮的操作
            item.result()
        }
    }
}

---------完----------

代码地址

rich-text

参考

jaredreich/pell
不到200行 JavaScript 代码如何实现富文本编辑器