Creating a one-click tweet editor plugin for EPiServer 7.x

24.09.2014 07.37.50

Being the new guy has its ups, especially since I started working for Geta a little more than two weeks ago. During this initial period there's been plenty of opportunity to broaden my horizons knowledgewise. So since we're all about knowledge sharing here I thought it was time to give something back.

Specification

The following is what we aim to create, a plugin with its own button in the TinyMCE editor of EPiServer 7.x.

TinyMCE Toolbar

This button opens a dialog based on the current selected text in the editor. The dialog creates a 'one-click tweet' link.

It's clickable when an appropriate selection is made, and disabled if:

  • Selection contains html that describes layout or functionality.
  • Selection is an ordinary link.

It's highlighted when current selection is inside an existing 'one-click tweet' link.

A dialog containing input for quote and author

The quote field is mandatory and the dialog is not submittable if character count is < 0.

The character count turns red if count is < 0. 

If selection is on an existing 'one-click tweet'-link all values are repopulated into the dialog to enable editing.

After submitting the dialog the selected text turns from

Believe you can and you're halfway there

into this

(linked content) Believe you can and you're halfway there

For a visitor, clicking this generated link results in

Dialog containing tweet entry for twitter.com

Pretty neat, if I'm allowed to be overly confident in your enthusiasm towards EPiServer 7 editor plugins. 

Plugin definition

Before we start I would like to point out that part of what I'm writing here is already pretty well documented over at EPiServer World, I'm doing things in a different order for pedagogical reasons.

First of all, we need to define the plugin in EPiServer, for that you create a class (preferrably in a fancy namespace like Project.Web.Infrastructure.Plugins) decorated with a EPiServer.Editor.TinyMCE.TinyMCEPluginButtonAttribute. Here is how ours look.

using EPiServer.Editor.TinyMCE;
namespace Project.Web.Infrastructure.Plugins
{
[TinyMCEPluginButton(PlugInName = "twitterquote", ButtonName = "twitterquotebutton", GroupName = "misc", LanguagePath = "/admin/tinymce/plugins/twitterqoute", IconUrl = "/Util/Images/PluginIcons/TwitterQuote.png")]
public class TwitterQuotePlugin
{

}
}

The official documentation offers depth on the different attributes. Remember the parameters PlugInName and ButtonName in the attributes, these need to match the TinyMCE plugin script exactly, so no errors, or you're screwed.

TinyMCE plugin

Secondly, we need to create the actual plugin that is going to run when TinyMCE loads inside EPiServer.

There are multiple ways to hook it up, but the easiest is to place a javascript file inside '/util/editor/tinymce/plugins/PlugInName/' named 'editor_plugin.js'. Folder naming is not case sensitive. Next we need to define the plugin. Here's how mine looks, find comments inside and a couple of highlights below.

/*
* Twitter quote plugin
*
* Developed by: Geta AS, Sven-Erik Jonsson
*/

