Creating a custom blockquote wrapper format with nested elements in TinyMCE

The Class Central blog design we implemented a while ago has an interesting blockquote element. It is moved outside of the text (horizontally) and it also has a blue border.

cc-blockquote

As you can see, it also needs to work on both sides of the content. An additional requirement was that this should work with text only, with both image and text, and with only an image.

At first, it probably doesn’t look very difficult to build. However, once you start thinking about the details of the implementation, you’ll quickly realize that it’s actually quite tricky.

(Note that this post assumes you already have some sort of understanding of TinyMCE.)

The HTML

Here’s the HTML required to make the blockquote work like it should. I won’t go into detail here, but we considered various different options and this was pretty much the only way to get it working properly.

<blockquote class="cc-blockquote cc-blockquote-left cc-blockquote-text">
  <p>
    <span>We begin learning from the moment we are born, yet as we go through life we never learn how to learn</span>
    <span class="cc-blockquote-border">&nbsp;</span>
  </p>
</blockquote>

Looking at the HTML, it becomes apparent that TinyMCE’s blockquote is not enough for this functionality, since we also require an inner span.border element. This means we’ll have to create a custom style in TinyMCE. Yay!

WordPress integration

Adding custom buttons to WP’s TinyMCE is relatively simple. Let’s quickly go over the 3 components required.

First, we’ll need to add a couple of filters when admin_head hook is triggered. The first one will register a TinyMCE plugin – the javascript file responsible for the behavior of the button. The second will add the buttons to TinyMCE toolbar.

function cc_add_tinymce() {
    global $typenow;
    
    if( ! in_array($typenow, array('post', 'page')) ) {
        return;
    }
    
    add_filter( 'mce_external_plugins', 'cc_add_tinymce_plugin' );
    add_filter( 'mce_buttons', 'cc_add_tinymce_button' );
}
add_action( 'admin_head', 'cc_add_tinymce' );

I want to add these hooks only when a Post or Page is being edited. To do this, I shall leverage WP’s superior application architecture and use the global $typenow. Eugh.

Next, the functions triggered by these filters. The first one simply adds my custom TinyMCE javascript plugin – blockquote.js – to the array of active plugins.

function cc_add_tinymce_plugin( $plugin_array ) {
    $plugin_array['blockquote'] = plugins_url( '/blockquote.js', __FILE__ );
    return $plugin_array;
}

And the second function adds new buttons to the array of active buttons.

function cc_add_tinymce_button( $buttons ) {
    array_push( $buttons, 'BlockquoteLeft' );
    array_push( $buttons, 'BlockquoteCenter' );
    array_push( $buttons, 'BlockquoteRight' );
    return $buttons;
}

Easy.

TinyMCE

The real magic happens in blockquote.js.

First, we wrap everything in a jQuery document.ready and initialize the plugin. The first parameter, “blockquote”, is the handle of the plugin. Note that it has to be the same handle that was included in $plugin_array before.

(function($) {
    tinymce.PluginManager.add('blockquote', function(editor, url) {
        // The actual plugin code goes here
    });
})(jQuery);

The requirements

This is where it actually gets interesting and also pretty complicated.

When parsing the DOM of a visual editor, there are so many possible corner cases that will screw up the whole HTML inside the editor if you don’t think about them. And apparently there are a lot of ways you can wrap a blockquote around an element in TinyMCE – and all of them should work, otherwise your users will be very frustrated. A simple example – paragraph with just a strong and a span tag:

<p>
Lorem ipsum dolor sit amet, <strong>consectetur adipisicing elit</strong>, 
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 
<span class="last">Excepteur sint occaecat cupidatat non proident, 
sunt in culpa qui officia deserunt mollit anim id est laborum.</span>
</p>

Before clicking on the “blockquote” button, the cursor must be placed somewhere or alternatively a part of the text should be selected. In this example, there are quite a few different ways to do it:

  • Select the whole paragraph;
  • Place cursor in front of the paragraph;
  • Place cursor at the end of the paragraph;
  • Place cursor in any position inside the paragraph.

By the way – when the cursor is at the end of the paragraph, editor.selection.getNode() returns the span element and not the paragraph element itself. Also, this one is obvious but still has to be considered: if you happen to place your cursor inside the <strong> tag, it will obviously return the <strong> element.

There should also be a way to remove the blockquote styles, so the button should also understand what to do in a situation like this:

<blockquote class="cc-blockquote-left">
<p>
<span class="quote-border">&nbsp;</span>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 
<span class="last">Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span>
</p>
</blockquote>

In this case, the button should remove span.quote-border and un-wrap the blockquote from around the paragraph. But only if this is a blockquote.cc-blockquote. If this is a regular blockquote, it should instead add the class and the border span.

In addition to what I outlined above, it’s also possible to select multiple paragraphs and wrap them inside a blockquote. After lots of trial and error I found out that if you call editor.selection.getNode() on a selection of multiple elements, it returns the common ancestor element for both start and end of the selection. For two paragraphs, that element is body#tinymce (<body> of the iframe) which is pretty much useless. I thought this wouldn’t be an issue because nobody needs to wrap two different paragraphs into a blockquote – until I tried wrapping an image and some text inside a blockquote..

I also realized that writing custom TinyMCE styles and formats (more complex than just a class or parameter) is rather difficult and in most cases something you probably don’t want to do.

The algorithm

To completely avoid the clusterfuck that comes with parsing the DOM inside the editor, we’re going to offload all the heavy duty to TinyMCE’s built-in formatter. More on that below.

