The Trials and Tribulations of WYSIWYG editors

Who: Kerry Gallagher | @Kerry350

What: Web Developer

Where: Currently Audacious

This all begins with one attribute

						

Content to edit

Good support: Chrome, Safari, Firefox, Opera, IE (>=5.5), iOS Safari, Opera Mobile and so on.

Meet the Range and Selection APIs

These allow us to programmatically access information about the text a user has selected on the page. Typically you'll deal with a selection, which contains a single range (although you can have multiple).

						
// Grabbing the selection
var selection = window.getSelection();

// Grabbing the range
var range = selection.getRangeAt(0);

// We can now grab some useful information via properties
range.collapsed;

range.commonAncestorContainer;

range.startContainer;

range.startOffset;

range.endContainer;

range.endOffset;

// Or manipulate the range in some way with the many methods
range.selectNode();

range.setStart();

range.setEnd();
					 	
					

Working with ranges

Ranges give us a lot of power, they can just be a little clunky to work with sometimes. Here's an example that would insert text at the cursor position:

						
function insertTextAtCaret(text) {
    var sel, range;
    
    sel = window.getSelection();
    
    if (sel.rangeCount) {
        range = sel.getRangeAt(0);
        range.deleteContents();

        var textNode = document.createTextNode(text);
        range.insertNode(textNode);

        // Show the selection
        range = range.cloneRange();
        range.setStartAfter(textNode);
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);
    } 
}
					 	
					

With a little context

The following selection would return us the following information when calling window.getSelection().getRangeAt(0);:

						
{
	collapsed: false // If collapsed there is no selection (highlighted text), just the cursor
	commonAncestorContainer: span.st // Reference to a regular DOM node
	endContainer: text // node type
	endOffset: 113 // character offset
	startContainer: text // node type
	startOffset: 0 // character offset
}
					 	
					

Make way for formatting. Meet execCommand().

When used in conjunction with contenteditable execCommand() will apply formatting to the currently selected text within the focussed editable element. There's plenty of options, but beware browser inconsistencies.

						
document.execCommand('bold');
document.execCommand('italic');
document.execCommand('undo');
document.execCommand('redo');
document.execCommand('strikeThrough');
document.execCommand('insertUnorderedList');
document.execCommand('insertOrderedList');
document.execCommand('subscript');
document.execCommand('superscript');
document.execCommand('createLink', false, url); // Sometimes we need to pass some extra parameters
document.execCommand('unlink');

// Allows us freedom to 'fill the gaps' for block level elements. But, this is also inconsistent between browsers.  
document.execCommand('formatBlock', false, 'blockquote'); 
					 	
					

Am I sexy yet?

To know if a certain type of formatting is applied to the currently selected text you can use document.queryCommandState('bold'); which will return a boolean to work with.

Great for those toolbars etc, but this gets a bit wonky with block level elements.

So, what about those browser inconsistencies, huh?

  • Each browser has it's own way of handling the enter key
  • Each browser has it's own way of formatting things when using execCommand()
  • Each browser 'exits' an empty list element in it's own way when pressing enter
  • Each browser reverts back to a default state differently when selecting all content with ctrl + a and pressing backspace

This makes things really hard

Examples

Here's some examples of each browsers way of handling the enter key

						
// Chrome
Text
// Firefox Text
// IE

Text

Workarounds

Some editors fully override the editable area.

The problem with manually doing everything, i.e. using selections, ranges and DOM manipulation rather than using document.execCommand() to format text, is that the browsers undo / redo stack will be rendered useless. You could roll your own, of course, but this is quite tricky to do.

There is hope (it's just in the future)

There is a spec for a proper, custom undo / redo API. Some parts are already in Webkit and Firefox.

IE11 already has ms-beginUndoUnit and ms-endUndoUnit

"Starts an undo unit. Any DOM changes between ms-beginUndoUnit and ms-endUndoUnit (including any changes from script) are collected into an undo unit and undone and redone as if they were a single command."

Sorry about the now though

Pasting content

By default content will be pasted in with some sad panda HTML and inline styles. Unless you are truly pasting plain text.

We can always listen out for the paste event like so:

						
element.addEventListener('paste', handlePaste);
					 	
					

Handling the paste event

Now we can use clipboardData() to access the clipboard's contents

						
function handlePaste(e) {
  if (e.clipboardData) {
     var plainText = e.clipboardData.getData('text/plain');
     var html = e.clipboardData.getData('text/html');
  }

  else {
    // IE
    if (window.clipboardData) {
      var plainText = window.clipboardData.getData('Text');
      var url = window.clipboardData.getData('URL');
    }
  }
}