tedgustaf.se

Episerver on-page edit with JavaScript frameworks

JavaScript frameworks such as Angular, React, and Aurelia, are powerful for creating engaging and interactive websites. However, these frameworks tend to want to own the HTML DOM, which may have an adverse affect on the on-page editing features in Episerver. In this post we look at how that can be fixed.

  • Ted Nyberg
  • 4 July 2017
  • 0

What JavaScript frameworks do

Simplified, JavaScript UI frameworks work by moving some of the data-binding and rendering logic from the web server to the web browser. While this works great for website visitors, we need to make make some framework-specific modifications to maintain on-page editing features for web editors inside the Episerver UI.

Traditionally, HTML is rendered by a web server and then sent to a web browser. For example, we may have a page template as a Razor view in ASP.NET MVC, which combines content (M) and partial views (V) to compose ready-to-display HTML which is served through a controller (C) to the web browser which displays it as-is.

With modern JavaScript frameworks, the server instead serves framework-specific HTML together with script files that compile and compose views directly in the web browser. In other words, without the JavaScript acting on the HTML from the server, there would not be much - if any - for the site visitor to see in the browser window.

Some background on property rendering in Episerver

Consider this trivial page type:

C# Expand
[ContentType]
public class StandardPage : PageData
{
    [UIHint("MyText")]
    public virtual string MyStringProperty { get; set; }
}

Notice the UI hint called "MyText". Because we have a matching display template with the same name...

...it will be used by default to render the property. The display template itself is trivial, simply wrapping the property value in a <p> element with a predefined CSS class:

HTML, XML Expand
@model string
    
<p class="my-text">@Model</p>

Now, let's say we have the following page template for our page type:

HTML, XML Expand
@{ Layout = null; }

@model StandardPage
   
<html>

    <h2>@Model.PageName</h2>

    @Html.PropertyFor(m => m.MyStringProperty)

</html>

Notice how we can simply use the PropertyFor helper to render MyStringProperty, as the UI hint will match the property to the display template.

Now, whatever the editor inputs in Episerver...

...will result in the following HTML when the page is rendered:

HTML, XML Expand
<html>

   <h2>My page</h2>

   <p class="my-text">Some text here.</p>  

</html>

Because we used the PropertyFor helper method to render the property in our page template, we automatically get on-page editing support for the property when the page is rendered in edit mode. Whenever the property value is modified, it is re-rendered automatically by Episerver, making the change instantly visible for preview.

The key takeaway from this, is that the entire display template will be re-rendered when the property value is changed through on-page editing.

This works fine and well if the HTML rendered by the web server is ready-for-display in the web browser. But what if the markup in the display template is some framework-specific HTML that can't be reasonably displayed by the web browser without some client-side JavaScript stepping in to compile the framework-specific elements?

Well, for one: you will notice on-page editing won't work properly. In many cases, the modified property will simply appear to disappear from the on-page editing preview, or at least not be displayed as you would expect unless you reload the page.

This is because most JavaScript frameworks don't support DOM changes by other means than through the JavaScript framework itself. So, when Episerver re-renders the framework-specific markup after initial page load, the JavaScript framework simply doesn't do anything to make sense of it.

Adding a JavaScript framework to our views

While I won't go into the details of different JavaScript frameworks, let's look at how we can add on-page editing support for virtually any client-side JavaScript framework.

I'll use Angular for this example, but the same principles apply to React and other frameworks as well.

First, let's modify our page template a little bit to allow for default bootstrapping of Angular:

HTML, XML Expand
@{ Layout = null; }

@model StandardPage
   
<html ng-app> <!-- ng-app attribute for Angular bootstrapping -->

    <h2>@Model.PageName</h2>

    @Html.PropertyFor(m => m.MyStringProperty)

    <!-- Include Angular scripts -->
    <script src="http://code.angularjs.org/snapshot/angular.js"></script>

</html>

If you look closely, you'll notice the only differences are that we added an ng-app attribute to our <html> element and included the Angular scripts.

