Bootstrap

canvas实现手写功能

1.从接口获取手写内容,处理成由单个字组成的数组(包括符号)
2.合成所有图的时候,会闪现outputCanvas合成的图,注意隐藏
3.可以进行多个手写内容切换
4.基于uniapp的

<template>
  <view class="content">
    <!-- 头部 -->
    <view class="navBarBox">
      <!-- 头部导航 -->
      <u-navbar height="120rpx">
        <view class="u-nav-slot" slot="left">
          <view class="flex alignCenterClass flexBetween" @click="_close">
            <view class="leftBox">
              <img src="static/imgs/leftIcon.png" alt="" />
            </view>
          </view>
        </view>
        <view class="centerBox" slot="center">
          <view class="title"> 批注声明 </view>
          <u--text
            type="error"
            text="请您在区域内逐字手写以下文字,全部写完后点击保存!"
            size="30rpx"
            align="center"
          >
          </u--text>
        </view>
        <view class="u-nav-slot flex" slot="right">
          <view
            class="btn-box signerBox"
            v-if="isComplete"
            @click="_submitDraw"
          >
            <text> 完成 </text>
            <u-icon name="checkmark" color="#fff" size="26"></u-icon>
          </view>
        </view>
      </u-navbar>
    </view>
    <view class="content-model">
      <view class="model-left">
        <view
          v-for="(item, index) in docNoteList"
          class="note-item"
          :key="item.code"
          @click="_checkNotes(item, 'click')"
        >
          <view
            class="note-btn btn-box"
            :class="curNode.code == item.code ? 'actice-node' : ''"
            ><text>批注{{ index + 1 }}</text>
            <u-icon
              v-if="curNode.code == item.code"
              name="edit-pen"
              color="#fff"
              size="28"
            ></u-icon
          ></view>
          <u-icon
            v-if="item.isDone"
            name="checkmark-circle"
            color="#087e6a"
            size="26"
          ></u-icon>
        </view>
      </view>
      <view class="container">
        <view class="notes-list">
          <scroll-view
            scroll-y="true"
            style="height: 600rpx"
            class="scroll-view_w"
            enable-flex="true"
            scroll-with-animation="true"
          >
            <view class="note-box">
              <view
                v-for="item in curNode.notesList"
                @click="_checkItem(item)"
                :key="item.index"
                class="notes-item"
                :class="activeItem.index == item.index ? 'active' : ''"
              >
                <view class="note-label">{{ item.label }}</view>
                <!-- 展示写好的字 -->
                <view class="note-img">
                  <img v-if="item.imgSrc" :src="item.imgSrc" alt="" />
                </view>
              </view>
            </view>
          </scroll-view>
        </view>
        <view class="main-model">
          <view class="show-canvas">
          	<!-- canvas背景字 -->
            <view class="bg-text"
              ><text>{{ activeItem.label }}</text></view
            >
            <!-- 当前需要手写的字的canvas -->
            <view class="canvas-container">
              <canvas
                canvas-id="inputCanvas"
                class="input-canvas"
                @touchstart="handleTouchStart"
                @touchmove="handleTouchMove"
                @touchend="handleTouchEnd"
              ></canvas>
            </view>
          </view>
        </view>
        <!-- 将单个字的图片合并章所用的canvas -->
        <view class="show-result">
          <canvas
            :style="{ height: outputHeight, width: outputWidth }"
            canvas-id="outputCanvas"
            class="output-canvas"
          ></canvas>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { pathToBase64 } from "../../utils/image-tools/index.js";
