Insert links to bookmarks in other EPiServer pages

EPiServer‘s insert link dialog has an option that allows web editors to insert links to anchor tag bookmarks contained within the page currently being edited. This option, however, is not yet available when using the Page on this website alternative as a Link target. A client requested similar functionality for their EPiServer CMS 6 R2 installation, so I and my collegue Joachim Widén spent a few hours extending the link dialog using a PageAdapter (more on how those works in Surviving IE, or: How to render different markup depending on devices and browsers) together with a little jQuery. A slightly modified version of what we came up with is available at GitHub should you be interested.

Linking to anchor tag bookmarks in other EPiServer pages from TinyMCE with the Insert links dialog

This version of the functionality works with the anchor link bookmarks created using EPiServer’s bookmark tool in TinyMCE, hightlighted in the image below.

Creating EPiServer anchor tag bookmarks using the built-in tool in TinyMCE.

When selecting an EPiServer page that has this kind of bookmarks, an additional drop down list will be added below the Address field in the popup dialog.

EPiServer Insert link dialog controls affected by the bookmark functionality.

The bookmark selector list contains all of the available anchor tags found on the target page, as well as the default one giving no hashtag in the created URL.

EPiServer anchor tag bookmarks found on the selected EPiServer page.

Selecting for instance the Meeting anchor tag from the list, it will be added to the URL rendered in the different modes as below.

<!-- In the TinyMCE IFrame -->
<a _moz_dirty="" href="/Templates/AlloyTech/Pages/Page.aspx?id=5#Meeting" _mce_href="/Templates/AlloyTech/Pages/Page.aspx?id=5#Meeting">Alloy Meet</a>

<!-- In the EPiServer preview tab -->
<a href="/en/Products/Alloy-Meet/?id=5#Meeting">Alloy Meet</a>

<!-- Out on the site, in view mode -->
<a href="/en/Products/Alloy-Meet/#Meeting">Alloy Meet</a>

When editing an existing link, the current bookmark is matched for the ones added to the drop down list, so that the correct one is selected.

Adding a bookmark selector to EPiServer’s Insert links dialog

As stated earlier, this bookmark tool is based on a PageAdapter adding functionality to the popup dialog. In EPiServer’s code, the page responsible for this is called HyperlinkProperties.aspx meaning the codebehind file is what requires our attention. Add something like the following to your browser file.

App_Browsers\AdapterMappings.browser

<browsers>
 <browser refID="Default">
  <controlAdapters>
   <adapter controlType="EPiServer.UI.Editor.Tools.Dialogs .HyperlinkProperties" adapterType="EPiServer.CodeSample.BookmarkLinks .HyperlinkPropertiesAdapter" />

You will need to change the Build Action for the two files BookmarkLinks.js and BookmarkLinks.css from the default Content option to Embedded Resource. This is because we let the PageAdapter inject them into the dialog’s page header on initialization. You may also want to change where you want to get the jQuery support from here as well.

The first thing that we need to do is find a way to alter EPiServer’s javascript responsible for opening the page selector dialog. EPiServer do not supply its own callback function here but rather pass in null in its place (more about opening dialogs in How to open those EPiServer Edit Mode selector browser popups in your custom properties). The below way of doing it is a bit dodgy, but on the other hand, EPiServer have not used callback functions here for ages and it’s not very likely that they will in the foreseeable future.

BookmarkLinks.js

function injectCallback() {
  var pageSelectButton = $("#linkInternalAddressContainer input[type=button].epismallbutton");
  var onClickAttribute = pageSelectButton.attr("onclick");
  var onClickAttrArr = onClickAttribute.split(',');
  onClickAttrArr[7] = "SampleCode.BookmarkLinks.pageSelectorCallback";
  onClickAttribute = onClickAttrArr.join(',');
  pageSelectButton.removeAttr('onclick');
  pageSelectButton.on('click', function () {
    $.globalEval(onClickAttribute);
  });
}

Our own callback function is injected into EPiServer’s dialog open call on initialization using string parsing. The callback handler, in turn, determines whether or not the selected page has changed.

Edit: We cannot simply put the modified string back into the onclick attribute of the button since Internet Explorer won’t accept it and not trigger the handler (would have worked in other browsers though). So to get around this we need to remove the attribute (71) and then attach our own click event to the button (72). The globalEval call (73) may be considered unsafe but this is only used in EPiServer’s pop-up dialog in Edit Mode. Besides, the same kind of malicious behaviour could be achieved by simply editing the value of the onclick attribute with for instance FireBug.

function pageSelectorCallbackHandler() {
  var newPageId = $("input[id$='linkinternalurl_Value']").val();

  if (newPageId == currentPageId) {
    return;
  }
  if (newPageId == "") {
    removeBookmarkComponents();
    return;
  }
  currentPageId = newPageId;
  createBookmarkSelector();
}

