用 Canvas 实现用于创建多个 clip-path 图像的画板

939 阅读5分钟

整体的绘画界面大致如下:

image.png

画板的线上地址

该画板主要是用来给碎片化组件来进行绘画的,碎片化大致效果如下图。具体可以看 上期文章

shard-img-reverse-xs.gif

Canvas 画板的实现

码上掘金简单 Demo:

创建 Canvas 实时绘画

useRTDraw 是一个以每秒60帧进行实时绘画的钩子,具体可以看 往期文章 这里不过多赘述。

绑定获取 Canvas 信息后,当调用 startAnimation ,就开始触发 onDraw 的回调了,大概每秒会触发60次,此时只需要调用 drawAllPolygon ,保持多边形的绘画即可。

export default function Page() {
  // ...
  const {drawState, canvasRef, canvasInfo, setCanvasInfo, startAnimation} = useRTDraw({
    onDraw() {
      drawState.ctx.clearRect(0, 0, canvasInfo.w * drawState.ratio, canvasInfo.h * drawState.ratio);
      drawAllPolygon()
    },
  })
  
  useEffect(() => {
    // 根据浏览器宽高,获取 canvas 宽高的,可看码上掘金就不细讲了
    getCardInfo()
    setTimeout(() => {
      setDrawPolygonData(firstShard)
      startAnimation()
    }, 200);
  }, [])
  
  // ...
return (
  <canvas
    ref={canvasRef}
    width={canvasInfo.w * drawState.ratio}
    height={canvasInfo.h * drawState.ratio}
    style={{
      display: 'block',
      width: canvasInfo.w + 'px',
      height: canvasInfo.h + 'px',
      background: '#1f1f1f'
    }}

  />
)
}

拖拽多边形到 Canvas

这里借助 ahooksuseDropuseDrag 来实现 dom 元素的拖拽效果。

当拖拽一个 trianglecanvas 中时,会触发 onDom 回调,此时在对应的位置生成一份三角形的数据即可。

const selectBlock: SelectBlock[] = [
  {
    key: 'triangle', // 三角形
    children: <IoTriangle />
  },
  {
    key: 'square', // 四边形
    children: <FaSquareFull />
  },
  // ...
]

const DragItem = ({ item, style }: {
  item: SelectBlock
  style?: React.CSSProperties
}) => {
  const dragRef = useRef(null);
  useDrag(item.key, dragRef);
  return (
    <div ref={dragRef} className="item" style={style}>
      {item.children}
    </div>
  );
};

