May 28, 2012

WYSIWYG Module + CKEditor Part Deux: Extreme Beastmaster

In my last post, we learned how to customize CKEditor with the WYSIWYG Module. We explored how to apply our own settings to CKEditor using a little javascript and a small custom module. Today we're going to delve even deeper into what we can customize in CKEditor. Specifically we'll be looking into the dialog API. By the time we're done with it, our CKEditor will be jacked up with a faux-hawk,  flaming skull tattoos, and ready to stroll into the club and punch the first guy that looks at him cock-eyed.

Getting Started

To start, you'll need to have read my last post (http://fuseinteractive.ca/blog/wysiwyg-module-ckeditor-taming-beast) because that's where we're taking off from. You'll also need the little module we made because we're going to be adding some stuff to it. We won't be doing much php here, but a bit of javascript knowledge and OOP concepts will be helpful, especially if you want to make your own customizations beyond what we go over in this tutorial.

This tutorial should work for either Drupal 6 or 7. If you want to skip the tutorial and just browse the module code (there's lots of comments), I've attached a zipped copy of the finished module at the bottom of the post.

1. Dialog Defaults

Clients always seem to want the "Table" button in CKEditor and I sort of hate giving it to them because they always look terrible in the end. More often than not, however, it can't be avoided. Recently I had a client who didn't want borders around their tables. I told them to just change the "border" field to zero in the dialog, and they said "Can't you just change the default?". I said "No", then they said "Seriously?", then I said "I'll look into it". And I looked into it. Turns out you can and it's actually kind of easy, thanks to our ckeditor_custom module. All you need to do is open up the ckeditor_custom_config.js file in your favorite text editor and add this to the end of the file:

 

// When opening a dialog, a "definition" is created for it. For
// each editor instance the "dialogDefinition" event is then
// fired. We can use this event to make customizations to the
// definition of existing dialogs.
CKEDITOR.on( 'dialogDefinition', function( event ) {
 
  // Take the dialog name
  var dialogName = event.data.name;
 
  // The definition holds the structured data that is used to eventually
  // build the dialog and we can use it to customize just about anything.
  // In Drupal terms, it's sort of like CKEditor's version of a Forms API and
  // what we're doing here is a bit like a hook_form_alter.
  var dialogDefinition = event.data.definition;
 
  // Uncomment to print the dialogDefinition to the console
  // console.log(dialogDefinition);
 
  // Check if the definition is from the dialog we're
  // interested in (the "table" dialog).
  if ( dialogName == 'table' ) {
 
    // .getContents() returns an object reference to a set of fields in the
    // dialog, also referred to as tabs. The Table dialog has two tabs:
    // "Table Properties" and "Advanced". Each of those has an id. In this case,
    // the id we're interested in is 'info' for the Table Properties tab.
    var infoTab = dialogDefinition.getContents( 'info' );
 
    // Once we have the tab reference, we can use the object's .get() method
    // to get another object reference, this time to the field we want to change
    // Fields also have ids. The border field id is "txtBorder"
    var borderSizeField = infoTab.get("txtBorder");
 
    // Set the border to 0 (who uses html table borders anyway?)
    borderSizeField['default'] = 0;
  }
});

Then, turn on your browser's javascript console (in chrome go View > Developer > Javascript Console), reload the page, and click the table button. An object will be spat out into your console. You can expand it to see the various properties and methods that belong to the dialog definition. Here's what mine looks like:

I expanded the "contents" propery because I know that's where they store the tabs and fields. Oh look, it's the id we're looking for: "advanced". I guess I could have guessed that, but now I know for sure. I can do the same thing to figure out which field to customize:

var advancedTab = dialogDefinition.getContents( 'advanced' );
console.log(advancedTab);

And the output again:

Cool. It's a big mess of nested objects and arrays. Spitting it out is easy, figuring out what you're looking for is a the hard part. It's sort of a mix of examining the object in the console, reading through the API documentation, and a bit of testing. For example, you may have been wondering how I knew about the .getContents() and .get() methods. Well it was a combination of the API documentation (specifically the definitionObject class and the contentObject class)  and the example code on one of the "How to" pages. Also, the dialog API example page was helpful.

Another REALLY nifty thing that you can do is use the Developer Tools plugin which was added in CKEditor 3.6.

2. Custom Dialogs

So what else can we do? Well, you could define your own dialog and assign it to a button in the toolbar. Why would you want to do this? I don't know. But it could come in handy. Just for fun, let's make a new dialog that takes a youtube Video ID, and a few parameters, and outputs the correct iframe html. (Yes, there are plenty of existing drupal modules that handle youtube embedding already, but really this is more about learning the API, not about innovation).

Open up your ckeditor_custom.js file and add this to the end: 

// Listen for the "pluginsLoaded" event, so we are sure that the
// "dialog" plugin has been loaded and we are able to do our
// customizations. We're going to do this for every instance of CKEditor
// but technically you could only do it for certain ones
for (var editorId in CKEDITOR.instances) {
 
  // Get a reference to the editor
  var editor = CKEDITOR.instances[editorId];
 
  // Add the even listener with the editor's .on() function
  editor.on('pluginsLoaded', function(ev) {
 
    // If our custom dialog has not been registered, do that now.
    if (!CKEDITOR.dialog.exists('youtubeDialog')) {
 
      // Register the dialog. The actual dialog definition is below
      CKEDITOR.dialog.add('youtubeDialog', ytDialogDefinition);
    }
 
    // Now that CKEditor knows about our dialog, we can create a
    // command that will open it
    editor.addCommand('youtubeDialogCmd', new CKEDITOR.dialogCommand( 'youtubeDialog' ));
 
    // Finally we can assign the command to a new button that we'll call youtube
    // Don't forget, the button needs to be assigned to the toolbar
    editor.ui.addButton( 'YouTube',
      {
        label : 'You Tube',
        command : 'youtubeDialogCmd'
      }
    );
  });
}
 
/*
  Our dialog definition. Here, we define which fields we want, we add buttons
  to the dialog, and supply a "submit" handler to process the user input
  and output our youtube iframe to the editor text area.
*/
var ytDialogDefinition = function (editor) {
 
  var dialogDefinition =
  {
    title : 'YouTube Embed',
    minWidth : 390,
    minHeight : 130,
    contents : [
      {
        // To make things simple, we're just going to have one tab
        id : 'tab1',
        label : 'Settings',
        title : 'Settings',
        expand : true,
        padding : 0,
        elements :
        [
          {
            // http://docs.cksource.com/ckeditor_api/symbols/CKEDITOR.dialog.definition.vbox.html
            type: 'vbox',
            widths : [ null, null ],
            styles : [ 'vertical-align:top' ],
            padding: '5px',
            children: [
              {
                // http://docs.cksource.com/ckeditor_api/symbols/CKEDITOR.dialog.definition.html.html
                type : 'html',
                padding: '5px',
                html : 'You can find the youtube video id in the url of the video. 
 e.g. http://www.youtube.com/watch?v=<strong>VIDEO_ID</strong>.'
              },
              {
                // http://docs.cksource.com/ckeditor_api/symbols/CKEDITOR.dialog.definition.textInput.html
                type : 'text',
                id : 'txtVideoId',
                label: 'YouTube Video ID',
                style: 'margin-top:5px;',
                'default': '',
                validate: function() {
                  // Just a little light validation
                  // 'this' is now a CKEDITOR.ui.dialog.textInput object which
                  // is an extension of a CKEDITOR.ui.dialog.uiElement object
                  var value = this.getValue();
                  value = value.replace(/http:.*youtube.*?v=/, '');
                  this.setValue(value);
                },
                // The commit function gets called for each form element
                // when the dialog's commitContent Function is called.
                // For our dialog, commitContent is called when the user
                // Clicks the "OK" button which is defined a little further down
                commit: commitValue
              },
            ]
          },
          {
            // http://docs.cksource.com/ckeditor_api/symbols/CKEDITOR.dialog.definition.hbox.html
            type: 'hbox',
            widths : [ null, null ],
            styles : [ 'vertical-align:top' ],
            padding: '5px',
            children: [
              {
                type : 'text',
                id : 'txtWidth',
                label: 'Width',
                // We need to quote the default property since it is a reserved word
                // in javascript
                'default': 500,
                validate : function() {
                  var pass = true,
                    value = this.getValue();
                  pass = pass &amp;&amp; CKEDITOR.dialog.validate.integer()( value )
                    &amp;&amp; value &gt; 0;
                  if ( !pass )
                  {
                    alert( "Invalid Width" );
                    this.select();
                  }
                  return pass;
                },
                commit: commitValue
              },
              {
                type : 'text',
                id : 'txtHeight',
                label: 'Height',
                'default': 300,
                validate : function() {
                  var pass = true,
                    value = this.getValue();
                  pass = pass &amp;&amp; CKEDITOR.dialog.validate.integer()( value )
                    &amp;&amp; value &gt; 0;
                  if ( !pass )
                  {
                    alert( "Invalid Height" );
                    this.select();
                  }
                  return pass;
                },
                commit: commitValue
              },
              {
                // http://docs.cksource.com/ckeditor_api/symbols/CKEDITOR.dialog.definition.checkbox.html
                type : 'checkbox',
                id : 'chkAutoplay',
                label: 'Autoplay',
                commit: commitValue
              }
            ]
          }
        ]
      }
    ],
 
    // Add the standard OK and Cancel Buttons
    buttons : [ CKEDITOR.dialog.okButton, CKEDITOR.dialog.cancelButton ],
 
    // A "submit" handler for when the OK button is clicked.
    onOk : function() {
 
      // A container for our field data
      var data = {};
 
      // Commit the field data to our data object
      // This function calls the commit function of each field element
      // Each field has a commit function (that we define below) that will
      // dump it's value into the data object
      this.commitContent( data );
 
      if (data.info) {
        var info = data.info;
        // Set the autoplay flag
        var autoplay = info.chkAutoplay ? 'autoplay=1': 'autoplay=0';
        // Concatenate our youtube embed url for the iframe
        var src = 'http://youtube.com/embed/' + info.txtVideoId + '?' + autoplay;
        // Create the iframe element
        var iframe = new CKEDITOR.dom.element( 'iframe' );
        // Add the attributes to the iframe.
        iframe.setAttributes({
          'width': info.txtWidth,
          'height': info.txtHeight,
          'type': 'text/html',
          'src': src,
          'frameborder': 0
        });
        // Finally insert the element into the editor.
        editor.insertElement(iframe);
      }
 
    }
  };
 
  return dialogDefinition;
};
 
// Little helper function to commit field data to an object that is passed in:
var commitValue = function( data ) {
  var id = this.id;
  if ( !data.info )
    data.info = {};
  data.info[id] = this.getValue();
};

I'm not going to go into too much detail about the above code (I'll let the comments help guide you along), but just to recap, there's a couple of key concepts going on here. The first bit of code, we're dealing with registering the dialog with CKEditor so it knows it exists and how to find it. We're also creating the button that will be available in the toolbar, and binding it to a new command that will launch the dialog. The second part of the code we're actually defining the dialog itself. We add some form elements and buttons, a couple layout boxes, and finally a "submit" function that handles the user input and outputs to the editor text area.

