Creating a Custom AEM RTE Plugin

Exadel Digital Experience Team Tech Insights September 12, 2023 16 min read

Developing a custom Rich Text Editor widget is a frequently encountered task that may be required for a variety of purposes. For example, on one of our projects we needed to create a custom widget in the Rich Text Editor to support managing footnotes. Initially, footnoted content was coming from another source in a special format and we had a front-end only code to parse and show this structure properly.

Then the authors decided to use both content sources: external from the API and the AEM-managed.

What Stands Behind AEM RTE Plugin?

As an RTE field is widely used by the authors, and it provides such flexibility and freedom to create and manage texts, we decided to have a new widget in the RTE field which can produce the same HTML markup. This allowed our existing Front-end library to function the same as before, no matter whether the content source was from the API or AEM-managed components.

In addition to that, an RTE field has options which can be mixed with the new functionality. For example, to make a footnote bold or italic or to insert a link inside.

AEM RTE Plugins: Initial HTML Markup

Initially the API provided the following html markup — the markup the existing FE js library understands:

<span class="c-footnote-container" data-delim=",">
<fe-footnote class="c-footnote sup">
<span class="footnote-content">Simple Footnote</span>
<sup><span class="footnote__number">*</span></sup>
</fe-footnote>
</span>
See more See less

AEM RTE Plugin That Seals the Deal

The idea was to add a new icon to the RTE field to manage footnotes:

  1. If we put an RTE cursor to any blank space – a popup appears with no data:

2. If we select any text and click this icon — the same popup appears with this text as a Footnote title:

3. If we click on a previously-added footnote — the popup opens with the filled data (the same image as in case no. 2)

4. In the RTE field itself, a footnote should be clearly visible (it has a custom style)

Custom Client Lib: AEM RTE Magic

This is a custom AEM clientlib to support footnotes

/apps/test-com/clientlibs/clientlib-author/rte/js/footnotebuilder.js:

We’ll start by defining constants and adding this new plugin to the UI settings

