EPiServer - Customize the drop behavior in TinyMCE

  • Feb 26, 2017
  • EPiServer
  • Drag and drop
  • TinyMCE
  • |

In one of my previous blog, I explained how you can use the drag and drop functionality of Dojo. Learning Dojo can be a hard time for a backend developer, but at the end, it will give you a lot of opportunities for extending the interface. In one of my latest project, I used the built-in drag and drop functionality of (dojo) EPiServer in combination with TinyMCE. What we all know is that we can show a TinyMCE editor by defining a XhtmlString property on a content type. By default, we're able to drag and drop pages, blocks, and media in the editor. When a page is dropped in the editor, a link will be created. When we do the same with a block a block placeholder is created. EPiServer will render the block when the page is loaded. You'll probably guess what will happen when we drop an image. Unfortunately, the drag and drop behavior in the TinyMCE editor isn't that flexible and it's hard to customize.

In this blog, I'll explain how we can customize the drop behavior in the TinyMCE editor. I'll do this based on a demo that I've created. This demo is based on the Alloy Tech demo site of EPiServer.

Let's start with an animation of the final solution in EPiServer.

 Drag and drop content in the TinyMCE editor

In the example, I can drop a contact page in a TinyMCE editor. Nothing special here, since by default it's already possible to drop pages in an editor. Whereas default a link is generated to that page, I've customized the behavior by showing contact information (name, email, and phone) of the contact page. I know, what I also could do is create a block and drop that in the editor. EPiServer will then create a block placeholder, as I explained in the introduction. The demo I've created is just a simple example how this could work. Use your imagination how you could use this for other purposes, like embed YouTube video's (generate the embed link in the editor).

 

How can we configure the drop behavior for content?

We can create a UIDescriptor to set some UI configurations for a specific content type. This class will be used when the content type needs to be rendered in the edit mode. The drop behavior is one of these configurations. This tells the TinyMCE editor what it needs to do when content (page, block or image) is dropped in the editor. The DropBehavior enum contains four options:

  • Undefined
  • CreateContentBlock
  • CreateLink
  • CreateImage

As far as I can tell the undefined option does nothing, but correct me if I'm wrong. So these are the options we can work with. Unfortunately, EPiServer doesn't provides us something to customize the drop behavior. The options I mentioned previously correspond with the following HTML:

  • Undefined -> ?
  • CreateContentBlock: <div data-classid="" class="mceNonEditable epi-contentfragment" data-contentlink="" data-mce-contenteditable="false">
  • CreateLink: <a title="Contact us" href="/about-us/contact-us/" data-mce-href="/about-us/contact-us/">Contact us</a>
  • CreateImage: <img alt="" src="/EPiServer/CMS/Content/globalassets/alloy-track/alloytrack.png,,152?epieditmode=False" height="450" width="940" data-mce-src="/EPiServer/CMS/Content/globalassets/alloy-track/alloytrack.png,,152?epieditmode=False">

When you look at the JavaScript part that is responsible for inserting the content, you'll find a TODO telling that EPiServer will probably support custom drop behaviors soon:

"TODO: move this to tinymce plugins instead. could be one which will be called to execute content and one which knows how to insert the specific content"

In my case, I wanted to display some text when a page is dropped in the editor. Unfortunately, there is no built-in drop behavior option I could use. I'm using the CreateLink option not for rendering a link but to display text in the editor. Definitely not the best solution, but I couldn't find any alternative.

 

The first step is to create a UIDescriptor for the contact page. Note that also inheritance is supported so you could also define a base type if you want. By implementing the IEditorDropBehavior interface we can configure the drop behavior. Note that by default already the create link option is used for IContent, I just wanted to show you how can configure this.  

    [UIDescriptorRegistration]
        public class ContainerPageUIDescriptor : UIDescriptor<ContainerPage>, IEditorDropBehavior
        {
            public ContainerPageUIDescriptor()
                : base(ContentTypeCssClassNames.Container)
            {
                DefaultView = CmsViewNames.AllPropertiesView;
                EditorDropBehaviour = EditorDropBehavior.CreateLink;
            }
    
            public EditorDropBehavior EditorDropBehaviour { get; set; }
        }

What I already mentioned is that when you drop a page in a TinyMCE editor by default a link is generated to that page. However, this is not the case for container pages, because container pages doesn't have a template. Meaning it's impossible to generate a link, so instead you'll get an error message.

 Drag and drop container pages in the TinyMCE editor

 

What is a type converter and how can we use it?

Basically, a type converter lets us convert a source to a target type. In my demo, a contact page is dropped in the editor. EPiServer uses the fully qualified name of the type (site.models.pages.contactpage) to search for a matching type converter. When a converter is found EPiServer will call the convert method, this method needs to return an object with the necessary information to create the proper HTML. The convert method accepts three parameters, sourceDataType, targetDataType, data. In my demo, these are the values of the parameters:

