Use javascript to implement rich text editor

by.

In the recent project, we need to develop a rich text editor compatible with PC and mobile terminals, which includes some special customization functions. Taking a look at the existing JS rich text editors, there are many on the desktop and few on the mobile. The desktop side is represented by UEditor. But we are not going to consider compatibility, so there is no need for a plug-in as heavy as the UEditor. Therefore, I decided to develop a rich text editor. This article focuses on how to implement a rich text editor and resolve some bugs between different browsers and devices.

Preparation stage

There are plenty of apis in modern browsers that allow HTML to support rich text editing, so we don’t have to do it all ourselves.

Contenteditable = “true”

First we need to make a div editable, adding the contenteditable=”true” property.

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

Inserting any node into such a

will be editable by default. If we want to insert non-editable nodes, we need to specify the property of the insert node as contenteditable=”false”.

The cursor operation

As a rich text editor, developers need to be able to control various state information of the cursor, position information and so on. Browsers provide selection and range objects to manipulate the cursor.

The selection object

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

let selection = window.getSelection();
Copy the code

Normally, we do not operate selection directly, but need to operate the user-selected ranges corresponding to the Seleciton object, commonly known as “drag blue”. Obtain it as follows:

let range = selection.getRangeAt(0);
Copy the code

Because browsers may currently have multiple text selections, the getRangeAt function accepts an index value. In rich text editing, we do not consider the possibility of multi-selection.

There are also two important methods for selecting objects, addRange and removeAllRanges. Add a range object to the current selection and remove all range objects, respectively. And then you’ll see what they’re used for.

Range object

The range object obtained by selection is the focus of cursor manipulation. Range represents a document fragment that contains nodes and some text nodes. Range objects may feel strange and familiar to you. Where have you seen them before? As a front-end engineer, you’ve probably read the book javascript Advanced Programming Edition 3. In section 12.4, the author introduces the Range interface provided by DOM2 level for better control of the page. Anyway, I was watching a face ???? What’s the point? There’s no need. We’re going to use this object a lot here. For the following nodes:

<div contenteditable="true" id="rich-editor"> <p> Baidu EUX team </p> </div>Copy the code

The cursor position is shown in the figure below:



Print out the range object at this point:

The attributes are described as follows: * startContainer: indicates the start node in the range. * endContainer: the end node of the range * startOffset: the offset of the starting position of the range. * endOffset: offset of range terminal position. CommonAncestorContainer: Returns the deepest node that contains startContainer and endContainer. * Collapsed: Returns a Boolean value that determines whether the starting and ending ranges are the same.

Here our startContainer, endContainer, commonAncestorContainer are #text text nodes’ Baidu EUX team ‘. Because the cursor is after the word ‘degree’, both startOffset and endOffset are 2. So an collapsed value is true because it does not produce drag blue. Let’s look at another example that produces a drag blue:

The cursor position is shown in the figure below:



Print out the range object at this point:

The value of collapsed becomes false because startContainer and endContainer are no longer consistent. StartOffset and endOffset just represent the starting and ending position of drag blue. More effect you try it yourself.

To operate a range node, do the following:

  • SetStart (): Sets the starting point of the Range
  • SetEnd (): Sets the end of the Range
  • SelectNode (): Sets a Range of nodes and their contents
  • Collapse (): Collapse the Range to the specified endpoint
  • InsertNode (): Inserts a node at the start of a Range.
  • CloneRange (): Returns a cloned Range object with the same endpoints as the original Range

So much for rich text editing, and there are many more.

Modify cursor Position

We can change the cursor position or drag range by calling the setStart() and setEnd() methods. These two methods take their respective start and end nodes and offsets. For example, IF I want the cursor position to be at the end of “Baidu EUX Team”, 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); 
Copy the code

Let’s add a timer to see the effect:

However, this method has a limitation, is when the cursor on the node changes. For example, when a node is replaced or added, this method will not have any effect. To do this, we sometimes need a way to force cursor positions to change. This is briefly coded as follows (in practice you may also need to consider things like closures 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);
}
Copy the code

We make sure that the cursor moves to the desired position by recreating a range object and deleting the original ranges.

Modify text formatting

To implement a rich text editor, we need to be able to modify the document format, such as bold, italics, text colors, lists, etc. DOM provides the Document. execCommand method for the editable area, which allows commands to be run to manipulate the contents of the editable area. Most commands affect the selection of the document (bold, italic, etc.), while others insert new elements (add links) or affect entire lines (indent). When using contentEditable, calling execCommand() affects the editable elements of the current activity. The syntax is as follows:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

  • ACommandName: a DOMString, the name of the command. For a list of available commands, see Commands.
  • AShowDefaultUI: A Boolean indicating whether to display the user interface. Mozilla doesn’t implement it.
  • AValueArgument: Some commands (such as insertImage) require additional arguments (insertImage needs to provide the URL to insert the image), which defaults to null.

