最近产品想让我在富文本里加个旋转图片的功能,我一想🤔,就以为事情并不简单,由于印象中好像没见过这种操做。果真,通过一番百度以后,确实没怎么看到相关信息,这也就意味着要本身动手丰衣足食了😢。但我本身对富文本又没什么了解,因此顺带稍微看了下富文本的实现方式,特此来沉淀一下,仍是那句话不喜勿喷哈🙄。
ok,这里先简要说下为何会有富文本这种东西吧🤓!大概可能也许是由于有一天产品用着用着 textarea
感受太单调了,单纯的文字已经没法表达他们心里的需求🤯,因而就想来点样式,顺便加个图片,来篇图文并茂的文章,就像小型 Word 那样,就再好不过了!因而富文本就这样诞生了,开发者们也纷纷开始了踩坑之旅🕳🕳🕳。javascript
好了,交代完了背景,让咱们先补充一些基础知识吧,不懂的请务必不要跳过🧐!css
假如咱们给一个标签加上 contenteditable="true"
的属性,就像这样:html
<div contenteditable="true"></div>
复制代码
那么在这个 div
中咱们就能够对其进行任意编辑了。若是想要插入的子节点不可编辑,咱们只须要把子节点的属性设置为 contenteditable="false"
便可,就像这样:java
<div contenteditable="true">
<p>这是可编辑的</p>
<p contenteditable="false">这是不可编辑的</p>
</div>
复制代码
该属性最先是在 IE 上实现的(厉害哦👍),且能够做用于其它标签,不限于 div
,你们应该或多或少都据说过这个属性。chrome
既然咱们能够对上面的 div
随意编辑,那具体怎么编辑呢,目前好像也仍是只能输入文本,要怎样才能进行其余操做呢(好比加粗、倾斜、插入图片等等)🤔?其实浏览器给咱们提供了这样的一个方法 document.execCommand
,经过它咱们就可以操纵上面的可编辑区。具体语法以下:npm
// 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>');
复制代码
这个命令就是富文本的核心(因此务必记住),浏览器把大部分咱们能想到的功能也都实现了,固然各浏览器之间仍是有差别的,这里就不考虑了。服务器
咱们在执行 document.execCommand
这个命令以前首先要知道对谁执行,因此这里会有一个选区的概念,也就是 Selection
对象,它用来表示用户选择的范围或光标位置(光标能够看作是范围重合的特殊状态),一个页面用户可能选择多个范围(好比 Firefox)。也就是说 Selection
包含一个或多个 Range
对象(Selection
能够说是 Range
的集合),固然对于富文本编辑器来讲,通常状况下,咱们只会有一个选择区域,也就是一个 Range
对象,事实上大部分状况也是如此。
因此一般咱们能够用 let range = window.getSelection().getRangeAt(0)
来获取选中的内容信息(getRangeAt
接受一个索引值,由于会有多个 Range
,而如今只有一个,因此写0)。
看得一头雾水😴?不要紧,看下面两张图就懂了😮: app
Selection
对象还有几个经常使用的方法,
addRange
、
removeAllRanges
、
collapse
和
collapseToEnd
等等。
开篇一顿扯,下面让咱们抓紧时间作一个属于本身的富文本吧💪,大概会包含如下几个功能:加粗、段落、H一、水平线、无序列表、插入连接、插入图片、后退一步、向前一步等等。🆗,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
地址再插入也是能够的。
pell
包,它已是个极简版的了。
其实富文本对文本的操做大多均可以用原生命令来实现,可是对图片的操做也许就不那么容易了,来个拉伸、旋转啥的就够咱们折腾了🤨,因此这里以图片拉伸为例子着重讲解一下。
咱们先看下大体效果,你们也能够先停下来思考一分钟看看如何实现🤔:
这个就是看点击事件 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);
}
}
}
复制代码
若是点击的是一个图片,那咱们就建立一个 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);
});
},
复制代码
这里咱们会在四个顶点监听 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
属性,固然了位置也要变,可能须要些计算,我也没试过,不知道效果咋样😂。 另一种方法就是在插入图片以前先对图片进行处理(好比多一步相似裁剪的功能)再上传,这样就能够不用在编辑区里面处理图片啦,嘿嘿,目前我就想到这两种方案了,实际工做中采用的是第二种方式,由于产品的需求不止于旋转😭。 最后的最后,不知道你们有没有更好的方法来对图片或内容进行处理,欢迎在下面留言探讨,See you👋。