HTML5 canvas realizes the functions of image marking, zooming, moving and saving historical state (with conversion formula)

Time:2020-4-4

Hahaha, I’m here again. This time, I’ll bring you some articles about canvas functions. I hope you like them!

Preface

Because I am a junior, I am looking for an internship recently. Before that, I had a small project of my own:

https://github.com/zhcxk1998/School-Partners

The interviewer said that he could think deeply, maybe add some new functions to increase the difficulty of the project. He put forward several suggestions, one of which is online marking of the test paper, on which the teacher can annotate the homework, circle and wait for me to start to study this subject that night, and finally I have found out!

What is used iscanvasDrawing brush, by CSS3’stransformProperties to pan and zoom, and then we will introduce them in detail

(I hope you can leave your precious praise and star hee hee)

Effect preview

If you can’t access it, you can log in to test.algbb.cn/ # / admin / con

Formula derivation if you don’t want to see how to deduce the formula, you can skip to see the specific implementation later. ~ 1. Introduction to coordinate conversion formula conversion formula

In fact, I wanted to find out whether there was any relevant information on the Internet at first, but unfortunately I couldn’t find it, so I pushed it out slowly. Let me give you an example of abscissa!

General formula

This formula means that the coordinates pressed by the mouse can be converted into relative coordinates in the canvas through the formula, which is particularly important

(transformOrigin – downX) / scale * (scale-1) + downX – translateX = pointX

Parameter interpretation

Transformrigin: the base point of the transform change (this attribute controls where the element changes)
Downx: the coordinates of the mouse down (note that the left offset distance of the container needs to be subtracted when using, because we want the coordinates relative to the container)
Scale: scale multiple, default is 1
Translatex: distance to translate

Derivation process

This formula, in fact, is more general and can be used in other applicationstransformAs for how to deduce the scene of attribute, I use the stupid method

The specific test code, placed at the end of the article, needs to be taken by itself~

1. First make two identical elements, then mark the coordinates, and set the container propertiesoverflow:hiddenTo hide overflow content

OK, now there are two same matrices. Let’s mark them with some red dots. Then let’s change the style of CSS3 on the lefttransform

The width and height of the rectangle are360px * 360pxYes, let’s define his change attribute, change the base point, select the positive center, and zoom in three times


// css
transform-origin: 180px 180px;
transform: scale(3, 3);

The results are as follows

OK, let’s compare the above results and find that when we zoom in three times, the black square in the middle occupies the whole width. Next, we can compare these points with the original unchanged rectangle (on the right) to get their coordinates

2. Start to compare the two coordinates, and then deduce the formula

Now let’s take a simple example, for example, let’s calculate the coordinates of the upper left corner (now it’s marked yellow)

In fact, we can calculate the relationship of coordinates directly

Here, the value of the coordinate on the left is the coordinate we press

Here, the value of the coordinate on the left is the coordinate we press

Here, the value of the coordinate on the left is the coordinate we press

  • Because width and height are360px, so it’s divided into three equal parts, each width is120px
  • Because the width and height of the container will not change after the change, only the rectangle itself will change
  • We can see that the yellow mark on the left isx:120 y:0, yellow on the rightx:160 y:120(in fact, it should be visible to the naked eye, but it can’t be calculated with paper and pen.)

This coordinate may be a little special. Let’s change it a few times to calculate it

Blue mark: left:x:120 y:120On the right side:x: 160 y:160Green mark: left:x: 240 y:240On the right side:x: 200: y:200

Well, we can almost get the relationship between coordinates. We can make a table

Still feel uneasy? Instead, we can calculate the zoom ratio and the width and height of the container

I don’t know if you feel it. Then we can slowly deduce the general formula according to the coordinates

(transformOrigin – downX) / scale * (scale-1) + down – translateX = point

Of course, we may have thistranslateXWithout trying, it’s a little easier. If you simulate it in the brain, you will know that we can subtract the displacement distance. Let’s test it

Let’s change the style and add the displacement distance


transform-origin: 180px 180px;
transform: scale(3, 3) translate(-40px,-40px);

