vue3+ts项目采用canvas模拟批注功能

vue3+ts项目模拟批注

一、项目需求:

       移动端:实现点击“批注”,随手指绘制出线条,线条封闭之后,视为圈记成功,进而输入评论内容——批注;

二、实现思路:

       1.“批注”按钮控制canvas画布显示,输入框回车确认代表完成此次批注,画布隐藏;
       2.获取touch的坐标,将所有获取到的(x,y)坐标存储至lineList
       3.线条是否满足闭合条件:
           ①线条只是一个点,即lineList.length为1,是不满足条件的,手指松开瞬间之后清空画布,并提示;
           ②线条没有相交的区域,没有即为不满足,清空画布,并提示;
           (注意:这里不能单纯的判断lineList中是否有坐标一致的点,线条看上去是连着的,实际上是有无数小点组成,每个小点之间是有间隙的)
在这里插入图片描述

三、重点部分

       1.segmentsIntr 函数:判断两条线段是否相交;
       2.在isClose函数中进行线条是否满足条件判断;
       3.handleReset:重置canvas与视口间距,主要解决在浏览器滚动到不同程度之后,画布中touch的位置与实际获取位置不符合,存在偏差的问题;
       4.水平与垂直方向缩放因子的获取:不同设备缩放因子不一致,避免在一个设备绘制之后,另一设备查看时存在较大偏差;

四、canvas部分的实现代码

<template>
  <div class="postil">
    <canvas ref="postil" class="canvas" id="canvas" @touchstart="drawStart" @touchmove="drawing" @touchend="drawEnd">
      你的浏览器不支持canvas,请升级浏览器.浏览器不支持
    </canvas>
  </div>
</template>

<script setup lang="ts">
import { drNotify } from '@/utils/vantHint';

// props类型
interface Props {

  // 画布宽
  canvasWidth?: number,

  // 画布宽
  canvasHeight?: number,

  //canvas 背景色
  canvasBackground: string,

  //线条颜色
  lineColor: string,

  //线条宽度
  lineWidth: number,

  //线条两端形状
  lineRound: string,

}

// 设置props默认
const props = withDefaults(defineProps<Props>(), {

  // 宽高需要默认为日志内容主体宽高

  canvasWidth: document.documentElement.clientWidth,

  canvasHeight: document.documentElement.clientHeight,

  canvasBackground: 'transparent',

  lineColor: '#4979E7',

  lineWidth: 3,

  lineRound: 'round',

})

console.log(props, 'props');

let direction = shallowRef(false); // 屏幕方向 true:横屏   false:竖屏

let el = ref<any>(null);   // canvas dom

let postil = ref(null);    // 绑定ref为postil

let ctx = reactive<any>({});  // canvas内容

let startX = shallowRef(0);  // 绘制开始pageX

let startY = shallowRef(0);  // 绘制开始pageY

let endX = shallowRef(0);  // 绘制结束pageX

let endY = shallowRef(0);  // 绘制结束pageY

const gap = shallowRef(20);  // 两点差距值

let lineList = reactive([]); // 绘制线条的点集合

// canvas 距离视口x,y
const gapCanvas = reactive({

  x: 0,

  y: 0,

  // 缩放
  scaleX: 1,

  scaleY: 1,

})

// 判断当前手机为竖屏还是横屏
const initPhoneDirection = () => {

  drawLine();
  // window.addEventListener(
  //   "onorientationchange" in window ? "oorientationchange" : "resize",
  //   () => {
  //     console.log(window.orientation, 'window.orientation');
  //     if (window.orientation === 180 || window.orientation === 0) {
  //       direction.value = false;
  //       drawLine();
  //       console.log(direction.value, '竖屏');
  //     }
  //     if (window.orientation === 90 || window.orientation === -90) {
  //       direction.value = true;
  //       console.log(direction.value, '横屏');
  //       drawLine();
  //     }
  //   },
  //   false
  // );
}

// 添加绘制线
const drawLine = () => {

  document.addEventListener("touchmove", (e) => {

    e.preventDefault()

  }, {

    passive: false,

  });

  el.value = postil.value;

  initCanvas();
}

// 初始化canvas配置
const initCanvas = () => {

  el.value.width = props.canvasWidth;

  el.value.height = props.canvasHeight;

  ctx = el.value.getContext('2d');

  setCanvas();
}

// canvas配置
const setCanvas = () => {

  ctx.fillStyle = props.canvasBackground;

  // 绘制矩形
  if (direction.value) {
    // 横屏
    // 立即对当前矩形进行fill填充
    ctx.fillRect(0, 0, props.canvasHeight, props.canvasWidth)
  } else {
    // 竖屏
    ctx.fillRect(0, 0, props.canvasWidth, props.canvasHeight)
  }

  // 设置线条颜色
  ctx.strokeStyle = props.lineColor;

  // 设置线条宽度
  ctx.lineWidth = props.lineWidth;

  // 设置线条两端形状
  ctx.lineCap = props.lineRound;

}

// 开始绘制
const drawStart = (e: any) => {

  console.log(gapCanvas.x, gapCanvas.y, '画布距离视口的距离');

  // clearSign();

  lineList = [];

  startX.value = (e.changedTouches[0].pageX - gapCanvas.x) * gapCanvas.scaleX;

  startY.value = (e.changedTouches[0].pageY - gapCanvas.y) * gapCanvas.scaleY;

  // 开始路径:核心的作用是将 不同绘制的形状进行隔离
  ctx.beginPath();

  //绘制起点 
  ctx.moveTo((e.changedTouches[0].pageX - gapCanvas.x) * gapCanvas.scaleX, (e.changedTouches[0].pageY - gapCanvas.y) * gapCanvas.scaleY)

  drawing(e);
}

