Adding Custom Text Patterns in the WP 4.5 Visual Editor

4.5 is out and with it new Text Patterns. I would like to know how to go about adding my own custom patterns.

Taking a look at wp-includes/js/tinymce/plugins/wptextpattern/plugin.js it seems pretty straight forward.

var spacePatterns = [
    { regExp: /^[*-]\s/, cmd: 'InsertUnorderedList' },
    { regExp: /^1[.)]\s/, cmd: 'InsertOrderedList' }
];

var enterPatterns = [
    { start: '##', format: 'h2' },
    { start: '###', format: 'h3' },
    { start: '####', format: 'h4' },
    { start: '#####', format: 'h5' },
    { start: '######', format: 'h6' },
    { start: '>', format: 'blockquote' },
    { regExp: /^(-){3,}$/, element: 'hr' }
];

var inlinePatterns = [
    { start: '`', end: '`', format: 'code' }
];

The 33300.6.patch looks ideal:

add_filter( 'tiny_mce_before_init', 'textpatterns_test' );
function textpatterns_test( $init ) {
    $init['wptextpattern_inline_patterns'] = '{
        strong: { start: "*", end: "*", format: "bold" },
        strong2: { start: "**", end: "**", format: "bold" },
        em: { start: "_", end: "_", format: "italic" }
    }';

    return $init;
}

Unfortunately, from what I can tell, these patterns are not pluggable and that patch never made it into the 4.5 release.

So at this point maybe the best solution is to go about duplicating this plugin, removing existing patterns (so as not to duplicate patterns), and adding the custom patterns? If that’s the case, what is the best practice on adding tinymce plugins in the correct order to add this new functionality?

Or is there another solution that is less obvious?

1

Here’s a way to test the core patch #33300.6 by Andew Ozz, through a test plugin in WP 4.5.2, to try out the text pattern filter.

Demo

Here’s a strikethrough example using ~

$init['wpsetextpattern_inline_patterns'] = '{
    strong:         { start: "*",   end: "*",   format: "bold"          },
    strong2:        { start: "**",  end: "**",  format: "bold"          },
    em:             { start: "_",   end: "_",   format: "italic"        },
    strikethrough:  { start: "~",   end: "~",   format: "strikethrough" }
}';

in the test plugin. It works like this:

strikethrough

Test plugin

The structure of the test plugin is:

/plugins/custom-text-patterns/
    custom-text-patterns.php 
    js/
        plugin.js

where the files are:

custom-text-patterns.php:

<?php
/**
 * Plugin Name:     Custom Text Patterns for WordPress 4.5
 * Description:     Trying out the core patch #33300.6 by azaozz, to test textpattern filtering. 
 * Version:         1.0.0
 */

/**
 * Remove the current wptextpattern plugin 
 */ 
add_filter( 'tiny_mce_plugins', function( $plugins )
{
    $key = array_search ( 'wptextpattern', $plugins );
    if( false !== $key )
        unset( $plugins[$key] );

    return $plugins;
} );

/**
 * Register patch #33300.6 as an external TinyMCE plugin
 */ 
add_filter( 'mce_external_plugins', function( $plugins )
{
    $plugins['wpsetextpattern'] = plugin_dir_url( __FILE__ ) . 'js/plugin.js';

    return $plugins;
} );

/**
 * Custom text patterns
 */ 
add_filter( 'tiny_mce_before_init', function( $init )
{   
    $init['wpsetextpattern_inline_patterns'] = '{
        strong:  { start: "*",  end: "*",  format: "bold" },
        strong2: { start: "**", end: "**", format: "bold" },
        em:      { start: "_",  end: "_",  format: "italic" },
        strikethrough: { start: "~", end: "~", format: "strikethrough" }
    }';

    $init['wpsetextpattern_enter_patterns'] = '{
        h2:         { start: "##", format: "h2" },
        h3:         { start: "###", format: "h3" },
        h4:         { start: "####", format: "h4" },
        h5:         { start: "#####", format: "h5" },
        h6:         { start: "######", format: "h6" },
        blockquote: { start: ">", format: "blockquote" },
        hr:         { regExp: /^(-){3,}$/, element: "hr" }      
    }';

    $init['wpsetextpattern_space_patterns'] = '{
        ul: { regExp: /^[*-]\s/, cmd: "InsertUnorderedList" },
        ol: { regExp: /^1[.)]\s/, cmd: "InsertOrderedList" }
    }';

    return $init;
} );

plugin.js: Merged with patch #33300.6, replacing wptextpattern with wpsetextpattern:

