📜 ⬆️ ⬇️

WYSIWYG HTML editor in the browser. Part 3

The article describes the practice of using the properties of designMode and contentEditable, as well as related APIs on the example of creating a simple text editor.
Translation of the first article in the series, examining the theory of using designMode and contentEditable and related APIs: Part 1 . Part 2 .

Introduction

In the first part of the article I examined in detail the theory of creating a browser editor using the properties designMode and contentEditable. These DOM properties are standardized in HTML 5 and more or less supported in most browsers. In the second part of the article, I will move from theory to practice, having considered the creation of a simple cross-browser text editor.
You can see the finished version of the editor online and download its code . Listings show only the most interesting parts of the code that require explanation, the rest of the code is not considered, because it is boring. The code is divided into three files:

Frame

As a basis, we will use a blank page inside the IFrame:
<iframe id="editorFrame" src="blank.html"></iframe> 
We can use about: blank to get an absolutely empty page, without elements inside the body, but I preferred to create my own “empty” page, as this will allow us to start working with an empty paragraph in the body.
 <title></title> <body><p></p></body> 
This is preferable, since Mozilla starts typing text into an empty p, like all other browsers. If this is not done, she enters the text directly into the body. Using the contentEditable property we can do without a frame, but Firefox 2 does not support contentEditable, so it’s best to still use iFrame. ( Translator's note: FF2, to put it mildly, is not relevant. So, I think, iframe is no longer needed. )

Setting the edit mode

We enable editing mode when the page loads, using the function (located in editor.js)
 function createEditor() { var editFrame = document.getElementById("editorFrame"); editFrame.contentWindow.document.designMode="on"; } bindEvent(window, "load", createEditor); 
bindEvent is a function responsible for binding a function to an event (defined in util.js). Frames, like jQuery, have corresponding functionality that you will most likely prefer to use. The next step is to create a control panel with minimal formatting functions.
')

Control Panel

Let's start with a simple control: the “bold” button, which will change the outline of the selected text to bold. Another button should display the state of the document - if the entry point is inside the text in boldface, the button should be highlighted. clicks and syncs button state. Separation is necessary, since different commands must separate one logic, as we will see later. Events are triggered at two points - when the user presses a button on the control panel, the controller triggers a command that runs on the document and when the user moves the cursor in the document, we change the state of the button on the control panel.

Command and controller implementation

Since the bold command is initially supported by the API, our command object is just a small wrapper.
 function Command(command, editDoc) { this.execute = function() { editDoc.execCommand(command, false, null); }; this.queryState = function() { return editDoc.queryCommandState(command) }; } 
Why do we need a wrapper at all? Since we want our non-standard commands to have the same interface as the standard ones.
Our button is just a span:
 <span id="boldButton">Bold</span> 
The span is associated with the command object through the controller:
 function TogglCommandController(command, elem) { this.updateUI = function() { var state = command.queryState(); elem.className = state?"active":""; } bindEvent(elem, "click", function(evt) { command.execute(); updateToolbar(); }); } 
A code that was responsible for maintaining the focus of the editing window when you clicked on the button on the control panel was dropped from the listing. Below we call the ToggleCommandController function to synchronize the state of the button and draw the text, taking into account their two states. When a button is pressed, the command is executed. When an updateUI event is raised, the span receives the “active” class, or loses it, depending on the state of the text. CSS properties that determine the appearance of the button:
 .toolbar span { border: outset; } .toolbar span.active { border: inset; } 
The components are related as follows:
 var command = Command("Bold", editDoc); var elem = document.getElementById(îboldButton); var controller = new TogglCommandController(command, elem); updateListeners.push(controller); 
The updateListeners collection contains controllers for the control panel. The updateToolbar function iterates the list and calls the updateUI method for each controller so that all controls are exactly up to date. We attach events so that the updateToolbar is called every time the document selection changes:
 bindEvent(editDoc, "keyup", updateToolbar); bindEvent(editDoc, "mouseup", updateToolbar); 
Just as shown above, updateToolbar is called when a command is executed. Why do we update the entire control panel after executing each command, instead of updating only the button associated with the command? Since the state of other controls as a result of the command may also change. For example, if we use the right alignment command, the state of the left alignment button and the centering button will also change. Instead of keeping track of all possible dependencies, it is easier to update the entire control panel. Now we have a basic interface for commands with two states. Using the resulting framework, the Bold, Italic, JustifyLeft, JustifyRight, and JustifyCenter commands are implemented.

Link

After we implemented the basic text formatting commands, I decided to give users the ability to add links to the document. Managing links requires more complex logic, since createLink does not work as we would like. It creates a link, but does not return information about whether the selection is inside the link or not. And we need this to synchronize the state of the control panel and the selection. How can we check if the selection is inside the link? We will do this by writing a function getContaining, which rises above the DOM tree from the element where the cursor is located, until we find the parent of the required type (References in this case. The function returns nothing if the required element is not found). If the selection is inside the a tag, we are inside the link. We also need a way to request a URL from the user. A cooler editor would create a custom dialog for this request, but to simplify the task, we simply use the standard window.prompt function. If the selection inside the link we show its current URL, so that the user could change it. Otherwise, we simply show the prefix http: //. The code of the Linkcommand function:
 function LinkCommand(editDoc) { var tagFilter = function(elem){ return elem.tagName=="A"; }; //(1) this.execute = function() { var a = getContaining(editWindow, tagFilter); //(2) var initialUrl = a ? a.href : "http://"; //(3) var url = window.prompt("Enter an URL:", initialUrl); if (url===null) return; //(4) if (url==="") { editDoc.execCommand("unlink", false, null); //(5) } else { editDoc.execCommand("createLink", false, url); //(6) } }; this.queryState = function() { return !!getContaining(editWindow, tagFilter); //(7) }; } 
The logic of the function is as follows:
  1. The function checks if the current item is the desired item. TagName is always returned in upper case, regardless of the case in the code.
  2. getContaining searches for an element with the given name containing the given one. If it is not found, returns null.
  3. If a link is found among the parent elements, we add the href attribute to the dialog. Otherwise, it will be standard http: //.
  4. The prompt returns null if the user clicks Cancel. In this case, the command is terminated.
  5. If the user deletes the URL and clicks OK, then we assume that the user wants to delete the link. For this we use the standard unlink command.
  6. If the user enters the URL and clicks OK, then we create the link using the createLink command. (if the link already exists, then we replace the URL with a new one).
  7. A double negative results in a Boolean type - true if the element is found and false otherwise.
  8. We can combine LinkCommand with standard ToggleCommandController, since the control panel interface remains unchanged: all the same methods execute and queryState.

Getcontaining

Let's look at the getContaining function (found in editlib.js). The function checks whether the selection is inside an element of a certain type. This is all somewhat more complicated, since the IE API works slightly differently than the API of other browsers. Therefore, we will have to create two independent implementations of the function and a mechanism that will determine which of them should be used - we will do this by determining the presence of the getSelection property. Here so:
 var getContaining = (window.getSelection)?w3_getContaining:ie_getContaining; 
The implementation of the function in IE is more interesting, since it shows some features of the selection API in IE.
 function ie_getContaining(editWindow, filter) { var selection = editWindow.document.selection; if (selection.type=="Control") { //(1) // control selection var range = selection.createRange(); if (range.length==1) { var elem = range.item(0); //(3) } else { // multiple control selection return null; //(2) } } else { var range = selection.createRange(); //(4) var elem = range.parentElement(); } return getAncestor(elem, filter); } 
It works like this:
  1. The type of the selection object is either “Control” or “Text”. Objects (Control) can be selected several (that is, the user can select several non-adjacent images using ctrl + click).
  2. We will not handle the situation with several selected objects; In this case, we simply cancel the command and nothing happens.
  3. If we have one object in the selection, we select it.
  4. If the selection is textual, we use this to get the container.
The API used by other browsers is relatively simple:
 function w3_getContaining(editWindow, filter) { var range = editWindow.getSelection().getRangeAt(0); //(1) var container = range.commonAncestorContainer; //(2) return getAncestor(container, filter); } 
It works like this:
  1. While the API allows for multiple selection, the user interface allows only one, so we consider only the first and only range.
  2. This method gets the element that contains the current selection.
The getAncestor function is simple - we simply go up the hierarchy of elements until we find what we are looking for or until we reach the top of the hierarchy, in this case we return null:
 /* walks up the hierachy until an element with the tagName if found. Returns null if no element is found before BODY */ function getAncestor(elem, filter) { while (elem.tagName!="BODY") { if (filter(elem)) return elem; elem = elem.parentNode; } return null; } 

Commands accepting multiple meanings

Such editing elements as the choice of font and size require a slightly different approach, since the user can select several options of values. In the interface for the implementation of this, we used the drop-down list instead of the button, as before. In addition, we will need to rewrite the objects Command and Controller, so that they can work with a set of values, and not just binary states. Here is the HTML code to select the font:
 <select id="fontSelector"> <option value="">Default</option> <option value="Courier">Courier</option> <option value="Verdana">Verdana</option> <option value="Georgia">Georgia</option> </select> 
The command object is still simple, as it is a superstructure of the standard FontName command:
 function ValueCommand(command, editDoc) { this.execute = function(value) { editDoc.execCommand(command, false, value); }; this.queryValue = function() { return editDoc.queryCommandValue(command) }; } 
The difference between ValueCommand and the previously described commands with binary states is the presence of the queryValue method, which returns the current value as a string. The controller executes the command when the user selects a value in the drop-down list.
 function ValueSelectorController(command, elem) { this.updateUI = function() { var value = command.queryValue(); elem.value = value; } bindEvent(elem, "change", function(evt) { editWindow.focus(); command.execute(elem.value); updateToolbar(); }); } 
The controller is quite simple, since we use the values ​​in the drop-down list directly as values ​​for the command. The drop-down list of font sizes works in the same way - we use the FontSize built-in command and use sizes from 1 to 7 as available values.

Non-standard teams

Until now, we have made all changes to HTML using standard, built-in commands. But sometimes you may need to change the HTML in a way that no built-in command can do. In this case, we use the DOM and Range API. As an example, we will create a command that will add some HTML to the entry point. To keep things simple, it will just span with the text “Hello World”. But the approach will not change if you want to insert paste any other HTML. The command will look like this:
 function HelloWorldCommand() { this.execute = function() { var elem = editWindow.document.createElement("SPAN"); elem.style.backgroundColor = "red"; elem.innerHTML = "Hello world!"; overwriteWithNode(elem); } this.queryState = function() { return false; } } 
The chip in the overwriteWithNode function, which inserts an element at the current entry point. (The name of the method indicates that if there is a non-empty selection, its contents will be overwritten). Due to the differences in DOM between IE and browsers that support the DOM Range standard, the method is applied differently. Let's first consider the version working with the DOM Range:
 function w3_overwriteWithNode(node) { var rng = editWindow.getSelection().getRangeAt(0); rng.deleteContents(); if (isTextNode(rng.startContainer)) { var refNode = rightPart(rng.startContainer, rng.startOffset) refNode.parentNode.insertBefore(node, refNode); } else { var refNode = rng.startContainer.childNodes[rng.startOffset]; rng.startContainer.insertBefore(node, refNode); } } 
range.deleteContents, according to its name, deletes the contents of the selection, if it is not degenerate. (If the selection is degenerate, then it simply does nothing). The DOM Range object has properties that allow us to define an input point in the DOM: startContainer is the node that contains the input point and startOffset is the number that indicates the position of the input point in the parent node. For example, if startContainer is an element and startOffset is three, then the input point is between the third and fourth descendant of the element. If startContainer is a text node, then startOffset means the offset in characters from the beginning of the parent. For example, startOffset equal to 3 means that the input point is between the third and fourth characters.

endContainer and endOffset in the same way indicate the end of the selection. If the selection is empty (degenerate), then they have the same value as that of startContainer and startOffset.


If the entry point is inside a text node, then we should split it into two, so that we can insert our data between them. rightPart is a function that does just that - splits a text node into two nodes and returns the right-hand side. Then we can use insertBefore to insert the new nodes to the desired point. The version for IE is somewhat trickier. In IE, the Range object does not provide access to information about the position of the insertion point in the DOM. Another problem is that we can only paste data using the pasteHTML method, which takes HTML as an argument as a string, not as a tree of DOM nodes. In general, the IE Range API is completely isolated from the DOM API! But there is a trick that allows you to still share the DOM API and the IE Range API: We use pasteHTML to insert a marker with a unique ID to find the desired entry point in the DOM:
 function ie_overwriteWithNode(node) { var range = editWindow.document.selection.createRange(); var marker = writeMarkerNode(range); marker.appendChild(node); marker.removeNode(); // removes node but not children } // writes a marker node on a range and returns the node. function writeMarkerNode(range) { var id = editWindow.document.uniqueID; var html = "<span id='" + id + "'></span>"; range.pasteHTML(html); var node = editWindow.document.getElementById(id); return node; } 
Pay attention to remove the marker node after it is finished. This is necessary not to clog the HTML code. Now we have a command that inserts arbitrary HTML into the selection point. We used the button on the control panel and the ToggleCommandController function to associate this action with the user interface.

findings

In this article, we looked at a simple framework for creating an HTML editor. The code can be used as a template for developing more complex editors.

Source: https://habr.com/ru/post/107907/


All Articles