Canvas Artistry: Mastering Selection, Dragging, and Scaling

Introduction

About half a year ago. I made a Canvas multifunctional drawing board with some of the following main features

  • Freestyle drawing
    • Supports color changes and real-time adjustment of line width according to the drawing speed.
    • Offers a multitude of effects such as fluorescent, multi-color, crayon, spray, bubbles and more.
  • Eraser.
    • Linear erase function with mouse movement.
  • Drawing text
    • By double clicking on the drawing board, the user can enter text and draw in the specified position.
  • Drawing board Drag & Drop
    • Holding down the space bar allows the user to drag and drop the drawing board infinitely.
  • Select Mode
    • Once in selection mode, the user can box in elements by clicking on them, zoom or move them by holding down the handle, and delete selected elements with the Backspace key.
  • Layer
    • The content of the drawing board is displayed in the order of the layers, and users can add, delete and sort layers.
  • Provides undo and reverse undo features that allow users to clear the board or save the content as an image.

Then recently I had some free time and decided to write a series of articles documenting my design ideas and code. If you are interested in this project, feel free to visit and also feel free to give the project a Star ⭐️.

In this article, I will introduce the Canvas drawing board in detail in the box to select elements of the realization of ideas and specific code, the effect is as follows.

Ideas For Realization

  1. First make sure that key rectangle properties such as width, height, and rectangle position are recorded when the element is initialized and updated. This is the basis for implementing box-select features.
  2. On mouseover, it is necessary to determine whether the mouse is hovering over any element based on the current coordinates. This allows us to handle clicks appropriately. Also, the cursor should change on mouseover based on the type of element it is hovering over to provide more intuitive feedback to the user.
  3. In the case of selete elements, the rendering stage should render the select effect at the end based on the rectangle property of the select element, making sure that the select effect is at the top of the canvas.
  4. When a select element exists, the drag operation needs to determine whether to move the element or change its size based on the location of the drag.
  5. There are two types of element resizing, scaling that maintains proportionality (e.g., text) and scaling that does not (e.g., free drawing).

Record Rectangle Properties

Because new coordinate points were being added all the time during the drawing process, I recorded the minimum and maximum xy coordinates in addition to the rectangle attribute to make it easier to calculate the width and height.

/**
 * Update the rectangle properties according to the new coordinate points
 * @param instance free drawing element
 * @param position current position
 */
export const updateRect = (instance: FreeDraw, position: MousePosition) => {
  const { x, y } = position
  let { minX, maxX, minY, maxY } = instance.rect
  if (x < minX) {
    minX = x
  }
  if (x > maxX) {
    maxX = x
  }
  if (y < minY) {
    minY = y
  }
  if (y > maxY) {
    maxY = y
  }
  const rect = {
    minX,
    maxX,
    minY,
    maxY,
    x: minX,
    y: minY,
    width: maxX - minX,
    height: maxY - minY
  }
  instance.rect = rect
  return rect
}

Calculate Mouse Movement Coordinates

  • As the mouse moves, we want to change the cursor so that the user can feel that the mouse has moved over the element. To realize this, we need to calculate whether the mouse coordinates are close to the drawing path to a certain distance.
  • A drawing path consists of a series of coordinate points. We can connect each coordinate point to the previous one to form a line segment. We define that the mouse can be considered to be hovering over an element when any of the following conditions are met
    • The mouse coordinates are less than 10 pixels from the start of the line segment.
    • The mouse coordinates are less than 10 pixels from the end of the line segment.
    • The mouse coordinates are less than 10 pixels away from the line segment and the x and y coordinates are within the range of the two endpoints of the line segment.
    • // Iterate over all coordinate points of the element
      for (let i = 1; i < positions.length; i++) {
        // Distance from the starting point
        const startDistance = getDistance(movePos, positions[i - 1])
        // Distance to the end point
        const endDistance = getDistance(movePos, positions[i])
        // Distance from line segment
        const lineDistance = getPositionToLineDistance(
          movePos,
          positions[i - 1],
          positions[i]
        )
        const rangeX =
          Math.max(positions[i - 1].x, positions[i].x) >= movePos.x &&
          movePos.x >= Math.min(positions[i - 1].x, positions[i].x)
        const rangeY =
          Math.max(positions[i - 1].y, positions[i].y) >= movePos.y &&
          movePos.y >= Math.min(positions[i - 1].y, positions[i].y)
      
        // The current element can be recorded if one of three conditions is met
        if (
          startDistance < 10 ||
          endDistance < 10 ||
          (lineDistance < 10 && rangeX && rangeY)
        ) {
          this.mouseHoverElementIndex = eleIndex
        }
      }
      // ...
      
      /**
       * Calculate the distance between two points
       * @param start starting point
       * @param end end point
       * @returns distance
       */
      export const getDistance = (start: MousePosition, end: MousePosition) => {
        return Math.sqrt(Math.pow(start.x - end.x, 2) + Math.pow(start.y - end.y, 2))
      }
      
      /**
       * Get the distance of the mouse position from the line segment
       * @param pos mouse position
       * @param startPos starting point
       * @param endPos end point
       * @returns distance
       */
      export const getPositionToLineDistance = (
        pos: MousePosition,
        startPos: MousePosition,
        endPos: MousePosition
      ) => {
        /**
         * 1. Calculate the straight line distance between three points
         * 2. Calculate the half perimeter of a triangle
         * 3. Finding area by Helen's formula
         * 4. Find the height of a triangle based on the area formula
         */
        const A = Math.abs(getDistance(pos, startPos))
        const B = Math.abs(getDistance(pos, endPos))
        const C = Math.abs(getDistance(startPos, endPos))
      
        const P = (A + B + C) / 2
        const area = Math.abs(Math.sqrt(P * (P - A) * (P - B) * (P - C)))
        const distance = (2 * area) / C
        return distance
      }