Anyway, the browser does most of what we think of as a rich text editor, and I’m not going to demonstrate it here. For those interested, see MDN — document.execCommand.

At this point, I believe you have a decent rich text editor. It’s exciting to think about it, but it’s not over yet. The browser screwed us again.

The actual combat begins, fills the pit the journey

Just when we all thought development was so easy, we encountered a lot of pitfalls in getting started.

Fix browser defaults

Rich text effects provided by browsers are not always easy to use. Here are some of the problems encountered.

Carriage returns

When we enter content in the edit and press Enter to continue typing, the editable box content generates nodes that are not what we expected.





You can see that the text that was typed first is not wrapped, but the content that is generated by the line break, the wrap element is<div>The label. In order to be able to let the text be<p>Element wrapped.

And we’re going to initialize it to<div>The default insert<p><br></p>Elements (<br>The tag is used as a placeholder and will be automatically deleted when the content is entered. This way, every new content generated by the carriage return in the future will be<p>Element wrapped (when editable, the new structure generated by the carriage return newline copies the previous content by default, wrapping nodes, class names, and so on).

We also need to listen for the keyUp eventevent.keyCode === 8The delete key. When the editor is completely empty (delete key will also delete<p>Tag removed), to rejoin<p><br></p>Tag and position the cursor inside.

Ul and OL are inserted incorrectly

When we calldocument.execCommand("insertUnorderedList", false, null)When you insert a list, the new list is inserted<p>Tag.



To do this, we need to make a correction before calling the command, with the following reference code:

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()
        }
    }
}
Copy the code

