Blurbette Plugin: TinyMCE Control


This time I’ll define a TinyMCE Control class that must be instantiated.

The last one, the Shortcode class, works fine without instantiating because all its properties are one-offs, and its public elements are available to all scopes (global and function).

This class defines a ‘thing’ that has unique properties, and there might be more than one of these ‘things,’ so it must be instantiated into an object. And remember, all our objects are going to live inside the objects property of our Registry object.

My objective in this chapter is to create a control that enables the user to select any defined Blurbette from a dropdown list. Now, there is a control called ‘createListBox’ that presents a dropdown within the TinyMCE row, and that might suit this purpose. But there are a few features I’d like to highlight in this chapter, so instead I’ll add a button that opens a modal dialog, and in that dialog will be a dropdown selector.

The Basic TinyMCE Registry

TinyMCE Controls are mostly handled through Javascript, so the PHP portion is pretty scant — mostly hooks and registration.

Since I’ll also add a modal dialog, I’ll need to expand on the TinyMCE basics. So here’s what I’ll do: I’ll create a general PHP class for standard TinyMCE controls, then extend that class to add a modal dialog, with specific methods that define Blurbette usage. The general class can be repurposed in all future projects that involve WordPress TinyMCE editors.

There are only three unique properties needed to define a basic TinyMCE control: its name, which row of buttons it’ll appear in, and the URL of its javascript engine. Here is the class in its entirety, since it’s so simple:

<?php 
class WPCX_MCEControl {
	
	protected $name;
	protected $row;
	protected $js;
	
	function __construct( $opts ) {
		if ( empty( $opts['name'] ) ) throw new Exception( 'MCE Plugin name not specified.' );
		if ( empty( $opts['js'] ) ) throw new Exception( 'MCE Plugin script filepath not specified.' );
		if ( is_array( $opts ) ) :
			$opts = wp_parse_args( $opts, array( 
				'name'=>null, 
				'row'=>2, 
				'js'=>null
			 ) );
			foreach( $opts as $k => $v ) :
				$this->$k = $v;
			endforeach;
		endif;
		$this->do_all_hooks();
	}
	
	protected function do_all_hooks() {
		add_action( 'admin_init', array( $this, 'add_mce_control' ) );
		add_filter( 'tiny_mce_version', array( __CLASS__, 'force_mce_refresh' ) );
	}
	
	public function add_mce_control() {
		if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) return;
		if ( get_user_option( 'rich_editing' ) == 'true' ) :
			add_filter( 'mce_external_plugins', array( $this, 'plugin_array' ) );
			add_filter( 'mce_buttons_' . $this->row, array( $this, 'ctrls_array' ) );
		endif;
	}
	
	// For some reason, this is required to make TinyMCE refresh and incorporate new changes.
	public static function force_mce_refresh( $ver ) {
		return $ver + 99;
	}
	
	public function plugin_array( array $mce_plugins ) {
		$mce_plugins[$this->name] = $this->js;
		return $mce_plugins;
	}

	public function ctrls_array( array $mce_ctrls ) {
		$mce_ctrls[] = $this->name;
		return $mce_ctrls;
	}
}

Upon instantiation, this class expects an $opts array that must include 'name' and 'js', and optionally may include 'row'.

I’ve made the do_all_hooks() method protected instead of private, so child classes (which extend this one) can call this method using parent::do_all_hooks(). Frankly, I rarely declare private methods because I usually want to enable classes — and their methods — to be extended; frequently that means calling a parent’s method first, then adding to it. This is what I’ll do later on when I write the second class; see below.

Note: I’ve chosen to require edit_posts or edit_pages capability, so the user must be so authorized and logged in.

The Extended Class, With Dialog

The extension to the above class makes use of WordPress’ included jQuery UI Dialog library.

Also, I’ll employ AJAX to load content. Since I’ll write an AJAX function specific to this instance, it makes perfect sense to include it as a method within this class.

I’ll present this class a chunk at a time:

