Add Custom List Button in Sitecore RTE

A client requested a new button in the rich text editor that would work exactly like an unordered list, but would look different based on a class that would be added to the unordered list. Since the RTE is a telerik control it took a lot of digging through javascript in the “\Website\sitecore\shell\Controls\Rich Text Editor” folder and Chrome web dev tools to find what we needed. The solution ended up being a custom button to replace the unordered list, a custom button for the new list, and javascript to handle button states. We need to replace the unordered list button to deal with button states – any time an unordered list is clicked in the RTE the Insert Unordered List button will show as the selected state. We need to take control of this so we know whether we’re in a normal unordered list or our new custom list button.

The first step is to create our buttons. In core db, /sitecore/system/Settings/Html Editor Profiles find your RTE and Toolbar with the ordered lists. You’ll need to create two Html Editor Buttons and set the Click field values, and get rid of the “Insert Unordered List”. I set the Click field to “InsertNewUnorderedList” for the replacement unordered list button and “InsertCustomUnorderedList” for the custom list. You will want to add a new icon for the custom list, and do nothing with the new unordered list icon. We don’t want to change this icon because we want it to look the same as the regular button. In order for that to happen you need to add css to the Default.css, targeting your new click value (Sitecore will set the class up for you) so we can recreate what the unordered button is already doing:

css

When you bring up your RTE you should now see the custom unordered button looks the same and the new button should be there as well

rte ribbon

Now for the fun part. We need to first get the list functionality working for the buttons. We’ll use the “RichText Commands.js” in \Website\sitecore\shell\Controls\Rich Text Editor. We can see from the other functions in here what our function will look like, we’re using the command list to grab our Click value. Next we insert this function into “RichText Commands.js”:

RadEditorCommandList["InsertCustomUnorderedList"] = function(commandName, editor, tool) { }

After some digging in the telerik js we can see what the unordered button is doing, so we will finish out the previous function like this:

RadEditorCommandList["InsertCustomUnorderedList"] = function(commandName, editor, tool) {
    editor.setFocus();
    var listType = "InsertUnorderedList";
    editor.executeCommand(new Telerik.Web.UI.Editor.InsertListCommand(editor.getLocalizedString(listType), editor.get_contentWindow(), editor.get_newLineMode() == Telerik.Web.UI.EditorNewLineModes.Br, listType, null, editor));
}

Now we need to do our custom work and worry about the button states. We can hook into some functions in “RTEfixes.js” which is in the same folder as above. We’re going to make our OnClientCommandExecuted function look like this:

function OnClientCommandExecuted(sender, args) {
   var li = "LI";
   var ul = "UL";
   var cTool = sender.getToolByName("InsertNewUnorderedList");
   if (args.get_commandName() === "SetImageProperties") {
      replaceClearImgeDimensionsFunction();
   } else if (args.get_commandName() === "InserCustomUnorderedList") {
      var selElem = sender.getSelectedElement();
      if (selElem.nodeName !== li && selElem.nodeName !== ul) return;
      if (selElem.nodeName === li) {
         selElem = selElem.parentNode;
      }
      selElem.className = "myCustomClassName";
      var kTool = sender.getToolByName("InsertCustomUnorderedList");
      kTool.setState(1);
      cTool.setState(0);
   } else if (args.get_commandName() === "InserNewUnorderedList") {
      var selElem2 = sender.getSelectedElement();
      if (selElem2.nodeName !== li && selElem2.nodeName !== ul) return;
      cTool.setState(1);
   }
}

For the custom button we need to add a class to the ul and then set our button states. We should be in the list, but I have a check in there to make sure. For the new unordered list button we just need to set the button state:

For button states we need to add some code to the OnClientSelectionChange function in “RTEfixes.js”. We access the states of the buttons by getting their “tool name” by click value. Then we’ll set the correct button state based off of the presence of the class on the ul. The resulting function looks like this:

function OnClientSelectionChange(editor, args) {
   var tool = editor.getToolByName("FormatBlock");
   if (tool) {
      setTimeout(function() {
         var defaultBlockSets = [
            ["p", "Normal"],
            ["h1", "Heading 1"],
            ["h2", "Heading 2"],
            ["h3", "Heading 3"],
            ["h4", "Heading 4"],
            ["h5", "Heading 5"],
            ["menu", "Menu list"],
            ["pre", "Formatted"],
            ["address", "Address"]
         ];

         var value = tool.get_value();
         var block = Prototype.Browser.IE
               ? defaultBlockSets.find(function(element) { return element[1] == value; })
               : [value];

         if (block) {
            var tag = block[0];
            var correctBlock = editor._paragraphs.find(function(element) { return element[0].indexOf(tag) > -1; });
            if (correctBlock) {
               tool.set_value(correctBlock[1]);
            }
         }
      }, 0);
   }

   var selElem = editor.getSelectedElement();
   var kTool = editor.getToolByName("InsertCustomUnorderedList");
   var cTool = editor.getToolByName("InsertNewUnorderedList");
   kTool.setState(0);
   cTool.setState(0);
   if (selElem.nodeName === ul || selElem.nodeName === li) {
      if (selElem.nodeName === li) {
         selElem = selElem.parentNode;
      }

      if (selElem.className === "myCustomClassName") {
         kTool.setState(1);
      } else {
         cTool.setState(1);
      }
   }
}