There is a minor side issue here, I am trying to maintain such an editor structure in

  • (there is no

    tag by default). The effects work well in Chrome. In Safari, however, the carriage return never produces a new

  • tag, so the list effect is removed.
  • Insert the dividing line

    calldocument.execCommand('insertHorizontalRule', false, null);Will insert a<hr>The label. The effect, however, is this:



    The cursor and<hr>The effect is consistent. To do this, determine whether the current cursor is in<li>Inside. If so, in<hr>Append an empty text node to it#textIf not, append<p><br></p>. Then place the cursor inside and search in the following way.

    / * * * find parent * @ 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; },Copy the code

    Insert the link

    Call document.execCommand(‘createLink’, false, URL); Method we can insert a URL link, but this method does not support inserting a link with the specified text. At the same time, you can repeatedly insert new links to the location of existing links. To do this we need to override this method.

    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); }}Copy the code

    Set the h1 to H6 title

    Document. execCommand(‘formatBlock’, false, tag) 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, ``);
        }
    }
    Copy the code

    Inserting custom content

    When the editor uploads or loads an attachment, insert a

    node card that shows the attachment into the editor. ExecCommand (‘insertHTML’, false, HTML); To insert content. To prevent div from being edited, set contenteditable=”false”.

    Handle paste

    In a rich text editor, the paste effect defaults to the following rules:

    1. If it’s text with formatting, keep formatting (formatting is converted to HTML tags)
    2. Paste text mixed content, the picture can be displayed, SRC is the real address of the picture.
    3. When pasting by copying images, do not paste in the content
    4. Paste content in other formats. Do not paste content

    To control what is pasted, we listen for the Paste event. The event object for this event contains a clipboardData clipboard object. We can use the object’s getData method to get formatted and unformatted content, as follows.

    let plainText = event.clipboardData.getData('text/plain'); / / plain text let plainHTML = event. The clipboardData. GetData (" text/HTML "); // Have formatted textCopy the code

    Then call document.execCommand(‘insertText’, false, plainText); Or the document. ExecCommand (‘ insertHTML, false, plainHTML; To override the paste effect on the edit.

    For rule 3, however, the above scenario does not work. Here we will introduce the event. The clipboardData. The items. This is an array that contains all the content objects in the clipboard. For example, if you copy a picture to paste, then the event. The clipboardData. The length of the items for the 2: Items [0] is the name of the picture, items[0]. Kind is’ string ‘, items[0]. Type is’ text/plain ‘or’ text/ HTML ‘. Obtain the content as follows:

    Items [0].getAsString(STR => {// handle STR})Copy the code

    Items [1] is the binary data of the picture, items[1]. Kind is’ file ‘, items[1]. Type is the format of the picture. To retrieve the contents, we need to create a FileReader object. Example code is as follows:

    let file = items[1].getAsFile(); // file.size = file size let reader = new FileReader(); Reader.onload = function() {// reader.result = function(); } if(/image/.test(item.type)) {read.readasdataurl (file);} if(/image/.test(item.type)) {read.readasdataurl (file); // Read base64 format}Copy the code

    After processing the image, what about copying and pasting content in other formats? In MAC, if you copy a disk file, the event. The clipboardData. The length of the items is 2. Items [0] is still the file name, whereas items[1] is an image, yes, a thumbnail of the file.

    Input method processing

    Sometimes unexpected things can happen when typing. For example, Baidu input method can input a local picture, so we need to monitor the content generated by the input method to do processing. This is handled by the following two events:

    • Compositionstart: The compositionStart event is triggered in synchronous mode when the browser has indirect text input
    • Compositionend: When the browser is typing text directly, compositionEnd is triggered in synchronous mode

    Fixed mobile issues

    On the mobile end, the problems with rich text editors focus on the cursor and keyboard. Let me show you some of the bigger ones.

    Automatic focus capture

    If we want our editor to automatically get focus and pop up the soft keyboard, we can use the focus() method. On ios, however, there is no outcome. This is mainly because in ios Safari, code is not allowed to get focus for security reasons. This can only be done by user interaction. Fortunately, this restriction can be removed:

    [self.appWebView setKeyboardDisplayRequiresUserAction:NO]
    Copy the code

    When iOS hits Enter, the scroll bar doesn’t scroll automatically

    On iOS, when we hit hit to wrap, the scrollbar doesn’t scroll down. So the cursor may be blocked by the keyboard, the experience is not good. To solve this problem, we need to listen for the selectionChange event, calculate the distance to the top of the cursor editor each time, and then call window.Scroll (). The problem is how to calculate the current position of the cursor. If we only calculate the position of the parent element where the cursor is, we may be wrong (multi-line text calculation is not correct). We can find the cursor position by creating a temporary element and calculate the position of the element. The code is as follows:

    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;
    }
    Copy the code

    Just when I was happy, android reacted and the editor became more and more jammed. What the hell? I checked the Chrome launch and found that the selectionchange function was always running, with or without an operation. One of the things I discovered when I went through it. The range.insertNode function also raises the selectionChange event. This creates an endless cycle. This loop doesn’t happen in Safari, it only happens in Safari, so we need to add the browser type judgment.

    The keyboard pops up to block the input

    The main online solution to this problem is to set timers. Limitations and the front end, really can only use such a clumsy solution. Finally, we asked the iOS students to subtract the webView height from the soft keyboard height when the keyboard pops up.

    CGFloat webviewY = 64.0 + self. NoteSourceView. Height; self.appWebView.frame = CGRectMake(0, webviewY, BDScreenWidth, BDScreenHeight - webviewY - height);Copy the code

    Failed to insert picture

    On the mobile side, an album selection image is evoked by calling Jsbridge. The insertImage function is then called to insert the image into the editor. However, inserting the picture has been unsuccessful. It turns out that earlier in Safari, if the editor loses focus, selection and range objects are destroyed. So when WE call insertImage, we don’t get the cursor position, so we fail. To do this, the backupRange() and restoreRange() functions need to be added. Record range information when the page loses focus and restore range information before inserting images.

    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); // Add a region to the selection. AddRange (range); }}Copy the code

    In Chrome, losing focus does not clear the seleciton and range objects, so we can easily use a focus().

    There are only so many important questions, and the rest are left out for lack of space. Overall, pit filling took most of the development time.

    Other features

    After tinkering with the basic features, the actual project may encounter some other requirements, such as the current cursor position text state, image drag and zoom, to-do list function, attachment cards and other functions, markdown switch, etc. After understanding the various pits of JS rich text, after the operation of the range object, I believe that these problems you can easily solve. Finally, here are a few of the problems encountered when doing the extension function.

    Enter newline tape format

    As mentioned earlier, the mechanics of a rich text editor are such that when you enter a line feed the new content is exactly the same as the previous format. If we define a card content using the.card class, the new paragraphs generated by the newline will contain the.card class and the structure will be copied directly from it. We want to block this mechanism, so we try to do it in the KeyDown phase (if the user experience is not good in the keyUp phase). However, it doesn’t help because the user-defined keyDown event is triggered before the default keyDown event for rich text in the browser, so you can’t do anything about it. To do this, we add a property attribute to each of these special individuals. The content added to the property will not be copied. So that we can distinguish them later and do the corresponding processing.

    Gets the style at which the cursor is currently located

    The main consideration here is underlined, strikeout and so on, which are all described by the tag class, so you have to traverse the tag hierarchy. Directly on the code:

    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; }Copy the code

    One last word

    The project is currently under test, so AS soon as I find any interesting holes, I will update them in time.

    Refer to the content

    • MDN – document. ExecCommand
    • MDN – selection
    • MDN range –
    • Input event compatibility processing and Chinese input method optimization
    • Js gets clipboard content, JS controls image paste
    • Full description of iOS UIWebView properties