<?php
class WPCX_Blurbette_MCE_WithDialog extends WPCX_MCEControl {
	
	const AJAX_GET_OPTS = 'wpcx_get_blurbette_opts';
	
	protected function do_all_hooks() {
		parent::do_all_hooks();
		add_action( 'admin_enqueue_scripts', array( $this, 'q_admin_scripts' ) );
		add_action( 'admin_footer', array( $this, 'output_hidden_dialog' ) );
		
		add_action( 'wp_ajax_' . self::AJAX_GET_OPTS, array( $this, 'get_blurbette_opts_ajax' ) );
		add_action( 'wp_ajax_nopriv_' . self::AJAX_GET_OPTS, array( $this, 'get_blurbette_opts_ajax' ) );
	}

Since I know I’ll make an AJAX call, I’ve chosen to define a constant AJAX_GET_OPTS with a unique action name. In do_all_hooks(), that action name gets registered to a method within this class.

As I mentioned above, this class wants to expand upon its parent’s do_all_hooks() method, so it’s a good thing it was declared protected instead of private. By calling the parent method first, I can simply add more hooks to this method. In this case, I’ve hooked into 'admin_enqueue_scripts' so I can include jQuery UI Dialog, 'admin_footer' so I can output an invisible element which will be presented as the modal dialog, and a pair of standard ajax hooks.

Continuing:

	function q_admin_scripts() {
		wp_enqueue_script( 'jquery' );
		wp_enqueue_script( 'jquery-ui-dialog', null, 'jquery' );
		wp_enqueue_style( 'wp-jquery-ui-dialog' );
	}

	function output_hidden_dialog() {
		?><div id="Blurbette_MCE_dialog" style="display:none" title="<?php _e( 'Blurbette', WPCX_Blurbette_Def::TEXT_DOMAIN ) ?>">
			<p><label><?php _e( 'Choose a Blurbette:', WPCX_Blurbette_Def::TEXT_DOMAIN ) ?>
			<select id="blurbetteSelector">
				<option></option>
			</select>
			</label></p>
		</div>
		<?php
	}

The hidden dialog includes a blank form selector, because it’ll be populated by AJAX when the dialog is opened.

Lastly, the AJAX-called method. This will call a method of the WPCX_Blurbette_Def class we have yet to write (see below). In essence this method is simply an AJAX ‘wrapper’ for the other method, since it dumbly echoes its output (JSON-encoded) and dies. It’ll be up to the Javascript to act upon the output.

	function get_blurbette_opts_ajax() {
		if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) die();
		if ( ! empty( $_GET['post_type'] ) ) :
			$post_type = sanitize_key( $_GET['post_type'] );
		endif;
		if ( ! empty( $_GET['exclude_id'] ) ) :
			$exclude_id = intval( $_GET['exclude_id'] );
		endif;
		$jjson = WPCX_Blurbette_Def::get_blurbettes_pairs( $post_type, $exclude_id );

		echo json_encode( $jjson );
		die();
	}
	
} // end class WPCX_Blurbette_MCE_WithDialog

Since any AJAX method could be exposed to user input, caution must be exercised. In this case, I’ve filtered all input (get to know WordPress’ sanitize_ functions); also I’ve chosen to check the user is logged in and authorized to edit.

The ‘Get Blurbettes Pairs’ Method

We’ll have to return to the WPCX_Blurbette_Def class to create its get_blurbettes_pairs() method. I’ve chosen to locate it there, because other classes will need to access it, and that’s a logical place for it to be.

I already know there will be some kind of filtering on allowed post types, so this method will optionally accept a $post_type (a.k.a. $context) argument. Also, remember from the last chapter, a recursion problem was solved by preventing output of a Blurbette that includes itself; here it makes sense to dis-include a Blurbette by accepting a second optional argument $exclude_id, an ID number. If this MCE Control happens to appear in the edit panel for a particular Blurbette, its ID number can be passed so it won’t appear in the dropdown options.

