2009/09/05

Seamless replacement of textareas with CKEditor

Edit: this version is now obsolete, go here for the latest version.

Everynow and then someone always pops up asking why they don't get the current contents of the editor if the read the value of the textarea. The answer is obvious: FCKeditor/CKEditor is a different object than the textarea so they must work with the editor, not with the textarea, but this might be more subtle when they are using some javascript framework to read all the form elements on form submit. In that situation the editor updates the value of its associated textarea using an event handler, it might run later than the read of the values and so the value that they get is the initial one.

What about making all of that automatic?

First step: textarea.value

In order to make it work we would need to use our own textarea.value property. In Firefox it's possible to do that since long ago, and IE 8 did bring this feature for us.

This would be the code to make it work for IE:

 var originalValuepropDesc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
Object.defineProperty(HTMLTextAreaElement.prototype, "value",
  {   
   get: function() {
    // if there's an editor, return its value
    if (this.editor)
     return this.editor.getData();
    // else return the native value
    return originalValuepropDesc.get.call(this);
   },
   set: function(data) {
    // If there's an editor, set its value
    if (this.editor) this.editor.setData(data);
    // always set the native value
    originalValuepropDesc.set.call(this, data);
   }
  }
 );

With this code we are modifying all the textareas in the page, if they have an .editor property associated, it will call it to read/write the data; if not, it will use the default system.

Believe it or not, that code is for IE, and it uses new ECMAScript accesor properties that so far no other browser has implemented, they have the first implementation and we should hope that the rest of the browsers write their parts when it's tested in Acid 4 or whatever.

For Firefox we can use this code:

    var originalGetter = HTMLTextAreaElement.prototype.__lookupGetter__("value");
    var originalSetter = HTMLTextAreaElement.prototype.__lookupSetter__("value");
        HTMLTextAreaElement.prototype.__defineGetter__("value", function() {
                // if there's an editor, return its value
                if (this.editor)
                    return this.editor.getData();
                // else return the native value
                return originalGetter.call(this);
                }
            );
        HTMLTextAreaElement.prototype.__defineSetter__("value", function(data) {
                // If there's an editor, set its value
                if (this.editor) this.editor.setData(data);
                // always set the native value
                originalSetter.call(this, data)
                }
            );

As you can see the idea is just the same, but the way to make it work is just different. It's a little nicer to use a single object as the property accessor, but it's not a big issue.

But for other browsers this fails. They have implemented the __defineGetter__ syntax, but Webkit (Safari & Chrome) doesn't allow to use getter/setters with native DOM properties, and in my first tests Opera seemed to work (at least partially) but failed with the final code. I'll try to review the Opera problem, I think that it might work to alter the properties of a DOM object, but not on the prototype. You  should read the follow-up post to get it working in Opera.

So we have the code for IE8 and Firefox that takes care to intercept the usage of any textarea.value, and is just linked to setting a property "editor" on that textarea DOM node. It could have been written in other ways, for example checking in CKEditor.instances for an instance matching the id or name of the textarea, but I just used this way.

Next step: avoid problems

Before writing the rest of the code, we need to think a little: when the form is submitted, or in general, whenever editor.updateElement() is called, it will set the textarea.value, but if we have it hooked to automatically update the editor we are wasting time, and what's worse, any selection and cursor position in the editor will be lost.

So it might be better to do a little modifications: besides altering textarea.value we can create a new property textarea.nativeValue that doesn't try to do anything fancy, it will call the original getter/setter without any relationship to CKEditor and we will be safe.

You might think that for IE you would need just to write a one line:

 Object.defineProperty(HTMLTextAreaElement.prototype, "nativeValue", originalValuepropDesc);

but that won't work, it just raises an error.
And even trying to use

   Object.defineProperty(HTMLTextAreaElement.prototype, "nativeValue", 
         {get:originalValuepropDesc.get, set:originalValuepropDesc.set});

in case that the object did include some special property that didn't allowed it to be reused also fails. So in the end the code is just like the one for the "value" property.

And then we do a little modification to the CKEditor routines so it tries to use that new property if it exist:

CKEDITOR.dom.element.prototype.getValue = function()
{
    if (typeof(this.$.nativeValue) == "undefined")
        return this.$.value;
    return this.$.nativeValue;
}
CKEDITOR.dom.element.prototype.setValue = function( value )
{
    if (typeof(this.$.nativeValue) == "undefined")
        this.$.value = value;
    else
        this.$.nativeValue = value;
    return this;
}

 Joining the pieces

So we got every textarea on the page with our new .nativeValue property and CKEditor is modified to use it instead of the original .value; what we need to finish this magic is to set the .editor property on the replaced textareas so they know which editor they must use. And that's easy:

CKEDITOR.on('instanceCreated', function(e) {
    if (e.editor.element.getName()=="textarea")
        e.editor.element.$.editor = e.editor;
});