Now both buttons should insert a list and we will know which list we’re in because the button states are correct. You may notice that you can still use the buttons interchangeably but this is as far as this blog covers.

Advertisements

Sitecore Orphan Children after Parent Component Removal

I was tasked with building a navigation carousel component, designed so the carousel itself is just a placeholder that will allow components to be added to it. This design allowed for easily adding components, removing them, and moving them around within the carousel. The problem arose when we tried to remove the carousel that had components on the placeholder, I’ll call them navigation items. When reviewing the layout details after removing the carousel, the navigation items would remain in the layout. If a new carousel was then added to the page, the old navigation items would appear in the component instead of an empty placeholder. I decided to use dynamic placeholders as part of the solution; this is the dynamic placeholder solution that appends the unique identifier of the parent component as the placeholder identifier. Dynamic placeholders got rid of the problem of the old navigation items showing up when adding a new carousel, but they were still present in the layout details. Dynamic placeholders would allow, not to mention more flexibility of the components added to the placeholder, finding any control that had a dynamic placeholder and making sure there was a parent control with that unique id on the page.

The first hurdle was to determine where to put this logic; I was pointed to this article based on a similar situation. From the article, we need to hook into the saveUI pipeline:

<sitecore>
  <processors>
    <saveUI>
      <processor type="Project.SaveUI.ConvertDynamicLayout, Project" 
       patch:instead="processor[@type='Sitecore.Pipelines.Save.ConvertLayoutField, Sitecore.Kernel']" />
    </saveUI>
  </processors>
</sitecore>

We are replacing the ConvertLayoutField in order to keep most of the logic but make our changes once we have the layouts in XmlDocuments. Once in XmlDocument form, we need to copy the InnerXml for comparison after attempting to remove the orphans. We want to save the new layout if it has changed after looking for orphans:

XmlDocument xmlDocument = XmlUtil.LoadXml(value);
XmlDocument xmlDocument1 = XmlUtil.LoadXml(str);

if (xmlDocument1 == null || xmlDocument == null) continue;

var before = xmlDocument1.InnerXml;
RemoveDynamicOrphans(xmlDocument1);

if (!string.IsNullOrWhiteSpace(before) &amp;&amp; before != xmlDocument1.InnerXml)
{
    saveField.Value = xmlDocument1.InnerXml;
}
else if (CompareNodes(xmlDocument1, xmlDocument))
{
    saveField.Value = xmlDocument1.InnerXml;
}

The first step in the RemoveDynamicOrphans method is to drill down to the components on the page, using the GetChildElements from the ConvertLayoutField class:

var root = GetChildElements(newNode);
if (!root.Any()) return;

var pageElement = GetChildElements(root.FirstOrDefault());
if (!pageElement.Any()) return;

var components = GetChildElements(pageElement.FirstOrDefault());
if (!components.Any()) return;

Then, separate the components into ones with dynamic placeholders and ones without:

var dynamicComponents = new Dictionary<XmlNode, string>();
var otherComponents = new Dictionary<XmlNode, string>();

ParseComponents(components, dynamicComponents, otherComponents);

if (dynamicComponents.Count < 1) return;

The ParseComponents method consists of looping through the components, checking for a Regex match on the placeholder and adding to the appropriate dictionary.

const string ph = "s:ph", uid = "uid";
foreach (var component in components)
{
    if (component.Attributes == null) continue;
    var attributes = component.Attributes;

    if (attributes[ph] == null) continue;

    var regex = new Regex(DYNAMIC_KEY_REGEX);
    var match = regex.Match(attributes[ph].Value);

    if (match.Success &amp;&amp; match.Groups.Count > 0)
    {
        dynamicComponents.Add(component, match.Groups[0].ToString().ToLower());
    }
    else
    {
        otherComponents.Add(component, attributes[uid].Value.ToLower().Trim('{', '}'));
    }
}

Once the dictionaries are set, we can look at the dynamic components and compare to the other components to determine if the dynamic placeholder uid matches the uid of another component. If not, it is an orphan that needs to be removed:

var orphans = (from node in dynamicComponents
    let foundOne = otherComponents.Any(other => other.Value == node.Value)
    where !foundOne
    select node.Key).ToList();

if (orphans.Count < 1) return;

Now that we have our orphans, all that’s left is to remove them and rebuild the InnerXml of the parent node:

foreach (var orphan in orphans)
{
    components.Remove(orphan);
}

var rebuiltInner = components.Aggregate(string.Empty, (current, component) => component.OuterXml + current);
if (!string.IsNullOrWhiteSpace(rebuiltInner))
{
    pageElement.First().InnerXml = rebuiltInner;
}