The scratch effect is so simple

Time:2021-12-17

1. The core API of scraping (eraser) effect

ctx.globalCompositeOperation = type;

Sets the type of compositing operation to apply when drawing a new shape.
The type we need to use here isdestination-out
The scratch effect is so simple
Details of this property:MDN document

2. Basic version scratch function

Canvs overlay on picture
The scratch effect is so simple

<style>
    body {
      margin: 0;
    }
    img {
      width: 400px;
      height: 300px;
      left: 200px;
      position: absolute;
      z-index: -1;
    }
    canvas {
      margin-left: 200px;
    }
  </style>
  
  <img src="./test.jpg" alt="pic"/>
  <canvas id="canvas" width="400" height="300"></canvas>
<script>
   let canvas = document.querySelector('#canvas');
   let context = canvas.getContext('2d');
   //Paint coating
   context.beginPath();
   context.fillStyle = 'grey';
   context.fillRect(0, 0, 400, 300);
   //Monitor mouse movement events
   canvas.addEventListener('mousemove', (e) => {
    //When the left mouse button is pressed & & to move the mouse, the coating near the mouse is removed
    if (e.which === 1 && e.button === 0) {
      const x = e.clientX, y = e.clientY;
      context.globalCompositeOperation = 'destination-out';
      context.beginPath();
      //Clear the range of a circle with a radius of 10px centered on the mouse position
      context.arc(x - 200, y, 10, 0, Math.PI * 2);
      context.fill();
    }
   })
  </script>

3. Advanced scraping function

Advanced functions:
When clicking, scrape a part of the area with the current position as the center of the circle;
After scraping the X percentage (can be customized), all will be displayed, and the animation will gradually fade;
Call the callback method for the first scraping and the callback method after scraping, which can be passed in or not passed;
Custom text can be displayed on the coating;

The scratch effect is so simple

First, change the form to class to facilitate the creation of scratch music many times.

class Scratch {
      constructor(id, { maskColor = 'grey', cursorRadius = 10 } = {}) {
        this.canvas = document.getElementById('canvas');
        this.context = this.canvas.getContext('2d');
        this.width = this.canvas.clientWidth;
        this.height = this.canvas.clientHeight;
        this. maskColor = maskColor; // coating color
        this. cursorRadius = cursorRadius; // cursor radius
        this.init();
      }
      init() {
        //Add coating
        this.addCoat();
        let bindEarse = this.erase.bind(this);
        this.canvas.addEventListener('mousedown', (e) => {
          //Press the left key
          if (e.which === 1 && e.button === 0) {
            //Wipe off the coating
            this.canvas.addEventListener('mousemove', bindEarse);
          }
        })
        document.addEventListener('mouseup', () => {
          this.canvas.removeEventListener('mousemove', bindEarse);
        })
      }
      addCoat() {
        this.context.beginPath();
        this.context.fillStyle = this.maskColor;
        this.context.fillRect(0, 0, this.width, this.height);
      }
      erase(e) {
        const x = e.clientX, y = e.clientY;
        this.context.globalCompositeOperation = 'destination-out';
        this.context.beginPath();
        this.context.arc(x - this.width / 2, y, this.cursorRadius, 0, Math.PI * 2);
        this.context.fill();
      }
    }
    new Scratch('canvas');

Then, record the mouse position, and judge whether to click or click & move the mouse during mouseup. If it is click, scrape a part of the area with the current position as the center of the circle;

this.canvas.addEventListener('mousedown', (e) => {
      this.posX = e.clientX;
      this.posY = e.clientY;
      ...
})
 document.addEventListener('mouseup', (e) => {
    if (this.posX === e.clientX && this.posY === e.clientY) {
      this.erase(e);
    }
     ...
})

Then, judge whether the scraping area is more than half, if it is to empty the coating;

ImageData ctx.getImageData(sx, sy, sw, sh);

SX: the X coordinate of the upper left corner of the rectangular area of the image data to be extracted.
SY: the y# coordinate of the upper left corner of the rectangular area of the image data to be extracted.
Sw: width of rectangular area of image data to be extracted.
SH: the height of the rectangular area of the image data to be extracted.

Imagedata object that contains rectangular image data given by canvas. It can be used to judge whether it is scratched.
Every four elements represent the RGBA value of one pixel, so it can be judged whether the value of the fourth element is less than half of 256, that is, 128. If it is less than 128, it can be regarded as transparent (scratched).

Empty the contents of the specified area:

void ctx.clearRect(x, y, width, height);
document.addEventListener('mouseup', (e) => {
   this.getScratchedPercentage();
    if (this.currPerct >= this.maxEraseArea) {
        this.context.clearRect(0, 0, this.width, this.height);
    }
})

getScratchedPercentage() {
    const pixels = this.context.getImageData(0, 0, this.width, this.height).data;
    let transparentPixels = 0;
    for (let i = 0; i < pixels.length; i += 4) {
         if (pixels[i + 3] < 128) {
            transparentPixels++;
          }
    }
    this.currPerct = (transparentPixels / pixels.length * 4 * 100).toFixed(2);
}

Then, set the callback method for the first scraping and the callback method after scraping;

constructor(id, { maskColor = 'grey', cursorRadius = 10, maxEraseArea = 50,
    firstEraseCbk = () => { }, lastEraseCbk = () => { } } = {}) {
    ...
    this. firstEraseCbk = firstEraseCbk; // callback function for the first scratch
    this. lastEraseCbk = lastEraseCbk; // scraped callback function
}