import { addNoteImg, getDocData } from "@/utils/api.js";
export default {
  components: {},
  data() {
    return {
      show: true,
      isDrawing: false,
      startX: 0,
      startY: 0,
      strokes: [],
      charObjects: [],
      timer: null,
      delay: 500, // 写完后的延迟
      fj: "",
      outputHeight: "100px",
      outputWidth: "100px",
      tempFilePath: "", //当前显示的图片
      activeItem: {}, //当前的批注文字
      document: "",
      docNoteList: [], //所有批注列表
      curNode: {
        notesList: [],
      }, //当前批注
      isComplete: false, //当前文档所有批注书否全部写完,控制完成按钮的显示
      tempPathObj: {},
      showImg: "", //批注合成图
    };
  },
  computed: {},
  methods: {
    // 返回上一页
    _close() {
      uni.redirectTo({
        url: "/pages/index/fileEdit?documentId=" + this.document.documentId,
      });
    },
    _checkItem(val) {
      this.activeItem = { ...val };
      this.tempFilePath = val.imgSrc || "";
    },
    handleTouchStart(e) {
      e.preventDefault(); // 阻止默认滚动行为
      if (this.timer) {
        clearTimeout(this.timer);
        this.timer = null;
      }
      const touch = e.touches[0];
      this.isDrawing = true;
      this.startX = touch.x;
      this.startY = touch.y;
      this.strokes.push({
        x: touch.x,
        y: touch.y,
      });
    },
    handleTouchMove(e) {
      e.preventDefault(); // 阻止默认滚动行为
      if (!this.isDrawing) return;
      const touch = e.touches[0];
      const context = uni.createCanvasContext("inputCanvas", this);
      context.setStrokeStyle("#000");
      context.setLineWidth(15);
      context.setLineJoin('round');
      context.setLineCap('round');
      context.moveTo(this.startX, this.startY);
      context.lineTo(touch.x, touch.y);
      context.stroke();
      context.draw(true);
      this.startX = touch.x;
      this.startY = touch.y;
      this.strokes.push({
        x: touch.x,
        y: touch.y,
      });
    },
    handleTouchEnd(e) {
      e.preventDefault(); // 阻止默认滚动行为
      this.isDrawing = false;
      // 写完后延迟,清空Canvas,获取手写图
      this.timer = setTimeout(this.addChar, this.delay);
    },
    addChar() {
      const inputContext = uni.createCanvasContext("inputCanvas", this);
      uni.canvasToTempFilePath({
        canvasId: "inputCanvas",
        success: (res) => {
          // 清空 inputCanvas 上的内容
          inputContext.clearRect(0, 0, 700, 700);
          inputContext.draw();
          this._pathToBase64(res.tempFilePath, "show");
        },
      });
    },
    //批注合成处理
    _drawImage(notesList, imgCode) {
      this.showImg = "";
      let outputContext = "";
      outputContext = uni.createCanvasContext("outputCanvas", this);
      const charSize = 40; // 调整字符大小
      const maxCharsPerRow = 20; // 每行最大字符数
      // 动态设置高度
      const numRows = Math.ceil(notesList.length / maxCharsPerRow); // 计算行数
      this.outputHeight = `${numRows * charSize}px`; // 动态计算输出画布的高度
      this.outputWidth = `${maxCharsPerRow * charSize}px`; // 动态计算输出画布的宽度
      // 绘制字符
      let rowSpacing = "";
      let colSpacing = "";
      notesList.forEach((item, index) => {
        const rowIndex = Math.floor(index / maxCharsPerRow); // 当前字符的行索引
        const colIndex = index % maxCharsPerRow; // 当前字符的列索引
        rowSpacing = rowIndex * charSize;
        colSpacing = colIndex * charSize;
        outputContext.drawImage(
          item.tempFilePath,
          colSpacing,
          rowSpacing,
          charSize,
          charSize
        );
      });
      setTimeout(() => {
        outputContext.draw(false, () => {
          uni.canvasToTempFilePath({
            canvasId: "outputCanvas",
            success: (result) => {
              uni.compressImage({
                //压缩图片
                src: result.tempFilePath,
                success: (res) => {
                  pathToBase64(res.tempFilePath)
                    .then((base64) => {
                      //赋值
                      const _nodeImg = this.filterBase64(base64);
                      this.tempPathObj[imgCode] = _nodeImg;
                      console.log("tempPathObj", this.tempPathObj);
                      this.showImg = _nodeImg;
                      // 清空 outputContext 上的内容
                      outputContext.clearRect(
                        0,
                        0,
                        colSpacing + charSize * charSize,
                        rowSpacing + numRows * charSize
                      );
                      outputContext.draw();
                    })
                    .catch((error) => {
                      console.error(error);
                    });
                },
              });
            },
          });
        });
      }, 500);
    },
    _checkNotePass() {
      let isComplete = true; //是否全部批注都写完,默认都写完了
      const _curNode = this.curNode;
      this.docNoteList.map((item) => {
        if (item.code == _curNode.code) {
          item.isDone = _curNode.notesList.every((item) => {
            return item.imgSrc != "";
          });
          if (item.isDone) {
            this._drawImage(_curNode.notesList, _curNode.imgCode);
          }
        }
        if (!item.isDone) {
          //如有未完成的
          isComplete = false;
        }
      });
      this.isComplete = isComplete;
    },
    _pathToBase64(val, code) {
      uni.compressImage({
        //压缩图片
        src: val,
        success: (res) => {
          pathToBase64(res.tempFilePath)
            .then((base64) => {
              const _notesList = this.curNode.notesList;
              const signImg = this.filterBase64(base64);
              // 手写处理
              _notesList.map((item) => {
                if (item.index == this.activeItem.index) {
                  item.imgSrc = signImg;
                  item.tempFilePath = res.tempFilePath;
                }
              });
              this.tempFilePath = signImg;
              this._checkNotePass();
              // 自动轮下一个
              if (this.activeItem.index < _notesList.length - 1) {
                this._checkItem(_notesList[this.activeItem.index + 1]);
              }
            })
            .catch((error) => {
              console.error(error);
            });
        },
      });
    },
    // 过滤base64太长有换行字符方法
    filterBase64(codeImages) {
      return codeImages.replace(/[\r\n]/g, "");
    },
    // 保存更新
    async _submitDraw() {
      let documentData = {
        ...this.document.documentData,
      };
      for (const key in this.tempPathObj) {
        documentData[key] = this.tempPathObj[key];
      }
      const query = {
        documentId: this.document.documentId,
        documentData: JSON.stringify(documentData),
      };
      await addNoteImg(query).then(({ data: res }) => {
        if (res.code == 0) {
          uni.showToast({
            icon: "success",
            title: "保存成功",
          });
          uni.redirectTo({
            url: "/pages/index/fileEdit?documentId=" + this.document.documentId,
          });
        }
      });
    },
    // 获取文书详情
    async _getDocData(documentId) {
      await getDocData({ documentId }).then(({ data: res }) => {
        const _documentData = JSON.parse(res.data.documentData);
        this.document = {
          documentId: res.data.documentId,
          documentData: _documentData,
        };
        this._checkDocData(_documentData);
      });
    },
    // 处理批注,形成列表
    _checkDocData(data) {
      let docNote = [];
      for (const key in data) {
        const element = data[key];
        //根据实际需求处理这一步
        if (key.includes("note_text")) {
          let notesList = [];
          // 处理批注内容
          const _text = element.split("");
          for (let i = 0; i < _text.length; i++) {
            const ele = _text[i];
            notesList.push({
              label: ele,
              imgSrc: "",
              index: i,
              tempFilePath: "",
              code: key,
            });
          }
          const _key = key.split("note_text")[1] || "";
          const _imgCode = "note_img" + _key;
          docNote.push({
            code: key,
            imgCode: _imgCode,
            label: element,
            nodeImg: "", //最终合成的图片
            isDone: false, //当前批注是否已写完
            notesList, //所有批注问字
          });
          this.$set(this.tempPathObj, _imgCode, "");
        }
      }
      console.log(docNote, 142545);
      this.docNoteList = docNote;
      this.curNode = { ...docNote[0] };
      this._checkItem(this.curNode.notesList[0]);
    },
    // 处理当前批注数据
    _checkNotes(value) {
      this.curNode = { ...value };
      this.showImg = this.tempPathObj[value.imgCode];
      this._checkItem(this.curNode.notesList[0]);
    },
  },
  beforeDestroy() {},
  onLoad(option) {
    this._getDocData(option.documentId);
  },
  created() {},
};
</script>

