最近のプロジェクトでは、PC およびモバイル端末と互換性のある、いくつかの特別なカスタマイズ機能を含むリッチ テキスト エディターを開発する必要があるためです。そこで、既存の JavaScript リッチ テキスト エディターを調べてみました。デスクトップ側にはたくさんありますが、モバイル側にはほとんどありません。デスクトップ側は UEditor によって表されます。ただし、互換性は当面考慮していないので、UEditor ほど重いプラグインを使用する必要はありません。このため、JavaScript を使用してリッチ テキスト エディターを実装することにしました。この記事では主に、リッチ テキスト エディターの実装方法と、異なるブラウザーやデバイス間でのいくつかのバグを解決する方法を紹介します。
準備段階
最新のブラウザには、HTML がリッチ テキスト編集機能をサポートできるようにするための API が多数用意されています。コンテンツ全体を自分で完成させる必要はありません。
contenteditable=”true”
まず、contenteditable="true" 属性を追加して div を編集可能にする必要があります。
<div contenteditable="true" id="rich-editor"></div>
そのような
カーソル操作
リッチテキストエディターとして、開発者はカーソルのさまざまなステータス情報、位置情報などを制御できる必要があります。ブラウザには、カーソルを操作するための選択オブジェクトと範囲オブジェクトが用意されています。
selection オブジェクト
Selection オブジェクトは、ユーザーが選択したテキスト範囲またはキャレットの現在の位置を表します。これは、ページ上のテキストの選択範囲を表し、複数の要素にまたがることがあります。テキスト選択は、ユーザーがマウスをテキスト上にドラッグすることによって作成されます。
選択オブジェクトを取得する
let selection = window.getSelection();
通常、選択オブジェクトを直接操作することはありませんが、一般に「ドラッグブルー」として知られる、選択オブジェクトに対応するユーザーが選択した範囲(領域)を操作する必要があります。取得方法は次のとおりです。
let range = selection.getRangeAt(0);
現在ブラウザには複数のテキスト選択がある可能性があるため、getRangeAt 関数はインデックス値を受け取ります。リッチ テキスト編集では、複数選択の可能性は考慮されません。
選択オブジェクトには、addRange と RemoveAllRanges という 2 つの重要なメソッドもあります。現在の選択に範囲オブジェクトを追加したり、すべての範囲オブジェクトを削除したりするためにそれぞれ使用されます。それらの使い方については後で説明します。
範囲オブジェクト
選択オブジェクトを通じて取得された範囲オブジェクトが、カーソル操作の焦点です。 Range は、ノードと部分的なテキスト ノードを含むドキュメント フラグメントを表します。初めてその範囲のオブジェクトを見たとき、それをどこで見たのかと同時に感じるかもしれません。フロントエンドエンジニアであれば、『JavaScript 上級プログラミング 第3版』という本を読んだことがあるはずです。セクション 12.4 では、ページをより適切に制御するために DOM2 レベルで提供される範囲インターフェイスについて著者が紹介します。それにしても、その時の私はどんな顔をしていたのでしょうか? ? ? ?これは何の役に立つのでしょうか?そのような必要はありません。ここではこのオブジェクトを広範囲に使用します。次のノードの場合:
<div contenteditable="true" id="rich-editor"> <p>123</p> </div>
この時点で範囲オブジェクトを出力します:
* startContainer: 範囲の開始ノード。
* endContainer: 範囲の終了ノード
* startOffset: 範囲の開始位置のオフセット。
* endOffset: 範囲の終了位置のオフセット。
* commonAncestorContainer: startContainer と endContainer を含む最も深いノードを返します。
* 折りたたまれた場合: Range の開始位置と終了位置が同じかどうかを判断するために使用されるブール値を返します。
ここで、startContainer、endContainer、commonAncestorContainer はすべて #text テキスト ノード「Baidu EUX Team」です。カーソルが「degree」という単語の後ろにあるため、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() メソッドを呼び出すことで、カーソルの位置またはドラッグ範囲を変更できます。これら 2 つのメソッドで受け入れられるパラメータは、それぞれの開始ノードと終了ノードおよびオフセットです。たとえば、カーソル位置を「Baidu EUX Team」の最後にしたい場合は、次の方法を使用できます:
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); }
範囲オブジェクトを再作成し、削除します。元の範囲 カーソルが希望の位置に確実に移動するようにするため。
修改文本格式
实现富文本编辑器,我们就要能够有修改文档格式的能力,比如加粗,斜体,文本颜色,列表等内容。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 中国語 Web サイトの他の関連記事を参照してください。