(function (tinymce, $) {

tinymce.create('tinymce.plugins.twitterquote', {

pageId: null,
pageUrl: null,
selectedLink: null,

// Initializes the plugin
init: function (ed, url) {

var context = ed.settings.epi_page_context;
if (context) {
this.pageId = this.getPageId(context);
}

var scope = this;

// add the button to the editor in edit mode, not done automatically by EPi
// button must have exactly the same name as in your plugin definition decorated
// with TinyMCEPluginButton
ed.addButton('twitterquotebutton', {
title: 'Quote tweet',
image: '/util/images/pluginicons/twitterquote.png',
onclick: function(e) {

var options =
{
url: scope.pageUrl,
link: scope.selectedLink,
twitterAuthority: 'https://twitter.com',
twitterAction: '/intent/tweet',
twitterMaxChars: 140,
linkClass: 'tweet-link'
}

// Opens a blocking modal window in edit mode
// Passes page id and language of current page to editor
ed.windowManager.open({
title: "Edit tweet quote",
url: '/util/dialogs/twitterquote.aspx?content=' + scope.pageId + "&language=" + context.epslanguage,
width: 640,
height: 330
}, options);
}
});

// hook up onNodeChange on editor and listen to selection changes
// this is useful for enabling and disabling the icon in real time
ed.onNodeChange.add(function (ed, cm, n, co) {

// get contents of current selection
var text = ed.selection.getContent();
var illegalHtml = false;

// check if selection contains block or layout elements
// in this case it is probably best to cancel
// since risk of messing up existing html
if (scope.nonAllowedHtmlInSelection(text)) {
illegalHtml = true;
}

// get <a> if present as parent of selected node
var a = ed.dom.getParent(n, 'a', ed.getBody()) || (n.tagName === 'A' ? n : null);

// check if <a> is a twitter link
var anchorIsTweet = ((a !== null) && !a.name && a.getAttribute('class') == 'tweet-link');

// check if the plugin button should be disabled
var disabled = co && (a === null) || (a === null) && illegalHtml || (a !== null) && !anchorIsTweet;

// if parent <a> is a tweet-link select it
if (anchorIsTweet) {

if (a.innerHTML === text)
ed.selection.select(a);

scope.selectedLink = a;

} else {
scope.selectedLink = null;
}

// set the status of plugin button in accordance with selection
cm.setDisabled('twitterquotebutton', disabled);
cm.setActive('twitterquotebutton', anchorIsTweet);
});
},
// this method searches for illegal html in a text
nonAllowedHtmlInSelection:function(text) {

// the expression matches all opening and closing tags
var expression = new RegExp(/<\/{0,1}([a-z]{0,})\s{0,1}[^>]*.?>/ig);

// a list of disallowed html |-separator could be anything, for example ','
var disallowed = "a|p|div|ul|ol|table";

var match;

// loop all matches in expression
while (match = expression.exec(text)) {

var capture = match[1].toLowerCase();

// check if matched tag present in disallowed
if (disallowed.indexOf(capture) > -1)
return true;
}

return false;
},

// this method uses epi editor settings context
// to find which content is currently edited.
getPageId: function (context) {
if (context == null) return "";

// expression captures page id as group 1
// expression captures page version as group 2
var expression = new RegExp(/([0-9]{1,})_([0-9]{1,})/i);
return expression.exec(context.id)[1];
},

// returns info about the plugin, write something snappy
getInfo: function () {
return {
longname: 'Twitter quote button',
author: 'Geta AS, Sven-Erik Jonsson',
authorurl: 'http://geta.se',
infourl: 'http://geta.se',
version: tinymce.majorVersion + "." + tinymce.minorVersion
};
}
});

// Register plugin with TinyMCE
tinymce.PluginManager.add('twitterquote', tinymce.plugins.twitterquote);

})(tinymce, epiJQuery);

The crucial parts of this script are 'tinymce.create('tinymce.plugins.PlugInName', params)' which defines the plugin in TinyMCE, the 'init(ed[itor], url)' method, and the ed[itor].addButton('ButtonName', params). Without these the plugin will not be visible.

Noteable is the ed.onNodeChange event, which is useful for enabling and disabling the plugin. Inside, use c[o]m[mand].setDisabled('ButtonName', true/false) to make the button clickable/nonclickeable, or cm.setActive('ButtonName', true/false) to highlight the button in a very fashionable blue color, as if active.

Also noteable in this script is that this plugin will only work if the editor is called from a property on a page, blocks will not have a friendly url handy. Suggestions are welcome on how to address this.

Dialog

You probably noted that the previous script contained practically nothing about the cool stuff I mentioned in the specification, unfortunately, we have a bit to go. Thank you for holding out so far. So, next it is time to create the contents the dialog window. For that I peeked inside the contents of '/Modules/_protected/CMS/EPiServer.Cms.Shell.UI.zip', which you should have in your solution, I suggest to do the same as to examples of how certain problems are solved inside EPiServer.