Click To Show Box Selection Effect

  • When the user clicks, we record this element as a select element if all three cases were met in the previous hover action.
  • In the drawing board rendering stage, we render the boxed effect according to the rectangle property of the boxed element.
    if (this.select.selectElementIndex !== -1) {
      // Get the rectangle attribute of the selected element and draw the boxed effect.
      const rect = this.select.getCurSelectElement().rect
      drawResizeRect(this.context, rect)
    }
    
    /**
     * Drawing Select Rectangles
     */
    export const drawResizeRect = (
      context: CanvasRenderingContext2D,
      rect: ElementRect
    ) => {
      const { x, y, width, height } = rect
      context.save()
      context.strokeStyle = '#65CC8A'
      context.setLineDash([5])
      context.lineWidth = 2
      context.lineCap = 'round'
      context.lineJoin = 'round'
    
      // Drawing Dashed Boxes
      drawRect(context, x, y, width, height)
    
      // Drawing the four corner handles
      context.fillStyle = '#65CC8A'
      drawRect(context, x - 10, y - 10, 10, 10, true)
      drawRect(context, x + width, y - 10, 10, 10, true)
      drawRect(context, x - 10, y + height, 10, 10, true)
      drawRect(context, x + width, y + height, 10, 10, true)
      context.restore()
    }
    
    /**
     * Drawing Rectangles
     */
    export const drawRect = (
      context: CanvasRenderingContext2D,
      x: number,
      y: number,
      width: number,
      height: number,
      fill = false // Fill or not
    ) => {
      context.beginPath()
      context.rect(x, y, width, height)
      if (fill) {
        context.fill()
      } else {
        context.stroke()
      }
    }

Drag And Drop Element

Dragging and dropping elements is relatively simple and is achieved by calculating the distance the mouse moves. Iterate over each coordinate point in the drawing path and add the corresponding distance to it to complete the drag operation.

// startMousePos is the coordinate of the last move.
const disntanceX = x - this.startMousePos.x
const disntanceY = y - this.startMousePos.y

/**
 * Update Position
 * @param distanceX
 * @param distanceY
 */
export const moveFreeDraw = (
  instance: FreeDraw,
  distanceX: number,
  distanceY: number
) => {
  initRect(instance)
  instance.positions.forEach((position) => {
    position.x += distanceX
    position.y += distanceY
    updateRect(instance, position)
  })
}

Element Zoom (doesn’t keep proportional)

  • I’ll analyze the element zoom by dragging and dropping the handle in the bottom right corner as an example
  • First of all, element scaling is divided into horizontal scaling and vertical scaling.
    • Horizontal scaling = (width of old rectangle + horizontal mouse movement) / width of old rectangle
    • Vertical Scaling = (height of old rectangle + vertical mouse movement) / height of old rectangle
  • Then iterate through all the coordinate points of the element to scale, which results in an offset scaling effect, as follows
  • Then you need to calculate how far the drag handle is currently moving from the corner vertices, and then subtract this distance to get the correct zoom effect
  • Of course the drag and drop calculations for the four corners are inconsistent, but the idea is the same
switch (this.resizeType) {
  // disntanceX Mouse Horizontal Move Distance
  // disntanceY Mouse Vertical Move Distance
  // bottom right
  case RESIZE_TYPE.BOTTOM_RIGHT:
    resizeFreeDraw(
      resizeElement as FreeDraw,
      (rect.width + disntanceX) / rect.width,
      (rect.height + disntanceY) / rect.height,
      rect,
      RESIZE_TYPE.BOTTOM_RIGHT
    )
    break
  // bottom left
  case RESIZE_TYPE.BOTTOM_LEFT:
    resizeFreeDraw(
      resizeElement as FreeDraw,
      (rect.width - disntanceX) / rect.width,
      (rect.height + disntanceY) / rect.height,
      rect,
      RESIZE_TYPE.BOTTOM_LEFT
    )
    break
  // top left
  case RESIZE_TYPE.TOP_LEFT:
    resizeFreeDraw(
      resizeElement as FreeDraw,
      (rect.width - disntanceX) / rect.width,
      (rect.height - disntanceY) / rect.height,
      rect,
      RESIZE_TYPE.TOP_LEFT
    )
    break
  // top right
  case RESIZE_TYPE.TOP_RIGHT:
    resizeFreeDraw(
      resizeElement as FreeDraw,
      (rect.width + disntanceX) / rect.width,
      (rect.height - disntanceY) / rect.height,
      rect,
      RESIZE_TYPE.TOP_RIGHT
    )
    break
  default:
    break
}

