Canvas实现点击选中形状时在边角显示小矩形选中标记
实现方案
Canvas是即时模式的位图绘制接口,不存在“修改已绘制图形样式”的概念,所有选中效果都需要通过全量重绘实现,核心流程如下:
- 不要硬编码坐标直接绘制图形,先把所有图形的类型、坐标、尺寸等参数存在结构化数组里,每个图形增加
selected布尔字段标记选中状态 - 每次画布状态变化(点击选中、拖拽、缩放)时,清空整个画布,重新绘制所有图形
- 绘制完成后,遍历所有标记为选中的图形,计算其控制点/外接矩形,在对应位置绘制小矩形选择手柄
核心逻辑说明
手柄位置计算
不同图形的手柄位置计算逻辑统一即可对齐Office类软件的交互体验:
- 直线、三角形等由离散点构成的图形:手柄直接落在图形顶点、外接矩形的边中点位置
- 矩形、圆形等闭合规则图形:手柄落在其外接矩形的4个角、4条边中点,共8个位置
- 手柄样式统一用6~8px见方的白色填充+1px黑色描边矩形,辨识度最高,绘制时注意以计算出的坐标为中心点,避免位置偏移
外接矩形计算规则
所有图形的外接矩形都可以通过自身参数快速计算:
- 矩形:直接取自身的x、y坐标与宽高
- 圆形:外接矩形起点为
(圆心x-半径, 圆心y-半径),宽高均为2倍半径 - 直线、多边形:遍历所有顶点坐标,取x、y的最小值作为外接矩形起点,x、y的最大值与最小值的差作为宽高
命中检测
简单场景可以直接用外接矩形做点击判断;如果需要精确命中(比如三角形内部空白区域、圆形外的矩形边角不触发选中),可以用Canvas原生的isPointInPath()API做路径级检测。
改造后的可运行代码
HTML
<div> <canvas id="draw_area" height="700" width="700"></canvas> </div>
CSS
#draw_area{ background-color: lightgrey; }
JavaScript
const draw_area = document.getElementById('draw_area'); const ctx = draw_area.getContext('2d'); // 选择手柄尺寸配置 const HANDLE_SIZE = 6; // 结构化存储所有图形参数 const shapes = [ { type: 'rect', x: 50, y: 50, w: 100, h: 100, selected: false }, { type: 'line', x1: 230, y1: 100, x2: 330, y2: 100, selected: false }, { type: 'triangle', points: [[420,50], [470,150], [370,150]], selected: false }, { type: 'circle', cx: 600, cy: 100, r: 50, selected: false } ]; // 计算图形外接矩形 function getBoundingBox(shape) { switch(shape.type) { case 'rect': return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }; case 'line': const minX = Math.min(shape.x1, shape.x2); const minY = Math.min(shape.y1, shape.y2); const maxX = Math.max(shape.x1, shape.x2); const maxY = Math.max(shape.y1, shape.y2); return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; case 'triangle': const xs = shape.points.map(p => p[0]); const ys = shape.points.map(p => p[1]); return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }; case 'circle': return { x: shape.cx - shape.r, y: shape.cy - shape.r, w: shape.r * 2, h: shape.r * 2 }; } } // 绘制单个图形 function drawShape(shape) { ctx.beginPath(); switch(shape.type) { case 'rect': ctx.rect(shape.x, shape.y, shape.w, shape.h); break; case 'line': ctx.moveTo(shape.x1, shape.y1); ctx.lineTo(shape.x2, shape.y2); break; case 'triangle': ctx.moveTo(shape.points[0][0], shape.points[0][1]); shape.points.slice(1).forEach(p => ctx.lineTo(p[0], p[1])); ctx.closePath(); break; case 'circle': ctx.arc(shape.cx, shape.cy, shape.r, 0, 2 * Math.PI); break; } ctx.stroke(); } // 绘制选中状态手柄 function drawHandles(shape) { const { x, y, w, h } = getBoundingBox(shape); const handlePoints = [ [x, y], [x + w/2, y], [x + w, y], [x, y + h/2], [x + w, y + h/2], [x, y + h], [x + w/2, y + h], [x + w, y + h] ]; ctx.save(); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; handlePoints.forEach(([px, py]) => { ctx.beginPath(); ctx.rect(px - HANDLE_SIZE/2, py - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE); ctx.fill(); ctx.stroke(); }); ctx.restore(); } // 全局渲染函数,状态变更时调用 function render() { ctx.clearRect(0, 0, draw_area.width, draw_area.height); shapes.forEach(shape => drawShape(shape)); shapes.filter(s => s.selected).forEach(shape => drawHandles(shape)); } // 点击选中逻辑示例 draw_area.addEventListener('click', (e) => { const canvasRect = draw_area.getBoundingClientRect(); const clickX = e.clientX - canvasRect.left; const clickY = e.clientY - canvasRect.top; shapes.forEach(s => s.selected = false); // 倒序遍历,优先选中上层图形 for(let i = shapes.length - 1; i >= 0; i--) { const bbox = getBoundingBox(shapes[i]); if(clickX >= bbox.x && clickX <= bbox.x + bbox.w && clickY >= bbox.y && clickY <= bbox.y + bbox.h) { shapes[i].selected = true; break; } } render(); }); // 初始渲染 render();
扩展优化方向
- 需要虚线选中边框时,在绘制选中图形前调用
ctx.setLineDash([5, 3]),绘制完成后重置ctx.setLineDash([])即可 - 实现拖拽缩放功能时,只需给每个手柄绑定鼠标事件,拖拽时修改对应图形的参数,调用
render()重绘即可 - 多图形选中、组选中逻辑可以基于当前结构扩展,给选中的组统一计算外接矩形绘制手柄即可
内容的提问来源于stack exchange,提问作者Cptain_Whitebeard
相关产品推荐
相关产品推荐