// 绘制过程
const drawing = (e: any) => {

  lineList.push({ x: (e.changedTouches[0].pageX - gapCanvas.x) * gapCanvas.scaleX, y: (e.changedTouches[0].pageY - gapCanvas.y) * gapCanvas.scaleY })

  // 绘制直线:绘制一条直线至起点或者上一个线头点
  ctx.lineTo((e.changedTouches[0].pageX - gapCanvas.x) * gapCanvas.scaleX, (e.changedTouches[0].pageY - gapCanvas.y) * gapCanvas.scaleY);

  // 描边:根据路径绘制线
  ctx.stroke();
}

// 绘制结束
const drawEnd = (e: any) => {

  endX.value = (e.changedTouches[0].pageX - gapCanvas.x) * gapCanvas.scaleX;

  endY.value = (e.changedTouches[0].pageY - gapCanvas.y) * gapCanvas.scaleY;

  // 闭合路径:自动把最后的线头和开始的线头连在一起
  ctx.closePath();

  isClose();
}

// 重置
const clearSign = () => {

  initCanvas();
}

interface Emits {
  (event: 'drawPie', bool: any, array?: any): void
}

const $emits = defineEmits<Emits>();

// 提交
const saveSign = () => {

  // toDataURL:把canvas绘制的内容输出成base64内容
  const imageBase64 = el.value.toDataURL();
  
  // 调用父组件写批注事件
  $emits('drawPie', true, imageBase64)

}

// 判断两条线段是否相交
const segmentsIntr = (a: any, b: any, c: any, d: any) => {

  // 三角形abc 面积的2倍  
  var area_abc = (a.x - c.x) * (b.y - c.y) - (a.y - c.y) * (b.x - c.x);

  // 三角形abd 面积的2倍  
  var area_abd = (a.x - d.x) * (b.y - d.y) - (a.y - d.y) * (b.x - d.x);

  // 面积符号相同则两点在线段同侧,不相交 (对点在线段上的情况,本例当作不相交处理);  
  if (area_abc * area_abd >= 0) {

    return false;

  }

  // 三角形cda 面积的2倍  
  var area_cda = (c.x - a.x) * (d.y - a.y) - (c.y - a.y) * (d.x - a.x);

  // 三角形cdb 面积的2倍  
  // 注意: 这里有一个小优化.不需要再用公式计算面积,而是通过已知的三个面积加减得出.  
  var area_cdb = area_cda + area_abc - area_abd;

  if (area_cda * area_cdb >= 0) {

    return false;

  }

  //计算交点坐标  
  var t = area_cda / (area_abd - area_abc);

  var dx = t * (b.x - a.x),

    dy = t * (b.y - a.y);

  return { x: a.x + dx, y: a.y + dy };

}

/** 判断绘制的是否满足封闭条件:
  1.开始与结束点距离小于等于20px  
  2.绘制的点集合大于1(不止一个点)
  3.线条相交,形成一个闭合区间
*/
const isClose = () => {
  // ctx.save() 保存当前环境的状态 可以把当前绘制环境进行保存到缓存中。

  const addX = startX.value + gap.value;
  const loseX = startX.value - gap.value;
  const addY = startY.value + gap.value;
  const loseY = startY.value - gap.value;

  const satifyX = (endX.value < addX || endX.value == addX) && (endX.value > loseX || endX.value == loseX);
  const satifyY = (endY.value < addY || endY.value == addY) && (endY.value > loseY || endY.value == loseY);

  let count = 0;

  // 线条的交点
  for (var i = 0; i < lineList.length - 1; i++) {

    for (var j = i + 1; j < lineList.length - 1; j++) {

      var crossoverPoint = segmentsIntr(lineList[i], lineList[i + 1], lineList[j], lineList[j + 1])

      if (crossoverPoint != false) {
        // 有交点
        count++;

      }
    }

  }

  if (((satifyX && satifyY) && lineList.length > 1) || count) {
    console.log('符合条件');
    saveSign();

  } else {
    drNotify('请将线条首尾相连!', 'danger')
    console.log('不符合条件');
    clearSign();

  }

}

// 重置canvas与视口间距
const handleReset = () => {

  // DOM元素到浏览器可视范围的距离
  const rect = el.value.getBoundingClientRect();

  gapCanvas.x = rect.left;

  gapCanvas.y = rect.top;

}

// 监听滚动条
const handleScroll = () => {

  window.addEventListener('scroll', async () => {

    await handleReset();

  }, true)

}

onMounted(() => {

  initPhoneDirection();

  handleScroll();

  handleReset();

  let style = window.getComputedStyle(el.value, null);

  let cssWidth = parseFloat(style["width"]);

  let cssHeight = parseFloat(style["height"]);

  gapCanvas.scaleX = el.value.width / cssWidth; // 水平方向的缩放因子

  gapCanvas.scaleY = el.value.height / cssHeight; // 垂直方向的缩放因子

  console.log(gapCanvas.scaleX, gapCanvas.scaleY, '缩放');

})

onBeforeUnmount(() => {

  window.removeEventListener('scroll', () => { })
})

</script>


<style lang="scss">
.postil {
  width: 100%;
  height: 100%;
  background-color: rgba(250, 235, 215, 0.382);

  .canvas {
    width: 100%;
    height: 100%;
    display: block;
  }
}
</style>

五、思考

1. 可以实现只存canvas中的路径吗?或是存储绘制出来的线?
    理想效果:
           回显的时候,通过点击不同的线条能够切换展示不同的评论
    现状:
           canvas转为base64存储的,存储的为整个画布,多条线段展示即是多个画布大小的图片叠加,并不能准确获取到对应线条

暂时没有处理思路,欢迎提供解决办法!!!!!!!感谢

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值