Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A Plugin for CKEditor With Dynamic Data

0.00/5 (No votes)
16 Dec 2010 1  
With JavaScript and a bit of ASP.NET, it is possible to create a plugin for CKEditor that allows users to select from items drawn from a database.
Plugin_open.gif

Introduction

This article demonstrates how to move server-side data into a client-side JavaScript application using ASP.NET, and how to use this data to create a drop-down plugin for the JavaScript-based CKEditor, the next generation of the FCKEditor.

I will not be covering the installation of CKEditor, nor will I be covering the ins and outs of writing a plugin. I am making the assumption that the reader already has the editor installed and is familiar with the basics of plugin development. If you are looking for this information, I would recommend the CKEditor website[^]. This tutorial[^] by George Wu on how to write a plugin was very helpful as well.

Background

I am in an on-going project to update my company's website. Part of that project required updating our in-house publishing system, which allows executives to write news articles and other items for distribution to our field reps. The original content publisher was written using FCKEditor and several compiled modules written by a contractor in China. In other words, I have almost no source code and much of the documentation is in Chinese. Yes, they admitted that this was so they would be retained as contractors. It didn't work.

I installed CKEditor to our test website and got it working with minimal fuss. The next challenge was to replicate several plugins that inserted links to staff pages, sections of the company policy manual and our library of forms. The plugin itself was not a huge challenge, but figuring out how to move data from our database into the JavaScript-based editor was. This article demonstrates how I got this to work.

Transferring Data

My first effort to make data available to the editor involved embedding it into the page itself as a JavaScript constant. This proved to be unwieldy: the data for just staff information took more bytes than everything else on the page. I was able to find examples that used PHP to funnel data, but the only server-side language we have available is .NET.

It turns out that there is an object, XMLHttpRequest, that is now a standard part of the JavaScript implementation. Older browsers, such as IE5 and IE6, are able to use a very similar object, Microsoft.XMLHTTP. With them, you can generate a web request independent of the page itself and get a JavaScript XML DOM object.

The code I used comes courtesy of W3 Schools[^] and looks like this:

if (window.XMLHttpRequest)
  {// code for IE7+, Firefox, Chrome, Opera, Safari
      xmlhttp=new XMLHttpRequest();
  }
else
  {// code for IE6, IE5
      xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");
  }
xmlhttp.open("GET","books.xml",false);
xmlhttp.send();
xmlDoc=xmlhttp.responseXML;

Once this executes, the object xmlDoc holds an XML object from which you can retrieve your data. We will be seeing more of this later.

Method 1: Static XML File

The obvious way to fetch your data file is to use a static XML file. Simply replace books.xml in the example with the correct path.

<Personnel>

    <Person id="1" firstName="Alvin" lastName="Aardvark" />
    <Person id="1" firstName="Betty" lastName="Butters" />
    <Person id="1" firstName="Chris" lastName="Carson" />
    <Person id="1" firstName="David" lastName="Daniels" />
    
</Personnel>

Method 2: Aliased XML File

I am fussy about how my websites are organized: I want application data to be kept in the app_data folder where I can easily find it. The downside is that app_data is out-of-bounds to IIS, and files there cannot be served. Rather than move (and with my memory, certainly lose) the data to somewhere else, I can use ASP.NET to "alias" the file and make it available. The technique is very simple.

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
    Dim Doc As New XmlDocument
    Doc.Load(Request.MapPath("/App_Data/Personnel.xml"))

    Response.Buffer = True
    Response.ClearContent()
    Response.ClearHeaders()
    Response.ContentType = "text/xml"
    Response.Write(Doc.OuterXml)
    Response.End()
End Sub

