3
\$\begingroup\$

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>

\$\endgroup\$
  • \$\begingroup\$ Destructuring: const { left: offsetX, top: offsetY } = canvas.getBoundingClientRect(); \$\endgroup\$ – gcampbell Jun 21 '16 at 19:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.