Knockout, CKEditor & Single Page App
Asked Answered
W

7

16

I have a situation involving KnockoutJS & CKEditor.

Basically we've got part of our site that is 'single page' app style, currently it just involves 2 pages but will likely expand over time, currently it's just a 'listings' page and a 'manage' page for the items in the list.

The manage page itself requires some sort of rich text editor, we've gone with CKEditor for a company wide solution.

Because these 2 pages are 'single page' style obviously CKEditor can't register against the manage elements because they aren't there on page load - simple enough problem to fix. So as a sample I attached CKEditor on a click event which worked great. The next problem was that then the Knockout observables that had been setup weren't getting updated because CKEditor doesn't actually modify the textarea it's attached too it creates all these div's/html elements that you actually edit.

After a bit of googleing I found an example of someone doing this with TinyMCE - http://jsfiddle.net/rniemeyer/GwkRQ/ so I thought I could adapt something similar to this for CKEditor.

Currently I'm quite close to having a working solution, I've got it initialising and updating the correct observables using this technique (I'll post code at the bottom) and even posting back to the server correctly - fantastic.

The problem I'm currently experiencing is with the 'Single Page' app part and the reinitialisation of CKEditor.

Basically what happens is you can click from list to manage then save (which goes back to the list page) then when you go to another 'manage' the CKEditor is initialised but it doesn't have any values in it, I've checked the update code (below) and 'value' definitely has the correct value but it's not getting pushed through to the CKEditor itself.

Perhaps it's a lack of understanding about the flow/initialisation process for CKEditor or a lack of understanding about knockout bindings or perhaps it's a problem with the framework that's been setup for our single page app - I'm not sure.

Here is the code:

//Test one for ckeditor
ko.bindingHandlers.ckeditor = {
    init: function (element, valueAccessor, allBindingsAccessor, context) {
        var options = allBindingsAccessor().ckeditorOptions || {};
        var modelValue = valueAccessor();

        $(element).ckeditor();

        var editor = $(element).ckeditorGet();

        //handle edits made in the editor
        editor.on('blur', function (e) {
            var self = this;
            if (ko.isWriteableObservable(self)) {
                self($(e.listenerData).val());
            }
        }, modelValue, element);


        //handle destroying an editor (based on what jQuery plugin does)
        ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
            var existingEditor = CKEDITOR.instances[element.name];
            existingEditor.destroy(true);
        });
    },
    update: function (element, valueAccessor, allBindingsAccessor, context) {
        //handle programmatic updates to the observable
        var value = ko.utils.unwrapObservable(valueAccessor());
        $(element).html(value);
    }
};

So in the HTML it's a fairly standard knockout 'data-bind: ckeditor' that applyies the bindings for it when the ViewModel is initialised.

I've put debugger; in the code to see the flow, it looks like when I load the first time it calls init, then update, when I go in the second time it hits the ko.utils.domNodeDisposal to dispose of the elements.

I've tried not destroying it which CKEditor then complains that something already exists with that name. I've tried not destroying it and checking for if it exists and initialising if it doesn't - that works the first time but the second time we have no CKEditor.

I figure there's just one thing I'm missing that will make it work but I've exhausted all options.

Does anyone have any knowledge on integrating these 3 things that can help me out?

Are there any knockout experts out there that might be able to help me out?

Any help would be much appreciated.

MD

Wolgast answered 23/5, 2012 at 3:44 Comment(0)
W
11

For anyone interested I sorted it:

All it was was a basic order of execution, I just needed to set the value to the textarea html before it got initialised.

Note this uses a jquery adaptor extension to do the .ckeditor() on the element.

There is probably also a better way to do the 'blur' part.

This extension also doesn't work with options at the moment but that should be quite simple in comparison.

ko.bindingHandlers.ckeditor = {
    init: function (element, valueAccessor, allBindingsAccessor, context) {
        var options = allBindingsAccessor().ckeditorOptions || {};
        var modelValue = valueAccessor();
        var value = ko.utils.unwrapObservable(valueAccessor());

        $(element).html(value);
        $(element).ckeditor();

        var editor = $(element).ckeditorGet();

        //handle edits made in the editor

        editor.on('blur', function (e) {
            var self = this;
            if (ko.isWriteableObservable(self)) {
                self($(e.listenerData).val());
            }
        }, modelValue, element);
    }
};
Wolgast answered 7/6, 2012 at 4:58 Comment(2)
Great job. But how come you've removed the domNodeDisposal binding and the update? Why?Killjoy
You only need the Update binding if you want the HTMLEditor to respond to changes in your ViewModel. 95% of the time this will not beneccessary as the HTMLEditor will be changing to the ViewModel.Severally
S
8

I've been working with this for a while now and ran again several problems with the .on("blur") approach. Namely, when people clicked in to the rich text and entered text then scrolled directly to the Save button on my form, the observable didn't get updated fast enough. There are a ton of ways to handle delays, but I wanted something more official. I dug in to the CKEditor documentation and found this gem: focusManager