Okay so that's all well and good. Clear the necessary caches and reload the page your editor is on. Wait... there's no button. That's because there's one more step we need to do. Since the available buttons are defined in the WYSIWYG profile, our button never gets passed on to the toolbar. To make things simple, we're going to manually add it in our ckeditor_custom.module file:

if (!empty($remaining_buttons)) {
  // reset the array keys and add it to the $new_grouped_toolbar
  $new_grouped_toolbar[] = array_values($remaining_buttons);
}
 
// This is our new youtube command / dialog that we created in
// ckeditor_custom_config.js. If we don't add this here, it won't
// show up in the toolbar!
$new_grouped_toolbar[] = array('YouTube');
 
// Replace the toolbar with our new, grouped toolbar.
$settings['toolbar'] = $new_grouped_toolbar;

Now, reload the page with your editor on it (you may need a cache clear) and you should see your new button in the toolbar. Unfortunately, the button is blank. That's okay though because we can style it up with a little css. First, create a css file called "ckeditor_custom.css" in the module folder with this css in it:

/* Hide the icon */
.cke_button_youtubeDialogCmd .cke_icon {
  display: none !important;
}
 
/* Show the label */
.cke_button_youtubeDialogCmd .cke_label {
  display: inline !important;
}

The we need to add the css to the page in our module file, just after where we added the YouTube button