It’s still the state above us. OK, now the blue and green marks correspond to each other. Let’s take a look at the current coordinates

  • Blue: left:x:0 y:0On the right side:x:160 y:160
  • Green: left:x:120 y:120On the right side:x:200 y:200

Let’s use the formula to figure out how the coordinates are (after coordinate conversion)

Blue: left:x:120 y:120On the right side:x:160 y:160Green: left:x:160 y:160On the right side:x:200 y:200

It’s not hard to find that we are actually different from the displacement distancetranslateX/translateYSo, we only need to subtract the distance of displacement to complete the coordinate conversion

Test formula

According to the above formula, we can simply test it! Can this formula work in the end!!!

Let’s use the demo above to test whether the location of a tag is displayed correctly if the element changes. It looks ok


const wrap = document.getElementById('wrap')
wrap.onmousedown = function (e) {
  const downX = e.pageX - wrap.offsetLeft
  const downY = e.pageY - wrap.offsetTop

  const scale = 3
  const translateX = -40
  const translateY = -40
  const transformOriginX = 180
  const transformOriginY = 180

  const dot = document.getElementById('dot')
  dot.style.left = (transformOriginX - downX) / scale * (scale - 1) + downX - translateX + 'px'
  dot.style.top = (transformOriginY - downY) / scale * (scale - 1) + downY - translateY + 'px'
}

One might ask, why subtract thisoffsetLeftFollowoffsetTopWell, as we have repeatedly stressed above, we calculate the coordinates of the mouse click, and this coordinate is still relative to the coordinates of the display container, so we need to subtract the offset of the container itself.

Component design

Now that demo and other components have been tested, let’s analyze how to design this component one by one (it’s still low configuration version at present, and then optimize it)

1. Basic canvas composition

Let’s start with a brief analysis of this structure. In fact, it’s mainly a container of canvas and a toolbar on the right. That’s all

That’s about it!

<div className="mark-paper__wrap" ref={wrapRef}>
  <canvas
    ref={canvasRef}
    className="mark-paper__canvas">
    <p>It's a pity that this thing doesn't work with your computer! </p>
  </canvas>
  <div className="mark-paper__sider" />
</div>

The only thing we need is for the container to set propertiesoverflow: hiddenIt is used to hide the overflow content of the internal canvas, that is, we need to control the visible area. At the same time, we need to get the width and height of the container dynamically to set the size of canvas

2. Initialize canvas and fill image

We can get a method to initialize and fill the canvas. The following is the main part of the screenshot, which is actually to set the size of canvas and fill our pictures

const fillImage = async () => {
  //Omit here
  
  const img: HTMLImageElement = new Image()

  img.src = await getURLBase64(fillImageSrc)
  img.onload = () => {
    canvas.width = img.width
    canvas.height = img.height
    context.drawImage(img, 0, 0)

    //Set the change base point to the center of the canvas container
    canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
    //Remove the effect of the last change
    canvas.style.transform = ''
  }
}

3. Monitor various mouse events on canvas

To control the movement, we can first find a way to listen to various events of the canvas mouse, and distinguish different modes to handle different events

const handleCanvas = () => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!context || !wrap) return

  //Clear the last set listening to prevent getting parameter errors
  wrap.onmousedown = null
  wrap.onmousedown = function (event: MouseEvent) {
    const downX: number = event.pageX
    const downY: number = event.pageY

    //Distinguish the mouse mode we choose now: move, brush, eraser
    switch (mouseMode) {
      case MOVE_MODE:
        handleMoveMode(downX, downY)
        break
      case LINE_MODE:
        handleLineMode(downX, downY)
        break
      case ERASER_MODE:
        handleEraserMode(downX, downY)
        break
      default:
        break
    }
  }

4. Move the canvas

This is easier to do. We only need to use the coordinates pressed by the mouse and the distance we drag to move the canvas. Because the latest displacement distance needs to be calculated for each move, we can define several variables for calculation.

This is listening to the mouse events of the container, not the events of the canvas, because in this way, we can move beyond the boundary again

To summarize briefly:

  • Incoming mouse down coordinates
  • Calculate the current displacement distance and update the effect of CSS change
  • Update the latest displacement state when the mouse is raised