First of all, we will register a custom format – blockquote.cc-blockquote.

editor.on('init', function(e) {
    editor.formatter.register(
        'blockquote_left_formatter', {
            block: 'blockquote',
            classes: ['cc-blockquote', 'quote-left'],
            wrapper: true
        }
    );
});

Detailed documentation about format parameters can be found here. The only interesting thing to note here is the wrapper parameter, which specifies that the specified format should be wrapped around the selection.

We also register the button – disregard the onPostRender function for now:

editor.addButton('BlockquoteLeft', {
    text: 'Blockquote Left',
    icon: false,
    cmd: 'blockquote_left',
    onPostRender: function() {

        var ctrl = this;

        editor.on('NodeChange', function(e) {

            // Check if the selection matches the format
            var formatMatch = editor.formatter.match('blockquote_left_formatter');
            
            // And check if either the selected element or its parent <blockquote> contains the .quote-border span
            $selectedElement = $(editor.selection.getNode());
            if ( $selectedElement.find('.quote-border').length || $selectedElement.closest('.cc-blockquote').find('.quote-border').length) {
                var innerElementMatch = true;
            }

            // If both conditions are true, the button should be in its active state
            ctrl.active(formatMatch && innerElementMatch);
        });

    }
});

This button simply runs the blockquote_left command, which we will implement below.

To avoid all the hassle with detecting which DOM element is inside the selection, we’ll just apply the custom format before doing anything else. The formatter does some magic behind the scenes to apply the blockquote.cc-blockquote format precisely to the selection, and after that editor.selection.getNode() is guaranteed to return either the blockquote element itself or one of its descendants (depending on where exactly the cursor is).

So basically, to enable the format, we apply blockquote_left_formatter and after that just prepend span.quote-border to the blockquote element.

editor.addCommand('blockquote_left', function() {

    if (!editor.formatter.match('blockquote_left_formatter')) {

        // If the blockquote format is not already applied to the element, we apply it before doing anything else.
        editor.formatter.apply("blockquote_left_formatter");

        // Now, editor.selection will return the <blockquote> element
        var $blockquote = $(editor.selection.getNode());

        // So we can simply prepend the span to this element
        $borderElement = $('<span>&nbsp;</span>').addClass('quote-border');
        $blockquote.prepend($borderElement);

    } else {

        // First we find the parent <blockquote> element
        var $selectedElement = $(editor.selection.getNode());
        var $blockquote = $selectedElement.closest('.cc-blockquote');

        // Since the format is already applied, we remove the border element from inside the blockquote
        $blockquote.find('span.quote-border').remove();

        // And then simply remove the format
        editor.formatter.remove("blockquote_left_formatter");
    }

});

To disable the format, we reverse the process. editor.selection.getNode() will always return the blockquote element, so we just remove any span.quote-border inside it, and then remove blockquote_left_formatter itself.

Finally, we need something that controls the state of the custom button. When we created the button, we added a callback for onPostRender:

var ctrl = this;

editor.on('NodeChange', function(e) {

    // Check if the selection matches the format
    var formatMatch = editor.formatter.match('blockquote_left_formatter');
    
    // And check if either the selected element or its parent <blockquote> contains the .quote-border span
    $selectedElement = $(editor.selection.getNode());
    if ( $selectedElement.find('.quote-border').length || $selectedElement.closest('.cc-blockquote').find('.quote-border').length) {
        var innerElementMatch = true;
    }

    // If both conditions are true, the button should be in its active state
    ctrl.active(formatMatch && innerElementMatch);
});

ctrl.active toggles the state of the button. In this case, we simply want to make sure it matches our custom format and also contains span.quote-border.

Conclusion

Implementing more complex custom stuff in TinyMCE is deceptively difficult and it would have been much much easier to use a shortcode instead. However, if you really need to make something complex, use as many of the built-in functions as possible and always try to consider all the various corner cases.

The full plugin code is available on github here.

Indrek Kõnnussaar

I'm a veteran Wordpress developer, context-driven tester, security enthusiast and the mastermind behind Codelight. I love building stuff that works and fixing stuff that doesn't.

Write me directly indrek@codelight.eu

5 Responses to “Creating a custom blockquote wrapper format with nested elements in TinyMCE”

  1. Norbert Felgendreher

    Hi, Indek,
    impressive post. I’m having a similar problem though not so complicated – just a double wrapper to create “resposive posts” inside a responsive blog. Started with wp shortcodes and I’m now evaluating, what tinymce may offer for help.
    Thanks a lot
    Norbert

    Reply
    • Indrek Kõnnussaar

      Hi there,

      What exactly do you mean, how should the end result look like?

      Reply
  2. Norbert Felgendreher

    Hello, Indrek,
    I have posted examples in my blog.
    If you are interested, please have a look:
    http://www.it-in-a-box.com/2014/12/wordpress-how-to-write-responsive-posts-part-2/
    It’s in german and english.

    Regards

    Norbert

    Reply
  3. Kenneth

    Greetings!

    Excellent post very descriptive , and concise thank you so much for writing it.

    I am trying to hijack the heading style button right now , and wrap the text selection in a tag before applying the heading (h1) style, im running into a lot of hang ups though.

    this library is more complicated then i expected it to be !

    Reply
  4. Marek Cevelicek

    Hi, so this is impressive. Long time I think over some tinymce plugin which prevents some content from delete. Every solution so far is ugly workaround or nonworking. Your way gives me very new ideas how to solve this properly. So thanks;)

    Reply

Leave a Reply

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×