Introduction
Uploading an image to a website is a task that is required in nearly every website. The requirement could exist due to a CMS background or due to user generated
content like profiles, posts, and various other usages. A common problem is the lack of image editing options. We do not want to build a very rich editor in this article,
however, we want to build an image uploader that can handle more than just plain file uploads. Additionally our image uploader should be able to perform an easy crop job.
Our image uploader will have (about) the same options as the one that is used by Google for uploading images in the contacts web application. We will include the following options:
- From the local file system (standard file upload)
- From another URL (resource from the internet)
- From Flickr (per image search, selecting an image from the list of results)
We will build everything with ASP.NET MVC 3 on the server-side. The client side will heavily use jQuery and a plugin called ImageAreaSelect. The plug-in will enable us to do (instant) cropping from the client's web browser. In order to preview images from the local file system we will use the FileAPI
, which is available in all current browsers.
Background
The basic idea of this whole process is displayed below.
So ASP.NET MVC has the responsibility to generate the page (which does not need much generation since it is mostly static). Then our JavaScript code will handle the client-side events on the generated website. Those events affect mainly the <form>
inside the page. Here we will offer the three possibilities (file upload, URL, Flickr). We need a checkbox to indicate the currently selected method. Some of the form's elements will be hidden and will be changed by the jQuery plug-in which helps us do the cropping.
We will not implement any fallback code, even though a non-JavaScript solution could be implemented as well. There are two main reasons for that:
- The whole thing of preprocessing an image before the image is actually uploaded is obsolete without JavaScript. Therefore a huge part of this article would be dispensable.
- The code would need to include funny things like previously hidden states that will be made visible again by JavaScript and vice versa. Overall any page that takes non-JavaScript browsers into account must first handle those users. The JavaScript will then modify the page for the second (usually much larger) type of users: those with JavaScript enabled. We do want to stay focused on our mission here.
Ready? So let's actually build this fancy image uploader!
Implementation
We start with the "Internet Application" template from the ASP.NET MVC 3 package. For simplification we just remove everything that has something to do with a database or user accounts. In general a webpage will have something like this - but for this mission those two things are not required.
Let's start with the model that will actually be transferred from the client to the server:
public class UploadImageModel
{
[Display(Name = "Internet URL")]
public string Url { get; set; }
public bool IsUrl { get; set; }
[Display(Name = "Flickr image")]
public string Flickr { get; set; }
public bool IsFlickr { get; set; }
[Display(Name = "Local file")]
public HttpPostedFileBase File { get; set; }
public bool IsFile { get; set; }
[Range(0, int.MaxValue)]
public int X { get; set; }
[Range(0, int.MaxValue)]
public int Y { get; set; }
[Range(1, int.MaxValue)]
public int Width { get; set; }
[Range(1, int.MaxValue)]
public int Height { get; set; }
}
So we named the model UploadImageModel
. This is not a required convention like the *Controller convention for any controller. This is, however, very useful in order to distinguish between controller-view exchange models and all other classes.
The next thing to do is to generate some actions. We just place the following action method into the HomeController
:
public ActionResult UploadImage()
{
return View();
}
So this will just show the corresponding view. The URL to this action with the current (default) routing options is "~/Home/UploadImage". The request type should use GET method. We will need another action for receiving the posted form data. This one should be created right before we come to the view and all the client side stuff. Let's have a look at the action first:
[HttpPost]
public ActionResult UploadImage(UploadImageModel model)
{
if (ModelState.IsValid)
{
Bitmap original = null;
var name = "newimagefile";
var errorField = string.Empty;
if (model.IsUrl)
{
errorField = "Url";
name = GetUrlFileName(model.Url);
original = GetImageFromUrl(model.Url);
}
else if (model.IsFlickr)
{
errorField = "Flickr";
name = GetUrlFileName(model.Flickr);
original = GetImageFromUrl(model.Flickr);
}
else
{
errorField = "File";
name = Path.GetFileNameWithoutExtension(model.File.FileName);
original = Bitmap.FromStream(model.File.InputStream) as Bitmap;
}
if (original != null)
{
var fn = Server.MapPath("~/Content/img/" + name + ".png");
var img = CreateImage(original, model.X, model.Y, model.Width, model.Height);
img.Save(fn, System.Drawing.Imaging.ImageFormat.Png);
return RedirectToAction("Index");
}
else
ModelState.AddModelError(errorField, "Your upload did not seem valid. Please try again using only correct images!");
}
return View(model);
}
Now this looks pretty complicated in the beginning. What does the action do exactly? First of all the model is checked for validity. This is a rather simple check just involving the data annotations we've set up in our model. After the simple values have been checked we can focus on more complicated stuff. We just look at each possibility (Url, Flickr and upload from the local file system) and execute some methods in order to find out the filename as well as image itself. Once we have the (original) image we can apply further transformations. In this case we want to crop the image.
In order to work nicely we've implemented some helper methods. Those could be included as extension methods. In this case we've written the appropriate methods in the same controller in order to have everything in one place. Let's start with a rather easy one:
string GetUrlFileName(string url)
{
var parts = url.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
var last = parts[parts.Length - 1];
return Path.GetFileNameWithoutExtension(last);
}
This method just splits the URL into several parts. The last part must contain the filename of the image. Therefore we can apply the usual Path.GetFileNameWithoutExtension()
method. The next method which could be interesting is the GetImageFromUrl()
method. This one will request a webpage from any webserver by a given URL and return the image.
Bitmap GetImageFromUrl(string url)
{
var buffer = 1024;
Bitmap image = null;
if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
return image;
using (var ms = new MemoryStream())
{
var req = WebRequest.Create(url);
using (var resp = req.GetResponse())
{
using (var stream = resp.GetResponseStream())
{
var bytes = new byte[buffer];
var n = 0;
while ((n = stream.Read(bytes, 0, buffer)) != 0)
ms.Write(bytes, 0, n);
}
}
image = Bitmap.FromStream(ms) as Bitmap;
}
return image;
}
We first look if the passed URL is a real absolute URL. If this is the case we can start our request. This request will stream the response data into the memory stream. This is needed in order to get a seekable stream. Afterwards we create an image from this memory stream. The image is then finally returned.
The last method which can be detected in the code of our action is the CreateImage()
method. This method will basically do the cropping. The code is straight forward for anyone who is familiar with GDI+.
Bitmap CreateImage(Bitmap original, int x, int y, int width, int height)
{
var img = new Bitmap(width, height);
using (var g = Graphics.FromImage(img))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.DrawImage(original, new Rectangle(0, 0, width, height), x, y, width, height, GraphicsUnit.Pixel);
}
return img;
}
In order to conclude the server-side of our code we need to attach the corresponding views. In our case we just have one view to attach: UploadImage.cshtml (yes, we are going to use Razer as view engine for this article). In order to maximize our efficiency the first draft will be scaffolded by using UploadImageModel
as model and Create as scaffolding option. The resulting HTML is far away from our final goal. Why should we still use the scaffolding option? Well, first of all it is a good testing point. Of course unit testing our controllers is a much better test, but sometimes all you need is a one time "does it already work?!" test. If this is the case then we just did provide a testing environment within a second. The second reason to use the scaffolding option is that we have already a good basis. From this point on everything we have to do is deleting, moving and maybe adding something.
So what kind of changes are required? First all we will heavily rely on JavaScript. Therefore we will have to add
- the
ImageAreaControl
plugin for jQuery and - a script tag containing our custom scripts for the UploadImage site.
Since we should always place any scripts at the bottom of the page it is necessary to work with sections here (of course it is also possible to work with the ViewBag
and extension methods, or other methods). So we will have to include the following snippet for script access:
@section Scripts
{
<script src="@Url.Content("~/Scripts/jquery.validate.min.js")"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")"></script>
<script src="@Url.Content("~/Scripts/jquery.imgareaselect.js")"></script>
<script>
</script>
}
We need to include the file jquery.imgareaselect.js for this to work. This file can be downloaded from http://odyniec.net/projects/imgareaselect/. We should place it in the Scripts folder. For this code, including the section Scripts
directive, to work we have modified the main layout page to look like
<!--
<script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")"></script>
<script src="@Url.Content("~/Scripts/modernizr-1.7.min.js")"></script>
@RenderSection("Scripts", false)
</body>
</html>
If we have now a short look at the scaffolded page of UploadImage.cshtml we will recognize that two important things are missing:
- First of all for any
File
upload to happen we need to ensure the right enctype
attribute in the <form>
tag. Right now we just have Html.BeginForm()
. - Second we currently miss a line including
Model.File
. This is the limitation of scaffolding.
Both things can be cured easily. For the first problem we will just specify some arguments for the BeginForm()
method. Finally it will look like this:
Html.BeginForm("UploadImage", "Home", FormMethod.Post, new { enctype = "multipart/form-data" })
For the second one we have multiple options. We could either write plain HTML or we could write an extension method for the type of the Model.File
property. The last one is certainly recommended. By adding a static class called HtmlExtensions
to the source we are able to insert some extension methods like
public static class HtmlExtensions
{
public static MvcHtmlString File(this HtmlHelper html, string name)
{
var tb = new TagBuilder("input");
tb.Attributes.Add("type", "file");
tb.Attributes.Add("name", name);
tb.GenerateId(name);
return MvcHtmlString.Create(tb.ToString(TagRenderMode.SelfClosing));
}
public static MvcHtmlString FileFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression)
{
string name = GetFullPropertyName(expression);
return html.File(name);
}
#region Helpers
static string GetFullPropertyName<T, TProperty>(Expression<Func<T, TProperty>> exp)
{
MemberExpression memberExp;
if (!TryFindMemberExpression(exp.Body, out memberExp))
return string.Empty;
var memberNames = new Stack<string>();
do
{
memberNames.Push(memberExp.Member.Name);
}
while (TryFindMemberExpression(memberExp.Expression, out memberExp));
return string.Join(".", memberNames.ToArray());
}
static bool TryFindMemberExpression(Expression exp, out MemberExpression memberExp)
{
memberExp = exp as MemberExpression;
if (memberExp != null)
return true;
if (IsConversion(exp) && exp is UnaryExpression)
{
memberExp = ((UnaryExpression)exp).Operand as MemberExpression;
if (memberExp != null)
return true;
}
return false;
}
static bool IsConversion(Expression exp)
{
return (exp.NodeType == ExpressionType.Convert || exp.NodeType == ExpressionType.ConvertChecked);
}
#endregion
}
This gives us two extension methods for the HtmlHelper
helper instance in any view which includes the used namespace and assembly. In order to stay coherent with the existing HtmlHelper
extension methods provided by the ASP.NET MVC team we did create the methods File()
and FileFor()
. While the first one is just a simple HTML helper, the second one can be used together with a strongly typed model to avoid typing errors.
After adding the namespace of the static class containing both extension methods to the Web.config file of the Views folder we are able to insert the following lines into our view:
<div class="editor-label">
@Html.LabelFor(model => model.File)
</div>
<div class="editor-field">
@Html.FileFor(model => model.File)
@Html.ValidationMessageFor(model => model.File)
</div>
Now the real transformations are up to come. We want to achieve the following:
- The possible choices should be placed on the left side of the page.
- The image cutting should be possible in an image preview that will be displayed on the right side of the choices.
- The upload button should be placed on the bottom of the page.
The new markup inside the <form>
tag is therefore:
@Html.HiddenFor(model => model.X)
@Html.HiddenFor(model => model.Y)
@Html.HiddenFor(model => model.Width)
@Html.HiddenFor(model => model.Height)
<div id="upload-choices">
<div class="editor-row">
<div class="editor-label">
@Html.EditorFor(model => model.IsUrl)
@Html.LabelFor(model => model.Url)
</div><div class="editor-field">
@Html.EditorFor(model => model.Url)
@Html.ValidationMessageFor(model => model.Url)
</div>
</div>
<div class="editor-row">
<div class="editor-label">
@Html.EditorFor(model => model.IsFlickr)
@Html.LabelFor(model => model.Flickr)
</div><div class="editor-field">
@Html.EditorFor(model => model.Flickr)
@Html.ValidationMessageFor(model => model.Flickr)
</div>
</div>
<div class="editor-row">
<div class="editor-label">
@Html.EditorFor(model => model.IsFile)
@Html.LabelFor(model => model.File)
</div><div class="editor-field">
@Html.FileFor(model => model.File)
@Html.ValidationMessageFor(model => model.File)
</div>
</div>
<div class="editor-row">
@Html.ValidationSummary(true)
</div>
</div>
<div id="upload-cut">
<img alt="Field for image cutting" id="preview" src="@Url.Content("~/Content/empty.png")" />
</div>
<div class="clear">
<button type="submit">Upload</button>
</div>
So basically we just modified the 4 degrees of freedom (X, Y, width and height) to hidden variables. Those will be modified by our JavaScript. We clearly seperated the upload choices from the image cropping. And we added containers to form kind of rows. All in all this is just markup and pretty much useless without the right styling. We need to place the following inside the Site.css file:
#upload-choices {
width: 450px;
float: left;
}
#upload-cut {
margin-left: 480px;
padding-top: 10px;
min-width: 150px;
}
#preview {
max-width: 100%;
display: block;
}
.editor-row {
margin: 10px 0;
height: 40px;
width: 100%;
}
.editor-row div {
margin: 0; height: 40px; display: inline-block;
}
.editor-row div.editor-label {
width: 150px;
}
.editor-row div.editor-field {
width: 300px;
}
.editor-row .editor-field input {
width: 300px; height: 100%; box-sizing: border-box;
}
button {
background: #3F9D4A; border: none; font-size: 1.2em; color: #FFF; padding: 7px 10px;
border-radius: 4px; font-weight: bold; text-shadow: 0 1px 0 rgba(0,0,0,0.4); margin: 5px 0;
}
button:hover {
box-shadow: 0 0 10px #666;
}
Now that we set up everything from the design perspective we need to write some fancy JavaScript. This brings us back to the <script></script>
tag that we introduced earlier. For the jQuery plugin (ImageAreaSelect) to work we will have to include the plugin (script) itself and the required style sheet. Both can be inserted by using sections as explained earlier. The basic programmatic flow of our client side JavaScript will be the following:
- User is able to select any of the possibilities to upload an image
- Once a possibility is selected (changed text / file upload) the checkbox is enabled and instantly checked
- If more then one possibilities are enabled the user can select the prefered upload by checking the appropriate checkbox
- The file will be checked and read out by the FileAPI in order to display a preview image
- The submit button should be disabled in the beginning (nothing selected) and in the end (post the upload)
So let's start with a basic construct of our code. This first version includes nearly everything of the list above:
$(document).ready(function () {
var boxes = $('input[type=checkbox]').attr('disabled', true);
var preview = $('#preview').load(function () {
setPreview();
ias.setOptions({
x1: 0,
y1: 0,
x2: $(this).width(),
y2: $(this).height(),
show: true
});
});
var setPreview = function (x, y, w, h) {
$('#X').val(x || 0);
$('#Y').val(y || 0);
$('#Width').val(w || preview[0].naturalWidth);
$('#Height').val(h || preview[0].naturalHeight);
};
var ias = preview.imgAreaSelect({
handles: true,
instance: true,
parent: 'body',
onSelectEnd: function (s, e) {
var scale = preview[0].naturalWidth / preview.width();
var _f = Math.floor;
setPreview(_f(scale * e.x1), _f(scale * e.y1), _f(scale * e.width), _f(scale * e.height));
}
});
var setBox = function (filter) {
boxes.attr('checked', false)
.filter(filter).attr({ 'checked': true, 'disabled': false });
};
setPreview(0, 0, 1, 1);
$('#Url').change(function () {
setBox('#IsUrl');
preview.attr('src', this.value);
});
$('#File').change(function (evt) {
var f = evt.target.files[0];
var reader = new FileReader();
if (!f.type.match('image.*')) {
alert("The selected file does not appear to be an image.");
return;
}
setBox('#IsFile');
reader.onload = function (e) { preview.attr('src', e.target.result); };
reader.readAsDataURL(f);
});
boxes.change(function () {
setBox(this);
$('#' + this.id.substr(2)).change();
});
});
Two important things that are missing there are the Flickr upload and the enabling and disabling of the button. The latter is actually not hard to implement. We need just to place a method call like $('button').attr('disabled', false)
in the setBox()
method and set the button's initial state to disabled. We need also to assign the $('form').submit()
event a valid handler. One possible way would be the following code:
$('form').submit(function () {
$('button').attr('disabled', true).text('Please wait ...');
});
The not so trivial part is the introduction of the image upload from Flickr images. Right now everything is set up so that we treat the Flickr string just as an URL - just like the URL field. Therefore this is pretty much useless! So we will now introduce a few really cool changes in order to utilize the Flickr JSON API per AJAX with jQuery.
First of all we make the Model.Flickr
field a hidden variable and place an input field with the ID FlickrQuery
at that position. Now the JavaScript function has the task of taking the FlickrQuery
input and query Flickr with this search string. Let's have a look at our JavaScript code first:
var fetchImages = function (query) {
$.getJSON('http://www.flickr.com/services/feeds/photos_public.gne?jsoncallback=?', {
tags: query,
tagmode: "any",
format: "json"
}, function (data) {
var screen = $('<div />').addClass('waitScreen').click(function () {
$(this).remove();
}).appendTo('body');
var box = $('<div />').addClass('flickrImages').appendTo(screen);
$.each(data.items, function (i, v) {
console.log(data.items[i]);
$('<img />').addClass('flickrImage').attr('src', data.items[i].media.m).click(function () {
$('#Flickr').val(this.src).change();
screen.remove();
}).appendTo(box);
});
});
};
$('#FlickrQuery').change(function () {
fetchImages(this.value);
});
$('#Flickr').change(function () {
setBox('#IsFlickr');
preview.attr('src', this.value);
});
Here we added a method to fetch the images from Flickr using the public JSON API. This choice is only for demo purposes since it has the advantage of simplicity and that no API key is required. The big disadvantage of this approach is that we get only a small image from Flickr and have no access (at least we do not know the URL) to the original image. After the URL of the service and a data map we set up a callback. This callback creates a modal <div>
which has includes all the returned images.
The modal box is styled with the following CSS directives:
.waitScreen {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.2);
z-index: 1000000;
}
.flickrImages {
position: absolute; left: 50%; top: 10%; width: 560px; margin-left: -300px; padding: 20px;
background: #FFF; border-radius: 10px; border: 0; box-shadow: 0 3px 10px #666;
}
.flickrImage {
height: 96px; margin: 4px; float: left; cursor: pointer;
}
That's it pretty much. What is not covered by this solution is a resize of the browser window during the image upload process. This could be done by rescaling the currently saved coordinates (which are relative to the image's original dimensions) to the screen.
Using the code
Feel free to use the necessary methods from the HomeController
. All methods excluding the Index()
action are related to the image uploader. You can add them to any controller since there are no dependencies with the exception of UploadImageModel
. The view is also related to the UploadImageModel
and the HTML extension methods for a File
<input>
tag. Please watch out for the required dependencies of the jQuery ImageAreaSelect plug-in (stylesheet, images, and the script source file itself).
The controller does upload the image right now into a directory called "~/Content/img/". This is for demo purposes only. In general we probably want the image in another application, database, or directory. Therefore these two lines have to be changed accordingly:
var fn = Server.MapPath("~/Content/img/" + name + ".png");
img.Save(fn, System.Drawing.Imaging.ImageFormat.Png);
Since the name and the image itself are provided, it should be no problem to find a suitable place and method.
A basic fallback for older browsers
The FileAPI
has been introduced to give JavaScript coders a little bit of access to the local file system. However, browser support is still limited. Currently all Internet Explorers up until the current version 9 do not support this feature (Source Can I Use). We will now include a fallback in the JavaScript and controller.
The JavaScript code has to detect the fallback mode and react properly:
$('#File').change(function (evt) {
if (evt.target.files === undefined)
return filePreview();
}
Now we have to code the filePreview()
method. This method will do the following things:
- Upload the selected image to an appropriate action
- Retrieve the image from the response in an iframe
- Show the image in the preview (crop) spot
The required (JavaScript) code is the following:
var filePreview = function () {
window.callback = function () { };
$('body').append('<iframe id="preview-iframe" onload="callback();" name="preview-iframe" style="display:none" />
');
var action = $('form').attr('target', 'preview-iframe').attr('action');
$('form').attr('action', '/Home/PreviewImage');
window.callback = function () {
setBox('#IsFile');
var result = $('#preview-iframe').contents().find('img').attr('src');
preview.attr('src', result);
$('#preview-iframe').remove();
};
$('form').submit().attr('action', action).attr('target', '');
};
Overall we are just following the steps outlined above. We are creating the iframe, changing the form, using it and finally removing all our steps. The callback is then eventually used to display the image as it would have been shown before. We are actually making the src
attribute being responsible for the image, i.e. we are receiving a base64 string.
This is the code in the HomeController
:
[HttpPost]
public ActionResult PreviewImage()
{
var bytes = new byte[0];
ViewBag.Mime = "image/png";
if (Request.Files.Count == 1)
{
bytes = new byte[Request.Files[0].ContentLength];
Request.Files[0].InputStream.Read(bytes, 0, bytes.Length);
ViewBag.Mime = Request.Files[0].ContentType;
}
ViewBag.Message = Convert.ToBase64String(bytes, Base64FormattingOptions.InsertLineBreaks);
return PartialView();
}
Nothing new here! We (really should) are just receiving one file and returning that as a base64 string. How are we returning that? With a partial view! If you wonder what is in that partial view:
<img src="data:@ViewBag.Mime;base64,@ViewBag.Message" />
This is the whole workaround. I tested the workaround in IE9 - works perfectly there. Let me know if older IEs can also handle this.
Using the uploader in a modal dialog
Another must have feature would be the ability to display the fancy image uploader in a modal dialog. For this we will first set up some CSS:
.modal_block
{
background: rgba(0, 0, 0, 0.3);
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.modal_part
{
z-index: 100;
display: none;
}
.modal_dialog
{
width: 60%;
height: 60%;
position: absolute;
top: 15%;
left: 15%;
padding: 5%;
border-radius: 10px;
border: 0;
background: #fff;
box-shadow: 0 0 5px #000;
}
.modal_dialog:empty
{
background: #fff url(ajax.gif) no-repeat center center;
}
Here we just declare some classes, which will eventually build the cornerstone of the modal dialog system. Next we outsource the inline JavaScript of the image uploader (everything in the $(document).ready()
method) in a new file called jquery.fancyupload.js. The will also rewrite the JavaScript a bit. In the end the new file looks like the following code:
var initSelect = function (context) {
context = context || $(document);
var button = $('button', context).attr('disabled', true);
var boxes = $('input[type=checkbox]', context).attr('disabled', true);
var form = $('form', context);
var preview = $('#preview', context).load(function () {
setPreview();
ias.setOptions({
x1: 0,
y1: 0,
x2: $(this).width(),
y2: $(this).height(),
show: true
});
});
var setPreview = function (x, y, w, h) {
$('#X', context).val(x || 0);
$('#Y', context).val(y || 0);
$('#Width', context).val(w || preview[0].naturalWidth);
$('#Height', context).val(h || preview[0].naturalHeight);
};
var ias = preview.imgAreaSelect({
handles: true,
instance: true,
parent: 'body',
onSelectEnd: function (s, e) {
var scale = preview[0].naturalWidth / preview.width();
var _f = Math.floor;
setPreview(_f(scale * e.x1), _f(scale * e.y1), _f(scale * e.width), _f(scale * e.height));
}
});
var setBox = function (filter) {
button.attr('disabled', false);
boxes.attr('checked', false)
.filter(filter).attr({ 'checked': true, 'disabled': false });
};
var filePreview = function () {
window.callback = function () { };
$('body').append('<iframe id="preview-iframe" onload="callback();" name="preview-iframe" style="display:none"></iframe>');
var action = $('form', context).attr('target', 'preview-iframe').attr('action');
form.attr('action', '/Home/PreviewImage');
window.callback = function () {
setBox('#IsFile');
var result = $('#preview-iframe').contents().find('img').attr('src');
preview.attr('src', result);
$('#preview-iframe').remove();
};
form.submit().attr('action', action).attr('target', '');
};
setPreview(0, 0, 1, 1);
var fetchImages = function (query) {
$.getJSON('http://www.flickr.com/services/feeds/photos_public.gne?jsoncallback=?', {
tags: query,
tagmode: "any",
format: "json"
}, function (data) {
var screen = $('<div />').addClass('waitScreen').click(function () {
$(this).remove();
}).appendTo('body');
var box = $('<div />').addClass('flickrImages').appendTo(screen);
$.each(data.items, function (i, v) {
console.log(data.items[i]);
$('<img />').addClass('flickrImage').attr('src', data.items[i].media.m).click(function () {
$('#Flickr', context).val(this.src).change();
screen.remove();
}).appendTo(box);
});
});
};
$('#FlickrQuery', context).change(function () {
fetchImages(this.value);
});
$('#Flickr', context).change(function () {
setBox('#IsFlickr');
preview.attr('src', this.value);
});
$('#Url', context).change(function () {
setBox('#IsUrl');
preview.attr('src', this.value);
});
$('#File', context).change(function (evt) {
if (evt.target.files === undefined)
return filePreview();
var f = evt.target.files[0];
var reader = new FileReader();
if (!f.type.match('image.*')) {
alert("The selected file does not appear to be an image.");
return;
}
setBox('#IsFile');
reader.onload = function (e) { preview.attr('src', e.target.result); };
reader.readAsDataURL(f);
});
boxes.change(function () {
setBox(this);
$('#' + this.id.substr(2), context).change();
});
form.submit(function () {
button.attr('disabled', true).text('Please wait ...');
});
};
Our old view UploadImage will therefore lose the inline JavaScript and replace it with the following directives:
<script src="@Url.Content("~/Scripts/jquery.fancyupload.js")"></script>
<script>
$(document).ready(function () {
initSelect();
});
</script>
Now we add a new action to the existing controller:
public HomeController : Controller
{
public ActionResult UploadImageModal()
{
return View();
}
}
And of course we need a new view! For our demo purposes it is enough to have a little view UploadImageModal.cshtml with the following source code:
@{
ViewBag.Title = "Modal image uploader";
}
@section Styles
{
<link href="@Url.Content("~/Content/Modal.css")" rel="stylesheet" />
<link href="@Url.Content("~/Content/ImageArea.css")" rel="stylesheet" />
}
@section Scripts
{
<script src="@Url.Content("~/Scripts/jquery.validate.min.js")"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")"></script>
<script src="@Url.Content("~/Scripts/jquery.imgareaselect.js")"></script>
<script src="@Url.Content("~/Scripts/jquery.fancyupload.js")"></script>
<script>
$(document).ready(function () {
$('.modal_block').click(function (e) {
$('#tn_select').empty();
$('.modal_part').hide();
});
$('#modal_link').click(function (e) {
$('.modal_part').show();
var context = $('#tn_select').load('/Home/UploadImage', function () {
initSelect(context);
});
e.preventDefault();
return false;
});
});
</script>
}
<div class="modal_block modal_part"></div>
<div class="modal_dialog modal_part" id="tn_select"></div>
<h2>Upload an image by using a modal dialog</h2>
<p>
This is a sample page with basically no content (but there could be one!). Now we will just
upload content as on the @Html.ActionLink("Upload Image page", "UploadImage"). The only
difference lies in the usage of a modal dialog to upload the actual image.
</p>
<p>
<a href="#" id="modal_link">Click here to open modal dialog.</a>
</p>
We are not doing much here - we are just including our CSS and Script includes and setting up content. The real image uploader is still missing, but we prepared a container for the dialog. The inline JavaScript has two responsibilites:
- Setting up the modal dialog, i.e. opening the dialog and closing it. When opening it the content should be loaded over AJAX.
- Once the content is loaded the functionality is generated by executing the method we have set up before. Therefore we overgive the specified context.
If we would execute the code now we would probably see a mess. This is due to the response of the server to the action link. We need to distinguish between Ajax and Non-Ajax requests here. Since jQuery is already appending a header variable and the ASP.NET MVC team included a very useful extension method, we just have to use this stuff to respond with a partial view:
public HomeController : Controller
{
public ActionResult UploadImage()
{
if (Request.IsAjaxRequest())
{
return PartialView();
}
return View();
}
}
Now everything is set up correctly and the modal dialog is working as expected.
Points of interest
This is kind of a little feature I programmed for my new webpage. My version is a little bit more advanced since it allows to create multiple croppings with fixed ratios. This is a screenshot from my version (I also do not use a Flickr upload but an existing image):
In order to achieve the fixed ratio, I just add the aspectRatio
property to the options object that is passed to the imgAreaSelect()
constructor. This property is basically a string with the format w:h
where w
is the width and h
is the height. So 16:9 is an example for the ratio of width to height.
I then store the coordinates for the different cropping modes as well as the different ratios in arrays which are placed as IEnumerable<int>
and such in the model. Working with JavaScript, it is possible to replace the ith X coordinate where i is the number of cropping mode (zero based index). This works quite nicely and can be passed by MVC without any problems. Therefore the model construction takes place resulting in less problems in the end.
The view is then populated like the following:
for (var i = 0; i < sizes.Length; i++)
{
<text>
@Html.Hidden("X[" + i + "]", 0)
@Html.Hidden("Y[" + i + "]", 0)
@Html.Hidden("Width[" + i + "]", 0)
@Html.Hidden("Height[" + i + "]", 0)
@Html.Hidden("TargetWidth[" + i + "]", sizes[i].Width)
@Html.Hidden("TargetHeight[" + i + "]", sizes[i].Height)
</text>
}
So I am building up a X, Y, Width and Height tuple for each cropping. The TargetWidth and TargetHeight needs to be stored so that JavaScript can read them out and show which cropping mode is currently active and can be activated. Also the ratio calculation is done over those two values.
Just to show a little bit of the resulting JavaScript:
$('#choice').change(function () {
choice = this.value * 1;
var x = $('#X_' + choice + '_').val() * 1;
var y = $('#Y_' + choice + '_').val() * 1;
ias.setOptions({
x1: x,
y1: y,
x2: $('#Width_' + choice + '_').val() * 1 + x,
y2: $('#Height_' + choice + '_').val() * 1 + y,
aspectRatio: getAspectRatio(choice),
show: true
});
});
The code above is responsible for showing the right image cropping view for the current choice. The getAspectRatio()
method does evaluate the current choice and returns the right aspect ratio string.
History
- v1.0.0 | Initial release | 06.05.2012.
- v1.0.1 | Fixed some typos | 07.05.2012.
- v1.0.2 | Fixed more typos, Added some information | 08.05.2012.
- v1.0.3 | Fixed CSS code display bug | 09.05.2012.
- v1.1.0 | Included some basic fallback | 10.05.2012.
- v1.2.0 | Included an explanation for modal dialogs | 09.07.2012.