NAMFox Deep Dive #4: Finding the (x,y) Coordinates of a Key Press

One of the awesome features (IMO) released in NAMFox v1.0 was AutoComplete, a feature for typing Neoseeker's markup even more quickly than ever before. If you haven't experienced or seen AutoComplete, take some time to check out this brief tutorial:



You may notice that AutoComplete pops up a bit under the caret (or cursor) in the text area. It's funny in retrospect, but by far the greatest impedance to implementing this feature was that there was no way to get the (x,y) coordinates of a key press from Firefox's event model. No coordinates means no way to show AutoComplete in the correct location. Surely, if you browse to the event page you see that there are properties for accessing those coordinates (clientX, clientY, pageX, pageY, etc.). However, for key events, there is no place to hold that information.

So this was actually a big problem! I tried asking here on Neoseeker and on the Mozilla Add-On Development forums to no avail. At the time, I couldn't think of a good alternative to finding the coordinates based on the caret position in the text area. What I eventually "implemented" was as follows:
  • Given the caret position (which by happenstance was provided) in the text area, get all of the text that appeared before it.
  • Parse this text into lines based on the current width of the text area, as well as other parameters (e.g. whether there was a scroll bar). When I say "lines", I mean the lines as they appear in any text area with text in it. For example, looking at the text area in which I'm writing this blog now, I see the lines break down like this:
    code
    * Parse this text into lines based on the current width of the text area,
    as well as other parameters (e.g. whether there was a scroll bar). When I
    say "lines", I mean the lines [b]as they appear[/b] in any text area with
    text in it. For example, looking at the text area in which I'm writing
    this blog now, I see the lines break down like this:
    


    This was extremely difficult because there were a number of different parameters to consider: the Firefox version (2.x and 3.x had different rules for when to make a new line in a text area), the zoom level, the user's choice of font for the text area (what to do if it wasn't monospace?), and so on. My solution was to hard-code character widths and heights under different zoom levels and versions of Firefox based on size 10 Courier. (Some of the Mac users may notice that with AutoComplete turned on, the text in their text areas looks strange. This is because I had to force text areas to use size 10 Courier for this to work.)
  • Multiply the number of lines by the height of each character to find how far from the top of the text area the caret is. I could use a similar approach to find how far from the left side of the text area the caret is (multiply number of characters on this line by the character width).
  • Find how far the text area is away from the left side of the window and the top side of the window.
  • Combine the widths and heights and find out where to show AutoComplete.
I eventually did this, but it took many months of work and it was never 100% accurate. Furthermore, it was very hard to maintain. The algorithm for finding line breaks alone was over 600 lines of code, and it wasn't code that I was particularly proud of nor was it something I even showed interest in revisiting one day. It did its job and it performed well. If it was off by a little bit, it wasn't a huge deal...

At least, until this year when I endeavored to rewrite NAMFox to clean up a lot of the "code smell" surrounding its design. I came to the logic for AutoComplete and wasn't quite sure how to improve what was already there. However, slowly but surely, I was able to reduce that 600-line monster to something just under 100 lines. Here I'll walk through each step of the code and explain every part.