This is built-in functionality that handles all the instances of focus and blur and allows you to hook up a true blur event to the control.

Here is my bindingHandler for rich text then

ko.bindingHandlers.richText = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel) {

       var txtBoxID = $(element).attr("id");
       var instance = CKEDITOR.instances[txtBoxID];

       var options = allBindingsAccessor().richTextOptions || {};
       options.toolbar_Full = [
            ['Source', '-', 'Format', 'Font', 'FontSize', 'TextColor', 'BGColor', '-', 'Bold', 'Italic', 'Underline', 'SpellChecker'],
            ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'Blockquote', 'CreateDiv', '-', 'JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock', '-', 'BidiLtr', 'BidiRtl'],
            ['Link', 'Unlink', 'Image', 'Table']
       ];

       //handle disposal (if KO removes by the template binding)
       ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
          if (CKEDITOR.instances[txtBoxID]) { CKEDITOR.remove(CKEDITOR.instances[txtBoxID]); };
       });

       $(element).ckeditor(options);

       //wire up the blur event to ensure our observable is properly updated
       CKEDITOR.instances[txtBoxID].focusManager.blur = function () {
          var observable = valueAccessor();
          observable($(element).val());
       };
    },
    update: function (element, valueAccessor, allBindingsAccessor, viewModel) {

       var val = ko.utils.unwrapObservable(valueAccessor());
       $(element).val(val);

    }
}
Stud answered 4/2, 2013 at 14:3 Comment(1)
I ran into similar issues around the delay when using the on('blur') approach. Subscribing to the focusManager's blur works much better in my app. Nice find and thanks for sharing!Diocesan
E
7

Building up on the work done in the other answers here's my solution:

  • handles changes using ckeditor's own change event (updates on keypress but not just that)
  • uses ckeditor's getData() so you don't get unwanted HTML like "magic line" and similar stuff
  • handles memory management (untested)

Code:

ko.bindingHandlers.ckeditor = {
    init: function(element, valueAccessor, allBindingsAccessor, context) {
        var options = allBindingsAccessor().ckeditorOptions || {};
        var modelValue = valueAccessor();
        var value = ko.utils.unwrapObservable(valueAccessor());

        $(element).html(value);
        $(element).ckeditor();

        var editor = $(element).ckeditorGet();

        //handle edits made in the editor
        editor.on('change', function(e) {
            var self = this;
            if (ko.isWriteableObservable(self)) {
                self($(e.listenerData).val());
            }
        }, modelValue, element);

        //handle disposal (if KO removes by the template binding)
        ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
            if (editor) {
                CKEDITOR.remove(editor);
            };
        });
    },
    update: function(element, valueAccessor, allBindingsAccessor, context) {
        // handle programmatic updates to the observable
        var newValue = ko.utils.unwrapObservable(valueAccessor());
        if ($(element).ckeditorGet().getData() != newValue)
            $(element).ckeditorGet().setData(newValue)
    }
};

The markup I use (note the afterkeydown):

<textarea 
    id="editor1" 
    data-bind="ckeditor: text, valueUpdate: 'afterkeydown'"
></textarea>

Update: as requested in the comments, here is a minimal working Fiddle.

Earmark answered 25/12, 2013 at 17:3 Comment(4)
any chance you can share your markup that uses this binding. I'm struggling to get this to work. I see the content rendered on first load. I have a textbox apart from the text area bound to the same observable that feeds the editor. When changing text in the editor the other textbox shows the data unchanged. I assumed that two way binding would update the textbox as changes in the editor would update the ViewModel. Is this correct? Is this what I should expect?Juliettejulina
awesome .. that's cracked it .. thank you so much for this .. I've been at this for some time :)Juliettejulina
I have been struggling to get a CKE Knockout binding working in a bidirectional fashion; your check in the update to see if the value had actually changed fixed it, thanks!Milkwhite
Can u guys please give me working fiddle of it. Iam stuck on it since two days. please.Jens
M
5

First post so let me know if I've done anything wrong

In my project, I gave visual feedback as to whether there were unsaved changes and so needed the observable updated on keyup. And on click for when a toolbar button was clicked. This also was consistent with me using valueUpdate:['afterkeydown','propertychange','input'] in my data-bind attributes.

Also, for performance, I used the callback method parameter of .ckeditor(callback,options) rather than .on(eventName,handler).

This is the custom binding I came up with:

ko.bindingHandlers.ckeditor = {
    init: function (element, valueAccessor, allBindingsAccessor, context) {
        // get observable
        var modelValue = valueAccessor();;

        $(element).ckeditor(function(textarea) {
            // <span> element that contains the CKEditor markup
            var $ckeContainer = $(this.container.$);
            // <body> element within the iframe (<html> is contentEditable)
            var $editorBody =
                    $ckeContainer.find('iframe').contents().find('body');
            // sets the initial value
            $editorBody.html( modelValue() );
            // handle edits made in the editor - by typing
            $editorBody.keyup(function() {
                modelValue( $(this).html() );
            });
            // handle edits made in the editor - by clicking in the toolbar
            $ckeContainer.find('table.cke_editor').click(function() {
                modelValue( $editorBody.html() );
            });
        });


        // when ko disposes of <textarea>, destory the ckeditor instance
        ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
            $(element).ckeditorGet().destroy(true);
        });
    },
    update: function (element, valueAccessor, allBindingsAccessor, context) {
        // handle programmatic updates to the observable
        var newValue = ko.utils.unwrapObservable(valueAccessor());
        var $ckeContainer = $(element).ckeditorGet().container;
        if( $ckeContainer ) {
            // <span> element that contains the CKEditor markup
            $ckeContainer = $($ckeContainer.$);
            // <body> element within the iframe (<html> is contentEditable)
            var $editorBody =
                    $ckeContainer.find('iframe').contents().find('body');
            // if new value != existing value, replace it in the editor
            if( $editorBody.html() != newValue )
                $editorBody.html( newValue );
        }
    }
};

Justification:

I know I should probably use .getData() and .setData(html) instead of this rather hacky way of finding <body> and <table class="cke_editor"> within the iframe contents.

Reason being, for update: the condition within:

if( $(element).ckeditorGet().getData() != newValue )
    $(element).ckeditorGet().setData( newValue )

was initially true due to HTML formatting that CKEditor does. And so, notified the user about a dirty record even though it wasn't. Very specific to me so I thought you should know, in case you were wondering why.

Millwater answered 27/9, 2012 at 13:36 Comment(1)
Glad I could help. Did you use this with the most recent version of CKEditor?Millwater
G
0

I just used this technique with CKEditor 4 to overwrite the existing (1-way) "html" binding with a 2-way binding. I'm using the inline CKEditor which may behave differently (not sure) than the full/static editor. I started with the "value" binding and tweaked it to work with the innerHTML instead:

ko.bindingHandlers.html = {
    'init': function (element, valueAccessor, allBindingsAccessor) {
        var eventsToCatch = ["blur"];
        var requestedEventsToCatch = allBindingsAccessor()["valueUpdate"];
        var valueUpdateHandler = null;

        if (requestedEventsToCatch) {
            if (typeof requestedEventsToCatch == "string")
                requestedEventsToCatch = [requestedEventsToCatch];

            ko.utils.arrayPushAll(eventsToCatch, requestedEventsToCatch);
            eventsToCatch = ko.utils.arrayGetDistinctValues(eventsToCatch);
        }

        valueUpdateHandler = function () {
            var modelValue = valueAccessor();
            var oldValue = ko.utils.unwrapObservable(modelValue);
            var elementValue = element.innerHTML;
            var valueHasChanged = (oldValue !== elementValue);

            if (valueHasChanged)
                modelValue(elementValue);
        }

        ko.utils.arrayForEach(eventsToCatch, function (eventName) {
            var handler = valueUpdateHandler;

            if (eventName.indexOf("after") == 0) {
                handler = function () {
                    setTimeout(valueUpdateHandler, 0)
                };

                eventName = eventName.substring("after".length);
            }

            ko.utils.registerEventHandler(element, eventName, handler);
        });
    },
    'update': function (element, valueAccessor) {
        var newValue = ko.utils.unwrapObservable(valueAccessor());
        var elementValue = element.innerHTML;
        var valueHasChanged = (newValue !== elementValue);

        if (valueHasChanged)
            element.innerHTML = newValue;
    }
};

Caveat: This should probably be updated to use CKEditor's own change event.

Gordie answered 25/4, 2013 at 14:36 Comment(0)
M
0

I rewrote this to update the observable on each keyup, instead of on blur. This is a lot more updates to the observable, but as long as you are saving with a button, this seems to work great so far!

//handle edits made in the editor
CKEDITOR.instances.thread_message.on('contentDom', function() {
  CKEDITOR.instances.thread_message.document.on('keyup', function(e) {

    var self = this;
    if (ko.isWriteableObservable(self)) {
      var ckValue = CKEDITOR.instances.element_id.getData();
      self(ckValue);
      //console.log("value: " + ckValue);
    }
  }, modelValue, element);
});
Mental answered 4/6, 2013 at 18:0 Comment(0)
D
-1

For the 'blur' part, I tried the code below and it seems to work

              editor.on('blur', function (e) {
                var self = this;
                if (ko.isWriteableObservable(self)) {
                    var ckValue = e.editor.getData();
                    self(ckValue);
                }
            }, modelValue, element);

I think the 'Update' part is still needed if you "update" the observable from somewhere else (not through editing as this is taken care of by the 'blur')

Desorb answered 26/8, 2012 at 23:41 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.