(function ($, CUI) {
    const BUILDER_URL = "/apps/test-com/components/dialogs/rte/footnote/cq:dialog.html",
        GROUP = "footnotebuilder",
        FOOTNOTE_BUILDER_FEATURE = "footnoteBuilder",
        REQUESTER = "requester",
        TCP_DIALOG = "touchUIFootnoteBuilderDialog",
        FOOTNOTES_NAME_IN_POPOVER = "footnotes",
        DELIM_NAME_IN_POPOVER = "delim",
        url = document.location.pathname;

    if (url.indexOf(BUILDER_URL) !== 0) {
        addPluginToDefaultUISettings();
        addDialogTemplate();
    }
See more See less

The functions to add a new plugin to the toolbar and the dialog template:

 function addPluginToDefaultUISettings() {
        let toolbar = CUI.rte.ui.cui.DEFAULT_UI_SETTINGS.inline.toolbar;
        toolbar.splice(3, 0, GROUP + "#" + FOOTNOTE_BUILDER_FEATURE);

        toolbar = CUI.rte.ui.cui.DEFAULT_UI_SETTINGS.fullscreen.toolbar;
        toolbar.splice(3, 0, GROUP + "#" + FOOTNOTE_BUILDER_FEATURE);
    }

    function addDialogTemplate() {
        const pickerUrl = BUILDER_URL + "?" + REQUESTER + "=" + GROUP;
        const html = "<iframe width='400px' height='350px' frameBorder='0' src='" + pickerUrl + "'></iframe>";

        if (_.isUndefined(CUI.rte.Templates)) {
            CUI.rte.Templates = {};
        }

        if (_.isUndefined(CUI.rte.templates)) {
            CUI.rte.templates = {};
        }

        CUI.rte.templates['dlg-' + TCP_DIALOG] = CUI.rte.Templates['dlg-' + TCP_DIALOG] = Handlebars.compile(html);
    }
See more See less

Footnote builder plugin itself:

  • UI initialization — adding an icon to the RTE toolbar
  • On-click widget handling — a custom dialog to enter footnote data
  • Calling a command to process HTML transformation
const FootnoteBuilderDialog = new Class({
        extend: CUI.rte.ui.cui.AbstractDialog,

        toString: "FootnoteBuilderDialog",

        initialize: function (config) {
            this.exec = config.execute;
        },

        getDataType: function () {
            return TCP_DIALOG;
        }
    });

    const TouchUIFootnoteBuilderPlugin = new Class({
        toString: "TouchUIFootnoteBuilderPlugin",

        extend: CUI.rte.plugins.Plugin,

        pickerUI: null,

        getFeatures: function () {
            return [FOOTNOTE_BUILDER_FEATURE];
        },

        initializeUI: function (tbGenerator) {
            let plg = CUI.rte.plugins;

            if (!this.isFeatureEnabled(FOOTNOTE_BUILDER_FEATURE)) {
                return;
            }

            this.pickerUI = tbGenerator.createElement(FOOTNOTE_BUILDER_FEATURE, this, false, {title: "Footnote Builder"});
            tbGenerator.addElement(GROUP, plg.Plugin.SORT_FORMAT, this.pickerUI, 10);

            let groupFeature = GROUP + "#" + FOOTNOTE_BUILDER_FEATURE;
            tbGenerator.registerIcon(groupFeature, "note");
        },

        execute: function (id, value, envOptions) {

            const context = envOptions.editContext,
                selection = CUI.rte.Selection.createProcessingSelection(context),

                ek = this.editorKernel;

            let tag = CUI.rte.Common.getTagInPath(context, selection.startNode, "span", {"class": "c-footnote-container"}),
                plugin = this,
                dialog,
                delim = $(tag).attr("data-delim"),

                footnotes = (tag !== null) ? $(tag).find("fe-footnote span.footnote-content").map(function () {
                    return encodeURIComponent($(this).html());
                }).get() : [],
                dm = ek.getDialogManager(),
                $container = CUI.rte.UIUtils.getUIContainer($(context.root)),
                propConfig = {
                    'parameters': {
                        'command': this.pluginId + '#' + FOOTNOTE_BUILDER_FEATURE
                    }
                },
                selectedHTML = getSelectionHTML(CUI.rte.Selection.getSelection(context));

            if(selectedHTML && selectedHTML !== "" && tag == null) {
                footnotes = [selectedHTML];
            }

            if (this.eaemFootnoteBuilderDialog) {
                dialog = this.eaemFootnoteBuilderDialog;
            } else {
                dialog = new FootnoteBuilderDialog();
                dialog.attach(propConfig, $container, this.editorKernel);
                dialog.$dialog.find("iframe").attr("src", getPickerIFrameUrl(delim, footnotes));
                this.eaemFootnoteBuilderDialog = dialog;
            }

            dm.show(dialog);

            registerReceiveDataListener(receiveMessage);

            function getSelectionHTML(selection) {
                let range = selection.getRangeAt(0);
                let clonedSelection = range.cloneContents();
                let div = document.createElement('div');
                div.appendChild(clonedSelection);
                return div.innerHTML;
            }

            function getPickerIFrameUrl(delim, footnotes) {
                let pickerUrl = BUILDER_URL + "?" + REQUESTER + "=" + GROUP;

                if (!_.isEmpty(delim)) {
                    pickerUrl = pickerUrl + "&" + DELIM_NAME_IN_POPOVER + "=" + delim;
                }

                if (!_.isEmpty(footnotes)) {
                    pickerUrl = pickerUrl + "&" + FOOTNOTES_NAME_IN_POPOVER + "=" + footnotes;
                }
                return pickerUrl;
            }

            function removeReceiveDataListener(handler) {
                if (window.removeEventListener) {
                    window.removeEventListener("message", handler);
                } else if (window.detachEvent) {
                    window.detachEvent("onmessage", handler);
                }
            }

            function registerReceiveDataListener(handler) {
                if (window.addEventListener) {
                    window.addEventListener("message", handler, false);
                } else if (window.attachEvent) {
                    window.attachEvent("onmessage", handler);
                }
            }

            function receiveMessage(event) {
                if (_.isEmpty(event.data)) {
                    return;
                }

                let message = JSON.parse(event.data),
                    action;

                if (!message || message.sender !== GROUP) {
                    return;
                }

                action = message.action;
                if (action === "submit") {
                    if (!_.isEmpty(message.data)) {
                        ek.relayCmd(id, message.data);
                    }
                }

                plugin.eaemFootnoteBuilderDialog = null;
                dialog.hide();
                removeReceiveDataListener(receiveMessage);
            }
        },

        updateState: function (selDef) {
            let hasUC = this.editorKernel.queryState(FOOTNOTE_BUILDER_FEATURE, selDef);

            if (this.pickerUI != null) {
                this.pickerUI.setSelected(hasUC);
            }
        }
    });
    CUI.rte.plugins.PluginRegistry.register(GROUP, TouchUIFootnoteBuilderPlugin);
See more See less

RTE command TouchUIFootnoteBuilderCmd which is being called above: HTML transformation is being performed here:

let TouchUIFootnoteBuilderCmd = new Class({
        toString: "TouchUIFootnoteBuilderCmd",

        extend: CUI.rte.commands.Command,

        isCommand: function (cmdStr) {
            return (cmdStr.toLowerCase() === FOOTNOTE_BUILDER_FEATURE);
        },

        getProcessingOptions: function () {
            let cmd = CUI.rte.commands.Command;
            return cmd.PO_SELECTION | cmd.PO_BOOKMARK | cmd.PO_NODELIST;
        },

        execute: function (execDef) {
            const delim = execDef.value ? (execDef.value[DELIM_NAME_IN_POPOVER] || ",") : ",",
                footnotes = execDef.value ? execDef.value[FOOTNOTES_NAME_IN_POPOVER] : undefined;

            const component = execDef.component;

            let parentContainer = CUI.rte.Common.getTagInPath(execDef.editContext, execDef.selection.startNode, "span", {"class": "c-footnote-container"})
            if (parentContainer !== null) {
                $(this).data('timeout', setTimeout(function () {
                    CUI.rte.Selection.selectNode(execDef.editContext, parentContainer);
                    component.relayCmd("Delete");
                }, 500));
            }

            if (_.isEmpty(footnotes)) {
                return;
            }

            let html = this.buildFootnoteHTML(delim, footnotes);
            $(this).data('timeout', setTimeout(function () {
                component.relayCmd("InsertHTML", html);
            }, 500));

        },

        buildFootnoteHTML: function (delim, footnotes) {
            let html = "<span class='c-footnote-container' data-delim='" + delim + "'>";
            for (let i = 0; i < footnotes.length; i++) {
                html += "<fe-footnote class='c-footnote sup'><span class='footnote-content'>"
                    + footnotes[i].replace(/href="([^"]+)"/g, "href='$1' _rte_href='$1'") +
                    "</span><sup>" + ((i !== 0) ? delim : "") +
                    "<span class='footnote__number'>*</span></sup></fe-footnote>";
            }

            return html + "</span>";
        }
    });
    CUI.rte.commands.CommandRegistry.register(FOOTNOTE_BUILDER_FEATURE, TouchUIFootnoteBuilderCmd);

}(jQuery, window.CUI));
See more See less

