前言
我大概在两个月之前做了一个基于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
}
总结
如果有发现问题或者有好的方案,欢迎讨论👻