//Define some variables to save the current / latest movement status
//Distance of current displacement
const translatePointXRef: MutableRefObject<number> = useRef(0)
const translatePointYRef: MutableRefObject<number> = useRef(0)
//Displacement distance at the end of last displacement
const fillStartPointXRef: MutableRefObject<number> = useRef(0)
const fillStartPointYRef: MutableRefObject<number> = useRef(0)

//Monitor function when moving
const handleMoveMode = (downX: number, downY: number) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const { current: fillStartPointX } = fillStartPointXRef
  const { current: fillStartPointY } = fillStartPointYRef
  if (!canvas || !wrap || mouseMode !== 0) return

  //Add a move event to the container to move the picture in the space
  wrap.onmousemove = (event: MouseEvent) => {
    const moveX: number = event.pageX
    const moveY: number = event.pageY

    //Update the current displacement distance, the value is: the coordinate at the end of the last displacement + the distance moved
    translatePointXRef.current = fillStartPointX + (moveX - downX)
    translatePointYRef.current = fillStartPointY + (moveY - downY)

    //Update CSS changes on canvas
    canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
  }
  
  wrap.onmouseup = (event: MouseEvent) => {
    const upX: number = event.pageX
    const upY: number = event.pageY
    
    //Cancel event listening
    wrap.onmousemove = null
    wrap.onmouseup = null;

    //When the mouse is raised, update the "last unique ending coordinate"
    fillStartPointXRef.current = fillStartPointX + (upX - downX)
    fillStartPointYRef.current = fillStartPointY + (upY - downY)
  }
}

5. Zoom the canvas

To zoom the canvas, I mainly use the slider on the right and the mouse wheel. First of all, we can add the event of monitoring the wheel to the function of monitoring the canvas mouse event

To summarize:

  • Monitor the change of mouse wheel
  • Update zoom factor and change style
//Monitor the mouse wheel and update the zoom ratio of canvas
const handleCanvas = () => {
  const { current: wrap } = wrapRef

  //Omit ten thousand words

  wrap.onwheel = null
  wrap.onwheel = (e: MouseWheelEvent) => {
    const { deltaY } = e
    //Note here that I use 0.1 to increase and decrease, but because JS uses IEEE 754 to calculate, there is a problem with precision. Let's deal with it ourselves
    const newScale: number = deltaY > 0
      ? (canvasScale * 10 - 0.1 * 10) / 10
      : (canvasScale * 10 + 0.1 * 10) / 10
    if (newScale < 0.1 || newScale > 2) return
    setCanvasScale(newScale)
  }
}

//Monitor slider to control zoom
<Slider
  min={0.1}
  max={2.01}
  step={0.1}
  value={canvasScale}
  tipFormatter={(value) => `${(value).toFixed(2)}x`}
  onChange={handleScaleChange} />
  
const handleScaleChange = (value: number) => {
  setCanvasScale(value)
}

Then we use the side effect function of hooks to update the style depending on the canvas zoom ratio

//Monitor zoom canvas
useEffect(() => {
  const { current: canvas } = canvasRef
  const { current: translatePointX } = translatePointXRef
  const { current: translatePointY } = translatePointYRef
  canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
}, [canvasScale])

6. Realize brush drawing

This needs to use the formula we deduced before! Because, think about it carefully, if we zoom in and out of the displacement, the coordinates of the mouse button may change with respect to the canvas, so we need to convert it to achieve the effect of one-to-one correspondence between the mouse button position and the canvas position

To summarize:

  • Incoming mouse down coordinates
  • Start drawing in corresponding coordinates through formula conversion
  • Cancel event monitoring when the mouse is raised
//Use the formula to change the coordinates
const generateLinePoint = (x: number, y: number) => {
  const { current: wrap } = wrapRef
  const { current: translatePointX } = translatePointXRef
  const { current: translatePointY } = translatePointYRef
  const wrapWidth: number = wrap?.offsetWidth || 0
  const wrapHeight: number = wrap?.offsetHeight || 0
  //Scaling displacement coordinate change law
  // (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
  const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
  const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY

  return {
    pointX,
    pointY
  }
}

