Blog: JS

TinyMCE 5: Creating an Autocomplete Plugin

Published

I had a great time last week hanging out with the Tiny team in Brisbane for their Product Week, seeing what the team are up to (and experimenting with during their Product Week projects), and having a chat with so many of the team from UX to UI engineers to project leads to documentation authors to marketing and even to the CEO to have my brain picked about the recent v5 release of Tiny (and giving me the chance to pick brains back), hands down the best WYSIWYG editor.

How good is it? Well, I’ve been using it for years now – and the evolution to v5 is a huge step forward for the Tiny team – and from a developer’s perspective, providing a superb playground to extend the editor’s functionality with consistently-styled plugins.

When having a 1-on-1 with Millie, one of Tiny's incredibly talented engineers, we were talking about ideas (from an external dev’s perspective) to help make some UI components be more flexible, and Millie introduced me to a feature that I didn’t even know was exposed to developers:

Autocompleter.

Yeah, that thing where suggestions for what you’re typing (querying) appear.

I don't know why I get so excited about this but I think this is so insanely cool. And even better, Millie's excitement at my excitement for the potential of this component!

But how does that fit in to a rich text editor?

Well, Tiny have used it for their Mentions plugin – in this use case, typing “@” and starting to type a name queries the “database” of names.

Exactly what an autocomplete could do.

But then the lightbulb moment went off in my head… oh wow, you mean I have access to this part of TinyMCE? I can create my own Autocompleters?

Yes and yes. I can. And I did.

Maybe you want to make it easy for emojis to be placed in content – wouldn’t it be great if a single keystroke could start the emoji querying and preview?

Maybe your authors have complex HTML that they re-use again and again – wouldn’t it be great if they could hit the trigger key and start to find their templates to insert in to the editor?

Maybe your editor is part of a more complex project and needs some shorthand or internal codes added – but who can remember them? Wouldn’t it be good to query using their name (rather than their code) to then easily insert in to the editor?

Those last two particularly had instant application for my clients – re-using complex HTML templates, inserting internal system codes – these are instantly things that can make the authoring process more streamlined (and keep the author’s hands on the keyboard rather than using a UI just for something like this).

Maybe even you have the need to be able to insert little flag images in to your content for a country – wait, you do? Awesome – that’s exactly what we’re going to do here.

Who is this for?

This article is aimed at developers who use Tiny.

You’ll need to have a certain degree of JavaScript knowledge, a bit of experience with using (and configuring) Tiny, as well as external plugins (did you read my last article about that?)

You’ll also need to know where your data comes from and how to query it – in this article we’re using the Fetch API to get data from the REST Countries endpoints, but maybe you have an internal JS structure to query, or your own backend system – that’s up to you.

Note that Fetch requires a modern browser – keep your browser up to date, and you’ll be able to run this demo. We won’t really focus on how all of this works – that’s a whole topic in itself.

Check out my playground repository on Github for a working demo that you can run locally. I’ll also host a demo at the end of this article.

Let’s get started

Before we go too far, take some time to review the basics of writing a plugin and the Autocompleter documentation.

Righto so what we’re going to do is create a plugin that allows the content author to hit a trigger key on their keyboard and start typing, with the Autocompleter presenting them with a list of countries that match their entered text. When they select a country, the country’s flag will be inserted in to the editor.

Disclaimer… not quite sure why you’d need this, but hey, thought it was a cool little example use case.

To get us started, we need to do 2 things:

  1. Create a plugin (and make sure you correctly reference that in your Tiny config file as an external plugin)
  2. Add an Autocompleter component to the plugin
(function () {
    let flags = (function () {
        'use strict';

        tinymce.PluginManager.add('flags', function (editor, url) {

            editor.ui.registry.addAutocompleter('autocompleter-flags', {
                ch: '#', // the trigger character to open the autocompleter
                minChars: 2, // lower number means searching sooner - but more lookups as we go
                columns: 1, // must be 1 for text-based results
                fetch: function (pattern) {
                    // perform a lookup on the Pattern, and return the lookup data
                },
                onAction: function (autocompleteApi, rng, value) {
                    // called when an item from the list is selected
                    // we still need to be the one who does the insert in to the editor
                }
            });
        });
    }());
})();

What we have here is a really empty skeleton that forms the shell of our Autocompleter plugin. We give our Autocompleter a name, and pass through a number of configuration options – the documentation goes through everything that’s possible, but we’ll have a chat about a few of them too.

The ch option is your trigger character – make sure this is a single character, and this is what the author will hit on their keyboard to kick off the Autocompleter process

