I worked on a WordPress site recently where the client needed to associate two different images with each piece of content. I couldn’t use a single image with a custom size because the images’ aspect ratios were so different.
I knew that I could use WordPress’s Featured Image functionality for the first image, but I wasn’t quite sure what to do for the second. I needed it to be as easy as possible to use because my client was non-technical. I looked for an existing solution by searching the WordPress plugin repository. I found a plugin that seemed promising, but Multiple Featured Images hasn’t been updated since May 2012 and didn’t support the media picker interface introduced in WordPress 3.5.
Because I knew this was something I would use on future projects, I decided to build my own plugin.
Building the Plugin
The plugin provides an interface that a developer can use to add multiple named image pickers to any type of content and access the images within the site’s theme. It uses the new media picker interface introduced in 3.5.
Laying the Foundation
Every WordPress plugin I build starts from the same basic plugin skeleton. If you’re interested in using my skeleton, feel free to download it from GitHub.
My plugin skeleton is ridiculously simple – it is a class with exclusively static methods. The class acts as a pseudo namespace that encapsulates the functionality of my plugin and ensures there are no function naming collisions. There are a million different ways to structure a WordPress plugin, but my skeleton has worked well for me over the past few years.
After forking my skeleton for this project, I renamed the plugin class to something appropriate (MFI_Reloaded
) and changed all other strings as necessary.
Defining the Public Interface
Since this plugin is going to be used by other developers, the first thing I wanted to do is define a well thought out interface. The following is what I came up with:
function mfi_reloaded_add_image_picker($name, $args = array());
function mfi_reloaded_has_image($name, $post_id = null);
function mfi_reloaded_get_image_id($name, $post_id = null);
function mfi_reloaded_get_image($name, $size = 'thumbnail', $post_id = null, $attributes = array());
function mfi_reloaded_the_image($name, $size = 'thumbnail', $post_id = null, $attributes = array());
This interface accomplishes both goals I had for the plugin. Developers can register new named image pickers using mfi_reloaded_add_image_picker
and access saved information with the rest.
With this interface, I consciously tried to stay close to the analogous functions provided by the WordPress post thumbnail template tags.
Implementing the Public Interface
Now that there is an interface for telling the plugin what image pickers to display, I need a way to store that data in the plugin. For the sake of simplicity, I decided to use an associative array which I added as a private static field on our plugin class.
At the top of my plugin class I added the following:
private static $image_pickers = array();
Now, I needed to actually make the template tags defined earlier do something. When I build template tags, I tend to make all “getters” and “setters” simply delegate to public static methods on the class. That’s what I did here.
At the bottom of my plugin class I added the following:
public static function add_image_picker($name, $args) {
return false;
}
public static function get_image_id($name, $post_id) {
return false;
}
public static function get_image($name, $size, $post_id, $attributes) {
return false;
}
I started here by implementing add_image_picker
. This could have been as easy as adding $args
as a new item on the previously defined $image_pickers
field with $name
as the array key. In the interest of making this plugin more robust, however, I went a little bit further:
- Return
false
immediately if the image picker name is not a string or if an image picker with that name has been previously registered
- Normalize the data passed in the
$args
parameter given the defaults specified in the template tag documentation
- Set
$image_pickers[$name] = $normalized_args
Here is what this method ended up looking like:
public static function add_image_picker($name, $args) {
if(!is_string($name) || isset(self::$image_pickers[$name])) {
return false;
}
self::$image_pickers[$name] = self::_normalize_args($args);
}
I needed to normalize the arguments so I created the helper method named _normalize_args
to do so. I usually prefix helper methods like this with an underscore (as you can see). That method reads as follows:
private static function _normalize_args($args) {
$normalized_args = array();
if(!isset($args['post_types'])) {
$normalized_args['post_types'] = array('post', 'page');
} else if(!is_array($args['post_types'])) {
$normalized_args['post_types'] = array($args['post_types']);
} else {
$normalized_args['post_types'] = $args['post_types'];
}
$default_labels = array(
'name' => __('Featured Image'),
'set' => __('Set featured image'),
'remove' => __('Remove featured image'),
'popup_title' => __('Set Featured Image'),
'popup_select' => __('Set featured image'),
);
if(!isset($args['labels']) || !is_array($args['labels'])) {
$normalized_args['labels'] = $default_labels;
} else {
$normalized_args['labels'] = shortcode_atts($default_labels, $args['labels']);
}
return $normalized_args;
}
For now, that’s all I could really implement. I wasn’t storing any data yet so there was no way to return anything meaningful.
Adding the Meta Boxes
Once the public API was defined, the next step was to show the user an interface they could use. It won’t be fully functional at first, but it will look like correct.
To register our meta boxes, we first add a callback to the add_meta_boxes
action. In my add_actions
method (inside the is_admin
conditional), I added the following code:
add_action('add_meta_boxes', array(__CLASS__, 'add_image_picker_meta_boxes'));
Then, I added the following public static method inside of my class:
public static function add_image_picker_meta_boxes($post_type) {
foreach(self::$image_pickers as $image_picker_name => $image_picker_args) {
if(in_array($post_type, $image_picker_args['post_types'])) {
add_meta_box(
'mfi-reloaded-' . sanitize_title_with_dashes($image_picker_name),
$image_picker_args['labels']['name'],
array(__CLASS__, 'display_image_picker_meta_box'),
$post_type,
'side',
'default',
compact('image_picker_name', 'image_picker_args')
);
}
}
}
This method iterates over each registered image picker and checks to see if it is registered for the post type currently being edited. If it is, it adds a meta box by calling add_meta_box
with the appropriate arguments.
In order to actually display the meta box, we need to implement the display_image_picker_meta_box
method. In my plugin class, I added another public static method with that name that displays some output:
public static function display_image_picker_meta_box($post, $meta_box) {
$image_picker_args = $meta_box['args']['image_picker_args'];
$image_picker_name = $meta_box['args']['image_picker_name'];
$image_id = mfi_reloaded_get_image_id($image_picker_name, $post->ID);
$image = mfi_reloaded_get_image($image_picker_name, 'full', $post->ID);
include('views/meta-boxes/image-picker.php');
}
We’re using the unimplemented public interface that we stubbed out earlier to grab the full size image for the picker in question (if it exists). We’ll be using this later. When printing HTML, I prefer to include a separate file (as you can see in the method above). The entirety of that file is as follows:
<div class="mfi-reloaded-image-picker"
data-mfi-reloaded-image-id="<?php printf('%d', $image_id); ?>"
data-mfi-reloaded-name="<?php esc_attr_e($image_picker_name); ?>"
data-mfi-reloaded-select="<?php esc_attr_e($image_picker_args['labels']['popup_select']); ?>"
data-mfi-reloaded-title="<?php esc_attr_e($image_picker_args['labels']['popup_title']); ?>">
<div class="mfi-reloaded-image-picker-preview"></div>
<a class="mfi-reloaded-image-picker-remove" href="#"><?php esc_html_e($image_picker_args['labels']['remove']); ?></a>
<a class="mfi-reloaded-image-picker-set" href="#"><?php esc_html_e($image_picker_args['labels']['set']); ?></a>
</div>
This code outputs a container for the thumbnail preview and two links that allow the user to take action. Of course, nothing happens yet.
Adding the Image Picking Functionality
The media picker is controlled via JavaScript and styled with CSS so I needed to enqueue the appropriate scripts and styles in the administrative panel on the editing pages. To do so, I hooked into admin_enqueue_scripts
by adding the following line inside of my add_actions
method (in the is_admin
conditional):
add_action('admin_enqueue_scripts', array(__CLASS__, 'enqueue_administrative_resources'));
The enqueue_administrative_resources
callback is responsible for checking if the post editing screen is being viewed and, if so, enqueueing the appropriate scripts.
public static function enqueue_administrative_resources() {
$screen = get_current_screen();
if('post' === $screen->base) {
wp_enqueue_media();
wp_enqueue_script('mfi-reloaded', plugins_url('resources/backend/mfi-reloaded.js', __FILE__), array('jquery'), self::VERSION, true);
wp_enqueue_style('mfi-reloaded', plugins_url('resources/backend/mfi-reloaded.css', __FILE__), array(), self::VERSION);
}
}
The stylesheet is pretty simple as it just prevents the preview image from flowing outside the bounds of its container:
.mfi-reloaded-image-picker-preview img {
height: auto;
max-width: 100%;
}
The JavaScript file is a little more complex, but I’ve added comments liberally where I think there could be confusion. I encourage you to read the source if you’re interested in what is going on:
jQuery(document).ready(function($) {
// Register a click event handler on the remove links
$('.mfi-reloaded-image-picker-remove').click(function(event) {
event.preventDefault();
var $remove = $(this),
$container = $remove.parents('.mfi-reloaded-image-picker'),
$preview = $remove.siblings('.mfi-reloaded-image-picker-preview'),
$set = $remove.siblings('.mfi-reloaded-image-picker-set'),
_name = $container.data('mfi-reloaded-name');
// Initiate an AJAX request to remove the image id for this image picker
$.post(
ajaxurl,
{
action: 'mfi_reloaded_set_image_id',
image_id: 0,
name: _name,
post_id: $('#post_ID').val()
},
function(data, status) { }
);
// Hide the link that allows a user to remove the image
$remove.hide();
// Remove the preview thumbnail because it is no longer valid
$preview.empty();
// Show the link that allows a user to set the image
$set.show();
});
// Register a click event handler in order to show the media picker
$('.mfi-reloaded-image-picker-set').click(function(event) {
event.preventDefault();
var $set = $(this),
$container = $set.parents('.mfi-reloaded-image-picker'),
$preview = $set.siblings('.mfi-reloaded-image-picker-preview'),
$remove = $set.siblings('.mfi-reloaded-image-picker-remove'),
_name = $container.data('mfi-reloaded-name'),
_select = $container.data('mfi-reloaded-select'),
_title = $container.data('mfi-reloaded-title');
// Set up the media picker frame
var mfi_reloaded_frame = wp.media({
// Open the media picker in select mode only
frame: 'select',
// Only allow a single image to be chosen
multiple: false,
// Set the popup title from the HTML markup we output for the active picker
title: _title,
// Only allow the user to choose form images
library: { type: 'image' },
button: {
// Set the button text from the HTML markup we output for the active picker
text: _select
}
});
mfi_reloaded_frame.on('select', function(){
var media_attachment = mfi_reloaded_frame.state().get('selection').first().toJSON();
// Initiate an AJAX request to set the image id for this image picker
$.post(
ajaxurl,
{
action: 'mfi_reloaded_set_image_id',
image_id: media_attachment.id,
name: _name,
post_id: $('#post_ID').val()
},
function(data, status) { }
);
// Add the image to the preview container
$preview.append($('<img />').attr('src', media_attachment.sizes.full.url).attr('alt', media_attachment.title));
});
// Show the remove link
$remove.show();
// Hide the set link
$set.hide();
mfi_reloaded_frame.open();
});
$('.mfi-reloaded-image-picker').each(function(index, element) {
var $container = $(element),
$preview = $container.children('.mfi-reloaded-image-picker-preview'),
$remove = $container.children('.mfi-reloaded-image-picker-remove'),
$set = $container.children('.mfi-reloaded-image-picker-set');
if(0 === $preview.children().size()) {
$remove.hide();
} else {
$set.hide();
}
});
});
Now that the JavaScript was in place and working, I need to make sure that the user’s choices were persisted.
Saving and Loading Data
When I’m saving data for a post, I generally like to create two small wrapper methods that get and set the data. That’s exactly what I did for this plugin:
private static function _get_meta($post_id, $meta_key = null) {
$post_id = empty($post_id) && in_the_loop() ? get_the_ID() : $post_id;
$meta = get_post_meta($post_id, 'rfi-reloaded-images', true);
if(!is_array($meta)) {
$meta = array();
}
return is_null($meta_key) ? $meta : (isset($meta[$meta_key]) ? $meta[$meta_key] : false);
}
private static function _set_meta($post_id, $meta) {
$post_id = empty($post_id) && in_the_loop() ? get_the_ID() : $post_id;
update_post_meta($post_id, 'rfi-reloaded-images', $meta);
return $meta;
}
Now that these were in place, I could address the AJAX call that was currently being unhandled. To start, I added an action in my add_actions
method for the ajax call:
add_action('wp_ajax_mfi_reloaded_set_image_id', array(__CLASS__, 'ajax_mfi_reloaded_set_image_id'));
Then, I created the callback and made sure it checked permissions for the post being modified and then persisted data appropriately.
public static function ajax_mfi_reloaded_set_image_id() {
$data = stripslashes_deep($_REQUEST);
$image_id = $data['image_id'];
$name = $data['name'];
$post_id = $data['post_id'];
if($post_id && current_user_can('edit_post', $post_id)) {
$images = self::_get_meta($post_id);
if(empty($image_id)) {
unset($images[$name]);
} else {
$images[$name] = $image_id;
}
self::_set_meta($post_id, $images);
}
exit;
}
Finishing the Public Interface
Now that we’ve started saving data, we can finally implement the rest of the public interface. I started with the get_image_id
method in the plugin class.
public static function get_image_id($name, $post_id) {
return self::_get_meta($post_id, $name);
}
I just used the wrapper function I created earlier to pull out the appropriate information from the serialized data and returned it.
Next, I implemented the get_image
method, which was straightforward enough now that I had the ID.
public static function get_image($name, $size, $post_id, $attributes) {
$image_id = self::get_image_id($name, $post_id);
if($image_id) {
return wp_get_attachment_image($image_id, $size, false, $attributes);
}
return false;
}
And that was it, everything was working like I wanted it to.
Using the Multiple Image Picker
Now that the plugin is done, using it is easy. For testing, I put the following code into a file in /wp-content/mu-plugins/
<?php
add_action('init', function() {
if(function_exists('mfi_reloaded_add_image_picker')) {
mfi_reloaded_add_image_picker('hero-image', array(
'post_types' => array('post'),
'labels' => array(
'name' => __('Hero Image'),
'set' => __('Set hero image'),
'remove' => __('Remove hero image'),
'popup_title' => __('Set Hero Image'),
'popup_select' => __('Set hero image'),
),
));
mfi_reloaded_add_image_picker('sidekick-image', array(
'post_types' => array('post'),
'labels' => array(
'name' => __('Sidekick Image'),
'set' => __('Set sidekick image'),
'remove' => __('Remove sidekick image'),
'popup_title' => __('Set Sidekick Image'),
'popup_select' => __('Set sidekick image'),
),
));
}
});
This registers two image pickers for use on posts only. I then used the mfi_reloaded_the_image
to display the images for posts in the loop.
If you’re interested in the plugin I built, download it from GitHub. If you’ve got suggestions for improvements, I’d love to see a pull request for patch. Let me know how you plan on using it in the comments!