//Listen to mouse brush events
const handleLineMode = (downX: number, downY: number) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !wrap || !context) return

  const offsetLeft: number = canvas.offsetLeft
  const offsetTop: number = canvas.offsetTop
  //Subtract the offset distance of the canvas (calculate the coordinates based on the canvas)
  downX = downX - offsetLeft
  downY = downY - offsetTop

  const { pointX, pointY } = generateLinePoint(downX, downY)
  context.globalCompositeOperation = "source-over"
  context.beginPath()
  //Set brush start point
  context.moveTo(pointX, pointY)

  canvas.onmousemove = null
  canvas.onmousemove = (event: MouseEvent) => {
    const moveX: number = event.pageX - offsetLeft
    const moveY: number = event.pageY - offsetTop
    const { pointX, pointY } = generateLinePoint(moveX, moveY)
    //Start drawing brush lines~
    context.lineTo(pointX, pointY)
    context.stroke()
  }
  canvas.onmouseup = () => {
    context.closePath()
    canvas.onmousemove = null
    canvas.onmouseup = null
  }
}

7. Realization of eraser

There are still some problems with the eraser at present. Now, it’s throughcanvasBackground image of canvas+globalCompositeOperationThis property simulates the implementation of the eraser. However, after the image is generated, the trace of the eraser will turn white instead of transparent

This step is similar to the brush implementation, with only a little change

set a propertycontext.globalCompositeOperation = "destination-out"

//At present, there are still some problems with the eraser. The front display is normal. If you save the picture, the erasing trace will turn white
const handleEraserMode = (downX: number, downY: number) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !wrap || !context) return

  const offsetLeft: number = canvas.offsetLeft
  const offsetTop: number = canvas.offsetTop
  downX = downX - offsetLeft
  downY = downY - offsetTop

  const { pointX, pointY } = generateLinePoint(downX, downY)

  context.beginPath()
  context.moveTo(pointX, pointY)

  canvas.onmousemove = null
  canvas.onmousemove = (event: MouseEvent) => {
    const moveX: number = event.pageX - offsetLeft
    const moveY: number = event.pageY - offsetTop
    const { pointX, pointY } = generateLinePoint(moveX, moveY)
    context.globalCompositeOperation = "destination-out"
    context.lineWidth = lineWidth
    context.lineTo(pointX, pointY)
    context.stroke()
  }
  canvas.onmouseup = () => {
    context.closePath()
    canvas.onmousemove = null
    canvas.onmouseup = null
  }
}

8. Function realization of cancellation and recovery

In this case, we first need to understand the logic of common undo and restore functions

  • Undo is not allowed if the current state is in the first position
  • If the current state is in the last position, recovery is not allowed
  • If the current status is revoked but updated, the current status is taken as the latest status (that is, recovery is not allowed, and the newly updated status is the latest)

Update of canvas status

So we need to set some variables to save the status list and the status subscript of the current brush

//Definition parameters stored in Dongdong
const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)

We also need to add the current state to the list when initializing canvas as the first empty canvas state

const fillImage = async () => {
  //Omit ten thousand words

  img.src = await getURLBase64(fillImageSrc)
  img.onload = () => {
    const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
    canvasHistroyListRef.current = []
    canvasHistroyListRef.current.push(imageData)
    setCanvasCurrentHistory(1)
  }
}

Then we will realize that when the brush is updated, we also need to add the current state to theBrush status list, and update the subscript corresponding to the current status. You need to deal with some details

To summarize:

  • When the mouse is raised, get the current canvas state
  • Add to status list and update status subscript
  • If it is currently in the undo state, if the brush is used to update the state, the current latest state will be cleared, and the state after the original position will be cleared
const handleLineMode = (downX: number, downY: number) => {
  //Omit ten thousand words
  canvas.onmouseup = () => {
    const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)

    //If it is in the undo state and the brush is used at this time, the later state will be cleared and the newly drawn one will be the latest canvas state
    if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
      canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
    }
    canvasHistroyListRef.current.push(imageData)
    setCanvasCurrentHistory(canvasCurrentHistory + 1)
    context.closePath()
    canvas.onmousemove = null
    canvas.onmouseup = null
  }
}