And finally, an iframe containing the dialog, its styling, configuring its header and buttons

(function ($, $document) {
    const SENDER = "footnotebuilder",
        REQUESTER = "requester",
        DELIM = "delim",
        FOOTNOTES = "footnotes";

    if (queryParameters()[REQUESTER] !== SENDER) {
        return;
    }

    $(function () {
        _.defer(stylePopoverIframe);
    });

    function queryParameters() {
        let result = {}, param,
            params = document.location.search.split(/?|&/);

        params.forEach(function (it) {
            if (_.isEmpty(it)) {
                return;
            }
            param = it.split("=");
            result[param[0]] = param[1];
        });

        return result;
    }

    function stylePopoverIframe() {
        const $dialog = $("coral-dialog");
        const $dialogWrapper = $("coral-dialog div.coral3-Dialog-wrapper");
        if (_.isEmpty($dialog) || _.isEmpty($dialogWrapper)) {
            return;
        }

        $dialog.css("overflow", "hidden").css("background-color", "#fff");
        $dialogWrapper.css("width", "100%").css("height", "100%")
            .css("overflow", "auto").css("-webkit-overflow-scrolling", "touch");
        $dialog[0].open = true;

        let delim = queryParameters()[DELIM],
            $delimField = $document.find("*[name='./delim']"),
            footnotes = queryParameters()[FOOTNOTES] ? queryParameters()[FOOTNOTES].split(",") : [],
            $footnotesField = $document.find("coral-multifield[data-granite-coral-multifield-name='./footnotes']"),
            $addButton = $footnotesField.find("button[coral-multifield-add]");

        if (!_.isEmpty(delim)) {
            delim = decodeURIComponent(delim);
            $delimField[0].value = delim;
        }

        for (let i = 0; i < footnotes.length; i++) {
            $addButton.click();
            $(this).data('timeout', setTimeout(function () {
                $footnotesField.find("input[name='./footnotes']:eq(" + i + ")")[0].value = decodeURIComponent(footnotes[i]);
            }, 500));
        }

        adjustHeader($dialog);

        $(this).data('timeout', setTimeout(function () {
            $(document).trigger("foundation-contentloaded");
        }, 500));
    }

    function adjustHeader($dialog) {
        const $header = $dialog.css("background-color", "#fff")
            .find(".coral3-Dialog-header");
        $header.find(".cq-dialog-submit").click(function (event) {
            event.preventDefault();

            let $rtes = $dialog.find("div [data-cq-richtext-editable][name='./footnotes']"),
            valid = true;

            _.each($rtes,  function (rte) {
                let api  = $(rte).adaptTo("foundation-validation");
                if(!api || !api.checkValidity()) {
                    valid = false;
                }
                if(api) {
                    api.updateUI();
                }
            });

            if (valid) {
                sendDataMessage();
            }
        });
        $header.find(".cq-dialog-cancel").click(function (event) {
            event.preventDefault();
            $dialog.remove();
            sendCancelMessage();
        });
    }

    function sendCancelMessage() {
        const message = {
            sender: SENDER,
            action: "cancel"
        };
        parent.postMessage(JSON.stringify(message), "*");
    }

    function sendDataMessage() {
        let message = {
            sender: SENDER,
            action: "submit",
            data: {}
        }, $dialog, delim, footnotes;

        $dialog = $(".cq-dialog");
        delim = $dialog.find("[name='./" + DELIM + "']").val();
        footnotes = $dialog.find("input[name='./" + FOOTNOTES + "']").map(function () {
            return $(this).val().replace(/n+$/g, "").replace(/^<p>/g,'').replace(/</p>$/g, '');
        }).get();

        message.data[DELIM] = delim;
        message.data[FOOTNOTES] = footnotes;

        parent.postMessage(JSON.stringify(message), "*");
    }
})(jQuery, jQuery(document));