So, here’s the method (within WPCX_Blurbette_Def) that fetches all allowable defined Blurbettes:

	public static function get_blurbettes_pairs( $context = null, $exclude_id = 0 ) {

		$query_args = array(
			'post_type'			=> self::POST_TYPE,
			'posts_per_page' 	=> -1,
			'orderby'			=> 'title',
			'order'				=> 'ASC'
		);
		$all_blurbettes = get_posts( $query_args );
		$return = array(
			'opts'			=> array(),
			'status'		=> 'empty',
			'errorString'	=> __( 'No Blurbettes are available.', self::TEXT_DOMAIN )
		);
		if ( is_array( $all_blurbettes ) ) :
			foreach( $all_blurbettes as $bbt ) :
				if ( $bbt->ID == $exclude_id ) continue;
				if ( ! self::check_availability( $bbt->ID, $context ) ) continue;
				$return['opts'][] = array(
					'ID'			=> esc_attr($bbt->ID),
					'post_name'		=> esc_attr($bbt->post_name),
					'label'			=> esc_html($bbt->post_title)
				);
			endforeach;
			if ( count( $return['opts'] ) ) :
				$return['status'] = 'ok';
			endif;
		else:
			$return = array(
				'status'		=> 'error',
				'errorString'	=> __( 'Sorry, there was a problem retrieving data.', self::TEXT_DOMAIN )
			);
		endif;
		return $return;
	}
	
} // end of class WPCX_Blurbette_Def

Fairly self-explanatory; I’ve defined a $return array variable that contains the returned data. Its keys are status so caller can quickly check against the string ‘ok’; errorString so a message can be displayed otherwise; opts = an array containing the fetched data.

Recall that the self::check_availability() method is still just a placeholder which always returns true, for now.

Each fetched item contains a title (label), and both ID and post_name, because (recall from last chapter) a shortcode can reference either an id or slug.

Javascript Variables

While we have WPCX_Blurbette_Def open, there’s another helpful method I like to include — one that defines common Javascript variables needed for AJAX calls:

	public static function define_js_ajax_vars() {
		?>
			<script type="text/javascript">
			var wpcxAjaxVars = {
				url: '<?php echo admin_url( 'admin-ajax.php' ); ?>',
				post_type: '<?php echo $GLOBALS['post_type'] ?>',
				post_id: '<?php echo get_the_ID(); ?>'
			};
			</script>
		<?php
	}

} // end of class WPCX_Blurbette_Def

Now, included Javascript files can access wpcxAjaxVars.url, wpcxAjaxVars.post_type and wpcxAjaxVars.post_id.

If your AJAX only runs within admin panels, then perhaps this output isn’t needed since certain global Javascript variables are defined by WordPress, in the admin header: ajaxurl and typenow (and others). I like to include this standard method so I can access them from anywhere, and quickly reference the page/post ID number if available.

This method’s action hook must be added to do_all_hooks():

    public static function do_all_hooks() {
        add_action( 'init', array( __CLASS__, 'register_cpt' ) );
        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'define_js_ajax_vars' ) );
    }

The Javascript file

I’ve chosen to locate the Javascript file within a separate directory, ‘mce/’, called ‘mce_blurbette.js’.

Taking it a chunk at a time, I simply start with a boilerplate definition in the format required by TinyMCE. I’ve already created its icon within the ‘mce’ directory:

