因為近期專案中需要開發一個相容於PC和行動端的富文本編輯器,當中包含了一些特殊的客製化功能。所以考察了下現有的javascript富文本編輯器,桌面端的很多,行動端的幾乎沒有。桌面端以UEditor為代表。但我暫時不考慮相容性,所以沒有必要採用UEditor這麼重的插件。為此我決定自己利用 javascript來實作一個富文本編輯器。本文,主要介紹如何實作富文本編輯器,並解決一些不同瀏覽器和裝置之間的bug。
準備階段
在現代瀏覽器中已經為我們準備好了許多API來讓 html 支援富文本編輯功能,我們沒有必要自己完成全部內容。
contenteditable=”true”
首先我們需要讓一個 div 成為可編輯狀態,加入contenteditable="true" 屬性即可。
<div contenteditable="true" id="rich-editor"></div>
在這樣的
遊標操作
作為富文本編輯器,開發者需要有能力控制遊標的各種狀態訊息,位置資訊等。瀏覽器提供了 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>123</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。
由於產生了拖曳藍色 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。
到这里,我相信你已经可以做出一个像模像样的富文本编辑器了。想想还挺激动的,但是呢,一切都没有结束,浏览器又一次坑了我们。
实战开始,填坑的旅途
就在我们都以为开发如此简单的时候,实际上手却遇到了许多坑。
修正浏览器的默认效果
浏览器提供的富文本效果并不总是好用的,下面介绍几个遇到的问题。
回车换行
当我们在编辑其中输入内容并回车换行继续输入后,可编辑框内容生成的节点和我们预期是不符的。
可以看到最先输入的文字没有被包裹起来,而换行产生的内容,包裹元素是
元素包裹起来。
我们要在初始化的时候,向
(
标签用来占位,有内容输入后会自动删除)。这样以后每次回车产生的新内容都会被
元素包裹起来(在可编辑状态下,回车换行产生的新结构会默认拷贝之前的内容,包裹节点,类名等各种内容)。
我们还需要监听 keyUp 事件下 event.keyCode === 8 删除键。当编辑器中内容全被清空后(delete键也会把
标签删除),要重新加入
插入 ul 和 ol 位置错误
当我们调用 document.execCommand("insertUnorderedList", false, null) 来插入一个列表的时候,新的列表会被插入
标签中。
为此我们需要每次调用该命令前做一次修正,参考代码如下:
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() } } }
这里有个附带的小问题,我试图在
标签的)。效果在 chrome 下运行很好。但是在 safari 中,回车永远不会产生新的
插入分割线
调用 document.execCommand('insertHorizontalRule', false, null); 会插入一个
光标和
/** * 查找父元素 * @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, ``); } }
插入定制内容
当编辑器上传或加载附件的时候,要插入能够展示附件的
节点卡片到编辑中。这里我们借助 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() 即可解决。问题在于我们要如何计算当前光标的位置,如果仅是计算光标所在父元素的位置很有可能出现偏差(多行文本计算不准)。我们可以通过创建一个临时 元素查到光标位置,计算元素的位置即可。代码如下:
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; }
以上就是我做富文本编辑器的思路,也希望给大家带来帮助。
以上是利用 javascript 實作富文本編輯器的詳細內容。更多資訊請關注PHP中文網其他相關文章!