As the user continues to type, when the length of the string reaches minChars, the fetch call will be made. Depending on the service you’re using (and the quantity of data being returned) you may want to limit this to 3 characters – or you may want to start straight away at 1. If you’re pinging an external server, 2 or 3 may be better just for user responsiveness (and less quick pings to the server), but that’s up to you to find the right balance for your users.

The columns option must be set to 1 for any text-based results. If set to “auto”, then only icons are shown – setting “1” forces text (and optionally icons) to be shown.

Now the two important parts – our fetch and onAction functions.

fetch gets run as the author types – and passes to it the string they’ve entered. It is your job as the developer to do something with that query, and return results.

onAction is run when an option from the Autocompleter list is selected, and we, most probably, need to do something with the typed text and the “Value” of the selected item.In our example, we’ll use both Text and Icon – but maybe you just want Text, or maybe just Icon. They’re not all required. Value is though – and one of Text or Icon, depending on how you’re configuring your Autocompleter.

Data for our Autocompleter

Within that fetch function, we need to return an array of objects. Each item in the array needs to include:

  • Value, the content to be placed in the editor when selected
  • Text, the text to be displayed in the Autocompleter list
  • Icon, an icon to be displayed

Tiny takes array this and makes an Autocomplete list for us and presents this to the user. Too easy. As you keep typing more characters, it keeps filtering down. Even better.

So where do we get our data from?

For this example, we are going to get a list of countries, including their Name and Flag image URL, from the REST Countries endpoint (check it out).

You will need to write this bit to work for your use case – hook in to your own back end system, use your own JS-based data structure – whatever queries and lookup you need.

Given we are calling a REST endpoint, we’ll quickly do this using the Fetch API, and this introduces Promises to us too.

The fetch function is expecting a Promise of a list of items – so we will simply return a new TinyMCE Promise, and within that can do our own logic. The function gets called – and when our lookup is complete, we will resolve the Promise with the list of items.

Within our fetch function, we will be:

  • Requesting the Name and Flag from the REST Countries endpoint
  • Converting the response to JSON so we can process it
  • Creating an array of items, with a Value, Text and Icon, and then manually sorting it by the “Text” value in alphabetical order
  • Resolving the Promise with the array of items

Why do I have to manually sort? Well for this example, the endpoint doesn’t let me specify a sort order – so I have to do it myself. But your backend might – so that step could be skipped.

You’ll also see, when setting the Value, we’re piping two values together – the Flag URL and the Name – this for two reasons:

  1. The “value” must be a string, and
  2. I need more than just the Flag URL – I also need the country name so we can add that as the alt attribute.

That’s just a bit of a quirk for this particular demo, just a heads up.

fetch: function (pattern) {
    return new tinymce.util.Promise(function (resolve) {
        // call the countries REST endpoint to look up the query, and return the name and flag
        fetch('https://restcountries.eu/rest/v2/name/' + pattern + '?fields=name;flag')
            .then((resp) => resp.json()) // convert response to json
            .then(function (data) {
                let results = [];

                // create our own results array
                for (let i = 0; i < data.length; i++)
                {
                    let result = data[i];

                    results.push({
                        value: result.name + '|' + result.flag,
                        text: result.name,
                        icon: '<img src="' + result.flag + '" alt="" />'
                    });
                }

                // sort results by the "name"
                results.sort(function (a, b) {
                    let x = a.text.toLowerCase();
                    let y = b.text.toLowerCase();
                    if (x < y)
                    {
                        return -1;
                    }
                    if (x > y)
                    {
                        return 1;
                    }
                    return 0;
                });

                // resolve the initial promise
                resolve(results);
            });
    });
}

Found, selected, insert!

Now that we have selected an item, we need to do something with it – and this is in our onAction function.

We will pass the AutoCompleter API instance, the current range and the selected value – and that’s all we need to insert the content.

Again, given the pipe in the “Value” item, we’re first splitting that in to two parts so we can correctly make an <img> element to insert. Just a quirk for this demo.

