I've finally decided to join the 21st century and use automated testing on my bililiteRange code; I'm using QUnit, with tests on github. I want to bililiteRange test on all three types of editable elements, <input type=text>, <textarea>, and <div contenteditable>, so I created a convenience function to run each test on each type of element, with

var elements = ['div', 'textarea', 'input'];
function multitest (message, fn){
  elements.forEach( function(name){
    test(message, function(){
      var $el = $('<'+name'>').attr('contenteditable','').appendTo('#qunit-fixture');
      fn($el); // the testing function
    });
  });
}

And the testing code calls multitest rather than test.

I feel so modern; maybe I'll start complaining about waterfalls next.

Playing with the kavanot editor, I wanted some way to encapsulate the process of noting that the text is "dirty"—meaning has been changed but not yet saved, and using a Promise to express the idea that the text is soon-to-be-saved, then saved.

I created a plugin that simply represents a state machine with four states, clean, dirty, pending, and failed. So you can do

var monitor = $.savemonitor();
$('textarea').on('input', function() { monitor.dirty() }); // track when the text area changes
// call this function to save (say, as a click handler for a Save button
function save(){
  var text = $('textarea').val();
  monitor.clean($.post('saveit.php', {data: text})); // jQuery Deferred's like $.post can be cast to Promises
}
// and keep track of the status
monitor.on('clean', function() {console.log('saved')})
monitor.on('failed', function() {console.log('not saved')})

See the code on github.

See a simple demo.

See a demo that uses FontAwesome icons and randomly fails to simulate a network failure.

Continue reading ‘New jQuery plugin, savemonitor’ »

I use Range and Selection extensively in bililiteRange in regular elements in standards-based browsers. I initially implemented scrolling a range by inserting an empty element, using scrollIntoView on that, then deleting the element.

But it turns out adding and removing elements messes up the selection. So I had to change it to manipulate scrollTop directly.

Continue reading ‘Odd bug with document ranges and selections’ »

With my status plugin, I'd generally like the input element to take up the whole line, or at least the part that's not taken up by the prompt and by other messages. Evidently, that's a common concern, with a pure CSS solution that relies on overflow: hidden to create a new block formatting context that makes width: 100% mean "100% of the space not taken up by floats".

