The project needs a feature to annotate the current page and add text, and finally generate an image to accompany the process. Leaders who facilitate approval make it clear which point they are not satisfied with.

The demo presentation

Demo address: github.com/dishui1238/…

This component uses the VUX component library and can also be transposed to other component libraries. Html2canvas script, used to draw the current page elements onto the canvas; And svG-sprite-loader, used to load SVG files, are also not used.

Implementation approach

After careful analysis, we found the following functional points:

  • Draws the current page to the canvas
  • Implement brush drawing
  • Implement undo
  • Implement the function of adding text in the specified position
  • To achieve text deletion function
  • Generate images

Let’s roll up our sleeves and work hard!!

The implementation process

1. Draw the current page to the canvas

I clap my forehead, this is easy, EMmm… I don’t think so, you need to draw the current element and all its children, and all the element styles… Find a ready-made script to help.

Using the HTML2Canvas script, which allows you to capture a “screen shot” of a page or part of a page directly in the user’s browser, rendering the current page as a Canvas image by reading the DOM and applying different styles to elements, helped me do half the job.

  <button @click="handleClick">click</button>
    <div v-transfer-dom>
      <popup v-model="canvasShow" :should-rerender-on-show="true">
        <notation-canvas
          :canvasWidth="canvasWidth"
          :canvasHeight="canvasHeight"
          :imagesBase64="dataURL"
          @closeCanvas="closeCanvas"
        ></notation-canvas>
      </popup>
    </div>
Copy the code
  handleClick() {
      // Draw the DOM and its children to the canvas
      const capture = document.getElementById('capture');
      html2canvas(capture).then((canvas) = > {
        this.dataURL = canvas.toDataURL('image/png');
        this.canvasWidth = capture.offsetWidth;
        this.canvasHeight = capture.offsetHeight;
        this.canvasShow = true;
      });
    },
Copy the code

Pass the height and width of the generated canvas and dataURL to the component NotationCanvas that we need to develop next. Then we will use these elements to draw the page on the Canvas tag to complete the other half of the first step.

First, prepare the Canvas graphic container

 <canvas
    id="notation-canvas"
    ref="notationCanvas"
    :width="canvasWidthFull"
    :height="canvasHeightFull"
  >
    <p>Your browser is not supported!</p>
  </canvas>
Copy the code