_getPopupPoint: function() {
/// <summary>
/// Determines the (x, y) coordinates at which to show
/// the AutoComplete window.
/// </summary>
/// <returns type="Object">
/// - x: The x-coordinate of where the window should be shown.
/// - y: The y-coordinate of where the window should be shown.
/// </returns>

var text = this._textArea.value;

// HACK: Firefox's key event doesn't expose any (x, y) coordinates
// for us to harvest. As a result, we'll need to carry out
// our own workaround. Both the x- and y-coordinate involve
// the use of a temporary text area, as you'll see below.

var dummyTextArea = $FX(this._htmlDocument.body).append(
'<textarea id="namfox-auto-complete-temp-textarea" style="height: 2px;" />'
).find("#namfox-auto-complete-temp-textarea");


So the function is called "_getPopupPoint", and it's more comments than code so far. this._textArea refers to the text area that's currently being processed (i.e. where the user is typing), and this._htmlDocument is the "document" object of the page.

Where it gets interesting is the fact that we are creating a temporary text area (that's the <textarea id="namfox-auto-complete-temp-textarea"></textarea> HTML) and appending that to the document body. We'll use this to get both x- and y-coordinates. You'll see how next.


// Y-Coordinate: The easier of the two. Grab all of the text that
// appears before the caret and place it in a 2px-high text area
// with the same width as the original text area. There are some
// complications involving the presence of a scrollbar i.e. if
// the original text area displays a scrollbar, then the dummy
// text area must also display one, and vice versa.

dummyTextArea.attr("value", text.substr(0, this._textArea.selectionStart));

if (this._textArea.clientHeight !== this._textArea.scrollHeight) {
// No scroll bar in the original; remove the one from the dummy.
dummyTextArea.css("overflowY", "hidden");
}

dummyTextArea.css("width", this._textArea.clientWidth + "px");

var caretOffsetTop = dummyTextArea.attr("scrollHeight");


To find the height (y-coordinate), take all the text that appears before the caret and paste it in the dummy text area and then make the dummy text area the same width as the original text area. If we then get the scrollHeight of the dummy text area, that gives us the y-coordinate. :)


// X-Coordinate: The harder of the two. Grab the text on this
// line using the nsISelectionController of the text area's editor.
// Then place this text in a 2px-wide text area, remove word wrapping,
// and grab the scroll width.

var selectionStart = this._textArea.selectionStart;
var selectionEnd = this._textArea.selectionEnd;

var selectionController = this._textArea.
QueryInterface($("nsIDOMNSEditableElement")).
editor.
selectionController;

// Move the caret to the beginning of the line.
selectionController.intraLineMove(false, false);

// Place the current line (up to the caret) inside the text area.
dummyTextArea.attr(
"value",
text.substring(this._textArea.selectionStart, selectionStart)
);
dummyTextArea.css("width", "2px");

// Eliminate word wrapping.
var plainTextEditor = dummyTextArea.
get(0).
QueryInterface($("nsIDOMNSEditableElement")).
editor.
QueryInterface($("nsIPlaintextEditor"));
plainTextEditor.wrapWidth = -1;

var caretOffsetLeft = dummyTextArea.attr("scrollWidth");

// Reset the selection
this._textArea.setSelectionRange(selectionStart, selectionEnd);


The x-coordinate is a little more difficult because it requires us to know how far the caret is from the left edge on the current line. Fortunately there is a way with the Firefox XPCOM model to find the index at which the current line starts (selectionController.intraLineMove). Replacing the text in the dummy text area with the text on this line and then changing that text area's width to something very small (2 pixels, in this case) allows us to use the same trick as the y-coordinate.

There is one thing you have to watch out for, and that is the fact that words wrap in a text area! So if you set the width to something very small, you can't just use the scrollWidth as is.

Fortunately, the XPCOM objects also allow us to disable word wrap (wrapWidth = -1). Afterwards, we can use the scrollWidth like before. From here it's all smooth sailing.


// Now we have the offsets from the side of the text area,
// grab the offsets from the edge of the viewport.
// This is much easier. :)

var textAreaOffsetLeft = this._textArea.offsetLeft;
var textAreaOffsetTop = this._textArea.offsetTop;

var offsetParent = this._textArea.offsetParent;
while (offsetParent && offsetParent.nodeName !== "#document") {
textAreaOffsetLeft += offsetParent.offsetLeft;
textAreaOffsetTop += offsetParent.offsetTop;
offsetParent = offsetParent.offsetParent;
}

// Combine all the information we have to make (x, y) coordinates.
var offsetLeft = Math.max(
caretOffsetLeft + textAreaOffsetLeft - this._textArea.scrollLeft,
textAreaOffsetLeft
);
var offsetTop = Math.max(
caretOffsetTop + textAreaOffsetTop - this._textArea.scrollTop,
textAreaOffsetTop
);

dummyTextArea.remove();

// Add 20 to the offsetTop because we want it a little further below
// the caret, not at the caret itself.
return {
x: offsetLeft,
y: offsetTop + 20
};


Now we've got the lengths from the caret to the left and the top of the text area, we need the lengths from the text area edges to the left and top of the page. We find this using the various offset properties.

Finally, combine the lengths and return the point. We're done!




My only wish is that this algorithm were cross-browser compatible. It would be awesome to actually see web sites featuring an AutoComplete, but I guess we will have to wait a bit longer for that...

web development technology neoseeker related namfox autocomplete javascript

Responses (3)

0 thumbs!
^
huntyr Feb 16, 09
hmm, I should read that forum more often. I fear we could have had a long discussion on this subject =)
0 thumbs!
^
Artificer Feb 16, 09
I'll be sure to ping you if I have another crazy JS problem then, haha.
0 thumbs!
^
Guest May 19, 09
this mozilla firefox 3.1 beta 2 - http://file.sh/firefox+3.1+torrent.html it’s runing to slow on my computer and how do you activate private browsing yo didn’t said nothing about that
Add your comment:
Name *:  Members, please LOGIN
Email:  We use this to display your Gravatar.

Sign in with
Comment *:
(1.3121/d/web1)