sourceDataType: 'site.models.pages.contactpage'
targetDataType:   'site.models.pages.contactpage.link'
data:                      JSON object (EPiServer.Cms.Shell.UI.Rest.Models.ContentDataStoreModel)


The sourceDataType is the type that is dropped in the editor, the targetDataType is the sourceDataType plus the drop behavior (what is configured in the UIDescriptor). The data parameter is a serialized JSON object of the type ContentDataStoreModel. Note that this object only contains the built-in IContent properties, it doesn't include the properties that are defined by the developer. In my case, the convert method returns an object (url and text property) to render a link. The url property contains a fixed value (#contact). The contact information that I want to display is set in the text property. Since the data parameter only contains the basic properties, I'm calling a custom rest store to return the contact information.

By default, EPiServer will render a link based on the returned data. In the next section, I'll explain how you can create a TinyMCE plugin and how I managed to override the default behavior. 

 

define([
        "dojo",
        "dojo/_base/lang",
        "epi/dependency"
    ],
    function (
        dojo,
        lang,
        dependency
    ) {
        return dojo.declare("alloy.ContactPageConverter", null, {
            store: null,
    
            constructor: function (params) {
                dojo.mixin(this, params);
            },
    
            registerConverter: function (registry) {
                registry.registerConverter("site.models.pages.contactpage",
                    "site.models.pages.contactpage.link", this);
            },
    
            convert: function (sourceDataType, targetDataType, data) {
                this.store = this.store || dependency.resolve('epi.storeregistry').get('contactpagestore');
                if (targetDataType === "site.models.pages.contactpage.link") {
                    return dojo.when(this.store.query({ id: data.contentLink }), function (data) {
                        return { url: "#contact", text: data.data };
                    });
                }
            }
        });
    });

 Type converters needs to be registered in an initializer module.

define([
        "dojo/_base/declare",
        "epi",
        "epi/_Module",
        "epi/routes",
        "epi/dependency",
        "epi/shell/conversion/ObjectConverterRegistry",
        "alloy/ContactPageConverter"
    ],
    
    function (
        declare,
        epi,
        _Module,
        routes,
        dependency,
        ObjectConverterRegistry,
        ContactPageConverter
    
    ) {
        return declare([_Module], {
    
            initialize: function () {
                this.inherited(arguments);
    
                var converterRegistry = ObjectConverterRegistry;
                var converter = new ContactPageConverter();
                converter.registerConverter(converterRegistry);
    
                var registry = dependency.resolve("epi.storeregistry");
                registry.create("contactpagestore", this._getRestPath("contactpagestore"));
            },
    
            _getRestPath: function (name) {
                return routes.getRestPath({ moduleArea: "app", storeName: name });
            }
        });
    });

In the file TinyMCEEditor.js you'll code that handles the drop behavior. This method will generate HTML in the editor.

 _dropDataProcessor: function (dropItem) {
                when(dropItem.data, lang.hitch(this, function (model) {
    
                    // TODO: move this to tinymce plugins instead. could be one which will be called to execute content
                    // and one which knows how to insert the specific content
    
                    // TODO: calculate drop position relative to tiny editor, send this to the plugin so it
                    // could handle content depending on where the drop was done
    
                    var self = this,
                        type = dropItem.type,
                        ed = this.getEditor();
    
                    function insertLink(url) {
                        ed.focus();
                        ed.execCommand("CreateLink", false, url);
                    }
    
                    function insertHtml(html) {
                        ed.focus();
                        if (ed.execCommand("mceInsertContent", false, html)) {
                            self._onChange(ed.getContent());
                        }
                    }
    
                    function createLink(data) {
                        if (!ed.selection.isCollapsed()) {
                            insertLink(data.url);
                        } else {
                            var strTemplate = "<a href=\"{0}\" title=\"{1}\">{1}</a>";
                            insertHtml(lang.replace(strTemplate, [data.url, htmlEntities.encode(data.text)]));
                        }
                    }
    
                    function createImage(data) {
                        var strTemplate = "<img alt=\"{alt}\" src=\"{src}\" width=\"{width}\" height=\"{height}\" />";
    
                        var imgSrc = data.previewUrl || data.url;
                        var imgPreviewNode = domConstruct.create("img", {
                            src: imgSrc,
                            style: { display: "none;" }
                        }, win.body(), "last");
    
                        // Use a temporary image to get it loaded and obtain the correct geometric attributes
                        // Then use the original url since the browser adds hostname to the src attribute which is not always wanted.
                        on.once(imgPreviewNode, "load", function () {
                            insertHtml(lang.replace(strTemplate, {
                                alt: this.alt,
                                width: this.width,
                                height: this.height,
                                src: imgSrc
                            }));
                            // destroy temporary image preview dom node.
                            domConstruct.destroy(imgPreviewNode);
                        });
                    }
    
                    if (type && type.indexOf("link") !== -1) {
                        createLink(model);
                        return;
                    } else if (type && type.indexOf("fileurl") !== -1) {
                        createImage(model);
                        return;
                    }
    
                    var typeId = model.typeIdentifier;
    
                    var editorDropBehaviour = TypeDescriptorManager.getValue(typeId, "editorDropBehaviour");
    
                    if (editorDropBehaviour) {
    
                        if (editorDropBehaviour === 1) {
                            //Default: Create a content object
                            var html = "<div data-contentlink=\"" + model.contentLink + "\" data-classid=\"36f4349b-8093-492b-b616-05d8964e4c89\" class=\"mceNonEditable epi-contentfragment\">" + model.name + "</div>";
                            insertHtml(html);
                            return;
                        }
    
                        var converter, baseTypes = TypeDescriptorManager.getInheritanceChain(typeId);
    
                        for (var i = 0; i < baseTypes.length; i++) {
                            var basetype = baseTypes[i];
                            converter = ObjectConverterRegistry.getConverter(basetype, basetype + ".link");
                            if (converter) {
                                break;
                            }
                        }
    
                        if (!converter) {
                            return;
                        }
    
                        when(converter.convert(typeId, typeId + ".link", model), lang.hitch(this, function (data) {
    
                            if (!data.url) {
                                //If the page does not have a public url we do nothing.
                                var dialog = new Alert({
                                    description: resources.notabletocreatelinkforpage
                                });
                                dialog.show();
                                this.own(dialog);
                            } else {
                                switch (editorDropBehaviour) {
                                    case 2://Link
                                        createLink(data);
                                        break;
                                    case 3://Image
                                        createImage(data);
                                        break;
                                }
                            }
                        }));
                    }
                }));
    
                domStyle.set(this.dndOverlay, { display: "none" });
            },

