应用 javascript 完成富文本编辑器
浏览 994
珍藏 148
2017-11-03
原文链接:eux.baidu.com
应用 javascript 完成富文本编辑器
by.田 光宇 28 小时前
近期项目中须要开辟一个兼容PC和挪动端的富文本编辑器,个中包括了一些特别的定制功用。考核了下现有的js富文本编辑器,桌面端的很多,挪动端的几乎没有。桌面端以UEditor为代表。然则我们并不盘算斟酌兼容性,所以没有必要采纳UEditor这么重的插件。为此决议自研一个富文本编辑器。本文,主要引见怎样完成富文本编辑器,和处置惩罚一些差别浏览器和装备之间的bug。
预备阶段
在当代浏览器中已为我们预备好了很多API来让 html 支撑富文本编辑功用,我们没有必要本身完成全部内容。
contenteditable=”true”
起首我们须要让一个 div 成为可编辑状况,到场contenteditable=”true” 属性即可。
<div contenteditable=”true” id=”rich-editor”></div>
在如许的 <div> 中插进去任何节点都将默许是可编辑状况的。假如想插进去不可编辑的节点,我们就须要指定插进去节点的属性为 contenteditable=”false”。
光标支配
作为富文本编辑器,开辟者须要有才能掌握光标的各种状况信息,位置信息等。浏览器供应了 selection 对象和 range 对象来支配光标。
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: range 范围的肇端节点。
- endContainer: range 范围的完毕节点
- startOffset: range 出发点位置的偏移量。
- endOffset: range 尽头位置的偏移量。
- commonAncestorContainer: 返回包括 startContainer 和 endContainer 的最深的节点。
- collapsed: 返回一个用于推断 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); 会插进去一个
标签。但是发作的效果倒是如许的:
光标和
的效果一致了。为此要推断当前光标是不是在 <li> 内里,假如是则在 背面追加一个空的文本节点 #text 不是的话追加 <p>
</p>。然后将光标定位在内里,可用以下体式格局查找。
/**
- 查找父元素
- @param {String} root
- @param {String | Array} name
*/
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;
}