Another Story on Browser Incompatibility

It has been months since the last post of this blog was published. Actually, I am working as intern at a startup since July and have been busy building a website.

Each modern website is embracing social features and the one I am up to is no exception. And sure, people use @john_doe to mention John Doe at a social website. So @-mention is an essential feature a social website is supposed to support. But...

To get that @-mention work, I took some time to investigate several existing implementations. (And you bet, to draw some inspirations from them, certainly!) It seems that there are four major ways to implement this feature:

  1. twitter's way: does not permit duplicate username, use @john_doe directly
  2. Renren's way: permit duplicate username, use plain-text input, append user ID to user name, e.g. @John Doe(20121221)
  3. Facebook's way: permit duplicate, use plain-text input, mark mentioned user's name with background
  4. Google+'s way: permit duplicate, use rich-text input (with contenteditable=true), use <input type=button> to represent mentioned user. Put user's name in value attribute and user's ID in data-ID attribute.

Obviously, the robustness rises from the first to the last, just as the level of difficulty to implement does. (BTW, I have to put a special awe to Google+'s genius implementation.) And it seems to implement this feature, there are plenty of problem to solve, and even more if you use contenteditable (just as Google+ does). Since we favor an iterative approach and want to have a prototype first, I'll stick with <textarea>.

Enough for the background. Now the problem becomes, in order to popup a floating menu for user to select mentioned user, how to determine the position of current cursor in the <textarea>? The best solution I could find so far is this. And I was even lucky to find one such function from one of github's compressed js file. A little decoding of the compressed source reveals the implementation as follows (a jQuery plugin as you can tell):

(function(window, undefined) {
    var $ = window.jQuery;
    var document = window.document;

    var fixedStyles = [
        'position: absolute;',
        'overflow: auto;',
        'white-space: pre-wrap;',
        'word-wrap: break-word;',
        'top: 0px;',
        'left: 0px;'
    ];

    var variableStyles = ['box-sizing', 'font-family', 'font-size', 'font-style',
        'font-variant', 'font-weight', 'height', 'letter-spacing', 'line-height',
        'padding', 'text-decoration', 'text-indent', 'text-transform', 'width',
        'word-spacing'];

    $.fn.textareaMirror = function(pos) {
        // make sure we are manipulating a <textarea>
        if(! this[0])
            return;
        var ta = this[0];
        if(ta.nodeName.toLowerCase() !== 'textarea')
            return;

        // create mirror <div> right next to the <textarea>
        var mirror = $(ta).next()[0];
        if(mirror && $(mirror).is('div.js-textarea-mirror')) {
            mirror.innerHTML = '';
        } else {
            mirror = document.createElement('div');
            mirror.className = 'js-textarea-mirror';
        }

        // retrieve and apply styles of <textarea> to mirror <div>
        var fabricatedStyles = fixedStyles.slice(0);
        var computedStyles = window.getComputedStyle(ta);
        for(var i = 0;i < variableStyles.length;i++) {
            var property = variableStyles[i];
            fabricatedStyles.push('' + property + ':' + computedStyles[property] + ';');
        }
        mirror.style.cssText = fabricatedStyles.join('');

        var text, textNode1, textNode2;
        if(pos) {
            if(text = ta.value.substring(0, pos))
                textNode1 = document.createTextNode(text);
            if(text = ta.value.substring(pos))
                textNode2 = document.createTextNode(text);
        } else if(text = ta.value) {
            textNode1 = document.createTextNode(text);
        }

        // create marker
        var marker = document.createElement('span');
        marker.className = 'js-marker';
        marker.innerHTML = '&nbsp;';

        // append marker and text nodes to mirror
        if(textNode1)
            mirror.appendChild(textNode1);
        mirror.appendChild(marker);
        if(textNode2)
            mirror.appendChild(textNode2);

        if(! mirror.parentElement)
            // set mirror next to <textarea> if it is newly created
            $(ta).after(mirror);

        // set the same scroll offset
        mirror.scrollTop = ta.scrollTop;

        return mirror;
    };
})(window);

The source should be understood without much difficulty with the Stackoverflow answer above and the annotations. The pos parameter, which needs a bit explanation, handles the situation when the cursor in the middle of the text.

However, the so-called textarea mirror does not work quite as expected in Firefox, my major browser. Interestingly, in Chrome, it works perfectly.

After some debugging, I nailed down the cause at line 41, where computedStyles[property] is used. In that statement, a CSS property like text-decoration is used to retrieve the value, which works perfectly in Chrome but returns undefined in Firefox. And with a little experiment, it seems that using property name like textDecoration (just what you'd write to access value in element.style) works in both browsers. So after adding the following function

function cssPropertyToJS(prop) {
    return prop.split('-').map(function(w, i) {
        return i==0 ? w : w[0].toUpperCase() + w.slice(1);
    }).join('');
}

and changing the former line 41 to

fabricatedStyles.push('' + property + ':' + computedStyles[cssPropertyToJS(property)] + ';');

Everything now works nicely. Awesome!

An incompatibility between Firefox and Chrome!

Just out of curiosity, I wonder: who's doing it wrong? Firefox or Chrome? Documentation at MDN tells you that window.getComputedStyle returns CSSStyleDeclaration, which is defined in DOM Level 2 Style. Now it turns out that, with CSSStyleDeclaration you can only access property value via getPropertyValue and no attribute like text-decoration or textDecoration is defined in CSSStyleDeclaration.

WTH? We are at the end accessing some non-standard property? Lucily not. At the end of the same documentation, we can find the CSS2Properties extended interface, where all those backgroundPosition or listStylePosition stuff are defined. Aha! So the conclusion goes that both browser implemented the essential as well as the extended interface. And diligent Chrome (or Webkit?) developers added CSS property support.

Now that the root cause is clear, the main story can end. Here's a dessert if you are still interested in the correction to that textarea mirror plugin: the cssPropertyToJS function seems far from optimal, so can we do better? After some pondering, I bet there must be similar stuff in jQuery so I decided to refer to jQuery's implementation. And here it is:

var rdashAlpha = /-([a-z])/ig,
    fcamelCase = function( all, letter ) {
        return letter.toUpperCase();
    };
// usage
var name = 'font-family';
name = name.replace(rdashAlpha, fcamelCase);

What a clean, simple and efficient implementation! This again reminds me of the power of regular expression.

A last joke: I haven't tested that on IE yet.


Update: I found AT.js, an @-mention implementation using similar techniques as described above, only more robust and with more features. I've already switched to it and hope you give it a try.