// This is our new youtube command / dialog that we created in
// ckeditor_custom_config.js. If we don't add this here, it won't
// show up in the toolbar!
$new_grouped_toolbar[] = array('YouTube');
// Add a css file to the page that will style our youtube button
drupal_add_css(drupal_get_path('module', 'ckeditor_custom') . '/ckeditor_custom.css');

Reload the page, and you should see your button there in all it's glory. Try it out. It should look something like this:

3. Conclusion

So we've done it. We customized an existing dialog and even created our own. To be honest, however, our dialog implementation probably wasn't the most robust way of doing it. The WYSIWYG module actually comes with api functions to add your own "plugins" as buttons in the toolbar. This would really be the way to go moving forward as it would integrate the plugin into WYSIWYG and would make the button available in the WYSIWYG profile settings page. Plus it would be the more "Drupal" way of doing things. My example above was really more of a quick and dirty dialog implementation that works in a pinch. Plus, it gave us a change to quickly explore the CKEditor API and learn something new.

Perhaps in a future post, I'll describe how to take our custom dialog and integrate it as a plugin in WYSIWYG, but until then, you can always browse the WYSIWYG module code yourself and see what you can figure out. There are also some good resources listed on the WYSIWYG Project page.

Here's the finished ckeditor_custom module:

ckeditor_custom_part_deux_d7.zip

ckeditor_custom_part_deux_d6.zip