用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()
}
}
}