Canvas 实现可拖拽矩形功能详解 🎨
功能概述 🎯
本文将详细介绍如何使用 Canvas 实现一个交互性强的矩形绘制与拖拽功能,主要包含以下特性:
- ✨ 绘制矩形
- 🔄 拖拽移动
- ↔️ 边缘拉伸
- 🎯 边界检测
- 🖱️ 智能光标
核心实现 💡
1. 基础设置
首先需要处理 Canvas 的基础设置,特别是针对高清屏的适配:
javascript
function init() {
const w = 500, h = 300;
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
}2. Rectangle 类设计 📐
Rectangle 类是整个功能的核心,包含以下关键方法:
javascript
class Rectangle {
constructor(startX, startY, color) {
this.startX = startX;
this.startY = startY;
this.endX = startX;
this.endY = startY;
this.color = color;
}
// getter 方法用于计算矩形边界
get minX() { return Math.min(this.startX, this.endX); }
get maxX() { return Math.max(this.startX, this.endX); }
// ... 其他 getter
}3. 边缘检测实现 🔍
为实现精确的边缘拖拽,我们需要实现边缘检测功能:
javascript
getEdge(x, y, threshold = 5) {
if (!this.isInside(x, y)) return null;
const edges = {
left: Math.abs(x - this.minX) <= threshold,
right: Math.abs(x - this.maxX) <= threshold,
top: Math.abs(y - this.minY) <= threshold,
bottom: Math.abs(y - this.maxY) <= threshold
};
return Object.entries(edges)
.filter(([_, value]) => value)
.map(([edge]) => edge);
}4. 智能光标反馈 🖱️
根据用户鼠标位置,提供适当的光标样式:
javascript
updateCursor(x, y, canvas) {
const edges = this.getEdge(x, y);
if (!edges || edges.length === 0) {
canvas.style.cursor = 'move';
return null;
}
// 处理不同边缘的光标样式
if (edges.includes('left') || edges.includes('right')) {
canvas.style.cursor = 'ew-resize';
}
// ... 其他光标样式逻辑
}性能优化技巧 🚀
- 使用 requestAnimationFrame
javascript
function draw() {
requestAnimationFrame(draw);
ctx.clearRect(0, 0, canvas.width, canvas.height);
shapes.forEach(shape => shape.draw());
}- 边界约束优化
javascript
constrainToBounds(canvasWidth, canvasHeight) {
const w = canvasWidth / devicePixelRatio;
const h = canvasHeight / devicePixelRatio;
// ... 边界约束逻辑
}注意事项 ⚠️
- 设备像素比处理
- 始终考虑 devicePixelRatio 以支持高清屏显示
- 在绘制时需要将所有坐标乘以 devicePixelRatio
- 事件处理
- 使用 offsetX/offsetY 获取相对于 Canvas 的坐标
- 正确处理 mouseup 事件以清理事件监听器
- 性能考虑
- 使用 requestAnimationFrame 进行动画循环
- 适当的边界检查以防止性能问题
扩展思路 🔮
- 添加旋转功能
- 实现缩放功能
- 添加图层管理
- 实现撤销/重做功能
- 添加快捷键支持
总结 📝
通过合理的类设计和事件处理,我们实现了一个功能完整的 Canvas 矩形编辑器。关键在于:
- 良好的代码组织结构
- 精确的边缘检测算法
- 流畅的拖拽体验
- 合理的性能优化
完整代码 💻
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
gap: 20px;
}
canvas {
background: #999;
}
</style>
</head>
<body>
<canvas></canvas>
<script>
const colorPicker = document.querySelector('input[type="color"]');
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function init() {
const w = 500, h = 300;
// devicePixelRatio 是设备像素比,用于处理高清屏显示
// 比如在 Retina 屏幕上,devicePixelRatio 可能是 2,意味着一个 CSS 像素对应 2 个物理像素
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
// style.width/height 设置的是 CSS 像素,决定了画布在页面上的实际显示大小
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
}
init();
const shapes = [] // 存储所有形状
class Rectangle {
constructor(startX, startY, color) {
// 记录矩形的起始点坐标
this.startX = startX;
this.startY = startY;
this.color = color;
// 初始时终点和起点相同,随着鼠标移动会更新终点位置
this.endX = startX;
this.endY = startY;
}
// 使用 getter 计算矩形的实际边界
// 因为用户可能从任意方向拖动,所以需要计算最小/最大值
get minX() {
return Math.min(this.startX, this.endX);
}
get minY() {
return Math.min(this.startY, this.endY);
}
get maxX() {
return Math.max(this.startX, this.endX);
}
get maxY() {
return Math.max(this.startY, this.endY);
}
draw() {
ctx.fillStyle = 'transparent';
ctx.fillRect(
this.minX * devicePixelRatio,
this.minY * devicePixelRatio,
(this.maxX - this.minX) * devicePixelRatio,
(this.maxY - this.minY) * devicePixelRatio
);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3 * devicePixelRatio;
ctx.strokeRect(
this.minX * devicePixelRatio,
this.minY * devicePixelRatio,
(this.maxX - this.minX) * devicePixelRatio,
(this.maxY - this.minY) * devicePixelRatio
);
}
isInside(x, y) {
return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY;
}
// 添加边缘检测方法
getEdge(x, y, threshold = 5) {
if (!this.isInside(x, y)) return null;
const edges = {
left: Math.abs(x - this.minX) <= threshold,
right: Math.abs(x - this.maxX) <= threshold,
top: Math.abs(y - this.minY) <= threshold,
bottom: Math.abs(y - this.maxY) <= threshold
};
// 返回检测到的边缘,可能同时检测到多个边缘(角落位置)
return Object.entries(edges)
.filter(([_, value]) => value)
.map(([edge]) => edge);
}
// 更新鼠标样式方法
updateCursor(x, y, canvas) {
const edges = this.getEdge(x, y);
if (!edges || edges.length === 0) {
canvas.style.cursor = 'move';
return null;
}
if (edges.includes('left') || edges.includes('right')) {
canvas.style.cursor = 'ew-resize';
}
if (edges.includes('top') || edges.includes('bottom')) {
canvas.style.cursor = 'ns-resize';
}
if ((edges.includes('left') && edges.includes('top')) ||
(edges.includes('right') && edges.includes('bottom'))) {
canvas.style.cursor = 'nwse-resize';
}
if ((edges.includes('right') && edges.includes('top')) ||
(edges.includes('left') && edges.includes('bottom'))) {
canvas.style.cursor = 'nesw-resize';
}
return edges;
}
// 添加边界检查方法
constrainToBounds(canvasWidth, canvasHeight) {
// 将 canvas 尺寸转换为 CSS 像素
const w = canvasWidth / devicePixelRatio;
const h = canvasHeight / devicePixelRatio;
// 如果整个矩形超出右边界,将其移回画布内
if (this.minX < 0) {
const width = this.maxX - this.minX;
this.startX = this.startX - this.minX;
this.endX = this.startX + width;
}
if (this.maxX > w) {
const width = this.maxX - this.minX;
this.endX = w;
this.startX = w - width;
}
// 如果整个矩形超出下边界,将其移回画布内
if (this.minY < 0) {
const height = this.maxY - this.minY;
this.startY = this.startY - this.minY;
this.endY = this.startY + height;
}
if (this.maxY > h) {
const height = this.maxY - this.minY;
this.endY = h;
this.startY = h - height;
}
}
// 添加边缘拖动约束方法
constrainEdgeToBounds(edge, value, canvasWidth, canvasHeight) {
const w = canvasWidth / devicePixelRatio;
const h = canvasHeight / devicePixelRatio;
switch (edge) {
case 'left':
return Math.max(0, Math.min(value, this.endX));
case 'right':
return Math.max(this.startX, Math.min(value, w));
case 'top':
return Math.max(0, Math.min(value, this.endY));
case 'bottom':
return Math.max(this.startY, Math.min(value, h));
default:
return value;
}
}
}
function getShape(x, y) {
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i];
if (shape.isInside(x, y)) {
return shape;
}
}
}
canvas.addEventListener('mousedown', (e) => {
const shape = getShape(e.offsetX, e.offsetY);
const canvasRect = canvas.getBoundingClientRect();
if (shape) {
const edges = shape.getEdge(e.offsetX, e.offsetY);
const sx = e.offsetX;
const sy = e.offsetY;
const { startX, startY, endX, endY } = shape;
window.onmousemove = (e) => {
const x = e.clientX - canvasRect.left;
const y = e.clientY - canvasRect.top;
if (edges && edges.length > 0) {
// 边缘拖动逻辑
if (edges.includes('left')) {
shape.startX = shape.constrainEdgeToBounds('left', x, canvas.width, canvas.height);
}
if (edges.includes('right')) {
shape.endX = shape.constrainEdgeToBounds('right', x, canvas.width, canvas.height);
}
if (edges.includes('top')) {
shape.startY = shape.constrainEdgeToBounds('top', y, canvas.width, canvas.height);
}
if (edges.includes('bottom')) {
shape.endY = shape.constrainEdgeToBounds('bottom', y, canvas.width, canvas.height);
}
} else {
// 整体拖动逻辑
const dx = x - sx;
const dy = y - sy;
shape.startX = startX + dx;
shape.startY = startY + dy;
shape.endX = endX + dx;
shape.endY = endY + dy;
// 确保矩形在画布范围内
shape.constrainToBounds(canvas.width, canvas.height);
}
};
} else {
const rect = new Rectangle(e.offsetX, e.offsetY, colorPicker.value);
shapes.push(rect);
window.onmousemove = (e) => {
const x = e.clientX - canvasRect.left;
const y = e.clientY - canvasRect.top;
rect.endX = x;
rect.endY = y;
}
}
window.onmouseup = () => {
window.onmousemove = null;
window.onmouseup = null;
}
});
canvas.addEventListener('mousemove', (e) => {
const shape = getShape(e.offsetX, e.offsetY);
if (shape) {
shape.updateCursor(e.offsetX, e.offsetY, canvas);
} else {
canvas.style.cursor = 'default';
}
});
function draw() {
requestAnimationFrame(draw);
// 清除整个画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
shapes.forEach(shape => shape.draw());
}
draw();
</script>
</body>
</html>