export default function Page() {

// ...

useDrop(dropRef, {
  onDom(type, event) {
    // 当图形拖拽到 canvas 中,触发的回调
  }
}

return (<>
// ...
{selectBlock.map((item, i) => (
<DragItem 
  key={i}
  item={item}
  style={{color: curColor}}
/>
))}
<div ref={dropRef}>
  <canvas 
    // ...
  />
</div>
</>)
}

生成三角形

根据鼠标拖拽三角形到 Canvas 上的位置,将此刻鼠标的位置当成该三角形的中心,同时基于该中心点生成三个顶点对应的位置。

顶点主要是用于后续的拖拽,以及生成 clip-path: polygon(x y) 对应的路径数据。

// 多边形的类型
type Polygon = {
  // 多边形中心 x y
  x: number
  y: number
  color: string
  pointColor: string
  pointList: Point[]    
}
type Point = {
  x: number
  y: number
}
// ...
const polygonListRef = useRef<Polygon[]>([])
// ...
useDrop(dropRef, {
  onDom(type, event) {
    // 当图形拖拽到 canvas 中,触发的回调
    const e = event as any
    const x = e.offsetX;
    const y = e.offsetY;
    const distance = 15;
    pList = [
      {x: x, y: y - distance},
      {x: x + distance, y: y + distance},
      {x: x - distance, y: y + distance}
    ]
    const p: Polygon = {
      x,
      y,
      color: curColor,
      pointColor: pointColor,
      pointList: pList,
    }
    polygonListRef.current.push(p)
  }
}
// ...

drawAllPolygon 绘画多边形

用 canvas 绘画多边形,相信大家都会,这里就不细说了。只需把多边形和对应的顶点绘画出来即可了。

image.png
function drawAllPolygon() {
  for(let i = 0; i < polygonListRef.current.length; i++) {
    drawPolygon(i)
  }
}

// 绘画一个多边形
function drawPolygon(pI: number) {
  const p = polygonListRef.current[pI];
  const ctx = drawState.ctx!
  const ratio = drawState.ratio
  ctx.beginPath();
  // 以一个顶点开始
  ctx.moveTo((p.pointList[0].x) * ratio, (p.pointList[0].y) * ratio);
  // 顶点连接到该点
  for(let i = 1; i < p.pointList.length; i++) {
    ctx.lineTo((p.pointList[i].x) * ratio, (p.pointList[i].y) * ratio);
  }
  // 连回顶点
  ctx.lineTo((p.pointList[0].x) * ratio, (p.pointList[0].y) * ratio);
  ctx.fillStyle = p.color;
  ctx.fill();
  // 拖拽时增加一些样式做区分
  if(pI === polygonIndex.current) {
    ctx.strokeStyle = HOVER_COLOR;
    ctx.lineWidth = 2 * ratio;
    ctx.stroke()
  }
  ctx.closePath();
  // 绘画点
  for(let i = 0; i < p.pointList.length; i++) {
    ctx.beginPath();
    ctx.arc((p.pointList[i].x) * ratio, (p.pointList[i].y) * ratio, POINT_R * ratio, 0, 2 * Math.PI);
    ctx.fillStyle = p.pointColor;
    ctx.fill();
    // 拖拽时增加一些样式做区分
    if(pI === polygonIndex.current && pointIndex.current === i) {
      ctx.strokeStyle = HOVER_COLOR;
      ctx.lineWidth = 2 * ratio;
      ctx.stroke()
    } 
    ctx.closePath();
  }
}

点击 Canvas 中对应的多边形。

当鼠标点击 Canvas 时,首先通过 isPointInsideCircle 判断是否点击到多边形的点上,然后再通过 isPointInsidePolygon 判断是否点到多边形上。紧接着调用各自的 Move 函数根据鼠标进行移动即可实现拖拽效果。

// 记录拖拽时选中的多边形索引
const polygonIndex = useRef(-1)
// 记录拖拽时选中的多边形的顶点索引
const pointIndex = useRef(-1)

const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
  if(e.button !== 0) return
  const polygonList = polygonListRef.current;
  const x = e.clientX - canvasRef.current!.offsetLeft;
  const y = e.clientY - canvasRef.current!.offsetTop;
  // 是否选中了多边形的点
  let polygonI = polygonList.findIndex((item) => {
    pointIndex.current = item.pointList.findIndex((i) => (
      isPointInsideCircle({x, y}, {x: i.x, y: i.y, r: POINT_R})
    ))
    return pointIndex.current >= 0
  })
  // 是否选中了多边形整体
  if(pointIndex.current < 0) {
    polygonI = polygonList.findIndex((item) => (
      isPointInsidePolygon({x: x, y: y}, item)
    ))
  }
  polygonIndex.current = polygonI
  if(pointIndex.current >= 0) {
    document.addEventListener('mousemove', onPolygonPointTouchMove)
    document.addEventListener('mouseup', onTouchEnd);
  } else {
    if(polygonI >= 0) {
      document.addEventListener('mousemove', onPolygonTouchMove)
      document.addEventListener('mouseup', onTouchEnd);
    }
  }
}

//...
<canvas 
  // ...
  // 长按拖拽移动多边形
  onMouseDown={onMouseDown}
  // 右键删除
  onContextMenu={(e) => {
    e.preventDefault()
    onDeleteItem(e.clientX, e.clientY)
  }}
/>

判断一个点是否在圆上的函数:

function isPointInsideCircle(point: Point, circle: { x: number, y: number, r: number }) {
  return Math.sqrt(Math.pow(point.x - circle.x, 2) + Math.pow(point.y - circle.y, 2)) < circle.r
}

判断一个点是否在多边形上的函数:(从 该博客 找来的)

function isPointInsidePolygon(point: Point, polygon: Polygon) {
  const pointList = polygon.pointList
  var iSum = 0
  var iCount = pointList.length

  if (iCount < 3) {
    return false
  }
  //  待判断的点(x, y) 为已知值
  var y = point.y
  var x = point.x
  for (var i = 0; i < iCount; i++) {
    var y1 = pointList[i].y
    var x1 = pointList[i].x
    if (i == iCount - 1) {
      var y2 = pointList[0].y
      var x2 = pointList[0].x
    } else {
      var y2 = pointList[i + 1].y
      var x2 = pointList[i + 1].x
    }
    // 当前边的 2 个端点分别为 已知值(x1, y1), (x2, y2)
    if (((y >= y1) && (y < y2)) || ((y >= y2) && (y < y1))) {
      //  y 界于 y1 和 y2 之间
      //  假设过待判断点(x, y)的水平直线和当前边的交点为(x_intersect, y_intersect),有y_intersect = y
      // 则有(2个相似三角形,公用顶角,宽/宽 = 高/高):|x1 - x2| / |x1 - x_intersect| = |y1 - y2| / |y1 - y|
      if (Math.abs(y1 - y2) > 0) {
        var x_intersect = x1 - ((x1 - x2) * (y1 - y)) / (y1 - y2);
        if (x_intersect < x) {
          iSum += 1
        }
      }
    }
  }
  if (iSum % 2 != 0) {
    return true
  } else {
    return false
  }
}

拖拽多边形

image.png

根据上面的点击判断出,需要拖拽的是顶点还是多边形整体。

拖拽多边形整体时,记录原来各顶点和中心点的距离,重新赋加到鼠标位置即可,将中心点位置和鼠标位置保持同步。

拖拽某个顶点时,让顶点的位置和鼠标位置保持同步,然后重新计算出中心点位置。

// 拖拽多边形整体
const onPolygonTouchMove = (e: MouseEvent) => {
  const x = e.clientX - canvasRef.current!.offsetLeft;
  const y = e.clientY - canvasRef.current!.offsetTop;
  const item = polygonListRef.current[polygonIndex.current]
  item.pointList.forEach(p => {
    const changeX = p.x - item.x
    const changeY = p.y - item.y
    p.x = x + changeX
    p.y = y + changeY
  })
  item.x = x
  item.y = y
}

// 拖拽某一个顶点
const onPolygonPointTouchMove = (e: MouseEvent) => {
  const x = e.clientX - canvasRef.current!.offsetLeft;
  const y = e.clientY - canvasRef.current!.offsetTop;
  const item = polygonListRef.current[polygonIndex.current]
  item.pointList[pointIndex.current].x = x
  item.pointList[pointIndex.current].y = y
  const centerPoint = getPolygonCenterPoint(item.pointList)
  item.x = centerPoint.x
  item.y = centerPoint.y
}

根据多边形的各个顶点,找到在多边形中心的点的位置。

function getPolygonCenterPoint(pointList: Point[]) {
  let x = 0
  let y = 0
  for(let i = 0; i < pointList.length; i++) {
    x += pointList[i].x
    y += pointList[i].y
  }
  return {
    x: x / pointList.length,
    y: y / pointList.length
  }
}

松开鼠标的点击,移除拖拽事件,至此一个拖拽过程大致完成。

onst onTouchEnd = () => {
  document.removeEventListener('mousemove', pointIndex.current < 0 ? onPolygonTouchMove : onPolygonPointTouchMove);
  document.removeEventListener('mouseup', onTouchEnd);
  pointIndex.current = -1;
  polygonIndex.current = -1;
}

画板的主要拖拽图像功能大致完成。紧接着导出数据即可。

导出数据

根据当前 Canvas 中的多边形数据导出一份用于碎片化图形绘画的数据。

image.png
const getShardData = (polygonList: Polygon[], canvasInfo: { w: number, h: number }) => {
  return polygonList.map((p) => {
    return {
      color: p.color,
      polygon: p.pointList.map((item) => (
        `${Math.round(item.x / canvasInfo.w * 10000) / 100}% ${Math.round(item.y / canvasInfo.h * 10000) / 100}%`
      )).join(', ')
    }
  })
}

const onExport = () => {
  const res = getShardData(polygonListRef.current, canvasInfo)
  console.log('res: ', res);
  alert(JSON.stringify(res))
}

最后将导出的数据放到碎片化组件中即可