Revisiting markdown in Episerver

The last write-up I can find about adding a markdown editor into Episerver is this one by Ted Nyberg that's now almost 4 years old. Sadly, the markdown editor he chose, SimpleMDE, has been dormant since just before the blog post.

With that in mind, I thought I'd aim to revisit the topic and achieve the following:

  1. 👇 Switch to EasyMDE, a maintained fork of SimpleMDE
  2. 👇 Implement linking to Episerver content
  3. 👇 Implement inserting of Episerver images

By the way, if you want to skip this lengthy blog post and just grab the code (I completely understand!) I stuck it in a Gist which is in the conclusion.

Because I like overusing animated GIFs, I thought I'd use one to show how this functionality looks:

Inserting links and images into EasyMDE in Episerver

That's plenty to strive for so let's get stuck in:

EasyMDE

The good news is, because EasyMDE is a fork of SimpleMDE, updating the code to support it was extremely easy (or simple). However, just to make more confusing I decided to slightly restructure (you're welcome 😉).

As such, your project should now look like this:

The expected structure of the markdown editor in your project

You can grab the minified EasyMDE JS and CSS from the UNPKG CDN, there's a link in the EasyMDE GitHub README.

The module.config should look something like this:

<?xml version="1.0" encoding="utf-8"?>
<module>
  <dojo>
    <paths>
      <add name="editors" path="Editors" />
    </paths>
  </dojo>
</module>

The MarkdownEditorDescriptor needs to use slash notation (opposed to dot notation) and should now look like this:

[EditorDescriptorRegistration(
    TargetType = typeof(string),
    UIHint = UIHint,
    EditorDescriptorBehavior = EditorDescriptorBehavior.PlaceLast)]
public class MarkdownEditorDescriptor : EditorDescriptor
{
    public const string UIHint = "Markdown";

    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
    {
        base.ModifyMetadata(metadata, attributes);

        metadata.ClientEditingClass = "editors/Markdown/MarkdownEditor";
    }
}

Thankfully, usage remains the same (something at last!):

[UIHint(MarkdownEditorDescriptor.UIHint)]
[Display(Name = "Markdown", Order = 10)]
public virtual string Markdown { get; set; }

Finally, the interesting bit...the JavaScript. At this point it looks very similar to the original, all I did was move the template into the Dojo widget, update it to point to EasyMDE and change the toolbar around to better match TinyMCE:

(Before you copy-and-paste this indiscriminately into your project, I've added a link to the Gist at the end which includes the link and image enhancements covered below—should you want them).

define([
    "dojo/_base/declare",
    "dojo/_base/config",
    "dojo/_base/lang",
    "dojo/ready",
    "dojo/aspect",
    "dojo/dom-class",

    "dijit/_Widget",
    "dijit/_TemplatedMixin",

    "epi/epi",
    "epi/shell/widget/_ValueRequiredMixin",

    "/ClientResources/Editors/Markdown/EasyMDE/easymde.min.js",

    "xstyle/css!./EasyMDE/easymde.min.css",
    "xstyle/css!./MarkDownEditor.css"
],

    function (
        declare,
        config,
        lang,
        ready,
        aspect,
        domClass,

        _Widget,
        _TemplatedMixin,

        epi,
        _ValueRequiredMixin,

        EasyMDE
    ) {
        return declare([_Widget, _TemplatedMixin, _ValueRequiredMixin],
            {
                editor: null, // The EasyMDE editor object

                templateString: "<div class='markdown-editor dijitInline'><textarea id='editor-${id}'></textarea></div>",

                onChange: function (value) {
                    /* Summary:
                       Notifies Episerver that the property value has changed. */

                    this.inherited(arguments);
                },

                constructor: function () {

                    /* Summary:
                       When the DOM has finished loading, we convert our textarea element to a EasyMDE editor.
                       We also wire up the 'blur' event to ensure editor changes propagate to the widget, i.e. property, value. */

                    this.inherited(arguments);

                    if (config.isDebug) {
                        console.log("Setting up EasyMDE markdown editor...");
                    }

                    aspect.after(this, "set", function (name, value) {
                        if (name === "value" && value) {
                            this._refreshEditor();
                        }
                    }, true);

                    ready(lang.hitch(this, function () {

                        this.editor = new EasyMDE({
                            element: document.getElementById("editor-" + this.id),
                            initialValue: this.get("value"),
                            placeholder: this.tooltip,
                            spellChecker: false,
                            status: ["lines", "words"],
                            toolbar: !this.readOnly ? [
                                "bold", "italic", "heading", "heading-1", "heading-2", "heading-3", "|", "link",
                                "|", "unordered-list", "ordered-list", "|", "preview"] : false
                        });

                        this.editor.codemirror.on("blur", lang.hitch(this, function () {
                            if (!epi.areEqual(this.get("value"), this.editor.value())) {
                                this.set("value", this.editor.value());
                                this.onChange();
                            }
                        }));

                        this._refreshEditor();
                    }));
                },

                resize: function () {
                    /* Summary:
                       The resize() function is called when the tab strip containing this widget switches tabs.
                       When this happens we need to refresh the editor to ensure it displays property.
                       This is a well-known characteristic of CodeMirror, which is part of the EasyMDE editor. */

                    this.inherited(arguments);

                    this._refreshEditor();
                },

                _refreshEditor: function () {
                    /* Summary:
                       This function refreshes the editor, and ensures its value matches the current property value.
                       It also switches to preview mode, making the editor read-only, if the underlying property
                       is in read-only mode. */

                    if (!this.editor) {
                        return;
                    }

                    if (typeof this.get("value") !== "object" && !epi.areEqual(this.editor.value(), this.get("value"))) {
                        this.editor.value(this.get("value"));
                    }

                    if (this.readOnly) {
                        var previewElement = this.editor.codemirror.getWrapperElement().lastChild;

                        var previewActive = domClass.contains(previewElement, "editor-preview-active");

                        if (!previewActive) {
                            this.editor.togglePreview();
                        } else {
                            previewElement.innerHTML = this.editor.options.previewRender(this.editor.value(), previewElement);
                        }
                    }

                    this.editor.codemirror.refresh();
                }
            });
    });

The CSS was updated slightly to align with Episerver UI refresh:

.markdown-editor {
    width: 580px;
    font-family: Verdana, Arial;
}

.markdown-editor .editor-toolbar {
    border-top-left-radius: 0;
    border-top-right-radius: 0;

    border: 2px solid #b2b9c1;
    border-bottom: 0;
}

.markdown-editor .CodeMirror {
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;

    border: 2px solid #b2b9c1;
    border-top: 1px solid #b2b9c1;
}

.markdown-editor .CodeMirror, .markdown-editor .CodeMirror-scroll {
    min-height: 200px; /* To make editor height static (i.e. prevent auto-growth), set 'height' to the desired height */
}

That's the basic setup covered, at this point we've got a working EasyMDE markdown editor in Episerver. Now let's get into the modifications:

Linking to Episerver content

To insert a link, I decided to look closely at how this the Episerver link plugin for TinyMCE works. What I came up with then, is very similar to that, although I hide the “target” field (which isn't supported in markdown).

The function inserts the permanent link which should be rewritten to the friendly URL when rendering the markdown (not something I covered, in fact, a nicer solution might be to insert the friendly URL and rewrite to the permanent URL when persisting in the database):

_drawEpiLink: function (editor) {
    // summary:
    //      Displays the Link Editor Dialog and generates a
    //      markdown link, based on epi-addon-tinymce/plugins/epi-link/epi-link.
    // tags:
    //      private

    var linkEditor = new LinkEditor({
        baseClass: "epi-link-item",
        //TODO: hardcoded for now
        modelType: "EPiServer.Cms.Shell.UI.ObjectEditing.InternalMetadata.LinkModel",
        // Hide text and target field from UI
        hiddenFields: ["text", "target"]
    });

    var linkObject = {};
    var cm = editor.codemirror;
    var selectedText = cm.getSelection();

    if (selectedText) {
        linkObject.title = selectedText;
    }

    var dialogTitle = lang.replace(editlinkResource.title.template.create, editlinkResource.title.action);

    var dialog = new Dialog({
        title: dialogTitle,
        dialogClass: "epi-dialog-portrait",
        content: linkEditor,
        defaultActionsVisible: false
    });

    dialog.startup();

    linkEditor.set("value", linkObject);

    dialog.show();

    var options = editor.options;
    var that = this;

    dialog.on("execute", function () {
        var value = linkEditor.get("value");
        var linkObject = lang.clone(value);

        //Destroy the editor when the dialog closes
        linkEditor.destroy();
        linkEditor = null;

        that._replaceSelection(cm, options.insertTexts.link, linkObject.title, linkObject.href);
    });
},

You might notice my use of the _replaceSelection method at end of this function. Unfortunately, the EasyMDE replaceSelection is private, so I couldn’t access it—in the end it also needed some customization to support the fact that the Episerver link dialog allows you to insert a “Link title” (again, the whole thing is in the Gist).

This can be added to the EasyMDE toolbar very easily:

{
    name: "epi-link",
    className: "fa fa-link",
    title: "Insert Episerver Link",
    action: (editor) => { this._drawEpiLink(editor); }
}

Last but not least:

Inserting Episerver images

The approach here isn't a much different, I started with the Episerver file browser functionality for TinyMCE and customized away.

I'll let you dig into the intricacies since there isn't too much to explain:

_drawEpiImage: function (editor) {
    // summary:
    //      Displays the Image Content Selector Dialog and generates a
    //      markdown link, based on epi-addon-tinymce/FileBrowser.
    // tags:
    //      private

    var registry = dependency.resolve("epi.storeregistry");
    var store = registry.get("epi.cms.contentdata");
    var contentRepositoryDescriptors = dependency.resolve("epi.cms.contentRepositoryDescriptors");
    var settings = contentRepositoryDescriptors.get("media");

    var contentSelector = new ContentSelectorDialog({
        canSelectOwnerContent: false,
        showButtons: false,
        roots: settings.roots,
        multiRootsMode: true,
        showRoot: true,
        allowedTypes: ["episerver.core.icontentimage"]
    });

    var dialog = new Dialog({
        title: contentselectorResource.title,
        dialogClass: "epi-dialog-portrait",
        content: contentSelector
    });

    dialog.own(contentSelector);
    dialog.show();

    var cm = editor.codemirror;
    var options = editor.options;
    var that = this;

    on.once(dialog, "execute", function () {
        var contentLink = contentSelector.get("value");

        if (!contentLink) {
            return;
        }

        when(store.get(contentLink)).then(function (content) {
            that._replaceSelection(cm, options.insertTexts.epiImage, content.properties["altText"] || content.name, content.permanentLink);
        });
    });
},

To get it to work you also need to add a custom insertText for EasyMDE:

insertTexts: {
    epiImage: ['![#text#](#url#)','']
}

which allows both the alt text (in this case the image alt text property (if it has one) or content name by default) to be inserted with the image URL.

Conclusion

Here, as promised, is the Gist that contains all the important bits: EasyMDE for Episerver. You'll probably still want to look at the structure above to get things setup.

Overall, I think there is a lot of potential for markdown in Episerver, which I only begin to touch on here. I'd love to see some, or all, of the following:

  • Drag-and-drop support support for links and images
  • Showing editors the friendly URL (and persisting the permanent URL), opposed to both showing and persisting the permanent URL
  • A IPropertySoftLinkIndexer so that editors are alerted when then delete content referenced by the markdown
  • A custom markdown property implementing IReferenceMap, allowing references to be remapped when transferring the property (and an IPropertyTransform implementation)

Well, that's my wish list as least. Hopefully someone else can pick it all up, preferably in the next 4 years or so. 🤞

Comments

There are zero comments 😢