of course we need to do some cleanup whenever the editor is destroyed:

CKEDITOR.on('destroy', function(e) {
    if (e.editor.element.$.editor)
        delete e.editor.element.$.editor;
});

And that's all that you need.!

Now whenever any javascript tries to read the .value of a textarea that is linked to a CKEditor instance you'll get the current value of CKEditor, and if you write that .value then it will set that to CKEditor at the same time, no need to change anything in the rest of your code!!!

All the code

You just need to put this code in the page after the CKEditor script and the magic is done. Using the automatic replacement described in the first article about CKEditor you can use it without any change to your code, no matter how it does work as long as you restrict your users to Firefox 3.5 or IE8. Edit: look at the follow-up post for the code to include Opera in the supported browsers.

CKEDITOR.dom.element.prototype.getValue = function()
{
    if (typeof(this.$.nativeValue) == "undefined")
        return this.$.value;

    return this.$.nativeValue;
}

CKEDITOR.dom.element.prototype.setValue = function( value )
{
    if (typeof(this.$.nativeValue) == "undefined")
        this.$.value = value;
    else
        this.$.nativeValue = value;

    return this;
}

// Hook each textarea with its editor
CKEDITOR.on('instanceCreated', function(e) {
    if (e.editor.element.getName()=="textarea")
        e.editor.element.$.editor = e.editor;
});

// House keeping.
CKEDITOR.on('destroy', function(e) {
    if (e.editor.element.$.editor)
        delete e.editor.element.$.editor;
});

// Wrap this in an anonymous function
(function() {
if (Object.defineProperty)
{
    // IE 8  OK
    var originalValuepropDesc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
    Object.defineProperty(HTMLTextAreaElement.prototype, "nativeValue",
            {   
                get: function() {
                    return originalValuepropDesc.get.call(this);
                },
                set: function(data) {
                    originalValuepropDesc.set.call(this, data);
                }
            }
        );

    Object.defineProperty(HTMLTextAreaElement.prototype, "value",
            {   
                get: function() {
                    // if there's an editor, return its value
                    if (this.editor)
                        return this.editor.getData();
                    // else return the native value
                    return originalValuepropDesc.get.call(this);
                },
                set: function(data) {
                    // If there's an editor, set its value
                    if (this.editor) this.editor.setData(data);
                    // always set the native value
                    originalValuepropDesc.set.call(this, data);
                }
            }
        );
}
    else if (document.__defineGetter__)
{
     // FF 3.5 OK
     // Opera 10 fails
     // Webkit fails: https://bugs.webkit.org/show_bug.cgi?id=12721
    var originalGetter = HTMLTextAreaElement.prototype.__lookupGetter__("value");
    var originalSetter = HTMLTextAreaElement.prototype.__lookupSetter__("value");
    if (originalGetter && originalSetter)
    {
        HTMLTextAreaElement.prototype.__defineGetter__("value", function() {
                // if there's an editor, return its value
                if (this.editor)
                    return this.editor.getData();
                // else return the native value
                return originalGetter.call(this);
                }
            );
        HTMLTextAreaElement.prototype.__defineSetter__("value", function(data) {
                // If there's an editor, set its value
                if (this.editor) this.editor.setData(data);
                // always set the native value
                originalSetter.call(this, data)
                }
            );

        HTMLTextAreaElement.prototype.__defineGetter__("nativeValue", function() {
                return originalGetter.call(this);
                }
            );
        HTMLTextAreaElement.prototype.__defineSetter__("nativeValue", function(data) {
                originalSetter.call(this, data)
                }
            );
    }
        else
            alert("Opera and Safari doesn't allow to read the originalGetter and Setter for the textarea value");
}
    else
{
    // detect IE8 in compatibility mode...
    if (document.documentMode)
        alert("The page is running in Compatibility Mode (" + document.documentMode + "). Fix that")
    else
        alert("Your version of IE is too old");
}
})();

 So, what do you think?

 :-)

Edit: this version is now obsolete, go here for the latest version.

2 comments:

Chris Thomas said...

Thats really clever, I like it, I found this whilst looking for a way to update a textarea and have ckeditor reflect those changes, it seems that this is one of those missing features.

I wonder why this isn't part of the default ckeditor system, it sounds pretty obvious to me that it's very useful, I wonder if it's been considered and reject for some reason.

I suppose being the guy who wrote it, you might know what the possible reason would be? if so, please elaborate, might help people more to know why this isn't the default behaviour.

I've just copied your extra scripts into a file and I'm going to try them out now!!

thanks again!

Alfonso said...

It's obvious that as the scripts don't work with IE6 and IE7 they can't be enabled as a general rule for everybody as unfortunately there are too many people that keeps using those versions.

But if you can control the browser used in your site, then go ahead and use them!