缘起
最近产品想让我在富文本里加个旋转图片的功能,我一想🤔,就觉得事情并不简单,因为印象中好像没见过这种操作。果然,经过一番百度之后,确实没怎么看到相关信息,这也就意味着要自己动手丰衣足食了😢。但我自己对富文本又没什么了解,所以顺带稍微看了下富文本的实现方式,特此来沉淀一下,还是那句话不喜勿喷哈🙄。
ok,这里先简要说下为什么会有富文本这种东西吧🤓!大概可能也许是因为有一天产品用着用着
textarea
感觉太单调了,单纯的文字已经无法表达他们内心的需求🤯,于是就想来点样式,顺便加个图片,来篇图文并茂的文章,就像小型 Word 那样,就再好不过了!于是富文本就这样诞生了,开发者们也纷纷开始了踩坑之旅🕳🕳🕳。
前置知识
好了,交代完了背景,让我们先补充一些基础知识吧,不懂的请务必不要跳过🧐!
contenteditable 属性
假如我们给一个标签加上
contenteditable="true"
的属性,就像这样:
<div contenteditable="true"></div>
复制代码
那么在这个
div
中我们就可以对其进行任意编辑了。如果想要插入的子节点不可编辑,我们只需要把子节点的属性设置为
contenteditable="false"
即可,就像这样:
<div contenteditable="true">
<p>这是可编辑的</p>
<p contenteditable="false">这是不可编辑的</p>
</div>
复制代码
该属性最早是在 IE 上实现的(厉害哦👍),且可以作用于其它标签,不限于
div
,大家应该或多或少都听说过这个属性。
document.execCommand 方法
既然我们可以对上面的
div
随意编辑,那具体怎么编辑呢,目前好像也还是只能输入文本,要怎样才能进行其他操作呢(比如加粗、倾斜、插入图片等等)🤔?其实浏览器给我们提供了这样的一个方法
document.execCommand
,通过它我们就能够操纵上面的可编辑区。具体语法如下:
// document.execCommand(命令名称,是否展示用户界面,命令需要的额外参数)
document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
复制代码
其中第一个参数就是一些命令名称,具体的可以查看 MDN;第二个参数写死为
false
就行了,因为早前 IE 有这样一个参数,为了兼容吧,不过这个参数在现代浏览器中是没有影响的;第三个参数就是一些命令可能需要额外的参数,比如插入图片就要多传个
url
或
base64
的参数,没有的话传个
null
就行。
我们简要列举下它的几个使用方式,大家就知道怎么用了👇:
// 加粗
document.execCommand('bold', false, null);
// 添加图片
document.execCommand('insertImage', false, url || base64);
// 把一段文字用 p 标签包裹起来
document.execCommand('formatblock', false, '<p>');
复制代码
这个命令就是富文本的核心(所以务必记住),浏览器把大部分我们能想到的功能也都实现了,当然各浏览器之间还是有差异的,这里就不考虑了。
Selection 和 Range 对象
我们在执行
document.execCommand
这个命令之前首先要知道对谁执行,所以这里会有一个选区的概念,也就是
Selection
对象,它用来表示用户选择的范围或光标位置(光标可以看做是范围重合的特殊状态),一个页面用户可能选择多个范围(比如 Firefox)。也就是说
Selection
包含一个或多个
Range
对象(
Selection
可以说是
Range
的集合),当然对于富文本编辑器来说,一般情况下,我们只会有一个选择区域,也就是一个
Range
对象,事实上大部分情况也是如此。
所以通常我们可以用
let range = window.getSelection().getRangeAt(0)
来获取选中的内容信息(
getRangeAt
接受一个索引值,因为会有多个
Range
,而现在只有一个,所以写0)。
看得一头雾水😴?没关系,看下面两张图就懂了😮:
Selection
对象还有几个常用的方法,
addRange
、
removeAllRanges
、
collapse
和
collapseToEnd
等等。
这个知识点是很重要的,因为它让我们有了操纵光标的能力(比如插入内容之后设置光标的位置),不过这篇文章中我并没有去深入它,只是浅出😏。
目标
开篇一顿扯,下面让我们抓紧时间做一个属于自己的富文本吧💪,大概会包含以下几个功能:加粗、段落、H1、水平线、无序列表、插入链接、插入图片、后退一步、向前一步等等。🆗,Let's do it!
起步
首先一个富文本大体分为两个区域,一个是按钮区,一个是编辑区。所以它的大致结构就像下面这样:
<template>
<div class="xr-editor">
<!--按钮区-->
<div class="nav">
<button>加粗</button>
...
</div>
<!--编辑区-->
<div class="editor" contenteditable="true"></div>
</div>
<template>
<!--全部样式就这些,这里就都先给出来了-->
<style lang="scss">
.xr-editor {
margin: 50px auto;
width: 1000px;
.nav {
display: flex;
button {
cursor: pointer;
}
&__img {
position: relative;
input {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
opacity: 0;
}
}
}
.row {
display: flex;
width: 100%;
height: 300px;
}
.editor {
flex: 1;
position: relative;
margin-right: 20px;
padding: 10px;
outline: none;
border: 1px solid #000;
overflow-y: scroll;
img {
max-width: 300px;
max-height: 300px;
vertical-align: middle;
}
}
.content {
flex: 1;
border: 1px solid #000;
word-break: break-all;
word-wrap: break-word;
overflow: scroll;
}
}
</style>
复制代码
嗯,起步工作到此结束,接下来就可以直接开始实现功能了😬。
加粗
现在假如我们要实现加粗的效果,该怎么做呢?很简单,只要在点击加粗按钮的时候执行
document.execCommand('bold', false, null)
这句话,就能达到加粗的效果,就像下面这样:
<template>
<div class="nav">
<button @click="execCommand">加粗</button>
</div>
...
</template>
<script>
export default {
name: 'XrEditor',
methods: {
execCommand() {
document.execCommand('bold', false, null);
}
}
};
</script>
复制代码
让我们运行一下看看效果:
当然了,我们开篇也说了我们的一切命令都是基于
document.execCommand
的,所以我们先小小改写一下上面代码中的
execCommand
方法,就像下面这样:
<template>
<div class="nav">
<button @click="execCommand('bold')">加粗</button>
</div>
...
</template>
<script>
export default {
name: 'XrEditor',
methods: {
execCommand(name, args = null) {
document.execCommand(name, false, args);
}
}
};
</script>
复制代码
这样一来代码就更具通用性了。实现列表、水平线、前进、后退功能和加粗是一样样的,只需传入不同的命令名即可,就像下面这样,这里就不一一赘述了:
<button @click="execCommand('insertUnorderedList')">无序列表</button>
<button @click="execCommand('insertHorizontalRule')">水平线</button>
<button @click="execCommand('undo')">后退</button>
<button @click="execCommand('redo')">前进</button>
复制代码
顺带给大家说几个注意点✍️:
-
有的同学可能用的不是
button
标签,然后执行命令就会无效,是因为点击其他标签大多都会造成先失去焦点(或者不知不觉就突然失去焦点了),再执行点击事件,此时没有选区或光标所以会没有效果,这点要留意一下。 -
我们执行的是原生的
document.execCommand
方法,浏览器自身会对contenteditable
这个可编辑区维护一个undo
栈和一个redo
栈,所以我们才能执行前进和后退的操作,如果我们改写了原生方法,就会破坏原有的栈结构,这时就需要自己去维护,那就麻烦了。 -
style
里面如果加上scope
的话,里面的样式对编辑区的内容是不生效的,因为编辑区里面是后来才创建的元素,所以要么删了scope
,要么用/deep/
解决(Vue 是这样)。
段落
这个功能就是把光标所在行的文字用
p
标签包裹起来,为了演示方便,我们顺便把编辑区的
html
结构打印出来,所以让我们稍微改一下代码,就像下面这样:
<template>
<div class="xr-editor">
<div class="nav">
<button @click="execCommand('bold')">加粗</button>
<button @click="execCommand('formatBlock', '<p>')">段落</button>
</div>
<div class="row">
<div class="editor" contenteditable="true" @input="print"></div>
<div class="content">{{ html }}</div>
</div>
</div>
</template>
<script>
export default {
name: 'XrEditor',
data() {
return {
html: ''
};
},
methods: {
execCommand(name, args = null) {
document.execCommand(name, false, args);
},
print() {
this.html = document.querySelector('.editor').innerHTML;
}
}
};
</script>
复制代码
运行效果如下:
h1
~
h6
也是一样的,命令为
execCommand('formatBlock', '<h1>')
,也不赘述了。
插入链接
这个功能因为需要第三个参数,所以我们一般会给个提示框获取用户输入,然后再执行
execCommand('createLink', 链接地址)
,代码如下:
<button @click="createLink">链接</button>
复制代码
createLink() {
let url = window.prompt('请输入链接地址');
if (url) this.execCommand('createLink', url);
}
复制代码
效果如下:
insertImgLink() {
let url = window.prompt('请输入图片地址');
if (url) this.execCommand('insertImage', url);
}
复制代码
插入图片
图片除了可以通过添加地址的形式外,还可以添加 base64 格式的图片,这里我们通过
readAsDataURL(file)
来读取图片,并执行
execCommand('insertImage', base64)
就大功告成啦,具体代码如下,并不复杂:
<button class="nav__img">插入图片
<!--这个 input 是隐藏的-->
<input type="file" accept="image/gif, image/jpeg, image/png" @change="insertImg">
</button>
复制代码
insertImg(e) {
let reader = new FileReader();
let file = e.target.files[0];
reader.onload = () => {
let base64Img = reader.result;
this.execCommand('insertImage', base64Img);
document.querySelector('.nav__img input').value = ''; // 解决同一张图片上传无效的问题
};
reader.readAsDataURL(file);
}
复制代码
运行一下,看看效果:
url
地址再插入也是可以的。
👌至此,一个简易版的富文本就完成了(当然了 bug 也是有的🤭,不过并不妨碍我们理解),具体代码可以参考 npm 上的
pell
包,它已经是个极简版的了。
进阶
其实富文本对文本的操作大多都可以用原生命令来实现,但是对图片的操作也许就不那么容易了,来个拉伸、旋转啥的就够我们折腾了🤨,所以这里以图片拉伸为例子着重讲解一下。
图片拉伸
我们先看下大致效果,大家也可以先停下来思考一分钟看看如何实现🤔:
1. 判断用户点击的是否是编辑区里面的图片
这个就是看点击事件
e.target.tagName
是不是
img
标签了,代码如下,应该比较简单:
mounted() {
this.editor = document.querySelector('.editor');
this.editor.addEventListener('click', this.handleClick);
},
methods: {
handleClick(e) {
if (
e.target &&
e.target.tagName &&
e.target.tagName.toUpperCase() === 'IMG'
) {
this.handleClickImg(e.target);
}
}
}
复制代码
2. 在点击的图片上创建个大小一样的 div
如果点击的是一个图片,那我们就创建一个
div
,暂且把这个
div
叫做蒙层吧,顺便先看张示意图:
handleClickImg(img) {
this.nowImg = img;
this.showOverlay();
}
showOverlay() {
// 添加蒙层
this.overlay = document.createElement('div');
this.editor.appendChild(this.overlay);
// 定位蒙层和大小
this.repositionOverlay();
},
repositionOverlay() {
let imgRect = this.nowImg.getBoundingClientRect();
let editorRect = this.editor.getBoundingClientRect();
// 设置蒙层宽高和位置
Object.assign(this.overlay.style, {
position: 'absolute',
top: `${imgRect.top - editorRect.top + this.editor.scrollTop}px`,
left: `${imgRect.left -
editorRect.left -
1