For this plugin to work, we need to know a couple of things from EPiServer, what is the friendly url of the current page? Also it is pretty nice to have your site settings and different translations available inside the dialog. Now, hear me out before you start booing and throwing tomatoes... I solved this by creating a web form. There are certainly nicer ways to solve this inside your brand new, shiny MVC or die trying web application, but this is the way EPiServer does it.

I created the web form named 'twitterquote.aspx' under '/util/dialogs/'. The form takes a query parameter 'content' that is used in finding the correct friendly url to the page. Let's take a look at the code behind.

using System;
using System.Web;
using System.Web.UI;
using EPiServer.Core;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Web.Routing;

namespace Geta.EPi.Cms.Plugins
{
public partial class TwitterQuote : Page
{
public UrlResolver _resolver;

protected override void OnLoad(EventArgs e)
{
_resolver = ServiceLocator.Current.GetInstance<UrlResolver>();
base.OnLoad(e);
}

public string Url
{
get
{
var content = Request["content"];
var language = Request["language"];

if (string.IsNullOrEmpty(content))
return string.Empty;

var contentId = int.Parse(content);
var contentRef = new ContentReference(contentId);

var pageUrl = _resolver.GetUrl(contentRef, language, new VirtualPathArguments(){ ContextMode = ContextMode.Default });
var baseUrl = VirtualPathUtility.RemoveTrailingSlash(SiteDefinition.Current.SiteUrl.ToString());

return baseUrl + pageUrl;
}
}
}
}

And then the code front.

<%@ Page Language="C#" AutoEventWireup="false" CodeFile="TwitterQuote.aspx.cs" Inherits="Geta.EPi.Cms.Plugins.TwitterQuote" %>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Twitter setning</title>
<link rel="stylesheet" href="/Util/Styles/Vendor/bootstrap.min.css" />
<link rel="stylesheet" href="/Util/Styles/Vendor/bootstrap-theme.min.css" />
<link rel="stylesheet" href="/Util/Styles/TwitterQuote.css" />
</head>
<body>
<div class="container-fluid">

<form class="plugin-twitterquote form-horizontal" role="form">

<input type="hidden" name="pageUrl" value="<%= Url %>" />

<%-- Replace following with settings from configuration --%>
<input type="hidden" name="bitlyAuthToken" value="*your auth token*" />

<div class="form-group header">
<div class="col-xs-2">
<img src="/Util/Images/twitter.png" />
</div>
<div class="col-xs-10">
<h3>Enter quote for twitter</h3>
</div>

</div>

<div class="form-group">

<%-- Translation of texts also possible from aspx --%>
<label for="quote" class="col-xs-2 control-label">Quote</label>
<div class="col-xs-10">
<textarea name="quote" class="input input-quote form-control" rows="2"></textarea>
</div>
</div>

<div class="form-group">
<label for="author" class="col-xs-2 control-label">Forfatter</label>
<div class="col-xs-10">
<input type="text" name="author" class="input input-author form-control" />
<em class="text-muted">Optional. If an author is entered, it will be displayed after the quote</em>
</div>
</div>

<div class="form-group is-last">

<label for="message" class="col-xs-2 control-label">Message</label>

<div class="col-xs-10">
<blockquote>
<p name="message" class="label-message">

</p>
</blockquote>
</div>

</div>

<div class="form-group is-footer">
<div class="col-xs-12 text-right">
<span class="label-character-count text-muted">140</span><span class="text-muted"> characters remain</span>
<button class="btn btn-info button-submit" type="button" href="#">Apply link</button>
</div>

</div>

</form>

</div>

<script src="/Util/Javascript/Vendor/bootstrap.min.js"></script>
<script src="/Util/Javascript/Api/api.connector.base.js"></script>
<script src="/Util/Javascript/Api/api.connector.bitly.js"></script>
<script src="/Util/Javascript/TwitterQuote.js"></script>
</body>
</html>

The front end code of things that could be improved upon, you could adopt minification of the included scripts with System.Web.Optimization. Translation of UI texts should be added using EPiServer and the bitlyAuthToken should be moved to a setting. Notice the hidden field 'pageUrl' which is populated with a friendly url to the current edited page.