/**
 * Text pattern plugin for TinyMCE
 *
 * @since 4.3.0
 *
 * This plugin can automatically format text patterns as you type. It includes two patterns:
 *  - Unordered list (`* ` and `- `).
 *  - Ordered list (`1. ` and `1) `).
 *
 * If the transformation in unwanted, the user can undo the change by pressing backspace,
 * using the undo shortcut, or the undo button in the toolbar.
 */
( function( tinymce, setTimeout ) {
    if ( tinymce.Env.ie && tinymce.Env.ie < 9 ) {
        return;
    }

    tinymce.PluginManager.add( 'wpsetextpattern', function( editor ) {
        var VK = tinymce.util.VK,
            settings = editor.settings,
            extend = tinymce.extend,
            each = tinymce.each,
            chars = [],
            canUndo;

        /**
         * Setting for the patterns can be added or replaced by using the
         * 'tiny_mce_before_init' filter (from PHP).
         *
         * The editor options are: wptextpattern_space_patterns,
         * wptextpattern_enter_patterns, and wptextpattern_inline_patterns.
         * The format is same as below: an object of objects containing the pattrern settings.
         *
         * Note: the keys in the settings objects are not signigicant. They can be used
         * to override the default settings if needed.
         *
         * Example:
         *     add_filter( 'tiny_mce_before_init', 'textpatterns_test' );
         *     function textpatterns_test( $init ) {
         *         $init['wptextpattern_inline_patterns'] = '{
         *             strong: { start: "*", end: "*", format: "bold" },
         *             strong2: { start: "**", end: "**", format: "bold" },
         *             em: { start: "_", end: "_", format: "italic" }
         *         }';
         *
         *         return $init;
         *     }
         */
        var spacePatterns = extend( {
                ul: { regExp: /^[*-]\s/, cmd: 'InsertUnorderedList' },
                ol: { regExp: /^1[.)]\s/, cmd: 'InsertOrderedList' }
            },
            settings.wpsetextpattern_space_patterns || {}
        );

        var enterPatterns = extend( {
                h2:         { start: '##', format: 'h2' },
                h3:         { start: '###', format: 'h3' },
                h4:         { start: '####', format: 'h4' },
                h5:         { start: '#####', format: 'h5' },
                h6:         { start: '######', format: 'h6' },
                blockquote: { start: '>', format: 'blockquote' },
                hr:         { regExp: /^(-){3,}$/, element: 'hr' }
            },
            settings.wpsetextpattern_enter_patterns || {}
        );

        var inlinePatterns = extend( {
                code: { start: '`', end: '`', format: 'code' }
            },
            settings.wpsetextpattern_inline_patterns || {}
        );

        // Convert to array and sort descending by start length.
        function toSortedArray( patterns ) {
            patterns = tinymce.map( patterns, function( pattern ) {
                return pattern;
            } );

            patterns.sort( function( a, b ) {
                if ( a.start && b.start ) {
                    if ( a.start.length > b.start.length ) {
                        return -1;
                    }

                    if ( a.start.length < b.start.length ) {
                        return 1;
                    }
                }

                return 0;
            });

            return patterns;
        }

        spacePatterns = toSortedArray( spacePatterns );
        enterPatterns = toSortedArray( enterPatterns );
        inlinePatterns = toSortedArray( inlinePatterns );

        each( inlinePatterns, function( pattern ) {
            each( ( pattern.start + pattern.end ).split( '' ), function( c ) {
                if ( tinymce.inArray( chars, c ) === -1 ) {
                    chars.push( c );
                }
            } );
        } );

        editor.on( 'selectionchange', function() {
            canUndo = null;
        } );

        editor.on( 'keydown', function( event ) {
            if ( ( canUndo && event.keyCode === 27 /* ESCAPE */ ) || ( canUndo === 'space' && event.keyCode === VK.BACKSPACE ) ) {
                editor.undoManager.undo();
                event.preventDefault();
                event.stopImmediatePropagation();
            }

            if ( event.keyCode === VK.ENTER && ! VK.modifierPressed( event ) ) {
                enter();
            }
        }, true );

        editor.on( 'keyup', function( event ) {
            if ( event.keyCode === VK.SPACEBAR && ! event.ctrlKey && ! event.metaKey && ! event.altKey ) {
                space();
            } else if ( event.keyCode > 47 && ! ( event.keyCode >= 91 && event.keyCode <= 93 ) ) {
                inline();
            }
        } );

        function inline() {
            var rng = editor.selection.getRng();
            var node = rng.startContainer;
            var offset = rng.startOffset;
            var startOffset;
            var endOffset;
            var pattern;
            var format;
            var zero;

            if ( ! node || node.nodeType !== 3 || ! node.data.length || ! offset ) {
                return;
            }

            if ( tinymce.inArray( chars, node.data.charAt( offset - 1 ) ) === -1 ) {
                return;
            }

            function findStart( node ) {
                var offset;

                each( inlinePatterns, function( currentPattern ) {
                    pattern = currentPattern;
                    offset = node.data.indexOf( pattern.end );

                    if ( offset !== -1 ) {
                        return false;
                    }
                } );

                return offset;
            }

            startOffset = findStart( node );
            endOffset = node.data.lastIndexOf( pattern.end );

            if ( startOffset === endOffset || endOffset === -1 ) {
                return;
            }

            if ( endOffset - startOffset <= pattern.start.length ) {
                return;
            }

            if ( node.data.slice( startOffset + pattern.start.length, endOffset ).indexOf( pattern.start.slice( 0, 1 ) ) !== -1 ) {
                return;
            }

            format = editor.formatter.get( pattern.format );

            if ( format && format[0].inline ) {
                editor.undoManager.add();

                editor.undoManager.transact( function() {
                    node.insertData( offset, '\u200b' );

                    node = node.splitText( startOffset );
                    zero = node.splitText( offset - startOffset );

                    node.deleteData( 0, pattern.start.length );
                    node.deleteData( node.data.length - pattern.end.length, pattern.end.length );

                    editor.formatter.apply( pattern.format, {}, node );

                    editor.selection.setCursorLocation( zero, 1 );
                } );

                // We need to wait for native events to be triggered.
                setTimeout( function() {
                    canUndo = 'space';

                    editor.once( 'selectionchange', function() {
                        var offset;

                        if ( zero ) {
                            offset = zero.data.indexOf( '\u200b' );

                            if ( offset !== -1 ) {
                                zero.deleteData( offset, offset + 1 );
                            }
                        }
                    } );
                } );
            }
        }

        function firstTextNode( node ) {
            var parent = editor.dom.getParent( node, 'p' ),
                child;

            if ( ! parent ) {
                return;
            }

            while ( child = parent.firstChild ) {
                if ( child.nodeType !== 3 ) {
                    parent = child;
                } else {
                    break;
                }
            }

            if ( ! child ) {
                return;
            }

            if ( ! child.data ) {
                if ( child.nextSibling && child.nextSibling.nodeType === 3 ) {
                    child = child.nextSibling;
                } else {
                    child = null;
                }
            }

            return child;
        }

        function space() {
            var rng = editor.selection.getRng(),
                node = rng.startContainer,
                parent,
                text;

            if ( ! node || firstTextNode( node ) !== node ) {
                return;
            }

            parent = node.parentNode;
            text = node.data;

            each( spacePatterns, function( pattern ) {
                var match = text.match( pattern.regExp );

                if ( ! match || rng.startOffset !== match[0].length ) {
                    return;
                }

                editor.undoManager.add();

                editor.undoManager.transact( function() {
                    node.deleteData( 0, match[0].length );

                    if ( ! parent.innerHTML ) {
                        parent.appendChild( document.createElement( 'br' ) );
                    }

                    editor.selection.setCursorLocation( parent );
                    editor.execCommand( pattern.cmd );
                } );

                // We need to wait for native events to be triggered.
                setTimeout( function() {
                    canUndo = 'space';
                } );

                return false;
            } );
        }

        function enter() {
            var rng = editor.selection.getRng(),
                start = rng.startContainer,
                node = firstTextNode( start ),
                text, pattern, parent;

            if ( ! node ) {
                return;
            }

            text = node.data;

            each( enterPatterns, function( currentPattern ) {
                if ( currentPattern.start ) {
                    if ( text.indexOf( currentPattern.start ) === 0 ) {
                        pattern = currentPattern;
                        return false;
                    }
                } else if ( currentPattern.regExp ) {
                    if ( currentPattern.regExp.test( text ) ) {
                        pattern = currentPattern;
                        return false;
                    }
                }
            } );

            if ( ! pattern ) {
                return;
            }

            if ( node === start && tinymce.trim( text ) === pattern.start ) {
                return;
            }

            editor.once( 'keyup', function() {
                editor.undoManager.add();

                editor.undoManager.transact( function() {
                    if ( pattern.format ) {
                        editor.formatter.apply( pattern.format, {}, node );
                        node.replaceData( 0, node.data.length, ltrim( node.data.slice( pattern.start.length ) ) );
                    } else if ( pattern.element ) {
                        parent = node.parentNode && node.parentNode.parentNode;

                        if ( parent ) {
                            parent.replaceChild( document.createElement( pattern.element ), node.parentNode );
                        }
                    }
                } );

                // We need to wait for native events to be triggered.
                setTimeout( function() {
                    canUndo = 'enter';
                } );
            } );
        }

        function ltrim( text ) {
            return text ? text.replace( /^\s+/, '' ) : '';
        }
    } );
} )( window.tinymce, window.setTimeout );

Leave a Comment