If the selected page was changed, we need to update the bookmark selector drop down list. This is done using an AJAX call to a simple handler (32) passing along the id of the current page, as well as the currently selected bookmark if such exists. You will need to change the url to match your own path.

function createBookmarkSelector() {
  $.ajax({
     type: "GET",
     url: "/CodeSample/BookmarkLinks/AvailableBookmarks.ashx",
     data: {
       pageId: currentPageId,
       bookmark: bookmarkOrDefault()
     },
     dataType: 'html',
     timeout: 10000,
     success: function (data) {
       removeBookmarkComponents();
       $("#linkInternalAddressContainer").append(data);
     },
     error: function (e, xhr, exception) {
       removeBookmarkComponents();
       var error = $("<div id='error-msg' class='error'>ERROR: Unable to locate bookmarks for page with id='" + currentPageId + "'.</p>");
       $("#linkInternalAddressContainer").append(error);
     }
  });
}

The handler returns the whole div container block with both the label and the populated select tag. This block is then appended to the appropriate place in the markup.

AvailableBookmarks.ashx in itself is only setting the ContentType properly and passing along the output string from the BookmarkLocator service, which in turn is searching for bookmarks and compiling the output HTML.

BookmarkLocator.cs

var selectedBookmark = request["bookmark"];
var generator = new HtmlService();
var html = generator.HtmlFor(new PageReference(pageId));
var htmlDocument = new HtmlDocument();
htmlDocument.LoadHtml(html);
var bookmarksLinks = htmlDocument.DocumentNode
                        .Descendants("a")
                        .Where(n => n.InnerText == string.Empty && n.Attributes.Contains("name"))
                        .ToArray();

var bookmarks = bookmarksLinks.Select(lnk => lnk.Attributes.First(a => a.Name == "name").Value).ToArray();

To retrieve the markup containing the EPiServer anchor tag bookmarks we use a fake http worker request; more about this in Allowing web editors to customize static error pages in EPiServer. The reason for doing this comes from the way that the previously mentioned client’s website is built in regards to article pages. It is, in short, a flexible compilation of pages and subpages used to ease the management of the articles; so, for us it wouldn’t be enough just to parse the content of predefined EPiServer properties, since it wouldn’t give us the whole picture.

The HtmlService makes a fake request to our article page using an anonymous visitor context, and then returns the rendered markup. In order to know what we are looking for, we need to examine the EPiServer bookmarks. Doing this, we find that they are all empty a tags containing only a name attribute with our bookmark value.

..are stored in <a name="Meeting"></a>meeting notebooks.

To parse the HTML output we install a NuGet package called HtmlAgilityPack created by Simon Mourrier and Jeff Klawiter; the version used for the sample code is 1.4.6. It is easy to find what we are looking for, as we can do so by simple Linq expressions. In this example we parse the entire webpage that is returned from the fake request. This is probably not necessary as you are likely to have very few anchor bookmark tags in for instance the page header, footer or side columns. Narrowing the scope of the search and optimizing it for your EPiServer pages may increase performance.

The interesting parts of the adapter itself is taking place in the OnLoad section. EPiServer does quite a bit of work here, not in the least for the initial load of the page. Since all we really care about is what happens on post backs, we add a short guard clause taking care of this (30-34).

HyperlinkPropertiesAdapter.cs

protected override void OnLoad(EventArgs e)
{
  if (!Page.IsPostBack)
  {
    base.OnLoad(e);
    return;
  }

As we want to swap EPiServer’s code for our own slightly modified copy of it, there is no need to run base.OnLoad on post backs. The main changes made are the ones on line 52-54 in the snippet below; the value of our ddlBookmarkSelector drop down list (created in our service and injected through javascript) is retrieved from the request, and if a bookmark is selected, it is concatenated onto the URL.

  var function = "function CloseAfterPostback(e) {";
  if (String.CompareOrdinal(activeTab.Value, "0") == 0 && linktypeinternal.Checked)
  {
    var urlBuilder = new UrlBuilder(DataFactory.Instance .GetPage(linkinternalurl.PageLink).StaticLinkURL);
    var linkLang = request.Form[linklanguages.UniqueID];
    if (!string.IsNullOrEmpty(linkLang))
    {
      urlBuilder.QueryCollection["epslanguage"] = linkLang;
    }
    var ddlSelectedBookmark = request["ddlBookmarkSelector"];
    var bookmarkOrDefault = !BookmarkLocator.DefaultBookmark.Equals(ddlSelectedBookmark) ? string.Concat("#", ddlSelectedBookmark) : string.Empty;
    function = function + "EPi.GetDialog().returnValue.href = '" + urlBuilder + bookmarkOrDefault + "';";
  }
  Page.ClientScript.RegisterClientScriptBlock(GetType(), "closeafterpostback", function + "EPi.GetDialog().Close(EPi.GetDialog().returnValue);}", true);
  ((PageBase)Page).ScriptManager.AddEventListener(Page, new CustomEvent(EventType.Load, "CloseAfterPostback"));
}