June 1, 2012

Adding a custom image upload dialog to the PageDown markdown editor

I’ve been using StackOverflow’s updated version of the WMD mark down editor, PageDown, for some time. They’ve fixed quite a few of the bugs in the old (no longer maintained) version and have provided a nice API for hooking in additional functionality.

One thing I’ve wanted to do for a while is provide a way of uploading images directly from the editor. Previously our users have had to upload the image first and then paste in its public URL.

Unfortunately there was little help and no examples of doing this on the web. It doesn’t look like StackOverflow have released the code for their uploader so it was down to me and my rusty JavaScript to do the work. Sufficed to say, it took me a long time, but I got there in the end.

On to the codez

To handle the insert image button yourself you need to add a “insertImageDialog” hook, a function that receives a callback for returning a link and returns true (to tell the editor that you’re handling the image button).

The example on the PageDown site gives a simple and good humoured examples of this:

editor.hooks.set("insertImageDialog", function (callback) {
    alert("Please click okay to start scanning your brain...");
    setTimeout(function () {
        var prompt = "We have detected that you like cats. Do you want to insert an image of a cat?";
        if (confirm(prompt))
            callback("http://icanhascheezburger.files.wordpress.com/2007/06/schrodingers-lolcat1.jpg")
        else
            callback(null);
    }, 2000);
    return true; // tell the editor that we'll take care of getting the image url
});

The timeout is necessary on the above to exit the current scope (not really necessary if you’re opening a dialog). Executing the callback function with a url will add the image to the editor, or you can pass null to return the editor without making changes.

My requirements

I wanted to support entering the URL in directly (as before) or allowing the user to upload a file. The latter of these requirements was the trickier one as we needed to perform an asynchronous upload and pass the result back into the editor’s callback function.

Uploading files Asynchronously

Rather than do it myself I of course looked at a number of JavaScript plugins available to perform async uploads. A key requirement was that I didn’t want to use flash (so SWFupload was out of the question - note if you’re up for a bit of flash - here’s a good article on wiring that up).

The first plugin I looked at was the jQuery form plugin. I got this working quite nicely with ASP.NET MVC (this article helped), but thought I would look for a smaller plugin since I didn’t need any of the other features that this plugin provided. Also, this would require the upload dialog to be placed outside of the form where the PageDown editor existed (my final solution didn’t require this).

The next plugin was Andrew Valums Ajax Upload. This is a really nice plugin, with built in progress bar (browser permitting), that supports multiple file uploads. Unfortunately it doesn’t really support single file uploads. Another downside is that the form elements are created for you automatically which I didn’t particularly like. That said, another great plugin (just not for this). You’ll find this post on SO useful for wiring it up in ASP.NET MVC.

Another one suggested to me was Uploadify. This plugin had lots of tricks up it’s sleeve but you had to pay for the non flash version. It’s only $5 but there were free options available. Plus this plugin is probably overkill for this purpose.

Plupload is another good uploader plugin (I’m actually using it in the same application). Unfortunately it doesn’t support single file uploads.

jQuery File Upload Plugin is really nice, has lots of features and there is a fork with an ASP.NET MVC example. Defintely worth a look if you want multiple file uploads, drag ’n drop and other sexiness. Again it was a bit of an overkill for this. Even the “Basic example” still required 3 scripts which put me off.

Finally (hooray I hear you say) I came across jQuery.AjaxFileUpload.js created by Jordan Feldstein. It’s so simple to use and took me no time at all to wire up for ASP.NET MVC. What I particularly like about this plugin (aside from being so lightweight) is that it still allows you to provide your own form elements (the plugin does the job of wrapping the elements in a form).

This plugin doesn’t use the new HTML5 file api, so doesn’t support progress bars. Instead it uses the fallback approach of posting the upload form to an IFRAME. The plugin does a nice job of making this seamless, so that you still receive a JSON response in your callback.

Wiring up to PageDown

Since we were using jQuery UI in our application already I decided to use a jQuery UI dialog for the image dialog.

Here’s the complete markup for the editor and dialog (we’re using an MVC editor template):

@model String
<div id="wmd-editor" class="wmd-panel">
	<div id="wmd-button-bar"></div>
	@Html.TextAreaFor(model => model, new { id = "wmd-input", cols = "80", rows = "18", @class = "wmd-panel" })
</div>
<div id="insertImageDialog" title="Insert Image">
    <h4>
        From the web</h4>
    <p>
        <input type="text" placeholder="Enter url e.g. http://yoursite.com/image.jpg" />
    </p>
    <h4>
        From your computer</h4>
    <span class="loading-small"></span>
    <input type="file" name="file" id="file" data-action="/media/editorupload" />
</div>

An interesting coincidence/(let’s say it was on purpose) is that the combination of this particular upload plugin and the jQuery UI dialog meant that we could keep the dialog markup with the editor without resulting in nested forms. This wouldn’t have worked on plugins that require you to use your own <form> tags since the dialog would strip them out. Since the dialog markup is actually generated at the bottom of the page and our form tags were generated by the upload plugin at runtime, we were able to circumvent this issue.

