前言
我大概在两个月之前做了一个基于canvas的画板,基于canvas实现的多功能画板,然后最近不是太忙,就利用下班的时间又迭代了一个版本,增加了以下内容
- 在选择模式下可以点击元素进行框选,并按住手柄进行缩放或者移动,点击Backspace键可以删除选择元素
- 双击画板输入文字绘制在指定位置
本篇我就详细介绍下框选元素的实现思路和具体代码,效果如下
预览
预览地址:https://songlh.top/paint-board/
repo:https://github.com/LHRUN/paint-board 欢迎Star⭐️
实现思路
- 首先需要框选的元素必须在初始化时和更新时记录矩形属性,比如宽高、矩形坐标,这是实现框选的基础
- 鼠标在移动时需要根据当前坐标判断悬浮在哪个元素上方,这样才能在点击时进行处理,并且鼠标移动时需要有光标的改变
- 在有框选元素的情况下,渲染时在最后根据框选元素的矩形属性渲染框选效果
- 在有框选元素的情况下,拖拽时根据拖拽的位置来判断是移动还是改变大小
- 元素改变大小有两种情况,保持比例(文字)的缩放和不保持比例(画笔)的缩放
记录矩形属性
因为画笔随着绘画一直在增加新的坐标点,所以我在矩形属性外另记录了最小和最大的xy坐标用于计算宽高
/**
* 根据新坐标点,更新矩形属性
* @param instance 画笔元素
* @param position 坐标点
*/
export const updateRect = (instance: FreeDraw, position: MousePosition) => {
const { x, y } = position
let { minX, maxX, minY, maxY } = instance.rect
if (x < minX) {
minX = x
}
if (x > maxX) {
maxX = x
}
if (y < minY) {
minY = y
}
if (y > maxY) {
maxY = y
}
const rect = {
minX,
maxX,
minY,
maxY,
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
}
instance.rect = rect
return rect
}
计算鼠标移动坐标
- 随着鼠标移动,我们需要改变光标让使用者感知到已经移动到元素上方,所以就需要计算鼠标坐标是否已经和绘画路径临近到一定距离
- 绘画路径是由一个个的坐标点组成,我们可以把每个坐标点和上一个坐标点连成一个线段,然后在满足以下任何一种情况就可以认为是悬浮在元素上方了
- 鼠标坐标距离线段起点小于10像素
- 鼠标坐标距离线段终点小于10像素
- 鼠标坐标距离线段小于10像素并且x和y坐标在线段的两端点范围内
// 遍历画笔元素所有坐标点 for (let i = 1; i < positions.length; i++) { // 距离起点距离 const startDistance = getDistance(movePos, positions[i - 1]) // 距离终点距离 const endDistance = getDistance(movePos, positions[i]) // 距离线段距离 const lineDistance = getPositionToLineDistance( movePos, positions[i - 1], positions[i] ) const rangeX = Math.max(positions[i - 1].x, positions[i].x) >= movePos.x && movePos.x >= Math.min(positions[i - 1].x, positions[i].x) const rangeY = Math.max(positions[i - 1].y, positions[i].y) >= movePos.y && movePos.y >= Math.min(positions[i - 1].y, positions[i].y) // 满足三种情况其中一种就可以记录下画笔元素 if ( startDistance < 10 || endDistance < 10 || (lineDistance < 10 && rangeX && rangeY) ) { this.mouseHoverElementIndex = eleIndex } } // ... /** * 计算两点之间的距离 * @param start 起点 * @param end 终点 * @returns 距离 */ export const getDistance = (start: MousePosition, end: MousePosition) => { return Math.sqrt(Math.pow(start.x - end.x, 2) + Math.pow(start.y - end.y, 2)) } /** * 获取鼠标坐标距离线段距离 * @param pos 鼠标坐标 * @param startPos 线段起点 * @param endPos 线段终点 * @returns 距离 */ export const getPositionToLineDistance = ( pos: MousePosition, startPos: MousePosition, endPos: MousePosition ) => { /** * 1. 计算三点之间的直线距离 * 2. 计算三角形半周长 * 3. 通过海伦公式求面积 * 4. 根据面积公式求三角形的高 */ const A = Math.abs(getDistance(pos, startPos)) const B = Math.abs(getDistance(pos, endPos)) const C = Math.abs(getDistance(startPos, endPos)) const P = (A + B + C) / 2 const area = Math.abs(Math.sqrt(P * (P - A) * (P - B) * (P - C))) const distance = (2 * area) / C return distance }
点击渲染框选效果
- 点击时如果在之前hover时满足三种情况已经记录下来了,就继续记录为框选元素
- 然后在画板渲染时,就按照框选元素的矩形属性渲染框选效果
if (this.select.selectElementIndex !== -1) { // 获取选择元素的矩形属性,绘制框选效果 const rect = this.select.getCurSelectElement().rect drawResizeRect(this.context, rect) } /** * 绘制拖拽矩形 */ export const drawResizeRect = ( context: CanvasRenderingContext2D, rect: ElementRect ) => { const { x, y, width, height } = rect context.save() context.strokeStyle = '#65CC8A' context.setLineDash([5]) context.lineWidth = 2 context.lineCap = 'round' context.lineJoin = 'round' // 绘制虚线框 drawRect(context, x, y, width, height) // 绘制四角手柄 context.fillStyle = '#65CC8A' drawRect(context, x - 10, y - 10, 10, 10, true) drawRect(context, x + width, y - 10, 10, 10, true) drawRect(context, x - 10, y + height, 10, 10, true) drawRect(context, x + width, y + height, 10, 10, true) context.restore() } /** * 绘制矩形 */ export const drawRect = ( context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, fill = false // 是否填充 ) => { context.beginPath() context.rect(x, y, width, height) if (fill) { context.fill() } else { context.stroke() } }
拖拽元素
拖拽元素比较简单,就是计算鼠标移动的距离,然后遍历坐标点加上距离即可
// startMousePos就是上一个移动的坐标
const disntanceX = x - this.startMousePos.x
const disntanceY = y - this.startMousePos.y
/**
* 更新位置
* @param distanceX
* @param distanceY
*/
export const moveFreeDraw = (
instance: FreeDraw,
distanceX: number,
distanceY: number
) => {
initRect(instance)
instance.positions.forEach((position) => {
position.x += distanceX
position.y += distanceY
updateRect(instance, position)
})
}
画笔缩放(不保持比例)
- 画笔缩放我先以右下角手柄拖拽为例分析
- 首先画笔的缩放比例是分为水平缩放比例和垂直缩放比例
- 水平缩放比例 = (旧矩形的宽 + 鼠标水平移动的距离) / 旧矩形的宽
- 垂直缩放比例 = (旧矩形的高 + 鼠标垂直移动的距离) / 旧矩形的高
- 然后遍历画笔的所有坐标点进行缩放,这时会出现一个偏移的缩放效果,如下图
- 这时就需要计算当前拖拽手柄对角顶点移动的距离是多少,然后减去这个距离就得到了正确的缩放效果了
- 当然四个角的拖拽计算是不一致的,但是思路一致
switch (this.resizeType) { // disntanceX 鼠标水平移动距离 // disntanceY 鼠标垂直移动距离 // 右下角 case RESIZE_TYPE.BOTTOM_RIGHT: resizeFreeDraw( resizeElement as FreeDraw, (rect.width + disntanceX) / rect.width, (rect.height + disntanceY) / rect.height, rect, RESIZE_TYPE.BOTTOM_RIGHT ) break // 左下角 case RESIZE_TYPE.BOTTOM_LEFT: resizeFreeDraw( resizeElement as FreeDraw, (rect.width - disntanceX) / rect.width, (rect.height + disntanceY) / rect.height, rect, RESIZE_TYPE.BOTTOM_LEFT ) break // 左上角 case RESIZE_TYPE.TOP_LEFT: resizeFreeDraw( resizeElement as FreeDraw, (rect.width - disntanceX) / rect.width, (rect.height - disntanceY) / rect.height, rect, RESIZE_TYPE.TOP_LEFT ) break // 右上角 case RESIZE_TYPE.TOP_RIGHT: resizeFreeDraw( resizeElement as FreeDraw, (rect.width + disntanceX) / rect.width, (rect.height - disntanceY) / rect.height, rect, RESIZE_TYPE.TOP_RIGHT ) break default: break } /** * 缩放绘画 * @param instance * @param scaleX * @param scaleY * @param rect * @param resizeType */ export const resizeFreeDraw = ( instance: FreeDraw, scaleX: number, scaleY: number, rect: FreeDrawRect, resizeType: string ) => { // 初始化矩形 initRect(instance) // 遍历所有坐标进行缩放 instance.positions.forEach((position) => { position.x = position.x * scaleX position.y = position.y * scaleY updateRect(instance, position) }) const { x: newX, y: newY, width: newWidth, height: newHeight } = instance.rect let offsetX = 0 let offsetY = 0 // 计算偏移距离,这个是要根据当前缩放手柄的对角顶点进行计算,所以要分为4种情况 switch (resizeType) { case RESIZE_TYPE.BOTTOM_RIGHT: offsetX = newX - rect.x offsetY = newY - rect.y break case RESIZE_TYPE.BOTTOM_LEFT: offsetX = newX + newWidth - (rect.x + rect.width) offsetY = newY - rect.y break case RESIZE_TYPE.TOP_LEFT: offsetX = newX + newWidth - (rect.x + rect.width) offsetY = newY + newHeight - (rect.y + rect.height) break case RESIZE_TYPE.TOP_RIGHT: offsetX = newX - rect.x offsetY = newY + newHeight - (rect.y + rect.height) break default: break } initRect(instance) // 减去偏移距离 instance.positions.forEach((position) => { position.x = position.x - offsetX position.y = position.y - offsetY updateRect(instance, position) }) }
文字缩放(保持比例)
- 文字缩放需要一直保持着宽高比,通过计算出新旧矩形的宽高比
- 当新的宽高比小于旧的宽高比时,宽度不变,计算 高度 = 宽度 / 旧的宽高比
- 但新的宽高比大于旧的宽高比时,高度不变,计算 宽度 = 高度 * 旧的宽高比
switch (this.resizeType) { // ... // 右下角 case RESIZE_TYPE.BOTTOM_RIGHT: resizeTextElement( resizeElement as TextElement, resizeElement.rect.width + disntanceX, resizeElement.rect.height + disntanceY, RESIZE_TYPE.BOTTOM_RIGHT ) break // 左下角 case RESIZE_TYPE.BOTTOM_LEFT: resizeTextElement( resizeElement as TextElement, resizeElement.rect.width - disntanceX, resizeElement.rect.height + disntanceY, RESIZE_TYPE.BOTTOM_LEFT ) break // 左上角 case RESIZE_TYPE.TOP_LEFT: resizeTextElement( resizeElement as TextElement, resizeElement.rect.width - disntanceX, resizeElement.rect.height - disntanceY, RESIZE_TYPE.TOP_LEFT ) break // 右上角 case RESIZE_TYPE.TOP_RIGHT: resizeTextElement( resizeElement as TextElement, resizeElement.rect.width + disntanceX, resizeElement.rect.height - disntanceY, RESIZE_TYPE.TOP_RIGHT ) break default: break } /** * 修改文本元素大小 * @param ele 文本元素 * @param width 改变后的宽度 * @param height 改变后的高度 * @param resizeType 拖拽类型 */ export const resizeTextElement = ( ele: TextElement, width: number, height: number, resizeType: string ) => { const oldRatio = ele.rect.width / ele.rect.height const newRatio = width / height // 按照之前的说明,修改宽高比不一致的情况 if (newRatio < oldRatio) { height = width / oldRatio } else if (newRatio > oldRatio) { width = oldRatio * height } /** * 因为这个缩放是按照左上角缩放的 * 所以为了达到当前拖拽手柄不移动,就需要进行偏移操作 */ switch (resizeType) { case RESIZE_TYPE.BOTTOM_RIGHT: break case RESIZE_TYPE.BOTTOM_LEFT: ele.rect.x -= width - ele.rect.width break case RESIZE_TYPE.TOP_LEFT: ele.rect.x -= width - ele.rect.width ele.rect.y -= height - ele.rect.height break case RESIZE_TYPE.TOP_RIGHT: ele.rect.y -= height - ele.rect.height break default: break } ele.rect.height = height ele.rect.width = width // 字体大小按照高度修改 ele.fontSize = ele.rect.height }
总结
如果有发现问题或者有好的方案,欢迎讨论👻