Next, we need to show the power of Canvas:

   drawImages() {
    const notationCanvas = this.$refs.notationCanvas;
    notationCanvas.style.width = this.canvasWidth + 'px';
    notationCanvas.style.height = this.canvasHeight + 'px';
    // Whether the browser supports the canvas tag
    if (notationCanvas.getContext) {
      // const canvasWidth = this.canvasWidth;
      The getContext() method returns an object that provides methods and properties for drawing on the canvas.
      const ctx = notationCanvas.getContext('2d'); // Get the context
      this.context = ctx;

      const canvasWidth = this.canvasWidth;
      this.canvasWidthFull = this.canvasWidth;
      this.canvasHeightFull = this.canvasHeight;

      const img = new Image();
      img.src = this.imagesBase64;
      img.onload = function () {
        ctx.drawImage(
          img,
          0.0,
          canvasWidth,
          (canvasWidth / img.width) * img.height // Maintain the aspect ratio); }; }},Copy the code

At this point, our page is drawn inside the Canvas container and can do whatever it wants…

2. Achieve the function of brush drawing

The first thing to know is that the browser has a TouchEvent event, which is a generic name for a series of events, including TouchStart, TouchEnd, touchMove, and so on, and we only need those three.

MDN says: TouchEvent is a kind of event that describes the state change of finger on the touch plane (touch screen, touchpad, etc.). These events are used to describe one or more contacts, allowing developers to detect movement of contacts, increase or decrease of contacts, and so on.

Each Touch object represents a Touch point; Each contact is described by its position, size, shape, pressure, and target element. The TouchList object represents a list of multiple contacts.

A TouchList that lists all Touch objects currently in contact with the Touch surface, regardless of whether the Touch point has changed or its target element is in the TouchStart phase.

  • Touchstart: Triggered when the user places a touch on the touch plane. This TouchList object lists the new touches added in this event
  • Touchend: changedTouches are a collection of touches that have moved away from the touch plane when a touch is removed by the user (i.e. a finger or stylus moves away from the touch plane)
  • Touchmove: Triggered when the user moves a touch on the touch plane, listing the touches that have changed compared to the last event

Now we bind the above three events to the canvas:

<canvas
    id="notation-canvas"
    ref="notationCanvas"
    :width="canvasWidthFull"
    :height="canvasHeightFull"
    @touchstart="touchstart($event)"
    @touchend="touchend($event)"
    @touchmove="touchmove($event)"
  >
    <p>Your browser is not supported!</p>
  </canvas>
Copy the code

Let’s see what useful data is passed to us in the touchStart event:

touchstart(e){ console.log(e) }
Copy the code


Let’s use these three events to record the points the user draws on the screen

Specific ideas:

  • Use linePoints array to store coordinates, colors, and types of all points (start/end/move)

Record the coordinates of the starting point

touchstart(e) {
  // Draw only when open, and hide the rest while drawing
  if (this.isGraffiti) {
    this.visibleBtn = false;
    // make the move method available
    this.painting = true;
    // Client is based on the coordinates of the entire page. Offset is the distance from the top and left of cavas
    // Calculate the coordinates of the start point, need to consider the scroll bar
    const canvasX =
      e.changedTouches[0].clientX - e.target.parentNode.offsetLeft;
    const canvasY =
      e.changedTouches[0].clientY -
      e.target.parentNode.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
    this.setCanvasStyle(); // Set the canvas configuration style
    this.context.beginPath(); // Start a path
    this.context.moveTo(canvasX, canvasY); // The starting point of the movement
    this.linePoints.push({
      x: canvasX,
      y: canvasY,
      color: this.currentGraffitiColor,
      mode: 'start'}); }},Copy the code

Record the coordinates of the movement and the end point

Similarly, we store the coordinates of the move process and the coordinates of the end process. Since many points are generated during the move process, we need to record the length of the point of the current path using the pointsLength array at the end. The purpose of using the array is that there is not only one path, but possibly multiple paths. You need to record the length of each path so that you know which points need to be deleted when you undo later.

 touchmove(e) {
  if (this.painting) {
    // called only when allowed to move
    const t = e.target;
    let canvasX = null;
    let canvasY = null;
    canvasX = e.changedTouches[0].clientX - t.parentNode.offsetLeft;
    canvasY =
      e.changedTouches[0].clientY -
      t.parentNode.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
    const ratio = this.getPixelRatio(this.context);
    // Connect to the moving position and color it
    this.context.lineTo(canvasX * ratio, canvasY * ratio);
    this.context.stroke(); // Draw a defined path
    this.linePoints.push({
      x: canvasX,
      y: canvasY,
      color: this.currentGraffitiColor,
      mode: 'move'}); }},touchend(e) {
  if (this.isGraffiti) {
    this.visibleBtn = true;
    // Move cannot be drawn
    this.painting = false;
    // called only when allowed to move
    const t = e.target;
    const canvasX = e.changedTouches[0].clientX - t.parentNode.offsetLeft;
    const canvasY =
      e.changedTouches[0].clientY -
      t.parentNode.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
    this.linePoints.push({
      x: canvasX,
      y: canvasY,
      color: this.currentGraffitiColor,
      mode: 'end'});/ / store
    this.pointsLength.push(this.drawImageHistory.length); }},Copy the code

LinePoints store path of all points [start,……, end, start,……, end]

Length of pointsLength storage point [length1, length2]

The pointsLength length is the same as the number of groups of linePoints (from start-move-end). There is a correspondence between this and the pointsLength length.

3. Undo

To undo, pointsLength[pointsLength.length-1] points are removed from the linePoints array, leaving the remaining points on the page. This is done by redrawing the remaining paths.

    // Undo doodle
    withdrawGraffiti() {
      const last = this.pointsLength.pop() || 0;
      const rest = this.pointsLength.length
        ? this.pointsLength[this.pointsLength.length - 1]
        : 0;
      // Undo the painting
      this.linePoints.splice(rest, last - rest);
      this.redrawAll();
    },

    redrawAll() {
      const length = this.linePoints.length;

      const ctx = this.context;
      const width = this.canvasWidth;
      const linePoints = this.linePoints;
      const config = this.config;

      // Create a new canvas as the cache canvas
      const tempCanvas = document.createElement('canvas');
      const tempCtx = tempCanvas.getContext('2d');

      const img = new Image();
      img.src = this.imagesBase64;
      img.onload = function () {
        const height = (width / img.width) * img.height;
        tempCanvas.width = width;
        tempCanvas.height = height; // Set width and height
        tempCtx.drawImage(this.0.0, width, height);

        // Clears the specified pixel in the given rectangle
        ctx.clearRect(0.0, width, height);

        ctx.drawImage(tempCanvas, 0.0);

        for (let i = 0; i < length; i++) {
          const draw = linePoints[i];

          if (draw.mode === 'start') {
            ctx.lineWidth = config.lineWidth;
            ctx.shadowBlur = config.shadowBlur;
            ctx.shadowColor = draw.color;
            ctx.strokeStyle = draw.color;
            ctx.beginPath();
            ctx.moveTo(draw.x, draw.y);
          }
          if (draw.mode === 'move') {
            ctx.lineTo(draw.x, draw.y);
          }
          if (draw.mode === 'end') { ctx.stroke(); }}}; },Copy the code

At this point, we are more than half done

4. Add text

For text addition, I used an icon. Clicking the icon will display a mask containing textarea, whose value is bound to a value. When it is confirmed to add, the value will be stored in the addTexts array, and the initial position of the text will be defined in the center of the page.

Page:

  <! -- Add text mask -->
    <div v-if="textMask" class="add-text-container" ref="addTextContainer">
      <div class="shadow-full"></div>
      <span class="cancel-add" @click="cancelAddText">cancel</span>
      <span class="confirm-add" @click="confirmAddText">complete</span>
      <textarea
        v-model="addTextValue"
        :style="{color: currentTextColor}"
        class="text-area"
        wrap="hard"
        spellcheck="false"
        autocapitalize="off"
        autocomplete="off"
        autocorrect="off"
      ></textarea>

      <div class="graffiti-colors">
        <template v-for="color in graffitiColors">
          <div class="select-color" v-bind:key="color" @click="() => selectTextColor(color)">
            <div
              :class="{'color-item-active': currentTextColor === color}"
              class="color-item"
              :style="{background: color}"
            ></div>
          </div>
        </template>
      </div>
    </div>
Copy the code

Logic:

// Make sure to add the font
    confirmAddText() {
      this.textMask = false;
      this.visibleBtn = true;
      this.addTexts[this.textActiveIndex].textContent = this.addTextValue;
      this.addTextValue = ' ';

      const _this = this;
      this.$nextTick(function () {
        / / position
        const textContents = _this.$refs.textContents;
        const t = textContents[_this.textActiveIndex];
        const content = t.children[0];
        const contentOffsetWidth = content.offsetWidth + 1; // A newline with a difference of less than 1 May occur
        const offsetWidth = t.parentNode.offsetWidth - 10;
        const offsetHeight = t.parentNode.offsetHeight;
        const width =
          (contentOffsetWidth > offsetWidth
            ? offsetWidth
            : contentOffsetWidth) + 1;
        // Add will exist because there is a scrolling situation, so to handle, add the scrolling distance
        if(! t.style.left) { t.style.left ='50%';
          t.style.top =
            offsetHeight / 2 +
            (this.$refs.notationCanvasContent.scrollTop || 0) +
            'px';
          t.style.marginTop = '-50px';
          t.style.marginLeft = The '-' + width / 2 + 'px';

          // Record the initial point
        }
        t.style.width = width + 'px';
        t.style.color = _this.addTexts[_this.textActiveIndex].textColor;
      });
    },
Copy the code

Then you are ready to display the added text on the canvas.

First prepare a text container, because we need to record the position of the text movement, we also need to use the touchStart, TouchMove and TouchEnd events

  <! -- Text container -->
   <template v-for="(item, index) in addTexts">
    <div
      v-bind:key="index"
      class="text-item"
      ref="textContents"
      @click="textClick(index)"
      @touchstart="textItemStart($event, index)"
      @touchmove="textItemMove($event, index)"
      @touchend="textItemEnd($event, index)"
    >
      {{item.textContent}}
      <! -- This element is hidden by style for later convenience -->
      <span class="text-item-content">{{item.textContent}}</span>
    </div>
  </template>
Copy the code

We need to start moving the marker text in TouchStart and record the position of the touch point relative to the element

 textItemStart(e, index) {
  // Mark the text to move
  this.addTexts[index].textItemMoveFlag = true;
  this.$refs.notationCanvasContent.style.overflow = 'hidden';
  
  // Record the position of the touch point relative to the element
  this.addTexts[index].moveStartX =
      e.changedTouches[0].clientX - e.target.offsetLeft;
    this.addTexts[index].moveStartY =
      e.changedTouches[0].clientY -
      e.target.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
},
Copy the code

In the process of TouchMove, the element needs to be moved. Here, the absolute position of the element is controlled by the coordinates of the moving point to achieve the effect of text container movement. In the TouchEnd event, the marker text moves to the end.

  textItemMove(e, index) {
      if (this.addTexts[index].textItemMoveFlag) {
        this.visibleBtn = false;
        this.showRemoveText = true;
        const t = e.target;
        const content = t.children[0];
        // The width of the text content
        const contentOffsetWidth = content.offsetWidth + 1; // Line breaks may occur with a difference of less than 1 to prevent line breaks
        // Screen width
        const offsetWidth = t.parentNode.offsetWidth - 10;
        // The maximum width is the width of the screen
        const width =
          contentOffsetWidth > offsetWidth ? offsetWidth : contentOffsetWidth;

        var moveWidth =
          e.changedTouches[0].clientX -
          this.addTexts[index].moveStartX -
          t.parentNode.offsetLeft;
        var moveHeight =
          e.changedTouches[0].clientY -
          this.addTexts[index].moveStartY -
          t.parentNode.offsetTop +
          (this.$refs.notationCanvasContent.scrollTop || 0);
        this.addTexts[index].moveEndX = moveWidth;
        this.addTexts[index].moveEndY = moveHeight;
       
        if (
          (moveWidth < 0 && -moveWidth >= width - 30) ||
          (moveWidth >= 0 && moveWidth >= offsetWidth - 30)) {// The element should remain at least 30px wide and high (spread your imaginary wings ~~~) or return to its original position
          t.style.left = '50%';
          t.style.top = '50%';
          t.style.marginTop = '-50px';
          t.style.marginLeft = The '-' + width / 2 + 'px';
        } else {
          // Control the font position by modifying the style
          t.style.left = moveWidth + 'px';
          t.style.top = moveHeight + 'px';
          t.style.marginTop = 'auto';
          t.style.marginLeft = 'auto'; }}},Copy the code

So we’re done adding text, and we’re two steps away.

5. Text deletion

Text deletion needs to achieve the effect is to drag the position to the specified position, let go will execute the deletion, in the daily application have seen similar operations.

First, the deleted area must be in the bottom center of the page and only displayed during text movement

<div
    v-show="showRemoveText"
    class="remove-text"
    :class="{'remove-text-active': removeTextActive}"
    ref="removeText"
  >
    <div class="remove-icon">
      <svg class="icon">
        <use xlink:href="#icon-delete" />
      </svg>
    </div>
    <div v-if="removeTextActive" class="remove-tip">Release to delete</div>
    <div v-else class="remove-tip">Drag to delete</div>
  </div>
Copy the code

Then, we need to determine if the element is moved into the region during the move. We modify the textItemMove method:

textItemMove(e, index) {
    if (this.addTexts[index].textItemMoveFlag) {
       
       ........
        
      // Determine whether to delete
      const removeTextEl = this.$refs.removeText; // Capture the element's container (perform the delete)
      const x = removeTextEl.offsetLeft;
      const y = removeTextEl.offsetTop;
      const x1 = removeTextEl.offsetLeft + removeTextEl.offsetWidth;
      const y1 = removeTextEl.offsetTop + removeTextEl.offsetHeight;

      if (
        e.changedTouches[0].clientX >= x &&
        e.changedTouches[0].clientX <= x1 &&
        e.changedTouches[0].clientY >= y &&
        e.changedTouches[0].clientY <= y1
      ) {
        this.removeTextActive = true;
      } else {
        this.removeTextActive = false; }}}Copy the code

In order to facilitate understanding, I also showed a poor drawing skills, made a map, we bear with 🌝

The green point is the center point, and is also desirable when the position of the touch point (clientX, clientY) appears in the delete container, i.e. X < clientX < x1 and y < clientY < y1.

At this point, the text part is complete, but notice that we are not drawing the text on the canvas, we are just displaying it with a div element, so if we download the current canvas image we will find no text!!

So, we need to draw the text one by one in the last step when generating the image download. As for why not draw in advance, because we want to do text deletion function.

6. Generate pictures

confirm() {
  const notationCanvas = this.$refs.notationCanvas;
  this.context.shadowColor = 'rgba(238, 238, 238)';
 
  for (let i = 0, length = this.addTexts.length; i < length; i++) {
    const addText = this.addTexts[i];
    const addTextEl = this.$refs.textContents[i];
    const x = addTextEl.offsetLeft;
    const y = addTextEl.offsetTop + 13;

    const offsetWidth = addTextEl.parentNode.offsetWidth - 10;

    this.drawText(offsetWidth, addText, x, y);
  }

  // get the image
  var base64Img = notationCanvas.toDataURL('image/jpg');
  const link = document.createElement('a');
  link.href = base64Img;
  link.download = 'Reason for rejection. PNG';
  link.click();
  this.$emit('closeCanvas');
},
Copy the code

Method of drawing text:

 drawText(contentWidth, addText, x, y) {
      // Draw the text
      this.context.font =
        "32px 'Helvetica Neue', -apple-system-font, sans-serif";

      // Set the color
      this.context.fillStyle = addText.textColor;
      // Set the horizontal alignment
      this.context.textAlign = 'start';
      // Set the vertical alignment
      this.context.textBaseline = 'middle';
      If the length is smaller than contentWidth, it is not processed; if it is larger, it is processed
      if (this.context.measureText(addText.textContent).width >= contentWidth) {
        // Split text into single characters
        const textChar = addText.textContent.split(' ');
        // For splicing
        let tmpText = ' ';
        const textRows = [];

        for (let i = 0, length = textChar.length; i < length; i++) {
          // If it is before the end, or at the end, then there is a case where it is still smaller than contentWidth, causing another part to be lost
          if (
            this.context.measureText(tmpText).width / 2 >=
            contentWidth - 15
          ) {
            textRows.push(tmpText);
            tmpText = ' ';
          } else if (
            i === length - 1 &&
            this.context.measureText(tmpText).width / 2 < contentWidth - 15
          ) {
            tmpText += textChar[i];
            textRows.push(tmpText);
            tmpText = ' ';
          }
          tmpText += textChar[i];
        }

        for (let i = 0, length = textRows.length; i < length; i++) {
          // Draw text (parameters: the word to write, x, y coordinates)
          this.context.fillText(textRows[i], x, (y + i * 24)); }}else {
        this.context.fillText(addText.textContent, x, y); }},Copy the code

The problem

Image fuzzy

After the image is generated, it will be found that the image is relatively fuzzy, as shown in the figure below

The devicePixelRatio thing is rendering a pixel with a few pixel widths. I found that the chrome browser I was using had a devicePixelRatio of 3, and I could get the devicePixelRatio using window.devicepixelratio. This means that a 100 pixel value is displayed on the device as 300 pixels, so it becomes blurred.

The solution is to create an image magnified by devicePixelRatio, that is, multiply the width and height of the canvas by the same multiplier and, of course, all coordinates used by the canvas by the same multiplier.

Below is a picture of the solution.

conclusion

Of course, there are still many functions left undone, such as text zooming and custom drawing line thickness, etc., because I don’t have such requirements, I didn’t do it. I may improve it later when I have time. Of course, you can also provide me with good ideas at 😉

It is highly recommended to use the demo, because the document only lists the logic points, even if I follow the step by step, it will not be able to run because I omitted a lot of code.