Revocation and restoration of canvas state

OK, in fact, we have finished the update of canvas status. Next, we need to deal with the function of state revocation and recovery

Let’s define this toolbar first

Then we set the corresponding events, which are undo, restore and clear, which are easy to understand. The most important thing is to deal with the boundary situation.

const handleRollBack = () => {
  const isFirstHistory: boolean = canvasCurrentHistory === 1
  if (isFirstHistory) return
  setCanvasCurrentHistory(canvasCurrentHistory - 1)
}

const handleRollForward = () => {
  const { current: canvasHistroyList } = canvasHistroyListRef
  const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
  if (isLastHistory) return
  setCanvasCurrentHistory(canvasCurrentHistory + 1)
}

const handleClearCanvasClick = () => {
  const { current: canvas } = canvasRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !context || canvasCurrentHistory === 0) return

  //Empty canvas history
  canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
  setCanvasCurrentHistory(1)

  Message. Success ('canvas cleared successfully! )
}

After the event is set, we can start to monitor thiscanvasCurrentHistorySubscript of current state, handle with side effect function


useEffect(() => {
  const { current: canvas } = canvasRef
  const { current: canvasHistroyList } = canvasHistroyListRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !context || canvasCurrentHistory === 0) return
  context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
}, [canvasCurrentHistory])

Fill canvas with image information!

So it’s done!!!

9. Change mouse icon

Let’s deal with it briefly. The brush mode is the icon of the brush. The mouse in the eraser mode is the eraser, and the move mode is the ordinary move icon

When switching modes, set different icons

const handleMouseModeChange = (event: RadioChangeEvent) => {
  const { target: { value } } = event
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef

  setmouseMode(value)

  if (!canvas || !wrap) return
  switch (value) {
    case MOVE_MODE:
      canvas.style.cursor = 'move'
      wrap.style.cursor = 'move'
      break
    case LINE_MODE:
      canvas.style.cursor = `url('https://cdn.algbb.cn/pencil.ico') 6 26, pointer`
      wrap.style.cursor = 'default'
      break
    case ERASER_MODE:
      Message. Warning ('eraser function is not perfect, there will be errors when saving pictures')
      canvas.style.cursor = `url('https://cdn.algbb.cn/eraser.ico') 6 26, pointer`
      wrap.style.cursor = 'default'
      break
    default:
      canvas.style.cursor = 'default'
      wrap.style.cursor = 'default'
      break
  }
}

10. Switch pictures

Now it’s just a demo state. Click the selection box to switch between different pictures

//Reset transform parameters, redraw picture
useEffect(() => {
  setIsLoading(true)
  translatePointXRef.current = 0
  translatePointYRef.current = 0
  fillStartPointXRef.current = 0
  fillStartPointYRef.current = 0
  setCanvasScale(1)
  fillImage()
}, [fillImageSrc])

const handlePaperChange = (value: string) => {
  const fillImageList = {
    'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
    'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
    'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
  }
  setFillImageSrc(fillImageList[value])
}

Matters needing attention

Note the offset of the container

We need to pay attention to it, because in the formuladownXIs the coordinate relative to the container, that is to say, we need to subtract the offset of the container, which will appear in usemarginIf there are other elements above or on the left

Let’s output our red element’soffsetLeftWhen we calculate the coordinates of mouse click, we need to subtract the offset of this part


window.onload = function () {
  const test = document.getElementById('test')
  console.log(`offsetLeft: ${test.offsetLeft}, offsetHeight: ${test.offsetTop}`)
}

html,
body {
  margin: 0;
  padding: 0;
}

#test {
  width: 50px;
  height: 50px;
  margin-left: 50px;
  background: red;
}

<div class="container">
  <div id="test"></div>
</div>

Pay attention to the relative layout of parent components

If we have a layout like this now, it looks normal to print the offset of red elements

But if the parent element (that is, the yellow part) of our target element is setrelativeRelative layout


