Proof-of-concept for inline editable areas in rich text fields in Kentico Xperience MVC
Vera van Klaveren
Posted on May 19, 2021
One thing I have always liked about Kentico Kontent is the out-of-the-box support for inserting arbitrary objects into rich text. This harnesses the powers of structured content, like better portability across different use cases, but still allows editors to spruce up their content with elements that would be difficult or even impossible to construct from standard rich text editor capabilities.
For an upcoming project using the full Kentico Xperience platform, we had identified the need for a similar amount of freedom in composing content. Xperience has somewhat comparable functionality in the form of its Page Builder. However, the page builder requires hard-coding the editable areas into the page, which means that there is a fixed number of editable areas per page, in fixed positions, and outside of any structured content.
To get around this limitation, we needed a solution that allowed editors to insert editable areas into their rich text. An additional requirement was that the functionality should be accessible even to low-tech content editors, ideally allowing them to insert editable areas into their text at the press of a button, and requiring no further tinkering.
This post describes our proof-of-concept build of this functionality, and the decisions we made along the way. The source code is available on GitHub. The resulting functionality can be seen in the following screen capture:
The functionality as described was developed for Xperience 13. Keep this in mind if you plan to follow along, as things might need some tweaking for earlier versions. If you can't get it to work, your best bet would probably be to look for differences in the relevant documentation for Xperience 13 and your version, and go from there.
Adding editable area placeholders to our rich text
Our basic idea was to insert a placeholder into the content, which could then be resolved into an actual editable area when the content is being rendered in the MVC view. For the placeholder, we chose the following markup:
<editable-area id="areaIdentifier"></editable-area>
Our reasoning was that this would fit in well with the rest of the HTML, and also has a lower chance of colliding with any actual content typed by the editors. Of course, any arbitrary piece of markup could be used, as long as the parsing logic is changed along with it. The only requirement here is that the placeholder specifies its own areaIdentifier
, which allows us to deterministically relate our placeholder to its rendered editable area. Otherwise, we might lose the contents of the area when some change leaves us unable to generate the same identifier that was used when the area was originally created.
Resolving placeholders into editable areas
On the MVC side, we retrieve our page data as usual, up to the point where we have to render our rich text content. To simplify the calling code, we will make our functionality available as an extension method to the IHtmlHelper
object, which can be accessed in the view by the Html
property. Keep this in mind when reading the following code snippets, as this means we cannot directly use the usual @Html.Raw(...)
and whatnot, since our code won't be running directly in the view.
First off, we need to separate our editable area placeholders from the actual text, and render each in order. We chose to use a regular expression for this:
var components = Regex.Split(
input: html,
pattern: @"<editable-area id=""(.*?)""><\/editable-area>");
Normally, I wouldn't recommend parsing HTML(-like) strings using regular expressions, as detailed in this StackOverflow answer. However, as detailed in the answer below it, "it's sometimes appropriate to use them for parsing a limited, known set of HTML", which is the case here. This does mean the solution is vulnerable to minor changes in the placeholder markup. You could tweak the pattern a bit to be more robust, but since the HTML is not really meant to be manipulated directly, we didn't think it necessary in our case, and this simple pattern also has the added benefit of being easier to understand and reason about.
As for the pattern, note that we have introduced a capturing group for the value of the id
attribute. An interesting feature of the Regex.Split(...)
overloads is that the substrings captured by the capturing groups in the regex are themselves included in the returned array. This means that our components
variable now references an array containing pieces of HTML, interspersed with the IDs of our editable areas. Specifically, this means the HTML is located at the even indices, and the editable area IDs at the odd indices. Thus, we can render everything to the view using the following loop:
for (var i = 0; i < components.Length; i++)
{
var isEditableArea = i % 2 == 1;
if (isEditableArea)
{
await RenderEditableAreaAsync(components[i]);
}
else
{
RenderHtml(components[i]);
}
}
To keep things readable, we have extracted the exact logic for rendering the HTML components into separate methods (or more specifically, local functions). Starting with the RenderHtml(string)
method, this is implemented as follows:
void RenderHtml(string value) =>
RenderContent(content: htmlHelper.Raw(value));
void RenderContent(IHtmlContent content) =>
htmlHelper.ViewContext.Writer.Write(content);
Notice that we still use the equivalent of Html.Raw(...)
to render the HTML, just as we would do when rendering it directly inside of a view, but that we have to manually write this to the view output.
The RenderEditableAreaAsync(string)
method is implemented using the same RenderContent(IHtmlContent)
method, as follows:
async Task RenderEditableAreaAsync(string identifier)
{
var kentico = htmlHelper.Kentico();
RenderContent(await kentico.EditableAreaAsync(identifier));
}
Again, nothing special is going on here. We just call the same methods as we would do in our view, but we have to manually write their output to the view output.
All of this combined gives us the following class. Note that we have added it to the same namespace as Kentico's own EditableAreaAsync(...)
method to make it accessible wherever you might otherwise directly render the editable areas. Also note that to allow our method to be called with the @Html.DoSomething
syntax in our views, we have to return an object
reference. However, since the output is written directly to the view context, we will always return null
.
using Kentico.Web.Mvc;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Kentico.PageBuilder.Web.Mvc
{
/// <summary>
/// A static class implementing operations for working with inline editable areas.
/// </summary>
public static class InlineEditableAreas
{
/// <summary>
/// Resolves the editable areas in the given HTML string and writes the result to the view context.
/// </summary>
/// <param name="htmlHelper">An <see cref="IHtmlHelper"/> object containing the view context.</param>
/// <param name="html">The HTML string.</param>
/// <returns>The return value is not used and will always be null.</returns>
public static async Task<object> ResolveEditableAreasAsync(
this IHtmlHelper htmlHelper,
string html)
{
var components = Regex.Split(html, @"<editable-area id=""(.*?)""><\/editable-area>");
for (var i = 0; i < components.Length; i++)
{
var isEditableArea = i % 2 == 1;
if (isEditableArea)
{
await RenderEditableAreaAsync(components[i]);
}
else
{
RenderHtml(components[i]);
}
}
return null;
async Task RenderEditableAreaAsync(string identifier)
{
var kentico = htmlHelper.Kentico();
RenderContent(await kentico.EditableAreaAsync(identifier));
}
void RenderHtml(string value) =>
RenderContent(content: htmlHelper.Raw(value));
void RenderContent(IHtmlContent content) =>
htmlHelper.ViewContext.Writer.Write(content);
}
}
}
In our views, we can then call our extension method as follows:
@await Html.ResolveWidgetAreasAsync(Model.SomeRichTextField)
Adding a button to the editor for inserting editable areas
While we could just instruct the content editors to copy-paste some markup directly into their HTML, that wouldn't be very user-friendly. Therefore, we also have to add a button that can directly insert these editable area placeholders into the rich text field, without ever having to directly work with the underlying HTML. Luckily, the CKEditor Kentico uses for its WYSIWYG editor has great support for adding custom plugins.
Basically, you create a plugin by adding a new folder in the CMS/CMSAdminControls/CKeditor/plugins
folder. The name of this folder determines the name of your plugin, which is important for later. Inside of this folder, you create a JavaScript file named plugin.js
, which will contain the actual button behaviour, and an icons
folder containing an image for our button icon, which should have the same name as our plugin. In our case, this gives us the following folder structure:
CMS/CMSAdminControls/CKeditor/plugins
└───inlineeditableareas
├───plugin.js
└───icons
└───inlineeditableareas.png
The following snippet contains our plugin.js
script. In it, we create a new plugin, which defines a new command, insertEditableArea
, and adds a button that executes that command when clicked. When executed, the command generates a random ID value for our placeholder, and then inserts the placeholder markup into the editor.
CKEDITOR.plugins.add('inlineeditableareas', {
icons: 'inlineeditableareas',
init: function (editor) {
editor.addCommand('insertEditableArea', {
exec: function (editor) {
var editableAreaId = Math.random().toString(36).substring(2);
var html = '<editable-area id="' + editableAreaId + '"></editable-area>';
var editableArea = CKEDITOR.dom.element.createFromHtml(html);
editor.insertElement(editableArea);
}
});
editor.ui.addButton('inlineeditableareas', {
label: 'Insert widget area',
command: 'insertEditableArea',
toolbar: 'insert'
});
}
});
With this in place, we just need to add our plugin to the CKEditor. This is done in the config.js
file, which is located at /CMS/CMSAdminControls/CKeditor/config.js
. This file should contain one big function that receives a config
object as a parameter. This object has a plugins
field that we need to add our plugin to, as follows:
config.plugins += ',inlineeditableareas';
Notice the +=
, and the leading comma. This is important because the plugins
field will already contain a comma-separated list of plugins, and we need to append a new element to it. Also, if your config.js
file already contains a line that adds some plugins to this field, you can of course combine the two and add the plugin to the existing assignment.
Finally, we need to actually add our button to the relevant toolbars. This is done in the same file, and takes the form of assigning a jagged array to a field like config.toolbar_Standard
. The nested arrays define the sub-sections of the toolbar. You can either choose to add our inlineeditableareas
to one of the existing nested arrays, or add a new array containing the new button.
Making the placeholders visible in the editor
At this point, we have fully functional inline editable areas. However, they are only visible in the Page tab, meaning they are still quite hard to work with, requiring us to switch to the page builder to even see that we have actually inserted an editable area. We can remedy this by adding some custom editor styling to make the editable areas stand out a bit more. In previous versions of the CMS, we could add a style sheet for this directly in Kentico, in the CSS stylesheets module, and add it to our site in the Sites module. For Xperience 13, we instead have to add a style sheet to the wwwroot
of our MVC project, and specify it in the Content > Content management > CSS stylesheet for WYSIWYG editor
setting in the Settings module. For example, if our MVC project contained a file /wwwroot/css/editor.css
, we would specify ~/css/editor.css
as our setting value.
Of course, there are infinite possibilities for styling these placeholders. We came up with the following:
editable-area::before {
content: '<widget area>';
display: block;
background-color: #f1f1f1;
box-shadow: 1px 1px darkgrey;
padding: 10px;
margin: 0 auto;
text-align: center;
color: #888;
}
One thing to note about this style sheet is that we style the ::before
, instead of the element itself. Beside the ::before
allowing us to add a bit of text to better describe its purpose, this also prevents the element from being modified in the WYSIWYG editor. If we had styled the element itself, and thus given it actual dimensions, the editor would actually allow you to enter text inside of it like any other container element, thus changing the placeholder structure and preventing our regular expressions from recognising it. Again, this could be remedied by changing the regular expression a bit, but since we don't want anything to be entered inside of the placeholder anyway, we thought this was the better solution.
Another minor thing is that we chose to refer to the editable area here as a widget area. Our reasoning was that calling it an editable area in a location where you cannot actually edit it might be confusing to the content editors, and we consider editable area to be more of a technical, under-the-hood term anyway.
Conclusion
With all this in place, we have reached our goal of enabling content editors to use the page builder functionality inside of their rich text, rather than having to choose between just using it around it, or letting go of structured content completely and just putting the rich text into the page builder as well. This promotes applicability of the content to more use cases, potentially putting less strain on future wishes or requirements. This also improves reusability of existing page types, as the pages can be composed into different forms more freely. Of course, if a consistent pattern emerges among these, a dedicated page type or template might be of more use, since this reduces the manual work required.
I think the main shortcomings of the current solution lie mostly with the WYSIWYG representation of the editable areas. While the initial flow of adding a new editable area works well, moving them around afterwards can be a bit tricky, since the editor thinks its just a regular empty container with a funny name. The CKEditor does have support for the concept of an 'object', which is actually what Kentico used for its own inline widgets before, which might be worthwhile to investigate further.
All in all, this has been a fun project to dig into. I hope you have found this post insightful or helpful in any way.
Cover image by HalGatewood.com on Unsplash.
Posted on May 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.