Handle the insert content command in TinyMCE

Since EPiServer will render a link to my contact page (as you can see in the last code snippet) we need to catch the command that will insert the anchor element in the editor. What we need to do is creating a nonvisual TinyMCE plugin, as the name already explains this plugin does not have an UI. The plugin will catch the command that is responsible for inserting HTML in the editor. This is the 'mceInsertContent' command. First thing we need to check is whether the inserted HTML is our contact page link. The element must be an anchor link and the href attribute must contain the '#contact' value. If that's the case, we will terminate the current execution and handle this command ourselves. The inner HTML of the anchor link contains the contact information text (note the inner HTML is the text property of the object that the typeconvert.convert method returns). So I can simply get that HTML and execute a new 'mceInsertContent' command. That will do the trick.

(function (tinymce) {
        tinymce.create("tinymce.plugins.dropcontentplugin",
        {
            initialized: false,
            init: function (ed) {
                var convertToDomElement = function (inputString) {
                    var tempParentElement = document.createElement("div");
                    tempParentElement.innerHTML = inputString;
                    return tempParentElement.firstChild;
                }
    
                ed.onBeforeExecCommand.add(function (editor, command, ui, value, cancellationToken) {
                    if (command !== "mceInsertContent") {
                        return null;
                    }
    
                    var node = convertToDomElement(value);
                    if (node && node.nodeName === "A") {
                        var hrefValue = node.getAttribute("href");
                        if (hrefValue === "#contact") {
                            //Cancel current execution
                            cancellationToken.terminate = true;
    
                            //Schedule new execution
                            setTimeout(function (newValue) {
                                editor.execCommand("mceInsertContent", false, newValue);
                            }.bind(this, node.innerText));
    
                            //Return false in order to stop processing
                            return false;
                        }
                    }
                });
            }
        });
        tinymce.PluginManager.add("dropcontentplugin", tinymce.plugins.dropcontentplugin);
    })(tinymce, false);

Enable a nonvisual TinyMCE plugin

There are two things we need to do for enabling a nonvisual TinyMCE plugin.

 

1. Save the plugin file in the default plugin folder or in a custom location.

So you've two options here. First option, save you JavaScript file in the default plugin location which is \Util\Editor\tinymce\plugins\[PLUGINNAME]\editor_plugin.js. Second option, save your JavaScript file in a custom location and add a virtual path in the web.config, like this:

<add name="TinyMCEPlugins" virtualPath="~/Util/Editor/tinymce/plugins" physicalPath="Static\tinymce\plugins" type="EPiServer.Web.Hosting.VirtualPathNonUnifiedProvider, EPiServer.Framework" />.

This line should be placed under <episerver.framework><virtualPathProviders>.

 

2. Register your plugin server side.

We need to register the plugin server side by creating a class and decorate it with the TinyMCEPluginNonVisual. The PlugInName property should match with the value you defined in your JavaScript file. 

    [TinyMCEPluginNonVisual(PlugInName = "dropcontentplugin", AlwaysEnabled = true)]
        public class DropContentTinyMcePlugin
        {
            
        }

 

 The source code can be found on my Github account.

 

Comments