The insertion of the content in to the editor is done through three calls:

  1. Select the range (basically to select the trigger and what the user has typed as part of the Autocompleter,
  2. Insert the content
  3. Hide the Autocompleter instance

Three basic lines to do this instant magic.

onAction: function (autocompleteApi, rng, value) {
    // split the value in to two parts - the name and the flag URL
    // we joined it above using a pipe (|)
    let parts = value.split('|');
    let name = parts[0];
    let flag = parts[1];

    // make an image element
    let img = '<img src="' + flag + '" alt="' + name + '" width="48" height="24" />';

    // insert in to the editor
    editor.selection.setRng(rng);
    editor.insertContent(img);

    // hide the autocompleter
    autocompleteApi.hide();
}

And we now have a working example of an Autocompleter UI Component. It really is that easy.

Completed Autocompleter Plugin

Let’s have a play and give this a whirl.

Note: for the purpose of demonstrating the v5 UI components, the mobile theme has been disabled on this demo, and desktop theme used.

In the editor, hit the trigger key, a hash (#), and start typing a country name. You should see a list appear of matching countries when you’ve entered at least 2 characters in your country name.

When you select one from the list, that country’s flag is inserted in to the editor.

So what’s next?

Maybe you also want to have a Button and Menu Item to help with this too – given you’ve already written the core plugin code, and the Autocompleter is merely a feature of your plugin, you can continue extending what and how your plugin wants to work.

This is just the tip of the iceberg.

I personally think this is such a versatile and powerful UI Component for Tiny to expose to us – there are so many use cases to explore, and one that can really help streamline content authoring.

You can check out the complete source code in my repository on Github to help you get started: https://github.com/martyf/tinymce-5-plugin-playground

(function () {
    let flags = (function () {
        'use strict';

        tinymce.PluginManager.add("flags", function (editor, url) {

            editor.ui.registry.addAutocompleter('autocompleter-flags', {
                ch: '#', // the trigger character to open the autocompleter
                minChars: 2, // lower number means searching sooner - but more lookups as we go
                columns: 1, // must be 1 for text-based results
                fetch: function (pattern) {
                    return new tinymce.util.Promise(function (resolve) {
                        // call the countries REST endpoint to look up the query, and return the name and flag
                        fetch('https://restcountries.eu/rest/v2/name/' + pattern + '?fields=name;flag')
                            .then((resp) => resp.json()) // convert response to json
                            .then(function (data) {
                                let results = [];

                                // create our own results array
                                for (let i = 0; i < data.length; i++)
                                {
                                    let result = data[i];

                                    results.push({
                                        value: result.name + '|' + result.flag,
                                        text: result.name,
                                        icon: '<img style="width:28px; height:14px;" src="' + result.flag + '" alt="" width="28" height="14" />'
                                    });
                                }

                                // sort results by the "name"
                                results.sort(function (a, b) {
                                    let x = a.text.toLowerCase();
                                    let y = b.text.toLowerCase();
                                    if (x < y)
                                    {
                                        return -1;
                                    }
                                    if (x > y)
                                    {
                                        return 1;
                                    }
                                    return 0;
                                });

                                // resolve the initial promise
                                resolve(results);
                            });
                    });
                },
                onAction: function (autocompleteApi, rng, value) {
                    // split the value in to two parts - the name and the flag URL
                    // we joined it above using a pipe (|)
                    let parts = value.split('|');
                    let name = parts[0];
                    let flag = parts[1];

                    // make an image element
                    let img = '<img src="' + flag + '" alt="' + name + '" width="48" height="24" />';

                    // insert in to the editor
                    editor.selection.setRng(rng);
                    editor.insertContent(img);

                    // hide the autocompleter
                    autocompleteApi.hide();
                }
            });

            // return metadata for the Help plugin
            return {
                getMetadata: function () {
                    return {
                        name: "Flags Autocompleter example",
                        url: "https://www.martyfriedel.com/blog/tinymce-5-creating-an-autocomplete-plugin"
                    };
                }
            };
        });
    }());
})();

Blog

View all
JS

How Tiny helps me deliver the best content authoring experience

At Joomla Day Australia 2019 in Brisbane, I spoke about how TinyMCE helps deliver the best content authoring experience. And for those who couldn’t make it on...

Continue reading...

Photo

How to show real-time highlights and shadows clipping in Photoshop

I’ve used Photoshop for years. Decades even. Yikes, showing age there. But for my photography, I tended to use Photoshop for specific things – such as cleaning...

Continue reading...

CSS

Safari: quirky or rule follower?

CSS

Internet Explorer still exists. Just. And while its last-supported version, Internet Explorer 11, generally behaves nicely, it’s the little things that can make...

Continue reading...

PHP

How to easily access to Custom Fields in Joomla

Over the past few years, I’ve had to get more and more involved in developing Joomla websites. Joomla is such a powerful, flexible and user-friendly CMS to work...

Continue reading...

I am the Development Director (and co-owner) at Mity Digital, a Melbourne-based digital agency specialising in responsive web design, custom web development and graphic design.
Mity Digital