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\$
1
  • \$\begingroup\$ Destructuring: const { left: offsetX, top: offsetY } = canvas.getBoundingClientRect(); \$\endgroup\$ Commented Jun 21, 2016 at 19:14

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.