Rich HTML editing in the browser: part 2
Introduction
In the first part of this article, I looked in great detail at the theory behind creating rich HTML editing functionality on Web pages using JavaScript constructs such as the designMode
and contentEditable
. These DOM properties have been standardized in HTML 5 and are supported in most modern browsers to a greater or lesser extent. In this, the second part, I will put this theory into practice, walking you through the construction of a simple online text editor that will work across different browsers.
You can check out the finished version online, and download the source code to play with, too. The listings below just show the most important parts of the code that we are focusing on in the explanations, leaving out all the boring parts.
The code is separated into three files:
editor.js
: The main application infrastructureeditlib.js
: A collection of functions for modifying selectionsutil.js
: General utility functions
The frame
We will use a blank page inside an IFrame
as the canvas:
<iframe id="editorFrame" src="blank.html"></iframe>
We could use about:blank
in the src
to get a completely blank page with no elements in the body at all, but I prefer to create a custom “blank” page, since this allows us to start with an empty paragraph:
<title></title> <body><p></p></body>
This is preferable, because it results in Mozilla starting with an empty p
element to contain the content like the other browsers do. (I we didn’t do this, Mozilla would start off entering content directly inside the body
element). Using the contentEditable
attribute, we could avoid using a frame at all and just make a div
on the same page editable. This however is not supported in Firefox 2, so it is best to stick to iFrame-based editing for cross-browser compatibility.
Activating the editing mode
We activate the editing mode when the page is loaded with the following function (contained in editor.js
)
function createEditor() {
var editFrame = document.getElementById("editorFrame");
editFrame.contentWindow.document.designMode="on";
}
bindEvent(window, "load", createEditor);
The bindEvent
is a utility function (defined in util.js
) that attaches a function to an event. Frameworks like JQuery have corresponding functionality, which you might prefer to use.
The next step is to create a toolbar with some common text formatting functionality.
Toolbar
We will start with a simple control: a “bold” button, which formats the current selection as bold. We also want the button to track state in the document—when the insertion point or selection is inside bold-formatted text, the button should be highlighted.
This logic is split into two objects: A command
object that encapsulates the actual operation on the document and queries the state of the selection and a controller
object that handles the click
event and updates the look of the HTML button. This separation makes sense, because different commands are likely to share the same controller logic, as we shall see later.
Events flow two directions—when a control on the toolbar is clicked, the controller tells the command to execute on the document. But we also have events flowing the other way; when the cursor is moved around in the document, we want the controls on the toolbar to update. We keep track of the controllers; when notified about a change in the selection, they query the command for its state and update the look of the button accordingly.
Command and controller implementation
Since the bold command is already supported by the command API, the command
object is just a thin wrapper upon it:
function Command(command, editDoc) {
this.execute = function() {
editDoc.execCommand(command, false, null);
};
this.queryState = function() {
return editDoc.queryCommandState(command)
};
}
Why have a wrapper at all? Because we want custom commands to have the same interface as the built-in commands.
The actual button is just a simple span
:
<span id="boldButton">Bold</span>
The span
element is connected to 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();
});
}
Left out from this listing is some additional code to ensure that the editing window does not lose focus when we click the button.
We call the above function a ToggleCommandController
because it connects two-state commands to a button that utilizes the two states. When the button is clicked, the command is executed. When the updateUI
event is called, the active
class is added to (or taken away from) the span
element to change the look of the button. The CSS properties that define the look of each button state are as follows:
.toolbar span {
border: outset;
}
.toolbar span.active {
border: inset;
}
The components are connected like this:
var command = Command("Bold", editDoc);
var elem = document.getElementById(îboldButton);
var controller = new TogglCommandController(command, elem);
updateListeners.push(controller);
The updateListeners
collection provides controllers for the toolbar. The updateToolbar
function iterates through the list and calls the updateUI
method on each controller to ensure that all controls are updated. We attach events so that updateToolbar
is executed anytime the selection changes in the document:
bindEvent(editDoc, "keyup", updateToolbar);
bindEvent(editDoc, "mouseup", updateToolbar);
updateToolbar
is also called when a command is executed, as shown in the command code above. Why do we update the whole toolbar when a command is executed, rather than just update the control for the command in question? This is because the state of other commands may also have changed. For example, if justify-right is executed, the state of the justify-left button should also be updated. Rather than keep track of all these dependencies, we just update the whole menu.
Now, we have the basic infrastructure for two-state commands. The commands Bold, Italic, JustifyLeft, JustifyRight, and JustifyCenter are all implemented using this framework.
Link
After implementing some basic text formatting commands, I decided to give site visitors the ability to add links into the document. The link control requires more customized command logic, since the built-in command createLink
does not work completely as we would like. It creates the link alright, but it doesn’t return state information to indicate whether the selection is inside a link or not. We need that feature to provide a consistent feel to the toolbar.
How do we check if the selection is inside a link, then? By creating a utility function—getContaining
, which walks upwards in the DOM tree from the current selection until it finds an element of the type we are asking it to seek (it returns none
if no matching element is found). We use it to check for a containing a
. If the selection is contained inside an a
element, we are inside a link.
Another extension we need is a means of asking the user for the URL. A fancier editor would use a custom dialog box for this, but, to keep it simple, we just use the built-in window.prompt
function. If the selection is inside a link, we want to show the current URL in the dialog, so the user can inspect and change it. Otherwise we just show the default prefix http://
.
Here is the code for 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 flow of this function is as follows:
- This function checks if an element is the one we are seeking. The
tagName
is always returned in uppercase from the DOM, regardless of the casing in the source. getContaining
looks for an element with the specified name, containing the current selection. If this is not found, it returnsnull
.- If a containing link is found, we insert the
href
attribute into the dialog; otherwise, we default tohttp://
. - The prompt returns
null
if the user clicks Cancel on the dialog, which aborts the command. - If the user deletes the URL but clicks OK, we assume it means the user wants to remove the link completely. We use the built-in command
unlink
for this. - If the user provides an URL and clicks OK, we use the built-in command
createLink
to actually create the link. (If the link already exists, the command updates thehref
value with the new URL). - The double negation turns the result into a boolean—
true
if an element is found andfalse
if nothing is found.
We can combine LinkCommand
with a standard ToggleCommandController
, because the interface to the toolbar control is the same: the execute
and queryState
methods.
GetContaining
Now let’s look at the getContaining
function (found in editlib.js
). This function tells us if the current selection is inside an element of a particular type.
Things get a little more complicated here, because the IE API does things differently to the other browsers. Therefore, we need to create two separate implementations and employ some capability detection to work out which one to use—we switch implementations by detecting the getSelection
property, like this:
var getContaining = (window.getSelection)?w3_getContaining:ie_getContaining;
The IE implementation is interesting because it illustrates some of the subtleties of the the IE selection API.
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);
}
This works like so:
- The
type
property of the selection object is either “Control” or “Text”. In the case of a control selection, more than one control might be selected (e.g., the user might select several non-adjacent images on a page by control-clicking). - We don’t handle multiple selections of controls; in such cases, we just exit the command, and nothing happens.
- If there is one selection, we highlight that.
- If it’s a text selection, we use this to get the container element.
The API used by the other browsers is comparatively straightforward:
function w3_getContaining(editWindow, filter) {
var range = editWindow.getSelection().getRangeAt(0); //(1)
var container = range.commonAncestorContainer; //(2)
return getAncestor(container, filter);
}
This works like so:
- While the API allows multiple selections, the UI only allows one, so we just look at the first and only range.
- This method gets the element that contain the current selection.
The getAncestor
function is straightforward—we just walk up the element hierarchy, until we either find what we are seeking or reach the top, in which 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;
}
Multivalue commands
Editing features like font type and size require a different approach, because the user can choose one of several options. For the UI widget, we use a select box rather than a two-state button like the previous controls. We also need a new variant of Command and Controller to manage multiple values rather than simple on/off state.
Here is the HTML for the font selector:
<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 again quite simple because we build upon the built-in 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 a ValueCommand
and the previously described two-state commands is that we have a queryValue
method, which returns the current value as a string. The controller executes the command when the user selects a value in the dropdown.
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, because we map the option values directly to command values.
The font-size dropdown is implemented in the same way—we just use the built-in FontSize
command instead, and use sizes (from 1-7) as the option values.
A custom command
Until now, all modifications of the HTML have been done through the built-in commands. But sometimes you might need to change HTML in a way not supported by any built-in command. In that case we have to dive into the DOM and Range APIs.
As an example, we will create a command that inserts some custom HTML at the insertion point. To keep it simple, we just insert a span with the text “Hello World”. The approach can be easily extended to insert any HTML you like.
The command looks 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 magic happens in overwriteWithNode
, which inserts the element at the current insertion point. (The name of the method indicates that if there is a non-empty selection, the selected content will be overwritten.) Due to the DOM differences between IE and DOM Range standard-compliant browsers, the method is implemented differently. Let’s look at the DOM Range version first:
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
does what it says on the tin: If the selection isn’t collapsed, it deletes the content of the selection. (If the selection is empty, it doesn’t do anything).
The DOM Range
object has properties that allow us to locate the insertion point in the DOM: startContainer
is the node that includes the insertion point, and startOffset
is a number that indicates the position of the insertion point in the parent node.
For example, if startContainer
is an element and startOffset
is 3, the insertion point is positioned between the 3rd and 4th child node of the element. If startContainer
is a text node, then startOffset
indicates the position in terms of numbers of characters from the start of the containing element. For example, a startOffset
of 3 would indicate the point between the 3rd and 4th character.
endContainer
and endOffset
address the end point of the selection in the same way. If the selection is empty, they have the same value as startContainer
and startOffset
.
If the insertion point is inside a text node, then we have to split the node in two, so that we can insert our content between them. rightPart
is an utility function that does just that—it splits a text node in two nodes and returns the right part. Then we can use insertBefore
to insert the new node at the correct position.
The IE version is somewhat trickier. The IE Range
object does not immediately give any access to the exact position in the DOM where the insertion point is. Another problem exists, too—we can only insert content with the pasteHTML
method, which accepts arbitrary HTML in the form of a string but not DOM nodes. Basically, the IE range API is completely isolated from the DOM API!
We can, however, use a trick to connect the two universes: we use pasteHTML
to insert a marker element with a unique ID, and then we use this ID to find the same element 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;
}
Note that we remove the marker node after we are finished. This is to stop the HTML becoming littered with junk.
We now have a command that inserts arbitrary HTML at the insertion point. We use a toolbar button and the ToggleCommandController
function to connect this to the UI.
Summary
In this article, we have walked through a framework for a simple HTML editor. The code can be used as a starting point for creating more advanced or customized editors.
This article is licensed under a Creative Commons Attribution, Non Commercial - Share Alike 2.5 license.
Comments
The forum archive of this article is still available on My Opera.