See more See less

The custom css /apps/test-com/clientlibs/clientlib-author/rte/css/rte.css:

span.footnote-content {
    vertical-align: super;
}

span.c-footnote-container {
    color: blue;
}

coral-multifield.fe-footnotes coral-icon.coral-Form-fielderror {
    right: 100px !important;
}
See more See less

And the popup dialog /apps/test-com/components/dialogs/rte/footnote/cq:dialog.xml:

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
          xmlns:granite="http://www.adobe.com/jcr/granite/1.0"
          jcr:primaryType="nt:unstructured"
          jcr:title="Footnote"
          sling:resourceType="cq/gui/components/authoring/dialog">
    <content
            jcr:primaryType="nt:unstructured"
            sling:resourceType="granite/ui/components/foundation/container">
        <items jcr:primaryType="nt:unstructured">
            <column
                    jcr:primaryType="nt:unstructured"
                    sling:resourceType="granite/ui/components/foundation/container">
                <items jcr:primaryType="nt:unstructured">
                    <delim
                            jcr:primaryType="nt:unstructured"
                            sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                            fieldLabel="Delim"
                            name="./delim"/>
                    <footnotes
                            jcr:primaryType="nt:unstructured"
                            sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
                            composite="{Boolean}false"
                            granite:class="fe-footnotes"
                            required="{Boolean}true"
                            fieldLabel="Footnotes">
                        <field jcr:primaryType="nt:unstructured"
                               sling:resourceType="cq/gui/components/authoring/dialog/richtext"
                               name="./footnotes"
                               required="{Boolean}true"
                               useFixedInlineToolbar="{Boolean}true">
                            <rtePlugins jcr:primaryType="nt:unstructured">
                                <subsuperscript jcr:primaryType="nt:unstructured" features="*"/>
                            </rtePlugins>
                            <uiSettings jcr:primaryType="nt:unstructured">
                                <cui jcr:primaryType="nt:unstructured">
                                    <inline jcr:primaryType="nt:unstructured"
                                            toolbar="[format#bold,format#italic,format#underline,links#modifylink,links#unlink,subsuperscript#subscript,subsuperscript#superscript]">
                                    </inline>
                                </cui>
                            </uiSettings>
                        </field>
                    </footnotes>
                </items>
            </column>
        </items>
    </content>
</jcr:root>
See more See less

How to Use AEM RTE Custom Plugin

The usage of the new widget is very simple. You just add it to an RTE definition:

 <rtePlugins jcr:primaryType="nt:unstructured">
…
     <footnotebuilder jcr:primaryType="nt:unstructured" features="*"/>
…
</rtePlugins>

<uiSettings jcr:primaryType="nt:unstructured">
   <cui jcr:primaryType="nt:unstructured">
      <inline jcr:primaryType="nt:unstructured"                                                              toolbar="[...,footnotebuilder#footnoteBuilder]">
      </inline>
      <dialogFullScreen jcr:primaryType="nt:unstructured"  toolbar="[...,footnotebuilder#footnoteBuilder]">
      </dialogFullScreen>
   </cui>
</uiSettings>
See more See less

The Exadel Team Creates an AEM RTE Custom Plugin

As a result, we now have a new widget inside the RTE field which gives us the ability to create AEM-managed footnotes without changing front-end library code. The widget, as a part of RTE, is easy to use because it extends the basic RTE and is easy for authors to get used to and start working with it. Inside the RTE text field, the footnotes are clearly visible and will not confuse editors. It’s easy to create a new footnote as well as to edit an existing one.

This is pretty much it. The client lib is ready to be used in any component with an RTE field. Stay tuned for more articles from the Exadel Marketing Technology Team!

Was this article useful for you?

Get in the know with our publications, including the latest expert blogs