jQuery().ready( function( $ ) {
	tinymce.create( 'tinymce.plugins.WPCX_BlurbettePlugin', {
        init : function( ed, url ) {
           ed.addButton( 'WPCXBlurbette', {
                title: 'Blurbette', 
                image: url + '/mce_button.png', 
                onclick: function() {
					wpcx_ajaxRetrieveBlurbetteOpts( ed );
                }
            } );
        }, 
        createControl: function( n, cm ) {
            return null;
        }, 
        getInfo: function() {
            return {
                longname: 'Blurbette', 
                author: 'Dave Bushnell', 
                authorurl: 'http://www.wpcraftsman.com', 
                infourl: 'http://www.wpcraftsman.com', 
                version: '1.0.0'
            };
        }
        
	} );
    
	tinymce.PluginManager.add( 'WPCXBlurbette', tinymce.plugins.WPCX_BlurbettePlugin );

Incidentally, if you’re not aware, WordPress’ included jQuery is a compatibility-mode version, so it must be referenced as jQuery() instead of the more common $(). This can be a pain when you include jQuery plugins that use $() throughout, since you have to define var $ = jQuery; after enqueueing jQuery. My solution is often to create a separate Javascript file, called dollarsign.js with that single line of code, and enqueue it thus:

wp_enqueue_script( 'jquery' );
wp_enqueue_script( 'dollarsign', 'path/to/dollarsign.js', 'jquery' );

In this case, I can use $() throughout the ready function scope, by declaring it as the first argument in jQuery().ready( function( $ ) { ... }).

Next, the function that inserts the shortcode:

	function wpcx_generateShortcode( blurbid, value_type ) {
	    tinymce.activeEditor.execCommand(
	    	'mceInsertContent',
	    	false,
	    	'[blurbette ' + value_type + '="' + unescape( blurbid ) + '"]'
	    	);
	}

Its arguments are blurbid, which can be either an ID number or a slug, and value_type which identifies which one it is. Next comes the change event which calls it, attached to the hidden dialog’s form select dropdown:

    $( '#Blurbette_MCE_dialog #blurbetteSelector' ).change( function() {
	    if ( '' != $(this).val() ) {
			$( '#Blurbette_MCE_dialog' ).dialog( 'close' );
			wpcx_generateShortcode( $(this).val(), 'slug' );
		}		    
    } );

Next, the function called by the onclick event, attached to the TinyMCE button above:

    function wpcx_ajaxRetrieveBlurbetteOpts() {
	    $.getJSON( 
	        wpcxAjaxVars.url, 
	        'action=wpcx_get_blurbette_opts&post_type=' + wpcxAjaxVars.post_type + '&exclude_id=' + wpcxAjaxVars.post_id, 
	        function( jjson ) {
	            if ( 'ok' == jjson.status ) {
		            wpcx_popNShowBlurbetteDialog( jjson );
		        } else {
			        alert( jjson.errorString );
		        }
	        }
	 );
	}

This accesses the AJAX method defined earlier — recall that it can be passed a post_type and exclude_id, so I use the ones output in the WPCX_Blurbette_Def::define_js_ajax_vars method created above. Here the returned status property is tested for ‘ok’, whereupon the next function (below) is called, or else the errorString property is presented to the user with an alert.

Lastly the function which populates the dialog’s select dropdown, and displays the dialog.

	function wpcx_popNShowBlurbetteDialog( jjson ) {
		$( '#Blurbette_MCE_dialog #blurbetteSelector' ).empty();
		$( '#Blurbette_MCE_dialog #blurbetteSelector' ).append( $( '<option />' )
			.attr( 'value', '' )
			.html( 'Choose...' )
		 );
		for ( var ix in jjson.opts ) {
			$( '#Blurbette_MCE_dialog #blurbetteSelector' ).append( $( '<option />' )
				.attr( 'value', jjson.opts[ix].post_name )
				.html( jjson.opts[ix].label )
			 );
		}
		$( '#Blurbette_MCE_dialog' ).dialog( {
			dialogClass:	'wp-dialog', 
			modal:			true
		} );
	}
} ); // end ready()

For simplicity, I empty and replenish the select dropdown every time the button is clicked. As a bonus, this makes new Blurbettes available right away, without reloading the page. Doubtful this little function can meaningfully increase your processor overhead, but if you have a lot of Blurbettes, a busy blog, and lots of contributors, you might consider:

  1. Checking if the #blurbetteSelector is empty, otherwise skipping the AJAX call;
  2. Using a transient.

In recent versions of WordPress, the dialogClass property isn’t necessary, however in older ones it must be defined as 'wp-dialog' otherwise the display will be garbled.

