I made a small(ish) image editor for a client that is supposed to be able to do the following:
- initially scale image to preset size to avoid tiny/huge images
- initially center image in canvas
- resize uploaded image (while always maintaining original aspect ratio)
- prevent image from going outside the canvas bounds
- prevent image from getting too small
It's a little bit glitchy, but for the most part it works. I think with cleaning up the structure/program flow it will work significantly better even though it's only ~300 lines of ES6 JavaScript. Thoughts?
I think my biggest gripe is that it is hard to read, although the code is simple and has well-named variables. The image properties are handled across multiple different methods at different times and this has a lot of room for failure. I was thinking I need to redesign it so it has a size updater that maintains proportions and more convenience methods instead of manually performing so much math that has large possible error margins.
Here is a working JSFiddle:
'use strict';
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.lineWidth = 1;
ctx.strokeStyle = 'black';
// cursors for different resize handle indices
const cursors = ['nw-resize', 'ne-resize', 'se-resize', 'sw-resize'];
// for use in calculating mouse x/y position
const canvasOffset = document.getElementById('canvas').getBoundingClientRect();
const offsetX = canvasOffset.left;
const offsetY = canvasOffset.top;
// starting position for the mouse x/y position
let startX;
let startY;
// resize all images to start at this size while maintaining aspect ratio
const INITIAL_IMG_SIZE = 50;
// minimum resize value allowed for either height or width
const MIN_IMG_SIZE = 25;
// current mouse x/y values
let mouseX;
let mouseY;
const RESIZE_HANDLE_SIZE = 8;
const RESIZE_HANDLE_SQUARED = RESIZE_HANDLE_SIZE * RESIZE_HANDLE_SIZE;
// keeps track of which resize handle is currently being used
let resizeHandleIndex;
// furthest top, right, bottom, and left points of the image respectively
let imageY;
let imageRight;
let imageBottom;
let imageX;
let imageWidth;
let imageHeight;
let draggingImage = false;
let imageAspectRatio;
const img = new Image();
img.src = 'http://www.vertical-corp.com/assets/images/vertical_logo.jpg';
function drawResizeHandles(x, y) {
const left = x - (RESIZE_HANDLE_SIZE / 2);
const top = y - (RESIZE_HANDLE_SIZE / 2);
const handle = new Path2D();
handle.rect(left, top, RESIZE_HANDLE_SIZE, RESIZE_HANDLE_SIZE);
ctx.stroke(handle);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, img.width, img.height, imageX, imageY, imageWidth, imageHeight);
// draw top left, top right, bottom right, and bottom left resize handles respectively
drawResizeHandles(imageX, imageY);
drawResizeHandles(imageRight, imageY);
drawResizeHandles(imageRight, imageBottom);
drawResizeHandles(imageX, imageBottom);
// draw image border
const rectangle = new Path2D();
rectangle.rect(imageX, imageY, imageWidth, imageHeight);
ctx.stroke(rectangle);
}
img.onload = () => {
imageWidth = img.width;
imageHeight = img.height;
imageAspectRatio = imageWidth / imageHeight;
// resize image to default initial size while keeping original aspect ratio
if (imageWidth > imageHeight) {
imageHeight = INITIAL_IMG_SIZE;
imageWidth = imageHeight * imageAspectRatio;
} else {
imageWidth = INITIAL_IMG_SIZE;
imageHeight = imageWidth / imageAspectRatio;
}
// center image in canvas
imageX = (canvas.width / 2) - (imageWidth / 2);
imageY = (canvas.height / 2) - (imageHeight / 2);
imageRight = imageX + imageWidth;
imageBottom = imageY + imageHeight;
draw();
};
function resizeHandleHitTest(x, y) {
let dx;
let dy;
// top-left
dx = x - imageX;
dy = y - imageY;
if (dx * dx + dy * dy <= RESIZE_HANDLE_SQUARED) {
return 0;
}
// top-right
dx = x - imageRight;
dy = y - imageY;
if (dx * dx + dy * dy <= RESIZE_HANDLE_SQUARED) {
return 1;
}
// bottom-right
dx = x - imageRight;
dy = y - imageBottom;
if (dx * dx + dy * dy <= RESIZE_HANDLE_SQUARED) {
return 2;
}
// bottom-left
dx = x - imageX;
dy = y - imageBottom;
if (dx * dx + dy * dy <= RESIZE_HANDLE_SQUARED) {
return 3;
}
// not a resize handle
return -1;
}
function imageHitTest(x, y) {
return (x > imageX && x < imageX + imageWidth && y > imageY && y < imageY + imageHeight);
}
function imageIsOutOfBounds() {
// outside the left, top, right, and bottom of canvas respectively
return (imageX < 0) || (imageY < 0) || ((imageX + imageWidth) > canvas.width) || ((imageY + imageHeight) > canvas.height);
}
canvas.onmousedown = e => {
startX = parseInt(e.clientX - offsetX, 10);
startY = parseInt(e.clientY - offsetY, 10);
resizeHandleIndex = resizeHandleHitTest(startX, startY);
draggingImage = resizeHandleIndex < 0 && imageHitTest(startX, startY);
};
canvas.onmouseup = () => {
resizeHandleIndex = -1;
draggingImage = false;
};
canvas.onmouseout = () => {
canvas.onmouseup();
};
function protectImageFrame() {
const minSizeReached = imageWidth <= MIN_IMG_SIZE || imageHeight <= MIN_IMG_SIZE;
const maxSizeReached = imageWidth >= canvas.width || imageHeight >= canvas.height;
if (!minSizeReached && !maxSizeReached) {
return;
}
// prevent image from becoming unreasonably small
if (minSizeReached) {
if (imageWidth >= imageHeight) {
imageHeight = MIN_IMG_SIZE;
imageWidth = imageHeight * imageAspectRatio;
} else {
imageWidth = MIN_IMG_SIZE;
imageHeight = imageWidth / imageAspectRatio;
}
}
imageX = Math.max(imageX, 0);
imageY = Math.max(imageY, 0);
imageRight = Math.min(imageRight, canvas.width);
imageBottom = Math.min(imageBottom, canvas.height);
const desiredWidth = imageRight - imageX;
const desiredHeight = imageBottom - imageY;
// if we've reached the max size, verify we're only attempting to shrink
let canUpdateSize;
if (maxSizeReached) {
if (imageWidth >= canvas.width) {
canUpdateSize = (desiredWidth <= canvas.width);
} else {
canUpdateSize = (desiredHeight <= canvas.height);
}
}
if (canUpdateSize) {
if (imageWidth > imageHeight) {
imageWidth = desiredWidth;
let possibleHeight = imageWidth / imageAspectRatio;
imageHeight = imageWidth / imageAspectRatio;
} else {
imageHeight = desiredHeight;
imageWidth = imageHeight * imageAspectRatio;
}
}
}
function canUpdateSize(desiredWidth, desiredHeight) {
const minSizeReached = imageWidth <= MIN_IMG_SIZE || imageHeight <= MIN_IMG_SIZE;
// we don't want to move images that are locked for resize
if (minSizeReached) {
if (imageWidth >= imageHeight) {
return desiredHeight >= MIN_IMG_SIZE;
} else {
return desiredWidth >= MIN_IMG_SIZE;
}
} else {
return true;
}
}
canvas.onmousemove = e => {
mouseX = parseInt(e.clientX - offsetX, 10);
mouseY = parseInt(e.clientY - offsetY, 10);
const cursorOverResizeHandle = resizeHandleHitTest(mouseX, mouseY);
if (cursorOverResizeHandle < 0) {
document.body.style.cursor = 'default';
if (imageHitTest(mouseX, mouseY)) {
document.body.style.cursor = 'move';
}
} else {
document.body.style.cursor = cursors[cursorOverResizeHandle];
}
// dont resize or move if the image is out of bounds
if (!imageIsOutOfBounds() && resizeHandleIndex > -1) {
// resize the image
let desiredWidth;
let desiredHeight;
document.body.style.cursor = cursors[resizeHandleIndex];
switch (resizeHandleIndex) {
case 0:
// top-left
desiredWidth = imageRight - mouseX;
desiredHeight = desiredWidth / imageAspectRatio;
if (canUpdateSize(desiredWidth, desiredHeight)) {
imageX = mouseX;
imageWidth = desiredWidth;
imageY = mouseY;
imageHeight = desiredHeight;
// to keep the bottom position immobile
imageY -= imageHeight - (imageBottom - imageY);
}
break;
case 1:
// top-right
desiredWidth = mouseX - imageX;
desiredHeight = imageWidth / imageAspectRatio;
if (canUpdateSize(desiredWidth, desiredHeight)) {
imageY = mouseY;
imageRight = imageX + imageWidth;
imageWidth = desiredWidth;
imageHeight = desiredHeight;
// to keep the bottom position immobile
imageY -= imageHeight - (imageBottom - imageY);
}
break;
case 2:
// bottom-right
imageBottom = imageY + imageHeight;
imageRight = imageX + imageWidth;
imageWidth = mouseX - imageX;
imageHeight = imageWidth / imageAspectRatio;
break;
case 3:
// bottom-left
imageBottom = imageY + imageHeight;
imageX = mouseX;
imageWidth = imageRight - mouseX;
imageHeight = imageWidth / imageAspectRatio;
break;
default:
document.body.style.cursor = 'default';
break;
}
protectImageFrame();
} else if (draggingImage) {
// move the image by the x/y deltas
const dx = mouseX - startX;
const dy = mouseY - startY;
const desiredX = imageX + dx;
const desiredY = imageY + dy;
const desiredRight = desiredX + imageWidth;
const desiredBottom = desiredY + imageHeight;
if (desiredX >= 0 && desiredRight <= canvas.width) {
imageX = desiredX;
imageRight = desiredRight;
}
if (desiredY >= 0 && desiredBottom <= canvas.height) {
imageY = desiredY;
imageBottom = desiredBottom;
}
// reset startX/Y
startX = mouseX;
startY = mouseY;
}
draw();
};
#canvas {
background: white;
border: 1px solid black;
}
<canvas id="canvas" width=350 height=350></canvas>
const { left: offsetX, top: offsetY } = canvas.getBoundingClientRect();
\$\endgroup\$