Dialog client side scripts

For the main script I went with a rather straightforward scope wrapped script setup and created a file named 'TwitterQuote.js' under '/util/javascript/'.

Let's dive straight in...

/*
* Twitter quote plugin dialog script
*
* Developed by: Geta AS, Sven-Erik Jonsson
*/ (function ($) {

// Get active editor and supplied parameters from top window
var editor = top.tinymce.activeEditor;
var params = editor.windowManager.params;

// Get bitly auth token from hidden field on dialog
var bitlyAuthToken = $("input[name='bitlyAuthToken']").val();

// Get pageUrl from hidden field on dialog
var pageUrl = $("input[name='pageUrl']").val();

// Construct a connector to bitly (described later)
var bitlyConnector = new ApiConnectorBitly(bitlyAuthToken, { async: false });
var twitterBaseUrl = params.twitterAuthority + params.twitterAction;

// Define variables
var id = null;
var idCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

var $form = $("form.plugin-twitterquote");
var $quote = $form.find(".input[name='quote']").first();
var $author = $form.find(".input[name='author']").first();
var $count = $form.find(".label-character-count").first();
var $message = $form.find(".label-message").first();
var $button = $form.find(".button-submit").first();

var shortUrl;

// Setup plugin
setDefaults();
bindEvents();
updateDisplay();

// Parse link and contents from editor and parameters
function setDefaults()
{
if (params.link != null)
editor.selection.select(params.link);

$quote.val(editor.selection.getContent({ format: 'text' }));

if (params.link != null)
parseHref(params.link);

// Generate a semi unique id, this has a 1/62^6 chance
// of not beeing unique (happens once every 57 million times or so)
// set to a higher number if required, also generates longer string if raised
if (!id)
id = getId(6);

// Get a short url from bit.ly
if (shortUrl == null)
shortUrl = getShortenedUrl(pageUrl);
}

// Setup events
function bindEvents()
{
$quote.on("keyup", updateDisplay);
$author.on("keyup", updateDisplay);
$button.on("click", submit);
}

// Parse the href and id in existing <a>
function parseHref(link)
{
var $link = $(link);
var raw = $link.attr("href");
var query = bitlyConnector.parseQueryString(raw);

// this expression matches
// everything inbetween "-quotes as group 1
// everything after group 1 following ' - ' until 'http://' or 'https://' as group 2
// everything in 'http://' or 'https://' as group 3
var expression = new RegExp(/\"(.+)\"\s-{0,1}\s{0,1}([^"]*?)\s{0,1}(https{0,1}:\/\/.*?){0,}$/i);
var match = expression.exec(query.text);

if (match)
{
// get group 2 as author
if (match.length > 3) {
$author.val(match[2]);
}

// get group 1 as match
$quote.val(match[1]);
}

// is an url is specified in the link
// we capture it from there
shortUrl = query.url;

// and get the id from the tag if present
if ($link.attr("id")) {
id = $link.attr("id");
}
}

// Update markup with current changes
function updateDisplay()
{
// Set a new message
$message.text(buildMessage() + shortUrl);

// count characters for twitter
var count = params.twitterMaxChars - ($message.text().length + 1);

// make the character count red if not enough
if (count < 0 && !$count.hasClass("text-danger"))
{
$count.addClass("text-danger");
$count.removeClass("text-muted");
}
else if (count >= 0 && $count.hasClass("text-danger"))
{
$count.addClass("text-muted");
$count.removeClass("text-danger");
}

$count.text(count);

// disable submit button if message is too long
if (isValid())
{
$button.removeAttr("disabled");
}
else
{
$button.attr("disabled", "disabled");
}
}