/**
 * Zoom Drawing
 * @param instance
 * @param scaleX
 * @param scaleY
 * @param rect
 * @param resizeType
 */
export const resizeFreeDraw = (
  instance: FreeDraw,
  scaleX: number,
  scaleY: number,
  rect: FreeDrawRect,
  resizeType: string
) => {
  // Initialize Rectangle
  initRect(instance)
  // Iterate over all coordinates for scaling
  instance.positions.forEach((position) => {
    position.x = position.x * scaleX
    position.y = position.y * scaleY
    updateRect(instance, position)
  })
  const { x: newX, y: newY, width: newWidth, height: newHeight } = instance.rect
  let offsetX = 0
  let offsetY = 0
  // Calculate the offset distance, this is to be calculated based on the diagonal vertices of the current zoom handle, so it is divided into 4 cases
  switch (resizeType) {
    case RESIZE_TYPE.BOTTOM_RIGHT:
      offsetX = newX - rect.x
      offsetY = newY - rect.y
      break
    case RESIZE_TYPE.BOTTOM_LEFT:
      offsetX = newX + newWidth - (rect.x + rect.width)
      offsetY = newY - rect.y
      break
    case RESIZE_TYPE.TOP_LEFT:
      offsetX = newX + newWidth - (rect.x + rect.width)
      offsetY = newY + newHeight - (rect.y + rect.height)
      break
    case RESIZE_TYPE.TOP_RIGHT:
      offsetX = newX - rect.x
      offsetY = newY + newHeight - (rect.y + rect.height)
      break
    default:
      break
  }

  initRect(instance)

  // Subtract offset distance
  instance.positions.forEach((position) => {
    position.x = position.x - offsetX
    position.y = position.y - offsetY
    updateRect(instance, position)
  })
}

Text zoom (keep proportional)

  • For text zoom, the aspect ratio needs to be kept constant. This can be done by calculating the aspect ratio of the old and new rectangles:
    • When the new aspect ratio is smaller than the old one, keep the width unchanged and calculate the height as Width / Old Aspect Ratio.
    • When the new aspect ratio is greater than the old one, keep the height unchanged and calculate the width as height * old aspect ratio.
      switch (this.resizeType) {
        // ...
        // bottom right
        case RESIZE_TYPE.BOTTOM_RIGHT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width + disntanceX,
            resizeElement.rect.height + disntanceY,
            RESIZE_TYPE.BOTTOM_RIGHT
          )
          break
        // bottom left
        case RESIZE_TYPE.BOTTOM_LEFT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width - disntanceX,
            resizeElement.rect.height + disntanceY,
            RESIZE_TYPE.BOTTOM_LEFT
          )
          break
        // top left
        case RESIZE_TYPE.TOP_LEFT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width - disntanceX,
            resizeElement.rect.height - disntanceY,
            RESIZE_TYPE.TOP_LEFT
          )
          break
        // top right
        case RESIZE_TYPE.TOP_RIGHT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width + disntanceX,
            resizeElement.rect.height - disntanceY,
            RESIZE_TYPE.TOP_RIGHT
          )
          break
        default:
          break
      }
      
      /**
       * Change text element size
       * @param ele text element
       * @param width Changed width
       * @param height Changed height
       * @param resizeType resize type
       */
      export const resizeTextElement = (
        ele: TextElement,
        width: number,
        height: number,
        resizeType: string
      ) => {
        const oldRatio = ele.rect.width / ele.rect.height
        const newRatio = width / height
        // Follow the previous instructions to change the aspect ratio inconsistency
        if (newRatio < oldRatio) {
          height = width / oldRatio
        } else if (newRatio > oldRatio) {
          width = oldRatio * height
        }
      
        /**
         * Because this zoom is scaled to the top left corner
         * So in order to achieve that the current drag handle does not move, an offset operation is required
         */
        switch (resizeType) {
          case RESIZE_TYPE.BOTTOM_RIGHT:
            break
          case RESIZE_TYPE.BOTTOM_LEFT:
            ele.rect.x -= width - ele.rect.width
            break
          case RESIZE_TYPE.TOP_LEFT:
            ele.rect.x -= width - ele.rect.width
            ele.rect.y -= height - ele.rect.height
            break
          case RESIZE_TYPE.TOP_RIGHT:
            ele.rect.y -= height - ele.rect.height
            break
          default:
            break
        }
        ele.rect.height = height
        ele.rect.width = width
        // Font size modified by height
        ele.fontSize = ele.rect.height
      }

Conclusion

If you’re also writing related features, I hope this will help you, and if you find any problems or have a good solution, please feel free to discuss 👻