Now, let's say we have a custom Angular directive called "myAngularDirective" which supports an attribute called "someText" (and just assume it provides some neat design and functionality for strings).

We'll modify our display template to make use of it:

HTML, XML Expand
@model string
    
<my-angular-directive someText="@Model"></my-angular-directive>

Now, when our page loads, the resulting HTML from the server will look like this:

HTML, XML Expand
<html ng-app>

   <h2>My page</h2>

   <my-angular-directive someText="Some text here."></my-angular-directive>

   <script src="http://code.angularjs.org/snapshot/angular.js"></script>

</html>

Now, when the page loads, this is what happens:

  1. The Razor views are compiled on the server
  2. The HTML is sent to the web browser
  3. The scripts get executed after the DOM has loaded
  4. The Angular directive is displayed as intended 

What happens in on-page edit mode

Ok, so we have our website up and running with our custom Angular directive. That's great!

But remember what happens if we edit the property in on-page edit mode? That's right, only the markup with the Angular directive gets re-rendered. But since the scripts have already been executed, the previously compiled Angular directive is simply replaced by the custom directive markup without Angular knowing about it.

From the browser's perspective, the modified HTML at this point is simply some erroneous markup with no notion of how it should be displayed in the browser. It's up to us to tell Angular to re-compile the modified HTML.

To do that, we add some code to a JavaScript file which we only load in edit mode:

HTML, XML Expand
@{ Layout = null; }

@model StandardPage
   
<html ng-app>

    <h2>@Model.PageName</h2>

    @Html.PropertyFor(m => m.MyStringProperty)

    <script src="http://code.angularjs.org/snapshot/angular.js"></script>

    @if (PageEditing.PageIsInEditMode)
    {
        <!-- On-page edit support -->
        <script src="/dist/on-page-edit.js"></script>
    }

</html>

It's worth noting that, by default, Episerver adds a <div class="epi-editContainer"> element around properties in edit mode only.

In other words, whenever a property gets re-rendered, we know the markup will be contained within one of those epi-editContainer elements.

So, during on-page editing, we add some JavaScript to:

  1. Add mutation observers for "epi-editContainer" elements
  2. Use Angular to compile elements added to the container (i.e. when Episerver re-renders the property)

The code in our on-page-edit.js file can look something like this:

JavaScript Expand
document.addEventListener("DOMContentLoaded", function (event) {

    function watchForChanges(component) {

        var observer = new MutationObserver(function(mutations) {
            mutations.forEach(mutation => {

                // Use Angular to compile all elements added by Episerver
                mutation.addedNodes.forEach(function(node) {

                    angular.element(document).injector().invoke(['$compile', '$timeout', function ($compile, $timeout) {
                        $timeout(function () {
                            var scope = angular.element(node).scope();

                            if (scope) {
                                $compile(node)(scope);
                            }
                        }, 0);
                    }]);
                });
            });
        });

        observer.observe(component, { attributes: false, childList: true, characterData: false });
    }

    // Watch all edit-mode containers for changes
    var editContainers = document.getElementsByClassName('epi-editContainer');

    for(let i=0; i<editContainers.length; i++) {
        watchForChanges(editContainers[i]);
    }
});

A simple way to test this without an Angular directive would be to change our display template to contain some Angular binding expression:

HTML, XML Expand
@model string

You entered <strong>@Model</strong>, a string with {{'@Model'.length}} characters.

Now if we edit the value of our string property...

...we'll notice the Angular expression is evaluated after the property is re-rendered:

Demo: On-page editing of Angular property

The following shows an integer property rendered with an Angular directive which animates from zero to the specified number. Notice that whenever the property is changed, the Angular directive is re-initialized, effectively restarting the animation up to the new value.

What about the other frameworks?

How you re-initialize components in on-page edit mode depends on the framework(s) used, but the common denominator is using some sort of bootstrapping included in the framework.

For example, if we are using React, we can use createElement and render to compile and render components:

JavaScript Expand
ReactDOM.render(
   React.createElement(
      nameOfReactClass, {}), elementAddedByEpiserver);