Introduction
This is part 2 of my article "Dealing with images in content management systems". Please read Part 1 of the article before you start this part:
Generating the Canvas
Regardless of whether the user uploaded a new raw image or picked a thumbnail, we have now supplied the control with information about the image's width and height, and what the server ID is (either newly generated or inferred from the thumbnail).
If we were in uploadButton_Click
, the new thumbnail is added in to the list of thumbnail names in the session:
SessionImages.Add(thumbnailFileName);
Then, both uploadButton_Click
and btnThumb_Command
call UseRaw()
. This method first checks to see if the raw image has the same dimensions as the control's ImageWidth
and ImageHeight
, and if so, assumes the user has uploaded a web ready image. In this case, the IImageProvider
's SaveRawImageAsWebImage
is called, which effectively copies the raw image to the web image directory, only doing any graphics redrawing if the uploaded format differs from the specified format. The mode of the control is then set to ControlMode.Changed
, and its work is done.
Usually, the image won't be the right size, so the control calls CanvasFromRaw()
to generate the user's canvas on which they can make a selection:
private void CanvasFromRaw()
{
aspectRatio = (float)rawWidth / (float)rawHeight;
string[] clientDimensions = hiddenField.Value.Split(new char[] { ',' });
int clientX = Convert.ToInt32(clientDimensions[0]) * 4 / 5;
int clientY = Convert.ToInt32(clientDimensions[1]) * 4 / 5;
float clientRatio = (float)clientX / (float)clientY;
if (clientRatio > aspectRatio)
{
canvasHeight = clientY;
canvasWidth = rawWidth * clientY / rawHeight;
}
else
{
canvasWidth = clientX;
canvasHeight = rawHeight * clientX / rawWidth;
}
canvasImageName = ImageProvider.CreateCanvas(
webImageFormat, webImageQuality,
canvasWidth, canvasHeight);
canvas.Src = getImageSource(canvasImageName, "canvas");
canvas.Width = canvasWidth;
canvas.Height = canvasHeight;
controlMode = ControlMode.Canvas;
}
This is where we make use of the viewport dimensions we captured earlier. We work out the aspect ratio of the uploaded image.
aspectRatio = (float)rawWidth / (float)rawHeight;
and that of the browser viewport:
float clientRatio = (float)clientX / (float)clientY;
Comparing them allows us to determine which dimension should constrain the canvas so that it fits in the user's browser. We also multiply the client dimensions by 4/5 so the canvas has some space around it and doesn't go right to the edge of the browser, which would be ugly and not allow any room for additional UI.
The call to IImageProvider.CreateCanvas
is more straightforward than the earlier thumbnail example:
public string CreateCanvas(WebImageFormat format,
WebImageQuality quality, int canvasWidth,
int canvasHeight)
{
purge(WebImageMaker.CanvasImageDirName);
string canvasFileName = null;
using (Image rawImg = Image.FromFile(getRawFilePath()))
{
using (Bitmap canvas = new Bitmap(
canvasWidth, canvasHeight, PixelFormat.Format24bppRgb))
{
using (Graphics g = Graphics.FromImage(canvas))
{
setGraphicsQuality(g, quality);
g.DrawImage(rawImg, 0, 0, canvasWidth, canvasHeight);
string filePath = getFilePath(
WebImageMaker.CanvasImageDirName, format);
canvas.Save(filePath, getGDIFormat(format));
canvasFileName = Path.GetFileName(filePath);
}
}
}
return canvasFileName;
}
The overload of Graphics.DrawImage
used here simply redraws the source image on the whole of the canvas, scaling appropriately. The calls to setGraphicsQuality
, getFilePath
and getGDIFormat
are the same as in the thumbnail case.
Back in the control, we set the source of the canvas image using:
getImageSource(canvasImageName, "canvas");
This method generates the image URL appropriately depending on whether the handler is being used or the control itself will be serving the image.
We are now in ControlMode.Canvas
, and the control will render the canvas UI when the Render
method is called.
Inserting Client-side Script References
We ensure that the client side UI has the correct JavaScript in the OnPreRender
phase. The conditional compilation in this method has already been discussed. The end result is that usually, WebImageMaker_normal.js will be referenced. This has script to position the thumbnails selector and control its visibility (particularly when there may be multiple instances of the control on the page at any one time), as well as the setViewportDimensions
function discussed earlier. When the control is in Canvas
mode, WebImageMaker_canvas.js will be referenced instead. We also need to inject an additional piece of JavaScript to initialise the canvas in the browser:
Page.ClientScript.RegisterStartupScript(
this.GetType(), this.ClientID, getInitScript(), true);
private string getInitScript()
{
string s = @"
function init{0}()
{{
initialise('{1}', '{2}', '{3}', '{4}', '{5}',
'{6}', '{7}', '{8}');
}}
window.onload = init{0};
";
return String.Format(s, this.ClientID,
popupDiv.ClientID, canvas.ClientID,
selectionBox.ClientID, imageWidth,
imageHeight, targetImage.ClientID,
confirmSelection.ClientID,
lblDebugInfo.ClientID);
}
(The double "{{" are to escape a single "{" when using String.Format
.)
Client Side Canvas Script
The key point to remember is that the user's selection is just a transparent DIV
element with a border set to a dashed line, and absolutely positioned to appear to float over the canvas image.
The job of the canvas script is to:
- Set all the elements up in the correct positions on the page.
- Initialise the selection
DIV
in a suitable default position and size, at the correct aspectRatio
if both ImageWidth
and ImageHeight
were specified on the control.
- Respond to the user's mouse moving and clicking, such that:
- When the mouse pointer is near the corners or edges of the selection, the cursor changes to a resize icon indicating a resize can be performed in the appropriate direction.
- When the mouse pointer is inside the selection and away from the edge, the cursor changes to a move icon.
- When the mouse pointer is near the corners or edges of the selection, clicking and dragging will have the effect of resizing the selection in an intuitive natural way.
- When the mouse pointer is inside the selection, clicking and dragging should move the selection.
- If the user does resize the selection and as
aspectRatio
is being enforced, the script should ensure that the selection is maintained at the correct aspect ratio even though the user's mouse movements would otherwise change the ratio. This constraining of the selection is familiar from image editing packages – the script should ensure that the selection behaves in an intuitive way as the user drags the edges to resize.
- Store the position and size of the selection relative to the canvas prior to the page being resubmitted.
When the page loads, the initialise(..)
method is called, with parameter values that the control wrote out in the pre-render phase described earlier.
First of all, references to all the HTML elements we need to manipulate are stored in variables for ease of use:
...
oCanvas = document.getElementById(canvasID);
oSelection = document.getElementById(selectionBoxID);
...
Then, we resize and position the popupDiv
element so that it is a little wider than the canvas image and inset from the top and left of the viewport. We also store the position and size of the canvas itself for later use:
canvasRect = rectangle(oCanvas);
The rectangle
function needs some explaining. It is used to represent the position and size of an element without having to continually access its style properties (e.g., oElement.style.left
). It also allows us to capture a snapshot of an element that might be changing. This is particularly useful when dealing with the user's selection. Rather than continuously adjusting the size and position of the selection DIV
, we use a more abstract object to represent the dimensions and location of the selection (or any other element):
function rectangle(oBlock)
{
return {
x: parseInt(oBlock.style.left),
y: parseInt(oBlock.style.top),
w: parseInt(oBlock.style.width),
h: parseInt(oBlock.style.height)
};
}
This method returns an object that has w
, h
, x
, and y
properties. It is analogous to the System.Drawing.Rectangle
we were using earlier. At the beginning of a sequence of operations on the selection, we can obtain a new rectangle object to represent a snapshot of the current selection. We can then scale, move, and constrain this representation by altering its x
, y
, w
, and h
properties, and when we're done, call setSelection
with our altered rectangle to apply its new position and location to the selection DIV
:
function setSelection(rect)
{
oSelection.style.left = rect.x + "px";
oSelection.style.top = rect.y + "px";
oSelection.style.width = rect.w + "px";
oSelection.style.height = rect.h + "px";
}
This means we are not continuously changing the style properties of the selection element itself, which might confuse the issue when resizing.
Back in the initialise(..)
function, we continue to set up the page by positioning and sizing the selection appropriately.
if(iReqdWidth > 0 && iReqdHeight > 0)
{
bConstrain = true;
aspectRatio = iReqdWidth / iReqdHeight;
}
bConstrain
is a global variable that is checked when the user resizes the selection to see if the selection should be "tweaked" to ensure that the correct aspectRatio
is maintained. This demonstrates the use of rectangle
:
var selection = rectangle(oSelection);
constrain(selection);
setSelection(selection);
We store a snapshot of the selection in the variable selection
, then call constrain(..)
, then apply the dimensions of the rectangle back to the "real" selection.
The constrain(..)
function will be described shortly.
To finish off the initialisation, we hook up some event handlers for mouse events:
document.onmousemove = move;
document.onmouseup = up;
document.onmousedown = down;
oCanvas.ondrag = function(){return false;}
oSelection.ondrag = function(){return false;}
document.ondrag = function(){return false;}
We need the mouse event handlers to be on the whole document rather than just the selection or the canvas, because in a dragging or resizing operation, the user might move the mouse outside of the canvas or even the popupDiv
as part of normal use. This is especially true when an aspect ratio is being enforced and the image is being constrained as it's being resized, as the point on the selection the user first grabbed might not stay in sync with the user's mouse as the selection dimensions are constrained by the script. The final three event handlers are essential to cancel any drag events. The browser might otherwise handle these by appearing to select those parts of the page the user moves the mouse over while moving or resizing the selection. This would give a very messy user experience.
Moving the mouse
As the user moves the mouse, the move(e)
function will be called. Two global variables, bMoving
and bResizing
, keep track of whether the script has decided the user is moving or resizing the selection. They will be set in response to a MouseDown
event. Initially, both these will be false
, indicating the user is just moving the mouse around. The bulk of the move
function is a three condition if
statement, the last of which is the "just moving the mouse around" condition. We will come back to the first two, which handle moving and resizing, in a moment.
...
else
{
resizeXMode = "";
resizeYMode = "";
var targetSize = 15;
if(p.x >= selection.x && p.x <= (selection.x + selection.w)
&& p.y >= selection.y && p.y <= (selection.y + selection.h))
{
oSelection.style.cursor = "move";
oCanvas.style.cursor = "move";
bCanMove = true;
...
The two global variables resizeXMode
and resizeYMode
keep track of what sort of resizing the user would be allowed to perform given the current mouse position. They vary independently. The resizeXMode
variable can be either "E
"(ast) or "W
"(est), and resizeYMode
can be either "N
"(orth) or "S
"(outh). As the user moves the mouse around the script in this else
block, we will set these two variables.
They are both blanked at the start of the block, and then we check to see whether or not the mouse pointer is inside the selection. If so, we set bCanMove
to true
, indicating that the user would be allowed to initiate a move operation from this point. We also set the cursor to the move symbol. We then further check to see if the user is in one of eight "hotspot" areas from which a resize operation could be started, and if so, set the resizeXMode
and resizeYMode
variables accordingly. We also set the cursor symbol to point in the appropriate direction. The sizes of these hotspots are governed by the targetSize
variable, which here is set at 15 pixels.
The rest of this condition is a lot of tedious code that finds out which of these hotspots the cursor is over, and sets the resizeXMode
and resizeYMode
variables, and the cursor icon, appropriately.
MouseUp and MouseDown
The down(e)
and up()
functions handle the mouse being pressed and released. Throughout this script, we make heavy use of the positions at which the mouse-move and mouse-down events occur. To make this easier to code against, we use a function which returns an object that represents the position at which an event occurred:
function point(e)
{
if (e.pageX || e.pageY)
{
this.x = e.pageX;
this.y = e.pageY;
}
else if (e.clientX || e.clientY)
{
this.x = e.clientX + document.body.scrollLeft
+ document.documentElement.scrollLeft;
this.y = e.clientY + document.body.scrollTop
+ document.documentElement.scrollTop;
}
this.x -= popupOrigin.x;
this.y -= popupOrigin.y;
}
This can be seen in the down(e)
event handler that follows. Note that the first line allows the event to be handled in a cross-browser manner as discussed on quirksmode.
function down(e)
{
if (!e) var e = window.event;
downPoint = new point(e);
originalRect = rectangle(oSelection);
if(bCanMove && resizeXMode == "" && resizeYMode == "")
{
bMoving = true;
}
if(resizeXMode != "" || resizeYMode != "")
{
bResizing = true;
}
return false;
}
function up()
{
bMoving = false;
bResizing = false;
}
When the user presses and holds the mouse button, we first of all store the position of the mouse at which the down event occurred:
downPoint = new point(e);
This will be used later to determine how far the mouse has moved. We also store the size and position of the selection as a rectangle in the originalRect
variable – again, this will be used to determine how much to resize or move the selection later.
If the mouse was over the selection but not in a hotspot (resizeXMode == "" && resizeYMode == ""
), then we set bMoving
to true
. The next time the move function is called in response to further mouse movement, we'll enter the first condition in the "if
" statement. However, if the mouse pointer is over a hotspot, then we set the global variable bResizing
to true
. The next time the move function is called in response to user mouse movements, the second condition in its "if
" statement will be called. This state of affairs continues until the user stops holding the mouse button down. The up event handler couldn't be simpler – it just sets the two globals bMoving
and bResizing
to false
, so that further mouse movements default to the "just moving the mouse around" state discussed already.
Moving is actually the simplest state to handle:
function move(e)
{
if(bInMove) return;
bInMove = true;
oCanvas.style.cursor = "auto";
oSelection.style.cursor = "auto";
bCanMove = false;
if (!e) var e = window.event;
var p = new point(e);
var selection = rectangle(oSelection);
if(bMoving)
{
var dx = p.x - downPoint.x;
var dy = p.y - downPoint.y;
selection.x = 0 + originalRect.x + dx;
selection.y = 0 + originalRect.y + dy;
setSelection(selection);
checkConfine(selection);
}
...
As already mentioned, we capture a snapshot of the current selection size and position. Then we find out how far the mouse has moved since the user started moving (in down(e)
), and store the difference in dx
and dy
. Then, all we do is set the new selection position to the old one plus the differences, and call setSelection
to apply the new selection rectangle to the selection DIV
. We then call checkConfine(..)
, which disables the OK button and displays a warning message if the selection is anywhere outside the canvas:
function checkConfine(rect)
{
var msg = "";
if((rect.x) < canvasRect.x)
msg += "Selection extends to the left of the canvas. ";
if(rect.y < canvasRect.y)
msg += "Selection extends above canvas. ";
if((rect.x + rect.w) > (canvasRect.x + canvasRect.w))
msg += "Selection extends to the right of the canvas. ";
if((rect.y + rect.h) > (canvasRect.y + canvasRect.h))
msg += "Selection extends below the canvas. ";
if(msg)
{
oConfirmButton.disabled = true;
oDebugInfo.innerHTML = "WARNING: " + msg;
}
else
{
oConfirmButton.disabled = false;
oDebugInfo.innerHTML = "";
}
}
Resizing
The middle condition in the move function's if
statement handles resizing. To change the size of the selection by moving the right hand edge to the right or the bottom edge downwards is relatively straightforward, as all you need to do is increase the width or height of the selection DIV
by the amount the user has moved the mouse. To give the appearance of moving the top of the selection upwards or the left of the selection to the left is more complicated. In these cases, you want to have the bottom or right edges appear to stay where they are, so you need to simultaneously increase the size of the selection by the amount the user has moved the mouse and move it up or leftwards by the same amount. This makes the experience feel natural to the user and is what they will be expecting.
...
else if(bResizing)
{
var dx = p.x - downPoint.x;
var dy = p.y - downPoint.y;
if(resizeXMode == "E")
{
selection.w = 0 + originalRect.w + dx;
if(selection.w < minimumSelectionSize)
{
selection.w = minimumSelectionSize;
bResizing = false;
}
}
if(resizeXMode == "W")
{
selection.w = 0 + originalRect.w - dx;
selection.x = 0 + originalRect.x + dx;
if(selection.w < minimumSelectionSize)
{
dx = selection.w - minimumSelectionSize;
selection.w = minimumSelectionSize;
selection.x += dx;
bResizing = false;
}
}
if(resizeYMode == "S")
{
selection.h = 0 + originalRect.h + dy;
if(selection.h < minimumSelectionSize)
{
selection.h = minimumSelectionSize;
bResizing = false;
}
}
if(resizeYMode == "N")
{
selection.h = 0 + originalRect.h - dy;
selection.y = 0 + originalRect.y + dy;
if(selection.h < minimumSelectionSize)
{
dy = selection.h - minimumSelectionSize;
selection.h = minimumSelectionSize;
selection.y += dy;
bResizing = false;
}
}
constrain(selection);
setSelection(selection);
checkConfine(selection);
}
...
Each direction is handled separately. If the user grabbed a corner hotspot then two of the four conditions will be met. Moving "W" or "N" is more complicated than moving "E" or "S" as discussed above. We also cancel the resizing operation (by setting bResizing
to false
) if the selection ends up too small (this script uses a default minimum selection size of 40 pixels square, which leaves enough to be able to still grab all the hotspots and move the selection around).
Once we have given the selection a new shape according to the user's mouse movements, we need to constrain it to the required aspect ratio if this is being enforced:
function constrain(rect)
{
if(bConstrain && rect)
{
var newRatio = rect.w / rect.h;
if(newRatio > aspectRatio)
{
var correctWidth = Math.round(aspectRatio * rect.h);
if(correctWidth >= minimumSelectionSize)
{
if(resizeXMode == "W")
{
var rightPos = rect.x + rect.w;
rect.x = rightPos - correctWidth;
}
rect.w = correctWidth;
}
else
{
rect.w = minimumSelectionSize;
var newH = Math.round(minimumSelectionSize / aspectRatio);
var dy = newH - rect.h;
rect.h = newH;
if(resizeYMode == "N")
{
rect.y -= dy;
}
}
}
else
{
var correctHeight = Math.round(rect.w / aspectRatio);
if(correctHeight >= minimumSelectionSize)
{
if(resizeYMode == "N")
{
var bottomPos = rect.y + rect.h;
rect.y = bottomPos - correctHeight;
}
rect.h = correctHeight;
}
else
{
rect.h = minimumSelectionSize;
var newW = Math.round(minimumSelectionSize * aspectRatio);
var dx = newW - rect.w;
rect.w = newW;
if(resizeXMode == "W")
{
rect.x -= dx;
}
}
}
}
}
First of all, we have to decide whether the current aspect ratio is wider (more landscapey) or taller more (portrait-y) than the required aspect ratio. This tells us which dimension can stay as it is, and which one needs to be altered to bring the overall shape back into proportion. Again, we have to keep the apparent position of the right or bottom edges constant if the user is resizing to the left or top. We also need to make sure that in constraining a dimension (which will always reduce the size), we're not bringing it under the minimumSelectionSize
. If so, we need to set the dimension to the minimum and increase the other dimension to compensate.
There are various ways in which this constrain function could work – for example, it could work the other way round and always increase the other dimension rather than reduce it. However, this set of rules seems to produce the most intuitive results when actually manipulating the selection.
Creating the Final Web Image
Pressing the OK button causes the current selection information to be written to a hidden form field:
function storeSelectionInfo(hiddenFieldID)
{
var field = document.getElementById(hiddenFieldID);
var selection = rectangle(oSelection);
field.value = (selection.x - canvasRect.x) + ","
+ (selection.y - canvasRect.y) + ","
+ selection.w + "," + selection.h;
}
Then the form posts back to the server. This takes us into the OK button's server-side event handler:
void confirmSelection_Click(object sender, EventArgs e)
{
string[] clientDimensions = hiddenField.Value.Split(new char[] { ',' });
int x = Convert.ToInt32(clientDimensions[0]);
int y = Convert.ToInt32(clientDimensions[1]);
int w = Convert.ToInt32(clientDimensions[2]);
int h = Convert.ToInt32(clientDimensions[3]);
float scaleFactor = (float)canvasWidth / (float)rawWidth;
Rectangle transformedSelection = new Rectangle(
(int)(x / scaleFactor),
(int)(y / scaleFactor),
(int)(w / scaleFactor),
(int)(h / scaleFactor));
float selectionAspectRatio = (float)w / (float)h;
int reqdWidth = intImageWidth;
int reqdHeight = intImageHeight;
if (reqdWidth <= 0)
{
reqdWidth = (int)(selectionAspectRatio * reqdHeight);
}
else if (reqdHeight <= 0)
{
reqdHeight = (int)(reqdWidth / selectionAspectRatio);
}
webImageName = ImageProvider.CropAndScale(
transformedSelection,
webImageFormat, webImageQuality,
reqdWidth, reqdHeight);
targetImage.Src = getImageSource(webImageName, "web");
controlMode = ControlMode.Changed;
}
Using the coordinates from the client, we create a new System.Drawing.Rectangle
called transformedSelection
, which represents the user's selection on the canvas transformed to the coordinate system of the raw image. Then, if only one of reqdWidth
and reqdHeight
has been set as a property of the control (i.e., evaluates as a positive integer rather than "*"), we need to work out what the other dimension of the final web image should be. We use the aspect ratio of the selection to determine this. Once we have all this information, we can call the IImageProvider
's CropAndScale
method:
public string CropAndScale(System.Drawing.Rectangle
transformedSelection, WebImageFormat format,
WebImageQuality quality, int reqdWidth, int reqdHeight)
{
string webFileName = null;
Rectangle dest = new Rectangle(0, 0, reqdWidth, reqdHeight);
using (Image rawImg = Image.FromFile(getRawFilePath()))
{
using (Bitmap webImage = new Bitmap(
reqdWidth, reqdHeight, PixelFormat.Format24bppRgb))
{
using (Graphics g = Graphics.FromImage(webImage))
{
setGraphicsQuality(g, quality);
g.DrawImage(
rawImg, dest, transformedSelection, GraphicsUnit.Pixel);
string filePath = getFilePath(
WebImageMaker.WebImageDirName, format);
webImage.Save(filePath, getGDIFormat(format));
webFileName = Path.GetFileName(filePath);
}
}
}
return webFileName;
}
This is very similar to the GDI+ code we've already seen. We make a new rectangle to represent the final web image (dest
), then draw the transformed selection onto it:
g.DrawImage(rawImg, dest, transformedSelection, GraphicsUnit.Pixel);
We then set the control's image to this new web image and change the controlMode
to ControlMode.Changed
. The developer can later query the control for the file path of the new web image by accessing its WebImagePath
property.
Possible Extra Features
There are many ways this control's features could be added to. One example would be to provide a hook into the web image creation stage that allowed external code to process the image before saving, for example, adding a border or a copyright notice.
AJAX
There are a few places in this code where the user experience might be a little improved with the use of some script callbacks. However, the "elephant in the room" with this control is the requirement to get the raw file back to the server, which is likely to be the mother of all postbacks. Saving a few postbacks elsewhere seems a bit trivial after that.
Click here to see the control in action.
History
- 22nd February, 2006: Initial post
- 17th February, 2009: Updated demo project