The css for the image dialog:

.loading-small { width: 16px; height: 16px; background: url('../images/loading-small.gif') no-repeat;
				 display: inline-block; }
#insertImageDialog { display:none; padding: 10px; }
#insertImageDialog h4 { margin-bottom: 10px; }
#insertImageDialog input[type=text] { width: 260px; }
#insertImageDialog .loading-small { display:none; float: left; padding-right: 5px; }

Now the bit that took some time, adding the image dialog hook:

if ($('#wmd-input').length > 0) {
	var converter = new Markdown.Converter();
	var help = function () { window.open('http://stackoverflow.com/editing-help'); }
	var editor = new Markdown.Editor(converter, null, { handler: help });

	var $dialog = $('#insertImageDialog').dialog({ 
		autoOpen: false,
		closeOnEscape: false,
		open: function(event, ui) { $(".ui-dialog-titlebar-close").hide(); }
	});

	var $loader = $('span.loading-small', $dialog);
	var $url = $('input[type=text]', $dialog);
	var $file = $('input[type=file]', $dialog);

	editor.hooks.set('insertImageDialog', function(callback) {

		// dialog functions
		var dialogInsertClick = function() {                                      
			callback($url.val().length > 0 ? $url.val() : null);
			dialogClose();
		};

		var dialogCancelClick = function() {
			dialogClose();
			callback(null);
		};

		var dialogClose = function() {
			// clean up inputs
			$url.val('');
			$file.val('');
			$dialog.dialog('close');
		};

		// set up dialog button handlers
		$dialog.dialog( 'option', 'buttons', { 
			'Insert': dialogInsertClick, 
			'Cancel': dialogCancelClick 
		});

		var uploadStart = function() {
			$loader.show();
		};
	
		var uploadComplete = function(response) {
			$loader.hide();
			if (response.success) {
				callback(response.imagePath);
				dialogClose();
			} else {
				alert(response.message);
				$file.val('');
			}
		};

		// upload
		$file.unbind('change').ajaxfileupload({
			action: $file.attr('data-action'),
			onStart: uploadStart,
			onComplete: uploadComplete
		});

		// open the dialog
		$dialog.dialog('open');

		return true; // tell the editor that we'll take care of getting the image url
	});

	editor.run();
}

The most beautiful code you’ve ever seen I’m sure!

To begin with we create an instance of MarkDown.Editor. Next we create our dialog instance (rather than creating on every time the user clicks the image button). An important thing here is that took me some time to realize is that the hook executes every time the user clicks the button. This means a) we don’t want to create the dialog within the hook (as it will create multiple instances still) and b) we need to set up our button handlers inside the hook as they require access to the callback. The same applies for wiring up the uploader plugin and why we need to clear any existing “change” handlers.

The code is fairly self explanatory, but in short:

  • Typing in a URL and clicking the “Insert” button will grab the url textbox input and pass this to the callback function (or null if it’s empty).
  • Clicking “Cancel” will just pass null to the callback function.
  • Selecting a file will upload the file asynchronously. The server will respond with the path of the image if the upload was successful which is passed to the callback function.

This is what we’re doing on the server:

[HttpPost]
public ActionResult EditorUpload(MediaEditorUploadModel model)
{           
	string result;
	var serializer = new JavaScriptSerializer();
	
	if (model.File.IsValidFile() && model.File.IsImage()) {
		// upload the file
		
		result = serializer.Serialize(
			new { success = true, imagePath = "http://{the url of the uploaded image}"});
	} else {
		result = serializer.Serialize(
			new { success = false, message = "Invalid image file"});
	}
	
	return Content(result); // IMPORTANT to return as HTML
}

It’s important here to return a string and not a JSON object. If you return JSON then browsers like Chrome will wrap the response in a <pre> tag inside the IFRAME. Returning a string with a HTML content type allows the upload plugin to parse the response correctly as JSON.

Finally, a little tweak to make

The jQuery.ajaxfileupload plugin expects you to set up the handler for the file input once. Unfortunately this is not possible with PageDown since we need to use a different callback each time (Javascript devs pitch in if we can do this better).

Therefore you need to comment out this line from the plugin (line 38):

// if ($element.data('ajaxUploader-setup') === true) return;

Otherwise it will only work the first time you use the dialog.

Oh and this is what it looks like in action.

Feedback

My JavaScript skills are far from great so if this code could be improved I would appreciate the feedback. The real issue I had was how to set up handlers once (for the dialog buttons, uploader) but use a different callback function each time.

Where’s can I download the code

You can’t (before you ask). I’ve put everything I can on this page and it’s taken me so long to get this working that I can’t afford to wrap it up in a nice extension for PageDown.

© 2022 Ben Foster