利用 javascript 实现富文本编辑器javascript
阅读 994
收藏 148
2017-11-03
原文连接:eux.baidu.com
利用 javascript 实现富文本编辑器
by.田 光宇 28 小时前
近期项目中须要开发一个兼容PC和移动端的富文本编辑器,其中包含了一些特殊的定制功能。考察了下现有的js富文本编辑器,桌面端的不少,移动端的几乎没有。桌面端以UEditor为表明。可是咱们并不打算考虑兼容性,因此没有必要采用UEditor这么重的插件。为此决定自研一个富文本编辑器。本文,主要介绍如何实现富文本编辑器,和解决一些不一样浏览器和设备之间的bug。html
准备阶段
在现代浏览器中已经为咱们准备好了许多API来让 html 支持富文本编辑功能,咱们没有必要本身完成所有内容。前端
contenteditable=”true”java
首先咱们须要让一个 div 成为可编辑状态,加入contenteditable="true" 属性便可。node
<div contenteditable="true" id="rich-editor"></div>
在这样的 <div> 中插入任何节点都将默认是可编辑状态的。若是想插入不可编辑的节点,咱们就须要指定插入节点的属性为 contenteditable="false"。ios
光标操做web
做为富文本编辑器,开发者须要有能力控制光标的各类状态信息,位置信息等。浏览器提供了 selection 对象和 range 对象来操做光标。chrome
selection 对象数组
Selection对象表示用户选择的文本范围或插入符号的当前位置。它表明页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标通过文字而产生。
得到一个 selection 对象浏览器
let selection = window.getSelection();
一般状况下咱们不会直接操做 selection 对象,而是须要操做用 seleciton 对象所对应的用户选择的 ranges (区域),俗称”拖蓝“。获取方式以下:
let range = selection.getRangeAt(0);
因为浏览器当前可能存在多个文本选取,因此 getRangeAt 函数接受一个索引值。在富文本编辑其中,咱们不考虑多选取的可能性。
selection 对象还有两个重要的方法, addRange 和 removeAllRanges。分别用于向当前选取添加一个 range 对象和 删除全部 range 对象。以后你会看到他们的用途。
range 对象
经过 selection 对象得到的 range 对象才是咱们操做光标的重点。Range表示包含节点和部分文本节点的文档片断。初见 range 对象你有可能会感到陌生又熟悉,在哪儿看见过呢?做为一个前端工程师,想必你必定拜读过《javascript 高级程序设计第三版》 这本书。在第12.4节,做者为咱们介绍了 DOM2 级提供的 range 接口,用来更好的控制页面。反正我当时看的一脸????这个有啥用,也没有这种需求啊。这里咱们就大量的用到这个对象。对于下面节点:
<div contenteditable="true" id="rich-editor">
<p>百度EUX团队</p>
</div>
光标位置如图所示:
打印出此时的 range 对象:
其中属性含义以下:
这里咱们的 startContainer , endContainer, commonAncestorContainer都为 #text 文本节点 ‘百度EUX团队’。由于光标在‘度‘字后面,因此startOffset 和 endOffset 均为 2。且没有产生拖蓝,因此 collapsed 的值为 true。咱们再看一个产生拖蓝的例子:
光标位置如图所示:
打印出此时的 range 对象:
因为产生了拖蓝 startContainer 和 endContainer 再也不一致,collapsed 的值变为了 false。startOffset 和 endOffset 正好表明了拖蓝的起终位置。更多的效果你们本身尝试吧。
操做一个 range 节点,主要有以下方法:
setStart(): 设置 Range 的起点
setEnd(): 设置 Range 的终点
selectNode(): 设定一个包含节点和节点内容的 Range
collapse(): 向指定端点折叠该 Range
insertNode(): 在 Range 的起点处插入节点。
cloneRange(): 返回拥有和原 Range 相同端点的克隆 Range 对象
富文本编辑里面经常使用的就这么多,还有不少方法就不列举了。
修改光标位置
咱们能够经过调用 setStart() 和 setEnd() 方法,来修改一个光标的位置或拖蓝范围。这两个方法接受的参数为各自的起终节点和偏移量。例如我想让光标位置到”百度EUX团队”最末尾,那么能够采用以下方法:
let range = window.getSelection().getRangeAt(0),
textEle = range.commonAncestorContainer;
range.setStart(range.startContainer, textEle.length);
range.setEnd(range.endContainer, textEle.length);
咱们加入一个定时器来查看效果:
然而这种方式有个局限性,就是当光标所在的节点若是发生了变更。好比被替换或者加入新的节点了,那么再用这种方式就不会有任何效果。为此咱们有时候须要一种强制更改光标位置手段, 简要代码以下(实际中你有可能还须要考虑自闭和元素等内容):
function resetRange(startContainer, startOffset, endContainer, endOffset) {
let selection = window.getSelection(); selection.removeAllRanges(); let range = document.createRange(); range.setStart(startContainer, startOffset); range.setEnd(endContainer, endOffset); selection.addRange(range);
}
咱们经过从新创造一个 range 对象而且删除原有的 ranges 来保证光标必定会变更到咱们想要的位置。
修改文本格式
实现富文本编辑器,咱们就要可以有修改文档格式的能力,好比加粗,斜体,文本颜色,列表等内容。DOM 为可编辑区提供了 document.execCommand 方法,该方法容许运行命令来操纵可编辑区域的内容。大多数命令影响文档的选择(粗体,斜体等),而其余命令插入新元素(添加连接)或影响整行(缩进)。当使用 contentEditable时,调用 execCommand() 将影响当前活动的可编辑元素。语法以下:
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
aCommandName: 一个 DOMString ,命令的名称。可用命令列表请参阅 命令 。
aShowDefaultUI: 一个 Boolean, 是否展现用户界面,通常为 false。Mozilla 没有实现。
aValueArgument: 一些命令(例如insertImage)须要额外的参数(insertImage须要提供插入image的url),默认为null。
总之浏览器能把大部分咱们想到的富文本编辑器须要的功能都实现了,这里我就不一一演示了。感兴趣的同窗能够查看 MDN – document.execCommand。
到这里,我相信你已经能够作出一个像模像样的富文本编辑器了。想一想还挺激动的,可是呢,一切都没有结束,浏览器又一次坑了咱们。
实战开始,填坑的旅途
就在咱们都觉得开发如此简单的时候,实际上手却遇到了许多坑。
修正浏览器的默认效果
浏览器提供的富文本效果并不老是好用的,下面介绍几个遇到的问题。
回车换行
当咱们在编辑其中输入内容并回车换行继续输入后,可编辑框内容生成的节点和咱们预期是不符的。
能够看到最早输入的文字没有被包裹起来,而换行产生的内容,包裹元素是 <div> 标签。为了可以让文字被 <p> 元素包裹起来。
咱们要在初始化的时候,向<div>默认插入<p>
</p> 元素(
标签用来占位,有内容输入后会自动删除)。这样之后每次回车产生的新内容都会被<p> 元素包裹起来(在可编辑状态下,回车换行产生的新结构会默认拷贝以前的内容,包裹节点,类名等各类内容)。
咱们还须要监听 keyUp 事件下 event.keyCode === 8 删除键。当编辑器中内容全被清空后(delete键也会把<p>标签删除),要从新加入<p>
</p>标签,并把光标定位在里面。
插入 ul 和 ol 位置错误
当咱们调用 document.execCommand("insertUnorderedList", false, null) 来插入一个列表的时候,新的列表会被插入<p>标签中。
为此咱们须要每次调用该命令前作一次修正,参考代码以下:
function adjustList() {
let lists = document.querySelectorAll("ol, ul"); for (let i = 0; i < lists.length; i++) { let ele = lists[i]; // ol let parentNode = ele.parentNode; if (parentNode.tagName === 'P' && parentNode.lastChild === parentNode.firstChild) { parentNode.insertAdjacentElement('beforebegin', ele); parentNode.remove() } }
}
这里有个附带的小问题,我试图在 <li><p></p></li> 维护这样的编辑器结构(默认是没有<p>标签的)。效果在 chrome 下运行很好。可是在 safari 中,回车永远不会产生新的 <li> 标签,这样就是去了该有的列表效果。
插入分割线
调用 document.execCommand('insertHorizontalRule', false, null); 会插入一个
光标和
/**
*/
function findParentByTagName(root, name) {
let parent = root; if (typeof name === "string") { name = [name]; } while (name.indexOf(parent.nodeName.toLowerCase()) === -1 && parent.nodeName !== "BODY" && parent.nodeName !== "HTML") { parent = parent.parentNode; } return parent.nodeName === "BODY" || parent.nodeName === "HTML" ? null : parent;
},
插入连接
调用 document.execCommand('createLink', false, url); 方法咱们能够插入一个 url 连接,可是该方法不支持插入指定文字的连接。同时对已经有连接的位置能够反复插入新的连接。为此咱们须要重写此方法。
function insertLink(url, title) {
let selection = document.getSelection(), range = selection.getRangeAt(0); if(range.collapsed) { let start = range.startContainer, parent = Util.findParentByTagName(start, 'a'); if(parent) { parent.setAttribute('src', url); }else { this.insertHTML(`<a href="${url}">${title}</a>`); } }else { document.execCommand('createLink', false, url); }
}
设置 h1 ~ h6 标题
浏览器没有现成的方法,但咱们能够借助 document.execCommand('formatBlock', false, tag), 来实现,代码以下:
function setHeading(heading) {
let formatTag = heading, formatBlock = document.queryCommandValue("formatBlock"); if (formatBlock.length > 0 && formatBlock.toLowerCase() === formatTag) { document.execCommand('formatBlock', false, ``); } else { document.execCommand('formatBlock', false, ``); }
}
插入定制内容
当编辑器上传或加载附件的时候,要插入可以展现附件的 <div> 节点卡片到编辑中。这里咱们借助 document.execCommand('insertHTML', false, html); 来插入内容。为了防止div被编辑,要设置 contenteditable="false"哦。
处理 paste 粘贴
在富文本编辑器中,粘贴效果默认采用以下规则:
若是是带有格式的文本,则保留格式(格式会被转换成html标签的形式)
粘贴图文混排的内容,图片能够显示,src 为图片真实地址。
经过复制图片来进行粘贴的时候,不能粘入内容
粘贴其余格式内容,不能粘入内容
为了可以控制粘贴的内容,咱们监听 paste 事件。该事件的 event 对象中会包含一个 clipboardData 剪切板对象。咱们能够利用该对象的 getData 方法来得到带有格式和不带格式的内容,以下。
let plainText = event.clipboardData.getData('text/plain'); // 无格式文本
let plainHTML = event.clipboardData.getData('text/html'); // 有格式文本
以后调用 document.execCommand('insertText', false, plainText); 或 document.execCommand('insertHTML', false, plainHTML; 来重写编辑上的paste效果。
然而对于规则 3 ,上述方案就没法处理了。这里咱们要引入 event.clipboardData.items 。这是一个数组包含了全部剪切板中的内容对象。好比你复制了一张图片来粘贴,那么 event.clipboardData.items 的长度就为2:
items[0] 为图片的名称,items[0].kind 为 ‘string’, items[0].type 为 ‘text/plain’ 或 ‘text/html’。获取内容方式以下:
items[0].getAsString(str => {
// 处理 str 便可
})
items[1] 为图片的二进制数据,items[1].kind 为’file’, items[1].type 为图片的格式。想要获取里面的内容,咱们就须要建立 FileReader 对象了。示例代码以下:
let file = items[1].getAsFile();
// file.size 为文件大小
let reader = new FileReader();
reader.onload = function() {
// reader.result 为文件内容,就能够作上传操做了
}
if(/image/.test(item.type)) {
reader.readAsDataURL(file); // 读取为 base64 格式
}
处理完图片,那么对于复制粘贴其余格式内容会怎么样呢?在 mac 中,若是你复制一个磁盘文件,event.clipboardData.items 的长度为 2。 items[0] 依然为文件名,然而 items[1] 则为图片了,没错,是文件的缩略图。
输入法处理
当使用输入发的时候,有时候会发生一些意想不到的事情。 好比百度输入法能够输入一张本地图片,为此咱们须要监听输入法产生的内容作处理。这里经过以下两个事件处理:
compositionstart: 当浏览器有非直接的文字输入时, compositionstart事件会以同步模式触发
compositionend: 当浏览器是直接的文字输入时, compositionend会以同步模式触发
修复移动端的问题
在移动端,富文本编辑器的问题主要集中在光标和键盘上面。我这里介绍几个比较大的坑。
自动获取焦点
若是想让咱们的编辑器自动得到焦点,弹出软键盘,能够利用 focus() 方法。然而在 ios 下,死活没有结果。这主要是由于 ios safari 中,为了安全考虑不容许代码得到焦点。只能经过用户交互点击才能够。还好,这一限制能够去除:
[self.appWebView setKeyboardDisplayRequiresUserAction:NO]
iOS 下回车换行,滚动条不会自动滚动
在 iOS 下,当咱们回车换行的时候,滚动条并不会随着滚动下去。这样光标就可能被键盘挡住,体验很差。为了解决这一问题,咱们就须要监听 selectionchange 事件,触发时,计算每次光标编辑器顶端距离,以后再调用 window.scroll() 便可解决。问题在于咱们要如何计算当前光标的位置,若是仅是计算光标所在父元素的位置颇有可能出现误差(多行文本计算不许)。咱们能够经过建立一个临时 <span> 元素查到光标位置,计算<span>元素的位置便可。代码以下:
function getCaretYPosition() {
let sel = window.getSelection(), range = sel.getRangeAt(0); let span = document.createElement('span'); range.collapse(false); range.insertNode(span); var topPosition = span.offsetTop; span.parentNode.removeChild(span); return topPosition;
}
正当我开心的时候,安卓端反应,编辑器越编辑越卡。什么鬼?我在 chrome 上线检查了一下,发现 selectionchange 函数一直在运行,无论有没有操做。
在逐一排查的时候发现了这么一个事实。range.insertNode 函数一样触发 selectionchange 事件。这样就造成了一个死循环。这个死循环在 safari 中就不会产生,只出如今 safari 中,为此咱们就须要加上浏览器类型判断了。
键盘弹起遮挡输入部分
网上对于这个问题主要的方案就是,设置定时器。局限与前端,确实只能这采用这样笨笨的解决。最后咱们让 iOS 同窗在键盘弹出的时候,将 webview 高度减去软键盘高度就解决了。
CGFloat webviewY = 64.0 + self.noteSourceView.height;
self.appWebView.frame = CGRectMake(0, webviewY, BDScreenWidth, BDScreenHeight - webviewY - height);
插入图片失败
在移动端,经过调用 jsbridge 来唤起相册选择图片。以后调用 insertImage 函数来向编辑器插入图片。然而,插入图片一直失败。最后发现是由于早 safari 下,若是编辑器失去了焦点,那么 selection 和 range 对象将销毁。所以调用 insertImage 时,并不能得到光标所在位置,所以失败。为此须要增长,backupRange() 和 restoreRange() 函数。当页面失去焦点的时候记录 range 信息,插入图片前恢复 range 信息。
backupRange() {
let selection = window.getSelection(); let range = selection.getRangeAt(0); this.currentSelection = { "startContainer": range.startContainer, "startOffset": range.startOffset, "endContainer": range.endContainer, "endOffset": range.endOffset }
}
restoreRange() {
if (this.currentSelection) { let selection = window.getSelection(); selection.removeAllRanges(); let range = document.createRange(); range.setStart(this.currentSelection.startContainer, this.currentSelection.startOffset); range.setEnd(this.currentSelection.endContainer, this.currentSelection.endOffset); // 向选区中添加一个区域 selection.addRange(range); }
}
在 chrome 中,失去焦点并不会清除 seleciton 对象和 range 对象,这样咱们轻轻松松一个 focus() 就搞定了。
重要问题就这么多,限于篇幅限制其余的问题省略了。整体来讲,填坑花了开发的大部分时间。
其余功能
基础功能修修补补之后,实际项目中有可能遇到一些其余的需求,好比当前光标所在文字内容状态啊,图片拖拽放大啊,待办列表功能,附件卡片等功能啊,markdown切换等等。在了解了js 富文本的种种坑以后,range 对象的操做以后,相信这些问题你均可以轻松解决。这里最后提几个作扩展功能时候遇到的有去的问题。
回车换行带格式
前面已经说过了,富文本编辑器的机制就是这样,当你回车换行的时候新产生的内容和以前的格式如出一辙。若是咱们利用 .card 类来定义了一个卡片内容,那么换行产生的新的段落都将含有 .card 类且结构也是直接 copy 过来的。咱们想要屏蔽这种机制,因而尝试在 keydown 的阶段作处理(若是在 keyup 阶段处理用户体验很差)。然而,并无什么用,由于用户自定义的 keydown 事件要在 浏览器富文本的默认 keydown 事件以前触发,这样你就作不了任何处理。
为此咱们为这类特殊的个体都添加一个 property 属性,添加在 property 上的内容是不会被copy下来的。这样之后就能够区分出来了,从而作对应的处理。
获取当前光标所在处样式
这里主要是考虑 下划线,删除线之类的样式,这些样式都是用标签类描述的,因此要遍历标签层级。直接上代码:
function getCaretStyle() {
let selection = window.getSelection(), range = selection.getRangeAt(0); aimEle = range.commonAncestorContainer, tempEle = null; let tags = ["U", "I", "B", "STRIKE"], result = []; if(aimEle.nodeType === 3) { aimEle = aimEle.parentNode; } tempEle = aimEle; while(block.indexOf(tempEle.nodeName.toLowerCase()) === -1) { if(tags.indexOf(tempEle.nodeName) !== -1) { result.push(tempEle.nodeName); } tempEle = tempEle.parentNode; } let viewStyle = { "italic": result.indexOf("I") !== -1 ? true : false, "underline": result.indexOf("U") !== -1 ? true : false, "bold": result.indexOf("B") !== -1 ? true : false, "strike": result.indexOf("STRIKE") !== -1 ? true : false } let styles = window.getComputedStyle(aimEle, null); viewStyle.fontSize = styles["fontSize"], viewStyle.color = styles["color"], viewStyle.fontWeight = styles["fontWeight"], viewStyle.fontStyle = styles["fontStyle"], viewStyle.textDecoration = styles["textDecoration"]; viewStyle.isH1 = Util.findParentByTagName(aimEle, "h1") ? true : false; viewStyle.isH2 = Util.findParentByTagName(aimEle, "h2") ? true : false; viewStyle.isP = Util.findParentByTagName(aimEle, "p") ? true : false; viewStyle.isUl = Util.findParentByTagName(aimEle, "ul") ? true : false; viewStyle.isOl = Util.findParentByTagName(aimEle, "ol") ? true : false; return viewStyle;
}