this.canvas.addEventListener('mousedown', (e) => { 
    if (this.currPerct === 0) {
        this.firstEraseCbk();
    }
})
document.addEventListener('mouseup', (e) => {
    if (this.currPerct >= this.maxEraseArea) {
        this.context.clearRect(0, 0, this.width, this.height);
        this.lastEraseCbk();
    }
})

Then, slowly empty the coating when scraping all, and set the background color gradient effect;
requestAnimationFrameThe animation is smoother
The callback function can pass parameters to the callback function in the form of closure

document.addEventListener('mouseup', (e) => {
    if (this.currPerct >= this.maxEraseArea) {
        this.done = true;
        requestAnimationFrame(this.fadeOut(255));
         this.lastEraseCbk();
    }
})

fadeOut(alpha) {
    return () => {
          this.context.save();
          this.context.globalCompositeOperation = 'source-in';
          this.context.fillStyle = this.context.fillStyle + (alpha -= 1).toString(16);
          this.context.fillRect(0, 0, this.width, this.height);
          this.context.restore();
          //The coating is no longer visible by 210
          if (alpha > 210) {
            requestAnimationFrame(this.fadeOut(alpha));
          }
     }
}

Then, when initializing the coating, the user-defined text is displayed on the coating;

addCoat() {
        ...
        if (this.text) {
          this.context.font = 'bold 48px serif';
          this.context.fillStyle = '#fff';
          this.context.textAlign = 'center';
          this.context.textBaseline = 'middle';
          this.context.fillText(this.text, this.width / 2, this.height / 2);
        }
}

Complete code

<!DOCTYPE html>
<html lang="en">


<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      margin: 0;
    }


    img {
      width: 400px;
      height: 300px;
      left: 200px;
      position: absolute;
      z-index: -1;
    }


    canvas {
      margin-left: 200px;
    }
  </style>
</head>


<body>
  <img src="./test.jpg" alt="pic" />
  <canvas id="canvas" width="400" height="300"></canvas>
  <script>
    class Scratch {
      constructor(id, { maskColor = 'grey', cursorRadius = 10, maxEraseArea = 50, text = '',
        firstEraseCbk = () => { }, lastEraseCbk = () => { } } = {}) {
        this.canvasId = id;
        this.canvas = document.getElementById(id);
        this.context = this.canvas.getContext('2d');
        this.width = this.canvas.clientWidth;
        this.height = this.canvas.clientHeight;
        this. maskColor = maskColor; // coating color
        this. cursorRadius = cursorRadius; // cursor radius
        this. maxEraseArea = maxEraseArea; // automatically empty the coating after scraping
        this.text = text;
        this. firstEraseCbk = firstEraseCbk; // callback function for the first scratch
        this. lastEraseCbk = lastEraseCbk; // scraped callback function
        this. currPerct = 0; // what is the current percentage of scraping
        this. done = false; // are you finished scraping
        this.init();
      }
      init() {
        //Add coating
        this.addCoat();
        let bindEarse = this.erase.bind(this);
        this.canvas.addEventListener('mousedown', e => {
          if (this.done) {
            return;
          }
          this.posX = e.clientX;
          this.posY = e.clientY;
          //Press the left key
          if (e.which === 1 && e.button === 0) {
            //Wipe off the coating
            this.canvas.addEventListener('mousemove', bindEarse);
          }
          if (this.currPerct === 0) {
            this.firstEraseCbk();
          }
        })
        document.addEventListener('mouseup', e => {
          if (this.done) {
            return;
          }
          if (e.target.id !== this.canvasId) {
            return;
          }
          if (this.posX === e.clientX && this.posY === e.clientY) {
            this.erase(e);
          }
          this.canvas.removeEventListener('mousemove', bindEarse);
          this.getScratchedPercentage();
          if (this.currPerct >= this.maxEraseArea) {
            this.done = true;
            requestAnimationFrame(this.fadeOut(255));
            this.lastEraseCbk();
          }
        })
      }
      //Add coating
      addCoat() {
        this.context.beginPath();
        this.context.fillStyle = this.maskColor;
        this.context.fillRect(0, 0, this.width, this.height);
        //Draw text on coating
        if (this.text) {
          this.context.font = 'bold 48px serif';
          this.context.fillStyle = '#fff';
          this.context.textAlign = 'center';
          this.context.textBaseline = 'middle';
          this.context.fillText(this.text, this.width / 2, this.height / 2);
        }
      }
      //Erase the coating at a certain position
      erase(e) {
        const x = e.clientX, y = e.clientY;
        this.context.globalCompositeOperation = 'destination-out';
        this.context.beginPath();
        this.context.arc(x - this.width / 2, y, this.cursorRadius, 0, Math.PI * 2);
        this.context.fill();
      }
      //Calculate the percentage of the erased part in the total
      getScratchedPercentage() {
        const pixels = this.context.getImageData(0, 0, this.width, this.height).data;
        let transparentPixels = 0;
        for (let i = 0; i < pixels.length; i += 4) {
          if (pixels[i + 3] < 128) {
            transparentPixels++;
          }
        }
        this.currPerct = (transparentPixels / pixels.length * 4 * 100).toFixed(2);
      }
      //Fade out effect when coating is empty
      fadeOut(alpha) {
        return () => {
          this.context.save();
          this.context.globalCompositeOperation = 'source-in';
          this.context.fillStyle = this.context.fillStyle + (alpha -= 1).toString(16);
          this.context.fillRect(0, 0, this.width, this.height);
          this.context.restore();
          //The coating is no longer visible by 210
          if (alpha > 210) {
            requestAnimationFrame(this.fadeOut(alpha));
          }
        }
      }
    }
    New scratch ('canvas', {text: 'scratch', maxerasearea: 10});
  </script>
</body>


</html>