A kind of Canvas realizes drawing, zooming, moving and saving historical state on the picture, which is pure dry goods (with css3 conversion formula attached)

Hahaha, I'm here again. This time, I'll bring you some articles about canvas functions. I hope you like them! This css3 change formula can be applied to the transform attribute we usually use or the map zooming on the mobile terminal!

Preface

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

github.com/zhcxk1998/S...

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!

The canvas painting brush is used, which is translated and zoomed by the transform attribute of css3. Considering that if the attributes of canvas such as drawImage or scale are used to change, the generated image will also have an impact, thinking about the direct change of css3, and canvas is used to do the functions such as brush. What are the tricks of the big guys? Give me some tips in the comment area!

(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 try online: test.algbb.cn/#/admin/con...

Formula deduction

If you don't want to see how the formula is derived, you can skip to see the specific implementation later~

1. Coordinate conversion formula

Introduction to conversion formula

In fact, at the beginning, I wanted to find out if there was any relevant information on the Internet, 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
 Copy code

Derivation process

This formula, in fact, is more general. It can be used in other scenarios where the transform attribute is used. As for how to deduce it, I use a 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, mark the coordinates, and set the container property overflow:hidden to hide the overflow content

ok, now there are two same matrices. Let's mark them with some red dots, and then we will transform the css3 style on the left

The width and height of the rectangle is 360px * 360px. Let's define its change attribute. Select the positive center of the change base point, and zoom in three times

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

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 give 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
(the value of the coordinate on the left here is the coordinate we press)
(the value of the coordinate on the left here is the coordinate we press)
(the value of the coordinate on the left here is the coordinate we press)


  • Because the width and height are 360px, they are divided into three equal parts, each with a width of 120px
  • 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 coordinate on the left is x:120 y:0, and the yellow mark on the right is x:160 y:120

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

  • Blue mark: left: x:120 y:120, right: x: 160 y:160
  • Green mark: left: x: 240 y:240, right: 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 not try to translate x, which is a little simpler. If we simulate it in the brain, we 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);
Copy code

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:0, right: x:160 y:160
  • Green: left: x:120 y:120, right: x:200 y:200

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

  • Blue: left: x:120 y:120, right: x:160 y:160
  • Green: left: x:160 y:160, right: x:200 y:200

It's not hard to find that we are actually different from the displacement distance translateX/translateY, so we only need to subtract the displacement distance 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'
}
Copy code

It may be asked why offset left and offset top should be subtracted. As we have repeatedly stressed above, we calculate the coordinates of the mouse click, and the coordinates are 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>Unfortunately, this thing doesn't work with your computer!</p>
  </canvas>
  <div className="mark-paper__sider" />
</div>
Copy code

The only thing we need is that the container needs to set the property overflow: hidden 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 = ''
  }
}
Copy code

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
    }
  }
Copy code

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)
  }
}

Copy code

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)
}
Copy code

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])
Copy code

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
  }
}
Copy code

7. Realization of eraser

At present, there are still some problems with eraser. Now, we use the canvas background image + globalCompositeOperation property to simulate the implementation of eraser. However, after the image is generated, the trace of eraser will turn white instead of transparent

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

  • Set the property context.globalcompositeoperation = "destination out"
// At present, there are still some problems with the eraser. The front display is normal. Save the picture, and 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
  }
}
Copy code

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)
Copy code

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)
  }
}
Copy code

Then we will implement it. When the brush is updated, we also need to add the current state to the brush state list, update the subscript corresponding to the current state, and 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
  }
}
Copy code

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!')
}
Copy code

After the event is set, we can start to listen to the current status subscript of the canvas current history and use the side effect function to process it

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])
Copy code

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('http://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('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
      wrap.style.cursor = 'default'
      break
    default:
      canvas.style.cursor = 'default'
      wrap.style.cursor = 'default'
      break
  }
}
Copy code

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])
}
Copy code

Matters needing attention

Note the offset of the container

We need to pay attention to that, because the downX in the formula is the coordinate relative to the container, that is to say, we need to subtract the offset of the container, which will occur when the parameter such as margin is used, or there are other elements above or on the left

When we output the offsetLeft and other attributes of our red element, we will find that it has an offset of 50. When we calculate the mouse click coordinates, 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>
Copy code

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 we set the relative layout of the parent element (that is, the yellow part) of the target element

.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>
Copy code

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 the 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. Save the picture, and 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('http://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('http://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 homework']} />
      <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>Unfortunately, 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 one">
                <Option value="xueshengjia">Student armor</Option>
                <Option value="xueshengyi">Student B</Option>
              </OptGroup>
              <OptGroup label="17 Software class two">
                <Option value="xueshengbing">Student C</Option>
              </OptGroup>
            </Select>
          </div>
          <div>
            //Canvas operation: < br / >
            <div className="mark-paper__action">
              <Tooltip title="Revoke">
                <i
                  className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`}
                  onClick={handleRollBack} />
              </Tooltip>
              <Tooltip title="recovery">
                <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="Determine"
                cancelText="cancel"
              >
                <Tooltip title="empty">
                  <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}>Paint 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 pictures</Button>
        </div>
      </div>
    </div >
  )
}

export default MarkPaper as ComponentType
Copy code

epilogue

If this article helps you, I hope you can give me some praise and encouragement!

Or give my project a star support!

github.com/zhcxk1998/S...

Vegetable chicken analysis is not in place, please point out my shortcomings! Arigado~

Tags: Attribute css3 React github

Posted on Mon, 23 Mar 2020 09:36:09 -0700 by jack_indigo