.wrap {
  position: relative;
  width: 400px;
  height: 300px;
  background: yellow;
}

<div class="container">
  <div class="sider"></div>
  <div class="wrap">
    <div id="test"></div>
  </div>
</div>

What offset will we print out at this time

The two answers are different, because our offset is calculated according to the relative position. If the parent container uses the relative layout, it will affect the offset of our child elements

Component code (low configuration version)

import React, { FC, ComponentType, useEffect, useRef, RefObject, useState, MutableRefObject } from 'react'
import { CustomBreadcrumb } from '@/admin/components'
import { RouteComponentProps } from 'react-router-dom';
import { FormComponentProps } from 'antd/lib/form';
import {
Slider, Radio, Button, Tooltip, Icon, Select, Spin, message, Popconfirm
} from 'antd';
import './index.scss'
import { RadioChangeEvent } from 'antd/lib/radio';
import { getURLBase64 } from '@/admin/utils/getURLBase64'
const { Option, OptGroup } = Select;
type MarkPaperProps = RouteComponentProps & FormComponentProps
const MarkPaper: FC<MarkPaperProps> = (props: MarkPaperProps) => {
const MOVE_MODE: number = 0
const LINE_MODE: number = 1
const ERASER_MODE: number = 2
const canvasRef: RefObject<HTMLCanvasElement> = useRef(null)
const containerRef: RefObject<HTMLDivElement> = useRef(null)
const wrapRef: RefObject<HTMLDivElement> = useRef(null)
const translatePointXRef: MutableRefObject<number> = useRef(0)
const translatePointYRef: MutableRefObject<number> = useRef(0)
const fillStartPointXRef: MutableRefObject<number> = useRef(0)
const fillStartPointYRef: MutableRefObject<number> = useRef(0)
const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
const [lineColor, setLineColor] = useState<string>('#fa4b2a')
const [fillImageSrc, setFillImageSrc] = useState<string>('')
const [mouseMode, setmouseMode] = useState<number>(MOVE_MODE)
const [lineWidth, setLineWidth] = useState<number>(5)
const [canvasScale, setCanvasScale] = useState<number>(1)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)
useEffect(() => {
setFillImageSrc('http://cdn.algbb.cn/test/canvasTest.jpg')
}, [])
//Reset transform parameters, redraw picture
useEffect(() => {
setIsLoading(true)
translatePointXRef.current = 0
translatePointYRef.current = 0
fillStartPointXRef.current = 0
fillStartPointYRef.current = 0
setCanvasScale(1)
fillImage()
}, [fillImageSrc])
//When canvas parameters change, listen to canvas again
useEffect(() => {
handleCanvas()
}, [mouseMode, canvasScale, canvasCurrentHistory])
//Monitor brush color change
useEffect(() => {
const { current: canvas } = canvasRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!context) return
context.strokeStyle = lineColor
context.lineWidth = lineWidth
context.lineJoin = 'round'
context.lineCap = 'round'
}, [lineWidth, lineColor])
//Monitor zoom canvas
useEffect(() => {
const { current: canvas } = canvasRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
}, [canvasScale])
useEffect(() => {
const { current: canvas } = canvasRef
const { current: canvasHistroyList } = canvasHistroyListRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !context || canvasCurrentHistory === 0) return
context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
}, [canvasCurrentHistory])
const fillImage = async () => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
const img: HTMLImageElement = new Image()
if (!canvas || !wrap || !context) return
img.src = await getURLBase64(fillImageSrc)
img.onload = () => {
//Take the middle rendered picture
// const centerX: number = canvas && canvas.width / 2 - img.width / 2 || 0
// const centerY: number = canvas && canvas.height / 2 - img.height / 2 || 0
canvas.width = img.width
canvas.height = img.height
//The background is set as a picture, so the effect of eraser can come out
canvas.style.background = `url(${img.src})`
context.drawImage(img, 0, 0)
context.strokeStyle = lineColor
context.lineWidth = lineWidth
context.lineJoin = 'round'
context.lineCap = 'round'
//Set the change base point to the center of the canvas container
canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
//Remove the effect of the last change
canvas.style.transform = ''
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
canvasHistroyListRef.current = []
canvasHistroyListRef.current.push(imageData)
// canvasCurrentHistoryRef.current = 1
setCanvasCurrentHistory(1)
setTimeout(() => { setIsLoading(false) }, 500)
}
}
const generateLinePoint = (x: number, y: number) => {
const { current: wrap } = wrapRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
const wrapWidth: number = wrap?.offsetWidth || 0
const wrapHeight: number = wrap?.offsetHeight || 0
//Scaling displacement coordinate change law
// (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY
return {
pointX,
pointY
}
}
const handleLineMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
//Subtract the offset distance of the canvas (calculate the coordinates based on the canvas)
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.globalCompositeOperation = "source-over"
context.beginPath()
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
//If it is in the undo state and the brush is used at this time, the later state will be cleared and the newly drawn one will be the latest canvas state
if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
}
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(canvasCurrentHistory + 1)
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
const handleMoveMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const { current: fillStartPointX } = fillStartPointXRef
const { current: fillStartPointY } = fillStartPointYRef
if (!canvas || !wrap || mouseMode !== 0) return
//Add a move event to the container to move the picture in the space
wrap.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX
const moveY: number = event.pageY
translatePointXRef.current = fillStartPointX + (moveX - downX)
translatePointYRef.current = fillStartPointY + (moveY - downY)
canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
}
wrap.onmouseup = (event: MouseEvent) => {
const upX: number = event.pageX
const upY: number = event.pageY
wrap.onmousemove = null
wrap.onmouseup = null;
fillStartPointXRef.current = fillStartPointX + (upX - downX)
fillStartPointYRef.current = fillStartPointY + (upY - downY)
}
}
//At present, there are still some problems with the eraser. The front display is normal. If you save the picture, the erasing trace will turn white
const handleEraserMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.beginPath()
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
context.globalCompositeOperation = "destination-out"
context.lineWidth = lineWidth
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
}
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(canvasCurrentHistory + 1)
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
const handleCanvas = () => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!context || !wrap) return
//Clear the last set listening to prevent getting parameter errors
wrap.onmousedown = null
wrap.onmousedown = function (event: MouseEvent) {
const downX: number = event.pageX
const downY: number = event.pageY
switch (mouseMode) {
case MOVE_MODE:
handleMoveMode(downX, downY)
break
case LINE_MODE:
handleLineMode(downX, downY)
break
case ERASER_MODE:
handleEraserMode(downX, downY)
break
default:
break
}
}
wrap.onwheel = null
wrap.onwheel = (e: MouseWheelEvent) => {
const { deltaY } = e
const newScale: number = deltaY > 0
? (canvasScale * 10 - 0.1 * 10) / 10
: (canvasScale * 10 + 0.1 * 10) / 10
if (newScale < 0.1 || newScale > 2) return
setCanvasScale(newScale)
}
}
const handleScaleChange = (value: number) => {
setCanvasScale(value)
}
const handleLineWidthChange = (value: number) => {
setLineWidth(value)
}
const handleColorChange = (color: string) => {
setLineColor(color)
}
const handleMouseModeChange = (event: RadioChangeEvent) => {
const { target: { value } } = event
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
setmouseMode(value)
if (!canvas || !wrap) return
switch (value) {
case MOVE_MODE:
canvas.style.cursor = 'move'
wrap.style.cursor = 'move'
break
case LINE_MODE:
canvas.style.cursor = `url('https://cdn.algbb.cn/pencil.ico') 6 26, pointer`
wrap.style.cursor = 'default'
break
case ERASER_MODE:
Message. Warning ('eraser function is not perfect, there will be errors when saving pictures')
canvas.style.cursor = `url('https://cdn.algbb.cn/eraser.ico') 6 26, pointer`
wrap.style.cursor = 'default'
break
default:
canvas.style.cursor = 'default'
wrap.style.cursor = 'default'
break
}
}
const handleSaveClick = () => {
const { current: canvas } = canvasRef
//It can be stored in database or directly generated pictures
console.log(canvas?.toDataURL())
}
const handlePaperChange = (value: string) => {
const fillImageList = {
'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
}
setFillImageSrc(fillImageList[value])
}
const handleRollBack = () => {
const isFirstHistory: boolean = canvasCurrentHistory === 1
if (isFirstHistory) return
setCanvasCurrentHistory(canvasCurrentHistory - 1)
}
const handleRollForward = () => {
const { current: canvasHistroyList } = canvasHistroyListRef
const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
if (isLastHistory) return
setCanvasCurrentHistory(canvasCurrentHistory + 1)
}
const handleClearCanvasClick = () => {
const { current: canvas } = canvasRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !context || canvasCurrentHistory === 0) return
//Empty canvas history
canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
setCanvasCurrentHistory(1)
Message. Success ('canvas cleared successfully! )
}
return (
<div>
< custombreadcrumb list = {['content management', 'marking job']} / >
<div className="mark-paper__container" ref={containerRef}>
<div className="mark-paper__wrap" ref={wrapRef}>
<div
className="mark-paper__mask"
style={{ display: isLoading ? 'flex' : 'none' }}
>
<Spin
Tip = "picture loading..."
indicator={<Icon type="loading" style={{ fontSize: 36 }} spin
/>}
/>
</div>
<canvas
ref={canvasRef}
className="mark-paper__canvas">
<p>It's a pity that this thing doesn't work with your computer! </p>
</canvas>
</div>
<div className="mark-paper__sider">
<div>
Select job:
<Select
defaultValue="xueshengjia"
style={{
width: '100%', margin: '10px 0 20px 0'
}}
onChange={handlePaperChange} >
< optgroup label = "17 software class 1" >
< option value = "xueshengjia" > student a < / option >
< option value = "xueshengyi" > Student B < / option >
</OptGroup>
< optgroup label = "17 software class 2" >
< option value = "xueshengbing" > student C < / option >
</OptGroup>
</Select>
</div>
<div>
Canvas operation: < br / >
<div className="mark-paper__action">
< tooltip title = "undo" >
<i
className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`}
onClick={handleRollBack} />
</Tooltip>
< tooltip title = "restore" >
<i
className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`}
onClick={handleRollForward} />
</Tooltip>
<Popconfirm
Title = "are you sure you want to empty the canvas? "
onConfirm={handleClearCanvasClick}
Oktext = "OK"
Canceltext = "Cancel"
>
< tooltip title = "clear" >
<i className="icon iconfont icon-qingchu" />
</Tooltip>
</Popconfirm>
</div>
</div>
<div>
Canvas zoom:
< tooltip placement = "top" title = 'zoom with mouse wheel' >
<Icon type="question-circle" />
</Tooltip>
<Slider
min={0.1}
max={2.01}
step={0.1}
value={canvasScale}
tipFormatter={(value) => `${(value).toFixed(2)}x`}
onChange={handleScaleChange} />
</div>
<div>
Brush size:
<Slider
min={1}
max={9}
value={lineWidth}
tipFormatter={(value) => `${value}px`}
onChange={handleLineWidthChange} />
</div>
<div>
Mode selection:
<Radio.Group
className="radio-group"
onChange={handleMouseModeChange}
value={mouseMode}>
< radio value = {0} > move < / radio >
< radio value = {1} > brush < / radio >
< radio value = {2} > eraser < / radio >
</Radio.Group>
</div>
<div>
Color selection:
<div className="color-picker__container">
{['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => {
return (
<Tooltip placement="top" title={color} key={color}>
<div
role="button"
className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`}
style={{ background: color }}
onClick={() => handleColorChange(color)}
/>
</Tooltip>
)
})}
</div>
</div>
< button onclick = {handlesaveclick} > Save Picture < / button >
</div>
</div>
</div >
)
}
export default MarkPaper as ComponentType

summary

This is the introduction of this article about HTML5 canvas’s implementation of image tagging, zooming, moving and saving historical state (with conversion formula). For more information about canvas’s image tag zooming and moving, please search previous articles of developepaer or continue to browse the relevant chapters below. I hope you can support developepaer in the future!