Home  >  Article  >  Web Front-end  >  Implement rich text editor using javascript

Implement rich text editor using javascript

php中世界最好的语言
php中世界最好的语言Original
2017-11-18 13:20:593869browse

Because the recent project requires the development of a rich text editor compatible with PC and mobile terminals, which contains some special customization functions. So I looked at the existing JavaScript rich text editors. There are many on the desktop side, but almost none on the mobile side. The desktop side is represented by UEditor. But I don't consider compatibility for the time being, so there is no need to use a plug-in as heavy as UEditor. For this reason, I decided to use javascript to implement a rich text editor. This article mainly introduces how to implement a rich text editor and solve some bugs between different browsers and devices.

Preparation phase

In modern browsers, many APIs have been prepared for us to enable html to support rich text editing functions. We do not need to complete the entire content ourselves.

contenteditable=”true”

First we need to make a div editable by adding the contenteditable="true" attribute.

<div contenteditable="true" id="rich-editor"></div>

Any node inserted into such a dc6dce4a544fdca2df29d5ac0ea9906b will be editable by default. If we want to insert a non-editable node, we need to specify the attribute of the inserted node as contenteditable="false".

Cursor operation

As a rich text editor, developers need to be able to control various status information, position information, etc. of the cursor. The browser provides selection objects and range objects to operate the cursor.

selection object

The Selection object represents the text range selected by the user or the current position of the caret. It represents a selection of text on the page, which may span multiple elements. Text selections are created by the user dragging the mouse over text.

Get a selection object

let selection = window.getSelection();

Normally we will not directly operate the selection object, but need to operate the user-selected ranges (regions) corresponding to the seleciton object, commonly known as "drag blue" ". The acquisition method is as follows:

let range = selection.getRangeAt(0);

Since the browser may currently have multiple text selections, the getRangeAt function accepts an index value. In rich text editing, we do not consider the possibility of multiple selections.

The selection object also has two important methods, addRange and removeAllRanges. Used to add a range object to the current selection and delete all range objects respectively. You'll see their uses later.

range object

The range object obtained through the selection object is the focus of our cursor operation. Range represents a document fragment containing nodes and partial text nodes. When you see the range object for the first time, you may feel strange and familiar at the same time. Where have you seen it? As a front-end engineer, you must have read the book "JavaScript Advanced Programming Third Edition". In Section 12.4, the author introduces us to the range interface provided by DOM2 level to better control the page. Anyway, what did I look like at that time? ? ? ? What is the use of this? There is no such need. Here we use this object extensively. For the following nodes:

<div contenteditable="true" id="rich-editor">
    <p>123</p>
</div>

Print out the range object at this time:

* startContainer: The starting node of the range.

* endContainer: The end node of the range

* startOffset: The offset of the starting position of the range.

* endOffset: range The offset of the end position.

* commonAncestorContainer: Returns the deepest node containing startContainer and endContainer.

* collapsed: Returns a Boolean value used to determine whether the starting and ending positions of the Range are the same.

Here our startContainer, endContainer, commonAncestorContainer are all #text text nodes ‘Baidu EUX Team’. Because the cursor is behind the word 'degree', both startOffset and endOffset are 2. And no drag is generated, so the value of collapsed is true.

Due to the delay, startContainer and endContainer are no longer consistent, and the value of collapsed becomes false. startOffset and endOffset exactly represent the starting and ending positions of the drag blue. You can try it yourself for more effects.

To operate a range node, there are mainly the following methods:

setStart(): Set the starting point of the Range

setEnd(): Set the end point of the Range

selectNode(): Set a Range containing nodes and node content

collapse(): Collapse the Range to the specified endpoint

insertNode(): Insert a node at the starting point of the Range.

cloneRange(): Returns a cloned Range object that has the same endpoint as the original Range

There are so many commonly used methods in rich text editing, and there are many more methods that I will not list.

Modify the cursor position

We can modify the position of a cursor or the drag range by calling the setStart() and setEnd() methods. The parameters accepted by these two methods are their respective starting and ending nodes and offsets. For example, if I want the cursor position to be at the end of "Baidu EUX Team", then I can use the following method:

let range = window.getSelection().getRangeAt(0),
    textEle = range.commonAncestorContainer;
range.setStart(range.startContainer, textEle.length);
range.setEnd(range.endContainer, textEle.length);

We add a timer to see the effect:

However, this method has limitations property, that is, if the node where the cursor is located changes. For example, if a node is replaced or a new node is added, using this method again will not have any effect. For this reason, we sometimes need a means to force the cursor position to change. The brief code is as follows (in practice, you may also need to consider self-closing and elements):

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);
}

We create a range object again and delete it The original ranges are used to ensure that the cursor will definitely move to the position we want.

修改文本格式

实现富文本编辑器,我们就要能够有修改文档格式的能力,比如加粗,斜体,文本颜色,列表等内容。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 === &#39;P&#39; && parentNode.lastChild ===parentNode.firstChild) {
                parentNode.insertAdjacentElement(&#39;beforebegin&#39;, 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, &#39;a&#39;);
        if(parent) {
            parent.setAttribute(&#39;src&#39;, url);
        }else {
            this.insertHTML(`<a href="${url}">${title}</a>`);
        }
    }else {
        document.execCommand(&#39;createLink&#39;, 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(&#39;formatBlock&#39;, false, ``);
    } else {
        document.execCommand(&#39;formatBlock&#39;, false, ``);
    }
}

插入定制内容

当编辑器上传或加载附件的时候,要插入能够展示附件的

节点卡片到编辑中。这里我们借助 document.execCommand('insertHTML', false, html); 来插入内容。为了防止div被编辑,要设置 contenteditable="false"哦。

处理 paste 粘贴

在富文本编辑器中,粘贴效果默认采用如下规则:

如果是带有格式的文本,则保留格式(格式会被转换成html标签的形式)

粘贴图文混排的内容,图片可以显示,src 为图片真实地址。

通过复制图片来进行粘贴的时候,不能粘入内容

粘贴其他格式内容,不能粘入内容

为了能够控制粘贴的内容,我们监听 paste 事件。该事件的 event 对象中会包含一个 clipboardData 剪切板对象。我们可以利用该对象的 getData 方法来获得带有格式和不带格式的内容,如下。

let plainText = event.clipboardData.getData(&#39;text/plain&#39;);  // 无格式文本
let plainHTML = event.clipboardData.getData(&#39;text/html&#39;);   // 有格式文本

之后调用 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(&#39;span&#39;);
    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;
}

以上就是我做富文本编辑器的思路,也希望给大家带来帮助。

The above is the detailed content of Implement rich text editor using javascript. For more information, please follow other related articles on the PHP Chinese website!

Statement:
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn