不管是你的,还是我的,终究是那帮孙子们的。所以说,看看别的人的东西,写一写,学一学。大家都是读书人,借嘛!
前置知识
contenteditable 属性
假如我们给一个标签加上 contenteditable="true"
的属性,就像这样:
那么在这个 div
中我们就可以对其进行任意编辑了。如果想要插入的子节点不可编辑,我们只需要把子节点的属性设置为contenteditable="false"
即可,就像这样:
这是可编辑的
这是不可编辑的
document.execCommand 方法
既然我们可以对上面的div
随意编辑,那具体怎么编辑呢,目前好像也还是只能输入文本,要怎样才能进行其他操作呢(比如加粗、倾斜、插入图片等等)?其实浏览器给我们提供了这样的一个方法 document.execCommand
,通过它我们就能够操纵上面的可编辑区。具体语法如下:
// document.execCommand(命令名称,是否展示用户界面,命令需要的额外参数)
document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
其中第一个参数就是一些命令名称,具体的可以查看 https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand#%E5%91%BD%E4%BB%A4
第二个参数写死为 false 就行了,因为早前 IE 有这样一个参数,为了兼容吧,不过这个参数在现代浏览器中是没有影响的;第三个参数就是一些命令可能需要额外的参数,比如插入图片就要多传个 url 或 base64 的参数,没有的话传个 null 就行。
我们简要列举下它的几个使用方式,大家就知道怎么用了:
// 加粗
document.execCommand('bold', false, null);
// 添加图片
document.execCommand('insertImage', false, url || base64);
// 把一段文字用 p 标签包裹起来
document.execCommand('formatblock', false, '');
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、水平线、无序列表、插入链接、插入图片、后退一步、向前一步等等。
起步
首先一个富文本大体分为两个区域,一个是按钮区,一个是编辑区。所以它的大致结构就像下面这样:
加粗
现在假如我们要实现加粗的效果,该怎么做呢?很简单,只要在点击加粗按钮的时候执行 document.execCommand('bold', false, null)
这句话,就能达到加粗的效果,就像下面这样:
...
当然了,我们开篇也说了我们的一切命令都是基于document.execCommand
的,所以我们先小小改写一下上面代码中的execCommand
方法,就像下面这样:
...
这样一来代码就更具通用性了。实现列表、水平线、前进、后退功能和加粗是一样样的,只需传入不同的命令名即可,就像下面这样,这里就不一一赘述了:
注意:
- 有的同学可能用的不是
button
标签,然后执行命令就会无效,是因为点击其他标签大多都会造成先失去焦点(或者不知不觉就突然失去焦点了),再执行点击事件,此时没有选区或光标所以会没有效果,这点要留意一下。 - 我们执行的是原生的
document.execCommand
方法,浏览器自身会对contenteditable
这个可编辑区维护一个undo
栈和一个redo
栈,所以我们才能执行前进和后退的操作,如果我们改写了原生方法,就会破坏原有的栈结构,这时就需要自己去维护,那就麻烦了。 -
style
里面如果加上scope
的话,里面的样式对编辑区的内容是不生效的,因为编辑区里面是后来才创建的元素,所以要么删了scope
,要么用/deep/
解决(Vue 是这样)。
段落
这个功能就是把光标所在行的文字用 p
标签包裹起来,为了演示方便,我们顺便把编辑区的html
结构打印出来,所以让我们稍微改一下代码,就像下面这样:
{{ html }}
怎么样,是不是也很 easy
,同理,h1 ~ h6
也是一样的,命令为 execCommand('formatBlock', '
,也不赘述了。')
插入链接
这个功能因为需要第三个参数,所以我们一般会给个提示框获取用户输入,然后再执行 execCommand('createLink', 链接地址)
,代码如下:
//js代码
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)
就大功告成啦,具体代码如下,并不复杂:
//html代码
//js代码
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);
}
具体代码可以参考 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 +
this.editor.scrollLeft}px`,
width: `${imgRect.width}px`,
height: `${imgRect.height}px`,
boxSizing: 'border-box',
border: '1px dashed red'
});
// 添加四个顶点拖拽框
this.createBox();
},
createBox() {
this.boxes = [];
this.addBox('nwse-resize'); // top left
this.addBox('nesw-resize'); // top right
this.addBox('nwse-resize'); // bottom right
this.addBox('nesw-resize'); // bottom left
this.positionBoxes(); // 设置四个拖拽框位置
},
addBox(cursor) {
const box = document.createElement('div');
Object.assign(box.style, {
position: 'absolute',
height: '12px',
width: '12px',
backgroundColor: 'white',
border: '1px solid #777',
boxSizing: 'border-box',
opacity: '0.80'
});
box.style.cursor = cursor;
box.addEventListener('mousedown', this.handleMousedown); // 顺便添加事件
this.overlay.appendChild(box);
this.boxes.push(box);
},
positionBoxes() {
let handleXOffset = `-6px`;
let handleYOffset = `-6px`;
[{ left: handleXOffset, top: handleYOffset },
{ right: handleXOffset, top: handleYOffset },
{ right: handleXOffset, bottom: handleYOffset },
{ left: handleXOffset, bottom: handleYOffset }].forEach((pos, idx) => {
Object.assign(this.boxes[idx].style, pos);
});
},
3. 在四个顶点框上添加拖拽事件
这里我们会在四个顶点监听mousedown
事件,按下鼠标时,首先会改变鼠标样式(就是鼠标会变成调整大小的那种图标),然后监听 mousemove
和 mouseup
事件,计算出水平拖拽距离,然后重新设置图片大小和浮层大小,大概这么个意思,简要代码如下:
handleMousedown(e) {
this.dragBox = e.target;
this.dragStartX = e.clientX;
this.preDragWidth = this.nowImg.width;
this.setCursor(this.dragBox.style.cursor);
document.addEventListener('mousemove', this.handleDrag);
document.addEventListener('mouseup', this.handleMouseup);
},
handleDrag(e) {
// 计算水平拖动距离
const deltaX = e.clientX - this.dragStartX;
// 修改图片大小
if (this.dragBox === this.boxes[0] || this.dragBox === this.boxes[3]) { // 左边的两个框
this.nowImg.width = Math.round(this.preDragWidth - deltaX);
} else { // 右边的两个框
this.nowImg.width = Math.round(this.preDragWidth + deltaX);
}
// 同时修改蒙层大小
this.repositionOverlay();
},
handleMouseup() {
this.setCursor('');
// 拖拽结束移除事件监听
document.removeEventListener('mousemove', this.handleDrag);
document.removeEventListener('mouseup', this.handleMouseup);
},
setCursor(value) {
// 设置鼠标样式
[document.body, this.nowImg].forEach(el => {
el.style.cursor = value;
});
}
当然问题还是有的,不过我们知道这个思路就行。具体代码可以去看下npm
上的quill-image-resize-module
包
操纵光标
除了不好对图片进行处理外,光标应该也是一大坑,你可能不知道什么时候就失去焦点了,此时再点击按钮执行命令就无效了;有时你又需要还原或设置光标的位置,比如插入图片后,光标要设置到图片后面等等之类的。
所以我们需要具有控制光标的能力,具体操作就是在点击按钮之前我们可以先存储当前光标的状态,执行完命令或者在需要的时候后再还原或设置光标的状态即可。由于在 chrome 中,失去焦点并不会清除Seleciton
对象和 Range
对象,所以就像我一开始说的我没怎么去了解。。。这里就只简要展示两个方法给大家看下:
function saveSelection() { // 保存当前Range对象
let selection = window.getSelection();
if(selection.rangeCount > 0){
return sel.getRangeAt(0);
}
return null;
};
let selectedRange = saveSelection();
function restoreSelection() {
let selection = window.getSelection();
if (selectedRange) {
selection.removeAllRanges(); // 清空所有 Range 对象
selection.addRange(selectedRange); // 恢复保存的 Range
}
}
回到开头我们讲的那个需求,关于图片旋转的,根据上面的思路,你可以在蒙层上加个旋转图标,并添加点击事件,然后修改图片和蒙层 transform
属性,当然了位置也要变,可能需要些计算,我也没试过,不知道效果咋样。
另外一种方法就是在插入图片之前先对图片进行处理(比如多一步类似裁剪的功能)再上传,这样就可以不用在编辑区里面处理图片啦,嘿嘿,目前我就想到这两种方案了,实际工作中采用的是第二种方式,因为产品的需求不止于旋转。
本文原文链接https://juejin.im/post/5cfe4e8a6fb9a07ec63b09a4