In the Load event (it won't work any earlier), load your data into a XmlDocument, clear the response stream, reset the page to serve as an XML file, write the XML to the response stream, and end processing of the page. Now this page will be treated as XML data even though its extension is .aspx.

Method 3: Dynamic Data

Method 2 shows how we can use ASP.NET to deliver dynamic data: instead of using XmlDocument.Load, we generate an ad hoc XML document and deliver it.

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
    Dim Doc As XmlDocument = GenerateData()

    Response.Buffer = True
    Response.ClearContent()
    Response.ClearHeaders()
    Response.ContentType = "text/xml"
    Response.Write(Doc.OuterXml)
    Response.End()
End Sub

Protected Function GenerateData() As XmlDocument
    Dim Doc As New XmlDocument
    Dim Root As XmlElement = Doc.CreateElement("Personnel")

    Doc.AppendChild(Doc.CreateXmlDeclaration("1.0", Nothing, Nothing))
    Doc.AppendChild(Root)

    Dim Sql As New StringBuilder
    Dim Conn As SqlConnection = Nothing
    Dim Cmd As SqlCommand = Nothing
    Dim Adapter As SqlDataAdapter = Nothing
    Dim DS As DataSet = Nothing

    Dim Person As XmlElement = Nothing
    Dim Node As XmlElement = Nothing

    Sql.Append("SELECT EmpId, FirstName, LastName ")
    Sql.Append("FROM Employees WHERE TerminatedDate IS NULL ")
    Sql.Append("ORDER BY LastName, FirstName")
    Try
        Conn = New SqlConnection(ConnectionString)
        Cmd = New SqlCommand(Sql.ToString, Conn)
        Cmd.CommandTimeout = 300
        Adapter = New SqlDataAdapter(Cmd)
        DS = New DataSet
        Adapter.Fill(DS)
        For Each DR As DataRow In DS.Tables(0).Rows
            Person = Doc.CreateElement("Person")
            Person.SetAttribute("id", DR("EmpId").ToString)
            Person.SetAttribute("firstName", DR("FirstName").ToString)
            Person.SetAttribute("lastName", DR("LastName").ToString)
            Root.AppendChild(Person)
        Next
    Catch ex As Exception
        Throw ex
    Finally
        If DS IsNot Nothing Then DS.Dispose()
        If Adapter IsNot Nothing Then Adapter.Dispose()
        If Cmd IsNot Nothing Then Cmd.Dispose()
        If Conn IsNot Nothing AndAlso Conn.State <> ConnectionState.Closed Then
            Conn.Close()
            Conn.Dispose()
        End If
    End Try

    Return Doc
End Function

Writing the Plugin

The next step is to write the staff_links plugin itself. The full source is in the example download.

In the plugin, we add a richcombo plugin, which is defined in the CKEditor core. We then define how the richcombo is initialized and what happens when a list item gets clicked.

init : function () {
    if (editor.StaffData && editor.loadXMLDoc) {
        var xml = editor.loadXMLDoc(editor.StaffData);
        var nodes=xml.selectNodes( 'Personnel/Person' ); 
        for ( var i = 0 ; i < nodes.length ; i++ ) {
            var Person = nodes[i];
            var id = Person.getAttribute('id');
            var firstName = Person.getAttribute('firstName');
            var lastName = Person.getAttribute('lastName');
            var FirstLast = firstName + ' ' + lastName;
            var LastFirst = lastName + ', ' + firstName;
            
            this.add(id + delim + FirstLast, LastFirst, LastFirst);
        }
    }
},

onClick : function(value) {
    var item = value.split(delim);
    var id = item[0];
    var name = item[1];
    var v = '<a href="' + staffUrl + '?id=' + id + '">' + name + '</a>';
    
    editor.fire('saveSnapshot');
    editor.insertHtml(v);
}

The editor object is a reference to the current instance of the CKEditor, and is passed in to the plugin by the editor's infrastructure. The StaffData property and loadXMLDoc method are added as part of the initialization of the editor; more about that later.

In the init event, the code verifies that there is both a path and a method, then uses them to load the data into an XML object. selectNodes takes an XPath parameter; in this case, it assigns nodes to an array of all Person elements inside Personnel. It then iterates through each Person and extracts the data from its attributes. The call to this.add adds a list item to the richcombo. Of its three parameters, the first is the item's value: note that I am passing the employee id and the name arranged first last, separated by a predefined delimiter. The second parameter is how the list item will appear when the list is dropped, and the third is the tooltip that will appear when the user hovers over the list item. This event gets fired only once, the first time the plugin is activated. This means that the data request will occur, at most, only once; if the plugin is not used, the request for data will never be made.

The onClick event gets fired when a list item is clicked. It receives the value of the list item, which gets split out into the id and name. These are used to create a link, which is inserted as HTML into the editor screen at the carat. The call to saveSnapshot creates a save point, allowing the Undo plugin to be used if desired.

Plugin_results.gif

Implementation: config.js

CKEditor makes it easy to modify the editor's configuration. In the root directory of the CKEditor installation is a file called config.js, which is called every time an instance of the editor is initialized. Normally, the function it holds is empty; it is here that we add the plugin and set up the menu to show it.

CKEDITOR.editorConfig = function( config )
{
    config.extraPlugins = 'staff_links';

    config.toolbar='Publisher';
    config.toolbar_Publisher= [
    ['Source'],
    ['Print','Preview'],
    ['Cut','Copy','Paste','PasteText','PasteFromWord'],
    ['NumberedList','BulletedList','Blockquote'],
    ['JustifyLeft','JustifyCenter','JustifyRight','JustifyBlock'],
    ['Link','Unlink'],
    ['Undo','Redo'],
    ['Spellchecker','Scayt'],
    ['Find','Replace'],
    '/',
    ['Bold','Italic','Underline','Strike'],
    ['Table','HorizontalRule','SpecialChar'],
    ['StaffLinks'],
    ['About']
];

};

In config.extraPlugins, place a comma-delimited list of all your plugins. We also define a custom menu layout and assign that layout to the editor. There are a lot of options for this; read the documentation for details. Note that StaffLinks is added to the menu layout, which is how the plugin is made visible.

Plugin_closed.gif

Implementation: ckeditor_initialize.js

Rather than put all of the initialization code in every page that uses the editor, I put it in a separate script file.

function Init_CKEditor(ClientId) {
    // Perform the replace on the given client id
    var Editor = CKEDITOR.replace(ClientId);
    
    // Add a function used by our custom plugins
    Editor.loadXMLDoc = function(path) {
        var xhttp=new XMLHttpRequest();
        xhttp.open("GET", path, false);
        xhttp.send();
        return xhttp.responseXML;
    }
    
    // The data used by the plugins
    Editor.StaffData = '/DynamicXml.aspx'; 
}

This method accepts the client id of the object that will be transformed into the editor. Technically, any control can be replaced, but the designers of CKEditor recommend sticking with a textarea or div tag. The id is passed into CKEDITOR.replace which does its magic and returns a reference to the newly-created instance of the editor.

JavaScript has a very useful - and dangerous - ability to add methods and properties to objects ad hoc. The next two statements make use of this by adding the loadXMLDoc method and StaffData property to the editor instance. This makes it easy for the plugin to find its data, and makes the XML loading method globally available to any other plugins I might want to write.

Implementation: The Page Itself

The last development step is to pull it all together and make the editor available to your users.

<%@ Page Language="VB" ValidateRequest="false"  %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" 
  "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">

<html xmlns="http://www.w3.org/1999/xhtml">
<head id="Head1" runat="server">
    <title>Test Page</title>
    <script type="text/javascript" src="/ckeditor/ckeditor_source.js"></script>
    <script type="text/javascript" src="/scripts/ckeditor_initialize.js"></script>	    
</head>
<body onload="Init_CKEditor('<%=TestText.ClientId %>');">
    <form id="form1" runat="server">
	<h3>Test CKEditor</h3>
    	
	<asp:TextBox ID="TestText" TextMode="MultiLine" runat="server" />
	<hr style="margin:1em 0;" />
	<asp:Button ID="PostbackOnlyButton" runat="server" Text="Generate Postback" />
    </form>
</body>
</html>

There are a few things to note. First off, page validation needs to be turned off. This is because the staff_link plugin injects HTML; when the data gets posted, page validation will treat this as a hostile act. Second, the script file ckeditor_source.js MUST be included on every page that uses the editor. Our own ckeditor_initialize.js file is included, and the onload event for the body calls our initialization script. There is something very interesting here also: the host for the editor is a asp:TextBox control. By the time the page reaches the client, ASP.NET has transformed the control into a textarea, so it is perfectly legal to pass it to CKEditor. This means that the text added by the user through the editor gets posted back as the control's Text property, which opens up some interesting coding possibilities. The downside is that CKEditor normally passes the data back through Request.Form. I need to verify this, but I suspect that using an ASP control will result in the text getting passed back twice, reducing efficiency. Unless you have a pressing need, you will probably be better off using HTML for your hosting control rather than ASP.NET.

A Few Caveats

There is one formatting issue that I was not able to resolve: when the user clicks on a list item, the text area of the combo box gets changed to reflect the item clicked. For a plugin like this, I would much rather not change the label. I could go into the source for the richcombo plugin and alter the behavior, but that would change ALL combo boxes in the editor, and would almost certainly get overwritten if a newer version of CKEditor gets installed. So for now, I will just call it a feature: Keep track of the last person linked in!

Another thing to keep in mind is that the developers of CKEditor have not yet published official documentation on how to write plugins for the new editor. In other words, a later version may implement things differently, or have different functionality than what I use here.

Conclusion

I expect that there are bugs and mistakes in this code; if you see any, please let me know.

Article History

  • Version 3 - December 16, 2010 - Initial release

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here