Maison >interface Web >js tutoriel >Implémenter un éditeur de texte enrichi en utilisant javascript
Parce que le récent projet nécessite le développement d'un éditeur de texte riche compatible avec les PC et les terminaux mobiles, qui contient des fonctions de personnalisation particulières. J'ai donc examiné les éditeurs de texte enrichi JavaScript existants. Il en existe beaucoup du côté ordinateur, mais presque aucun du côté mobile. Le côté bureau est représenté par UEditor. Mais je ne considère pas la compatibilité pour le moment, il n'est donc pas nécessaire d'utiliser un plug-in aussi lourd que UEditor. Pour cette raison, j'ai décidé d'utiliser javascript pour implémenter un éditeur de texte enrichi. Cet article présente principalement comment implémenter un éditeur de texte enrichi et résoudre certains bugs entre différents navigateurs et appareils.
Phase de préparation
De nombreuses API ont été préparées pour nous dans les navigateurs modernes pour permettre au HTML de prendre en charge les fonctions d'édition de texte enrichi. Nous n'avons pas besoin de compléter l'intégralité du contenu nous-mêmes.
contenteditable=”true”
Nous devons d'abord rendre un div modifiable en ajoutant l'attribut contenteditable="true".
<div contenteditable="true" id="rich-editor"></div>
Tout nœud inséré dans un tel dc6dce4a544fdca2df29d5ac0ea9906b sera modifiable par défaut. Si nous voulons insérer un nœud non modifiable, nous devons spécifier l'attribut du nœud inséré comme contenteditable="false".
Fonctionnement du curseur
En tant qu'éditeur de texte enrichi, les développeurs doivent être capables de contrôler diverses informations d'état, informations de position, etc. du curseur. Le navigateur fournit des objets de sélection et des objets de plage pour faire fonctionner le curseur.
Objet de sélection
L'objet de sélection représente la plage de texte sélectionnée par l'utilisateur ou la position actuelle du curseur. Il représente une sélection de texte sur la page, qui peut s'étendre sur plusieurs éléments. Les sélections de texte sont créées par l'utilisateur en faisant glisser la souris sur le texte.
Obtenir un objet de sélection
let selection = window.getSelection();
Normalement, nous n'exploiterons pas directement l'objet de sélection, mais devons exploiter les plages (régions) sélectionnées par l'utilisateur correspondant à l'objet de sélection, communément appelé comme " Faites glisser le bleu". La méthode d'acquisition est la suivante :
let range = selection.getRangeAt(0);
Le navigateur pouvant actuellement avoir plusieurs sélections de texte, la fonction getRangeAt accepte une valeur d'index. Dans l'édition de texte enrichi, nous ne considérons pas la possibilité de sélections multiples.
L'objet de sélection a également deux méthodes importantes, addRange et removeAllRanges. Utilisé pour ajouter un objet de plage à la sélection actuelle et supprimer respectivement tous les objets de plage. Vous verrez leurs utilisations plus tard.
objet plage
L'objet plage obtenu via l'objet de sélection est au centre de notre opération de curseur. Range représente un fragment de document contenant des nœuds et des nœuds de texte partiels. Lorsque vous voyez l'objet de portée pour la première fois, vous pouvez vous sentir à la fois étrange et familier. Où l'avez-vous vu ? En tant qu'ingénieur front-end, vous devez avoir lu le livre "JavaScript Advanced Programming Third Edition". Dans la section 12.4, l'auteur nous présente l'interface de plage fournie par le niveau DOM2 pour mieux contrôler la page. D’ailleurs, à quoi avais-je ressemblé à ce moment-là ? ? ? ? A quoi ça sert ? Ce n’est pas nécessaire. Ici, nous utilisons largement cet objet. Pour les nœuds suivants :
<div contenteditable="true" id="rich-editor"> <p>123</p> </div>
Imprimez l'objet plage à ce moment :
* startContainer : Le nœud de départ de la plage.
* endContainer : Le nœud de fin de la plage
* startOffset : Le décalage de la position de départ de la plage.
* endOffset : range Le décalage de la position finale.
* commonAncestorContainer : renvoie le nœud le plus profond contenant startContainer et endContainer.
* réduit : renvoie une valeur booléenne utilisée pour déterminer si les positions de début et de fin de la plage sont les mêmes.
Ici, nos startContainer, endContainer, commonAncestorContainer sont tous des nœuds de texte #text 'Baidu EUX Team'. Étant donné que le curseur se trouve derrière le mot « degré », startOffset et endOffset valent tous deux 2. Et aucune traînée n’est générée, donc la valeur de collapsed est vraie.
En raison du retard, startContainer et endContainer ne sont plus cohérents et la valeur de collapsed devient fausse. startOffset et endOffset représentent exactement les positions de début et de fin du glisser bleu. Vous pouvez l'essayer vous-même pour plus d'effets.
Pour faire fonctionner un nœud de plage, les principales méthodes sont les suivantes :
setStart() : définir le point de départ de la plage
setEnd() : définir le point final de la plage
selectNode() : définir une plage qui contient des nœuds et le contenu des nœuds
collapse() : réduire la plage jusqu'au point de terminaison spécifié
insertNode() : insérer un nœud au point de départ de la Range.
cloneRange() : renvoie un objet Range cloné qui a le même point de terminaison que la plage d'origine
Il existe de nombreuses méthodes couramment utilisées dans l'édition de texte enrichi, et il existe de nombreuses autres méthodes que j'ai ne listera pas.
Modifier la position du curseur
Nous pouvons modifier la position d'un curseur ou la plage de déplacement en appelant les méthodes setStart() et setEnd(). Les paramètres acceptés par ces deux méthodes sont leurs nœuds et décalages respectifs de début et de fin. Par exemple, si je souhaite que la position du curseur soit à la fin de « Baidu EUX Team », alors vous pouvez utiliser la méthode suivante :
let range = window.getSelection().getRangeAt(0), textEle = range.commonAncestorContainer; range.setStart(range.startContainer, textEle.length); range.setEnd(range.endContainer, textEle.length);
On ajoute un timer pour voir l'effet :
Cependant, cette méthode a une limitation lorsque le nœud où se trouve le curseur change. Par exemple, si un nœud est remplacé ou qu'un nouveau nœud est ajouté, la réutilisation de cette méthode n'aura aucun effet. Pour cette raison, nous avons parfois besoin d'un moyen pour forcer le changement de position du curseur. Le code bref est le suivant (en pratique, vous devrez peut-être également prendre en compte la fermeture automatique et les éléments) :
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); }
Nous recréez un objet de plage et supprimez les plages d'origine pour vous assurer que le curseur se déplacera définitivement vers la position souhaitée.
修改文本格式
实现富文本编辑器,我们就要能够有修改文档格式的能力,比如加粗,斜体,文本颜色,列表等内容。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。
到这里,我相信你已经可以做出一个像模像样的富文本编辑器了。想想还挺激动的,但是呢,一切都没有结束,浏览器又一次坑了我们。
实战开始,填坑的旅途
就在我们都以为开发如此简单的时候,实际上手却遇到了许多坑。
修正浏览器的默认效果
浏览器提供的富文本效果并不总是好用的,下面介绍几个遇到的问题。
回车换行
当我们在编辑其中输入内容并回车换行继续输入后,可编辑框内容生成的节点和我们预期是不符的。
可以看到最先输入的文字没有被包裹起来,而换行产生的内容,包裹元素是 dc6dce4a544fdca2df29d5ac0ea9906b标签。为了能够让文字被 e388a4556c0f65e1904146cc1a846bee元素包裹起来。
我们要在初始化的时候,向dc6dce4a544fdca2df29d5ac0ea9906b默认插入 e388a4556c0f65e1904146cc1a846bee0c6dc11e160d3b678d68754cc175188a94b3e26ee717c64999d7867364b1b4a3元素
(0c6dc11e160d3b678d68754cc175188a标签用来占位,有内容输入后会自动删除)。这样以后每次回车产生的新内容都会被 e388a4556c0f65e1904146cc1a846bee元素包裹起来(在可编辑状态下,回车换行产生的新结构会默认拷贝之前的内容,包裹节点,类名等各种内容)。
我们还需要监听 keyUp 事件下 event.keyCode === 8 删除键。当编辑器中内容全被清空后(delete键也会把 e388a4556c0f65e1904146cc1a846bee标签删除),要重新加入e388a4556c0f65e1904146cc1a846bee0c6dc11e160d3b678d68754cc175188a94b3e26ee717c64999d7867364b1b4a3标签,并把光标定位在里面。
插入 ul 和 ol 位置错误
当我们调用 document.execCommand("insertUnorderedList", false, null) 来插入一个列表的时候,新的列表会被插入e388a4556c0f65e1904146cc1a846bee标签中。
为此我们需要每次调用该命令前做一次修正,参考代码如下:
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() } } }
这里有个附带的小问题,我试图在 25edfb22a4f469ecb59f1190150159c6e388a4556c0f65e1904146cc1a846bee94b3e26ee717c64999d7867364b1b4a3bed06894275b65c1ab86501b08a632eb 维护这样的编辑器结构(默认是没有 e388a4556c0f65e1904146cc1a846bee标签的)。效果在 chrome 下运行很好。但是在 safari 中,回车永远不会产生新的 25edfb22a4f469ecb59f1190150159c6标签,这样就是去了该有的列表效果。
插入分割线
调用 document.execCommand('insertHorizontalRule', false, null); 会插入一个f32b48428a809b51f04d3228cdf461fa标签。然而产生的效果却是这样的:
光标和f32b48428a809b51f04d3228cdf461fa的效果一致了。为此要判断当前光标是否在 25edfb22a4f469ecb59f1190150159c6里面,如果是则在 f32b48428a809b51f04d3228cdf461fa后面追加一个空的文本节点 #text 不是的话追加 e388a4556c0f65e1904146cc1a846bee0c6dc11e160d3b678d68754cc175188a94b3e26ee717c64999d7867364b1b4a3。然后将光标定位在里面,可用如下方式查找。
/** * 查找父元素 * @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; }
以上就是我做富文本编辑器的思路,也希望给大家带来帮助。
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!