<style scoped lang="scss">
.content {
  height: 100vh;
}
.content-model {
  width: 100%;
  margin-top: 80rpx;
  display: flex;
  justify-content: space-around;
  align-items: flex-start;
  .model-left {
    width: 12%;
    .note-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .note-btn {
      margin-bottom: 30rpx;
      background-color: #ee7c36;
    }
    .actice-node {
      background-color: $mainColor;
    }
  }
}
.centerBox {
  text-align: center;
}
.signerBox {
  background-color: $mainColor;
}
.btn-box {
  display: flex;
  justify-content: space-around;
  align-items: center;
  color: #fff;
  padding: 12rpx 50rpx;
  border-radius: 40rpx;
  font-size: 40rpx;
}
.navBarBox {
  .leftBox {
    width: 40rpx;
    height: 40rpx;
    margin-right: 40rpx;
    margin-top: -20rpx;
    img {
      width: 100%;
      height: 100%;
    }
  }
}

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 87%;
  .show-result {
    position: fixed;
    bottom: -200prx;
    width: 40%;
    z-index: 1;
    background-color: #ee7c36;
  }
  .notes-list {
    width: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    flex-wrap: wrap;
    // background-color: rgba(223, 220, 219,0.2);
    background-color: #fff;
    margin-bottom: 25rpx;
    position: relative;
    z-index: 999;
    .scroll-view_w {
      width: 100% !important;
    }
    .note-box {
      width: 100%;
      display: flex;
      flex-direction: row;
      justify-content: center;
      flex-wrap: wrap;
    }
    .note-label,
    .note-img {
      width: 120rpx;
      height: 120rpx;
      line-height: 120rpx;
      text-align: center;
      margin-right: 5rpx;
      margin-bottom: 3rpx;
      border: 2rpx solid #999;
      background-color: #fff;
      font-size: 100rpx;
      img {
        width: 100%;
        height: 100%;
      }
    }
    .active {
      .note-label,
      .note-img {
        border: 2rpx solid rgba(212, 21, 53, 0.9);
      }
    }
  }
}
.main-model {
  display: flex;
  justify-content: center;
  width: 100%;
  .show-img,
  .show-canvas {
    position: relative;
    width: 700rpx;
    height: 700rpx;
    border: 8rpx dashed #dddee1;
  }
  .show-img img {
    z-index: 999;
  }
  .show-canvas .bg-text {
    position: absolute;
    top: -30rpx;
    left: 0;
    width: 100%;
    height: 100%;
    font-size: 300px;
    line-height: 700rpx;
    text-align: center;
    color: rgba(153, 153, 153, 0.1);
  }
  .canvas-container {
    width: 100%;
    height: 100%;
    .input-canvas {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      border-radius: 10rpx;
      touch-action: none;
      // background-color: #ee7c36;
      /* 禁止默认触摸动作 */
    }
  }
}
.temp-img {
  width: 300rpx;
  height: 100rpx;
  img {
    width: 100%;
    height: 100%;
  }
  border: 2rpx solid #dddee1;
}
</style>

效果图:
在这里插入图片描述

;