Instantiating it

All the pieces are in place. Now the only thing left to do is to instantiate the class. Recall that this happens within the WPCX_Blurbette_Registry class, using its register method.

I could simply stick $this->register( 'WPCX_Blurbette_MCE_WithDialog', array( ... ) ); inside the __construct() method, however there are two considerations:

  1. I only want to instantiate if WordPress is currently displaying an admin panel.
  2. Certain functions aren’t yet defined in the WordPress load order, and this class (and others) may need to access them. I’ll have to delay instantiation until the plugins_loaded action.

So, it’s necessary to add a new method and hook it into the plugins_loaded action.

	function instantiate_the_rest() {
		if ( is_admin() ) :
				$this->register( 'WPCX_Blurbette_MCE_WithDialog', array(
					'registry'	=> $this,
					'name'		=> 'WPCXBlurbette',
					'row'		=> 2,
					'js' 		=> plugin_dir_url( __FILE__ ) . 'mce/mce_blurbette.js'
				) );
		endif;
	}

… and add the hook to the __construct() method:

    function __construct() {
        spl_autoload_register( array( $this, 'class_autoloader' ) );
        WPCX_Blurbette_Def::do_all_hooks();
        WPCX_Blurbette_Shortcode::do_all_hooks();
        add_action( 'plugins_loaded', array( $this, 'instantiate_the_rest' ) );
    }

Recapping the code

Here’s the whole (generic, repurposable) WPCX_MCEControl class:

<?php 
class WPCX_MCEControl {
	
	protected $name;
	protected $row;
	protected $js;
	
	function __construct( $opts ) {
		if ( empty( $opts['name'] ) ) throw new Exception( 'MCE Plugin name not specified.' );
		if ( empty( $opts['js'] ) ) throw new Exception( 'MCE Plugin script filepath not specified.' );
		if ( is_array( $opts ) ) :
			$opts = wp_parse_args( $opts, array( 
				'name'=>null, 
				'row'=>2, 
				'js'=>null
			 ) );
			foreach( $opts as $k => $v ) :
				$this->$k = $v;
			endforeach;
		endif;
		$this->do_all_hooks();
	}
	
	protected function do_all_hooks() {
		add_action( 'admin_init', array( $this, 'add_mce_control' ) );
		add_filter( 'tiny_mce_version', array( __CLASS__, 'force_mce_refresh' ) );
	}
	
	public function add_mce_control() {
		if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) return;
		if ( get_user_option( 'rich_editing' ) == 'true' ) :
			add_filter( 'mce_external_plugins', array( $this, 'plugin_array' ) );
			add_filter( 'mce_buttons_' . $this->row, array( $this, 'ctrls_array' ) );
		endif;
	}
	
	// For some reason, this is required to make TinyMCE refresh and incorporate new changes.
	public static function force_mce_refresh( $ver ) {
		return $ver + 99;
	}
	
	public function plugin_array( array $mce_plugins ) {
		$mce_plugins[$this->name] = $this->js;
		return $mce_plugins;
	}

	public function ctrls_array( array $mce_ctrls ) {
		$mce_ctrls[] = $this->name;
		return $mce_ctrls;
	}
}

The WPCX_Blurbette_MCE_WithDialog class:

<?php
class WPCX_Blurbette_MCE_WithDialog extends WPCX_MCEControl {
	
	const AJAX_GET_OPTS = 'wpcx_get_blurbette_opts';
	
	protected function do_all_hooks() {
		parent::do_all_hooks();
		add_action( 'admin_enqueue_scripts', array( $this, 'q_admin_scripts' ) );
		add_action( 'admin_footer', array( $this, 'output_hidden_dialog' ) );
		
		add_action( 'wp_ajax_' . self::AJAX_GET_OPTS, array( $this, 'get_blurbette_opts_ajax' ) );
		add_action( 'wp_ajax_nopriv_' . self::AJAX_GET_OPTS, array( $this, 'get_blurbette_opts_ajax' ) );
	}