// submit changes
function submit(e)
{
// prevent form postback from button
e.preventDefault();

$button.attr("disabled", "disabled");

// build the resulting tag
var $link = $("<a>");

var text = buildMessage();
text = text.substr(0, text.length - 1);

if (id != null)
{
$link.attr("id", id);
}

var originUrl = getShortenedUrl(pageUrl);
var twitterUrl = buildUrl(originUrl, text);

$link.addClass(params.linkClass);
$link.attr("href", twitterUrl);
$link.html(editor.selection.getContent({ format: 'text' }));

var $container = $("<div>");
$container.append($link);

// capture errors when setting content
// prevents a bug in firefox
try { editor.selection.setContent($container.html()); }
catch (error) { }

parent.tinyMCE.activeEditor.windowManager.close(window);
}

function isValid()
{
return ($count.text() * 1) > -1;
}

function buildMessage()
{
var result = "\"" + $quote.val().replace(/[\"]/g, "") + "\" ";

if ($author.val().length > 0)
result += "- " + $author.val().replace(/[\"]/g, "") + " ";

return result;
}

function getShortenedUrl(url)
{
if (id != null)
url = stripUrl(url, "#") + "#" + id;

bitlyConnector.request(this, { longUrl: url },
function (data, status, xhr) {
url = data;
},
function (xhr, status, error)
{
console && console.log(error);
}
);

return url;
}

function getId(length)
{
var result = '';
var size = idCharacters.length - 0.00000001;

for (var i = 0; i < length; i++)
{
var pos = Math.floor(Math.random() * size);
result += idCharacters.charAt(pos);
}

return result;
}

function buildUrl(url, text)
{
return twitterBaseUrl + "?" + bitlyConnector.toQueryString({ url: url, text: text });
}

function stripUrl(url, char)
{
var index = url.indexOf(char);

if (index < 0)
return url;

return url.substr(0, index);
}

})($);

This is some pretty self explaining jQuery. Noteable is the getId-method which generates a random id for the link. Increase the number from 6 to 8 if you need an absolutely astronomically low chance of a duplicate id.

Sidenote: the 'isValid' method uses a numeric hack in javascript and could seem a little counterintuitive. The principle is if you have a numeric in a string and multiply it by 1, you get a number back without parsing. This could backfire on you though if the text contains alphabetics.

Url shortening API connector

As you surely noted some additional class was called inside the dialog script. It contains some helper classes that deal with parsing query strings and getting a short url from bitly. Create 'api.connector.base.js' and 'api.connector.bitly.js' under '/util/javascript/api'.

/*
* API base
*
* Developed by: Geta AS, Sven-Erik Jonsson
*/

function ApiConnectorBase()
{
this.init();
}

ApiConnectorBase.prototype =
{
settings: {
serviceAuthority: '',
serviceBaseUrl: '',
serviceAjaxOptions: { dataType: 'json' },
dataUrlTemplate: ''
},
init: function ()
{
},
request: function (scope, params, onSuccess, onError, onComplete)
{
var internalScope = this;

var options = $.extend({}, this.settings.serviceAjaxOptions,
{
url: this.getRequestUrl() + '?' + this.toQueryString(params),
success: function (data, status, xhr)
{
internalScope.onRequestSuccess(data, status, xhr, scope, onSuccess);
},
error: function (xhr, status, error)
{
internalScope.onRequestError(xhr, status, error, scope, onError);
},
complete: function (xhr, status)
{
internalScope.onRequestComplete(xhr, status, scope, onComplete)
}
});

$.ajax(options);
},
onRequestSuccess: function (data, status, xhr, scope, onSuccess)
{
if (typeof (onSuccess) === 'function')
{
data = this.formatApiData(data);

try { onSuccess.call(scope, data, status, xhr); }
catch (error) { onSuccess(data, status, xhr); }

}
},
onRequestError: function (xhr, status, error, scope, onError) {
if (typeof (onError) === 'function')
{
try { onError.call(scope, xhr, status, error); }
catch (error) { onError(xhr, status, error); }
}

},
onRequestComplete: function (xhr, status, scope, onComplete)
{
if (typeof (onComplete) === 'function')
{
try { onComplete.call(scope, xhr, status); }
catch (error) { onComplete(xhr, status); }
}

},
formatApiData: function(data)
{
return data;
},
getRequestUrl: function ()
{
return this.settings.serviceAuthority + this.settings.serviceBaseUrl;
},
getDataUrl: function (data)
{
return this.settings.dataUrlTemplate.tokenize(data);
},
toQueryString: function (collection)
{
var result = [];

for (var parameter in collection)
{
if (!collection.hasOwnProperty(parameter)) continue;
else if (collection[parameter] == null) continue;
else if (typeof collection[parameter] === 'function') continue;
else if (typeof collection[parameter] === 'object') continue;

var keyValue = parameter + '=' + encodeURIComponent(collection[parameter]);

result.push(keyValue);
}

return result.join('&');
},
parseQueryString: function (queryString)
{
var result = {}
var parameters;

var queryIndex = queryString.indexOf('?');
var keyValue, i, l;

if (queryIndex > -1)
queryString = queryString.substr(queryIndex + 1);

parameters = queryString.split('&');
l = parameters.length;

for (i = 0; i < l; i++)
{
keyValue = parameters[i].split('=');
result[keyValue[0]] = decodeURIComponent(keyValue[1]);
}

return result;
},
tokenize: function (template, data) {
var value = template;
return value.replace(/{([a-z0-9_]+)}/g, function(match, token) {
return typeof data[token] != 'undefined'
? data[token]
: match;
});
}
}

The base class sets up a couple of nice things to have when communicating with an external api. For example the tokenizer, that allows you to map object properties against named template tags for example the template '?query={parameter}' will replace '{parameter}' with the string contents of data.parameter, or the methods 'parseQueryString' and 'toQueryString' that encodes and decodes well, you figure.

/*
* API connector against bitly
*
* Developed by: Geta AS, Sven-Erik Jonsson
*/

function ApiConnectorBitly(token, options, action, authority)
{
this.init(token, options, action, authority);
}

ApiConnectorBitly.prototype = new ApiConnectorBase();
ApiConnectorBitly.prototype.constructor = ApiConnectorBitly;

$.extend(ApiConnectorBitly.prototype,
{
settings: {
serviceAuthority: 'https://api-ssl.bitly.com',
serviceBaseUrl: '/v3/shorten',
serviceAuthToken: '',
serviceFormat: 'json',
serviceAjaxOptions: { dataType: 'json' },
},
init: function (token, options, action, authority)
{
this.settings.serviceAuthToken = token;

if (action)
this.settings.serviceBaseUrl = action;

if (authority)
this.settings.serviceAuthority = authority;

$.extend(this.settings.serviceAjaxOptions, options);
},
request: function (scope, params, onSuccess, onError, onComplete) {

$.extend(params,
{
access_token: this.settings.serviceAuthToken
});

ApiConnectorBase.prototype.request.call(this, scope, params, onSuccess, onError, onComplete);
},
formatApiData: function (response) {

var result = '';

if (response != null)
{
if (response.status_code == 200 && response.data != null)
{
result = response.data.url;
}
else
{
console && console.log(response);
}
}

return result;
}
});

The bitly connector just extends with specifics for getting short urls from bitly. After adding this functionality, you should be all set to start testing out this neat new functionality, but before you do... remember to add the TinyMCE plugin to an editor inside admin.

Epilogue, some styles for the dialog

Here are some additional styles for the dialog, should tidy it up a bit.

.header { background-color: #f9f9f9; padding-bottom: 10px; margin-bottom: 25px;position: relative; padding-left:0; }
.header img { position: absolute;left:10px; top: 0; }

.button-submit { margin-left: 10px; }

textarea { resize: none; }

.form-group.is-last { margin-bottom: 0; }
.form-group.is-last blockquote { margin-bottom: 10px; }
.form-group.is-footer { position: absolute; bottom: 15px; right: 15px; margin-bottom: 0; }

.label-message { max-height: 75px;overflow: hidden; text-overflow: ellipsis; }

There is a part I left out, mainly because the blog post was getting rather lengthy as is. It is the styles for the site and the scripts that pops up a new window. Please comment and let me know what you think, or if you're interested in aforementioned.