So the solution is to make sure that everything that is not the input element is floated, and that the input element is wrapped with an element that is display: block and overflow: hidden, and the input element is width: 100% (and box-sizing: border-box to make sure the border doesn't overflow).

It's unfortunate that we need the non-semantic wrapper to set the size that the input can be 100% of, but I don't see another option. It also requires that the floated elements come before the input element in the DOM, so my status plugin has to use prependTo for messages and appendTo for the input element.

As an example (the text is all editable):


<div>
  <span style="float: left" contenteditable>Prompt String:</span>
  <span style="float: right; color: red" contenteditable>Important Message</span>
  <span style="overflow: hidden; display: block; padding: 0 1em"> <!-- the padding isn't essential; it's just decorative -->
    <input style="width: 100%; box-sizing: border-box; -moz-box-sizing: border-box"/>
  </span>
</div>

One of those ongoing unsolved problems with web apps is how to keep track of the selection or insertion point in an editing element, when the focus has moved onto some other element. For instance, in a rich-text editor, you want to be able to select a word, then click the "bold" button and make that word bold. But once you click the button, the original word is no longer selected!

One solution that is used by Yahoo mail (and I believe Gmail as well) is to put the editing element in its own <iframe>. That acts as a separate window, and the selection is kept even when that "window" is no longer active. You can then get the selection with window.getSelection() and the like (look at the source for bililiteRange._nativeGetSelection() for the variations on that in different browsers and elements). That's a perfectly good solution, but I would like to be able to use any element on the current page.

There are two related issues we need to deal with:

  1. We want to know what the selection was when the element was active, after the element loses focus.
  2. We want to be able to restore the selection when focus returns to the element.

In each case, we have to deal with standards-based browsers in input and textarea elements (which behave identically) and other elements, and with Internet Explorer 8 ("we" here means just me, unfortunately).

Finding the selection

For input elements, that's easy. The browsers maintains element.selectionStart and element.selectionEnd. For the others, we have to somehow catch the element before it loses focus and grab the selection then. Internet Explorer actually does this right; it fires a beforedeactivate event, so we can use element.attachEvent('onbeforedeactivate', function() { saveSelection(document.selection) }. I don't like IE's nonstandard nomenclature, but at least the event exists.

In standard browsers, there's nothing. The blur event fires after the focus is lost, so the selection is lost. I can't find anything else to do but listen to every user interaction and record the selection each time, and remember to programmatically save the selection whenever it is changed in code: element.addEventListener('mouseup', function() { saveSelection(window.getSelection() }) and similarly for keyup. I'm pretty sure that those are the only two user interactions that browsers fire; all the others, like cut or drop, also fire one of those. It's inefficient to have to check the selection so often, but I don't see another option.

Restoring the focus

With the selection in hand, we can manipulate the text as desired, but when we return the focus to the text, we would like the selection to be in the right place. It's easy enough to do element.addEventListener('focus', function() {window.getSelection().addRange (savedSelection)}) or the equivalent in the other use cases, but that sets the selection even when we don't want it to.

There are three ways to put the focus in an element: click in it, tab into it, and in script with element.focus(). With a mouse click, we want the selection to be where the user clicked, not where it used to be. The effect of tabbing into an element varies (see below), but I would like the selection to be restored. So the only question is whether the focus event happened as the result of a click or not.

Unfortunately, the focus event happens before the mouseup or click events, and the change of the selection happens before that (even for input events that record their own selections), so I can't listen for mouseup. The only solution I found was to check whether the mouse button is down at the time of the focus event:

element.addEventListener('focus', function() {
  if (mouseButtonIsDown) window.getSelection().addRange (savedSelection)
})

But how to get mouseButtonIsDown? There is no way in Javascript in modern browsers. It's a hack, but I had to track mousedown and mouseup:

mouseButtonIsDown= false;
document.addEventListener ('mousedown', function() {
  mouseButtonIsDown = true;
});
document.addEventListener ('mouseup', function() {
  mouseButtonIsDown = false;
});

Ugly, and fails in all sorts of edge cases when the user hits more than one mouse button or drags from one window to the next. But it's not critical; the user gets immediate feedback where the selection/insertion point is.

The other subtlety is tabbing in; I'd like to restore the selection rather than use the browser's default of selecting the whole thing or placing the insertion point at the beginning. With the focus event listener above, there's a flash as the insertion point goes to the default position then moves to where I want it. I don't know how to avoid that.

Conclusion

Putting the pieces together, I can keep the selection in the right place even with other elements programmatically manipulating it. See the source code for bililiteRange.select() and bililiteRange.bounds('selection'), along with all the event listeners in the bililiteRange constructor.

To watch it in action, see the sendkeys demo. Enter text in each box, then click or tab between them or click the radio buttons above them to change the focus with Javascript.

Postscript

Browsers vary in how they handle focussing in response to a tab key or an element.focus(). I tested it on my Windows XP machine:

<input> <textarea> <div contenteditable>
Firefox tab all saved start
Firefox focus() saved saved start
Chrome tab all start start
Chrome focus() saved saved start
IE 8 tab all end start
IE 8 focus() start start start

"All" means the entire text is selected, "start" means the insertion point is placed before the first character, and "saved" means that the selection is restored to where it was when the element lost focus.

I've been looking at the DOM 3 event model and decided that the bililiteRange.text() andbililiteRange.select methods ought to implement those, even though they're not in browsers yet. Nobody implements beforeinput, or event.data in the input event handler, and select only works in <input> and <textarea> (not in contenteditable). And that's in modern browsers.

But that's no excuse for me. beforeinput is now dispatched before changing the text, and input after, with event.data set to the text to be inserted, and event.bounds set to the bounds array in the original text (this last is not part of the spec, but it's hard to work with changing text without it). This is an incompatible change from the way it used to work (with event.detail being set to {text: text, bounds: bounds}), but I don't think that many people are using that aspect of the library.

I also moved a few methods from the utilities library into the main code, since I used them so much.

It doesn't look like my workplace is ever going to get past IE8 (many of the machines are still on Windows XP!), so I really want to support those. There are polyfills for getting the event listeners to work, and I incorporated a quick and cheap version of that into the code, and event listenders to dispatch input events on keystrokes, drag and drop, and cut/paste.

bililiteRange is now at version 2.0.

I will be forever grateful when I can drop IE8.

Prism is fast, generally fast enough to use for a live syntax highlighter. But for large texts (multiple kilocharacters) having the text re-highlighted with every input bogs down, and I can type faster than the text can show up. I remember old word processors that were like that, back in the dark ages of computerdom. But it's unacceptable today.

What I need to do is "debounce" the input events, so they aren't piling up and creating a backlog of highlighting that is going to be redone with the next event anyway. John Hann has a nice little routine that I simplified into:

function debounce (func, threshold){
	if (!threshold) return func; // no debouncing
	var timeout;
	return function(){
		var self = this, args = arguments;
		clearTimeout(timeout);
		timeout = setTimeout(function(){
			func.apply(self, args);
		}, threshold);
	};
}

Basically, it uses setTimeout to delay the application of the function, and resets the timer every time it's called. Use it as var debouncedFunc = debounce(func, 100);

This is a plugin for bililiteRange that implements a simple undo/redo stack. It listens for input events (so to use it in IE<10 you need some kind of polyfill) and records the old text each time, so it's pretty memory-inefficient, but it works for what I want to do.

See the code on github.

See a demo with the Prism editor (control-z to undo, control-y to redo).

Usage is simple:

bililiteRange(element).undo(0); // initializes the event listener without trying to undo anything; safe to call multiple times
bililiteRange(element).undo(); // restores the text from the last undo point
bililiteRange(element).undo(-1); // redo--undoes the last undo

bililiteRange(element).undo(n) for any n works and will undo (for positive n) or redo (for negative n) that many times, though I'm not sure how useful that would be.

There are convenience methods to be used as event handlers:

bililiteRange.undo(event);
bililiteRange.redo(event);

So a simple keyboard shortcut handler would be:

		bililiteRange(editor).undo(0); // initialize
		editor.addEventListener ('keydown', function(evt){
			// control z
			if (evt.ctrlKey && evt.which == 90) bililiteRange.undo(evt);
			// control y
			if (evt.ctrlKey && evt.which == 89) bililiteRange.redo(evt);
		});

or, if you use my hotkeys jQuery plugin,


$(editor).on('keydown', {keys: '^z'}, bililiteRange.undo);
$(editor).on('keydown', {keys: '^y'}, bililiteRange.redo);

Hope that this is useful to someone.

It tries to be sophisticated about typing, and bunches all typing done in a row into one, so that undo undoes the entire line. Backspacing, moving the insertion point, or typing a newline starts a new undo point.

Restoring the insertion point isn't as sophisticated as in a real word processor yet; moving the insertion point then typing then undoing moves the insertion point back to where is was originally, not to the start of typing.

As luck would have it, right after I wrote about synchronous vs. asynchronous event handlers, I found exactly that problem in by bililiteRange code. It uses dispatchEvent to fire an input event when text is inserted, but that fires synchronously, so that the event handlers run before the bililiteRange.text() has fully run. I ended up having to wrap the dispatchEvent in a setTimeout to force it to be asynchronous.

The bililiteRange.js code is now at version 1.7.

As promised, I have updated the status plugin to use Promises. Unfortunately, that required a small change to the parameters. Since I doubt anyone is using it, that will not likely affect anyone but me. The version is now 1.1.