	function q_admin_scripts() {
		wp_enqueue_script( 'jquery' );
		wp_enqueue_script( 'jquery-ui-dialog', null, 'jquery' );
		wp_enqueue_style( 'wp-jquery-ui-dialog' );
	}

	function output_hidden_dialog() {
		?><div id="Blurbette_MCE_dialog" style="display:none" title="Blurbette">
			<p><label>Choose a Blurbette:
			<select id="blurbetteSelector">
				<option></option>
			</select>
			</label></p>
		</div>
		<?php
	}

	function get_blurbette_opts_ajax() {
		if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) die();
		if ( ! empty( $_GET['post_type'] ) ) :
			$post_type = sanitize_key( $_GET['post_type'] );
		endif;
		if ( ! empty( $_GET['exclude_id'] ) ) :
			$exclude_id = intval( $_GET['exclude_id'] );
		endif;
		$jjson = WPCX_Blurbette_Def::get_blurbettes_pairs( $post_type, $exclude_id );

		echo json_encode( $jjson );
		die();
	}
	
} // end class WPCX_Blurbette_MCE_WithDialog

The Javascript file:

jQuery().ready( function( $ ) {
	tinymce.create( 'tinymce.plugins.WPCX_BlurbettePlugin', {
        init : function( ed, url ) {
           ed.addButton( 'WPCXBlurbette', {
                title: 'Blurbette', 
                image: url + '/mce_button.png', 
                onclick: function() {
					wpcx_ajaxRetrieveBlurbetteOpts( ed );
                }
            } );
        }, 
        createControl: function( n, cm ) {
            return null;
        }, 
        getInfo: function() {
            return {
                longname: 'Blurbette', 
                author: 'Dave Bushnell', 
                authorurl: 'http://www.wpcraftsman.com', 
                infourl: 'http://www.wpcraftsman.com', 
                version: '1.0.0'
            };
        }
        
	} );
    
	tinymce.PluginManager.add( 'WPCXBlurbette', tinymce.plugins.WPCX_BlurbettePlugin );
	
	function wpcx_generateShortcode( blurbid, value_type ) {
	    tinymce.activeEditor.execCommand(
	    	'mceInsertContent',
	    	false,
	    	'[blurbette ' + value_type + '="' + unescape( blurbid ) + '"]'
	    	);
	}

    $( '#Blurbette_MCE_dialog #blurbetteSelector' ).change( function() {
	    if ( '' != $(this).val() ) {
			$( '#Blurbette_MCE_dialog' ).dialog( 'close' );
			wpcx_generateShortcode( $(this).val(), 'slug' );
		}		    
    } );
    function wpcx_ajaxRetrieveBlurbetteOpts() {
	    $.getJSON( 
	        wpcxAjaxVars.url, 
	        'action=wpcx_get_blurbette_opts&post_type=' + wpcxAjaxVars.post_type + '&exclude_id=' + wpcxAjaxVars.post_id, 
	        function( jjson ) {
	            if ( 'ok' == jjson.status ) {
		            wpcx_popNShowBlurbetteDialog( jjson );
		        } else {
			        alert( jjson.errorString );
		        }
	        }
	 );
	}
	
	function wpcx_popNShowBlurbetteDialog( jjson ) {
		$( '#Blurbette_MCE_dialog #blurbetteSelector' ).empty();
		$( '#Blurbette_MCE_dialog #blurbetteSelector' ).append( $( '<option />' )
			.attr( 'value', '' )
			.html( 'Choose...' )
		 );
		for ( var ix in jjson.opts ) {
			$( '#Blurbette_MCE_dialog #blurbetteSelector' ).append( $( '<option />' )
				.attr( 'value', jjson.opts[ix].post_name )
				.html( jjson.opts[ix].label )
			 );
		}
		$( '#Blurbette_MCE_dialog' ).dialog( {
			dialogClass:	'wp-dialog', 
			modal:			true
		} );
	}
} ); // end ready()