基于Canvas实现Photoshop式可编辑贝塞尔曲线图像遮罩裁剪路径需求
原生JavaScript实现Photoshop风格钢笔工具(贝塞尔路径遮罩)
核心设计思路
基于原生Canvas API,通过维护锚点+双向手柄的数据结构,结合鼠标事件实现动态贝塞尔路径绘制与编辑,最终将路径应用为图像遮罩。全程无冗余框架,仅依赖原生JS与Canvas。
关键实现步骤
1. 数据结构定义
用数组存储路径节点,每个节点包含锚点、入手柄、出手柄坐标:
let pathPoints = []; // 节点示例: // { // anchor: { x: 100, y: 100 }, // handleIn: { x: 80, y: 100 }, // 前一个节点到当前锚点的手柄 // handleOut: { x: 120, y: 100 } // 当前锚点到下一个节点的手柄 // }
2. 基础绘制逻辑
- 添加锚点:鼠标点击时,若为第一个节点直接记录;后续节点自动生成对称手柄(与前一锚点连线垂直方向,长度可自定义)
- 实时预览曲线:鼠标拖拽未松开时,动态更新当前节点的出手柄,实时绘制路径
- 完成路径:双击或点击第一个锚点闭合路径
3. 编辑功能实现
- 选中交互:鼠标点击检测是否命中锚点或手柄,标记选中状态
- 拖拽调整:拖拽选中的锚点时,同步移动两个手柄;拖拽手柄时仅调整对应手柄坐标
- 删除节点:右键点击锚点可删除该节点,自动重连前后路径
4. 路径复用与保存
- 序列化:将
pathPoints转为JSON字符串,可存储到localStorage或导出为文本 - 反序列化:加载存储的JSON数据,解析为节点数组后重新绘制路径
集成至javascript-canvas-image-mask应用
- 新增工具切换:添加钢笔工具按钮,点击切换到钢笔编辑状态,暂停原遮罩工具的事件监听
- 遮罩应用:完成路径编辑后,调用Canvas的
clip()方法,或设置globalCompositeOperation = 'source-in',将路径作为遮罩应用到图像上 - 状态隔离:确保钢笔工具与原有工具的状态互不干扰,比如切换工具时清空路径或保存当前路径
完整示例代码
<canvas id="maskCanvas" width="800" height="600"></canvas> <button id="penToolBtn">钢笔工具</button> <button id="savePathBtn">保存路径</button> <button id="loadPathBtn">加载路径</button> <script> const canvas = document.getElementById('maskCanvas'); const ctx = canvas.getContext('2d'); const penBtn = document.getElementById('penToolBtn'); const saveBtn = document.getElementById('savePathBtn'); const loadBtn = document.getElementById('loadPathBtn'); let isPenActive = false; let pathPoints = []; let selectedElement = null; // 选中的锚点/手柄:{ type: 'anchor'|'handleIn'|'handleOut', index: number } let isDragging = false; let lastMousePos = { x: 0, y: 0 }; // 鼠标坐标转换(解决Canvas偏移) function getMousePos(e) { const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } // 绘制路径与编辑控件 function drawPath() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制贝塞尔路径 if (pathPoints.length > 1) { ctx.beginPath(); ctx.moveTo(pathPoints[0].anchor.x, pathPoints[0].anchor.y); for (let i = 1; i < pathPoints.length; i++) { const prev = pathPoints[i-1]; const curr = pathPoints[i]; ctx.bezierCurveTo( prev.handleOut.x, prev.handleOut.y, curr.handleIn.x, curr.handleIn.y, curr.anchor.x, curr.anchor.y ); } ctx.strokeStyle = '#0099ff'; ctx.lineWidth = 2; ctx.stroke(); } // 绘制锚点与手柄 pathPoints.forEach((point, idx) => { // 锚点 ctx.beginPath(); ctx.arc(point.anchor.x, point.anchor.y, 4, 0, Math.PI*2); ctx.fillStyle = selectedElement?.type === 'anchor' && selectedElement.index === idx ? '#ff0000' : '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.fill(); ctx.stroke(); // 手柄连线 ctx.beginPath(); ctx.moveTo(point.handleIn.x, point.handleIn.y); ctx.lineTo(point.anchor.x, point.anchor.y); ctx.lineTo(point.handleOut.x, point.handleOut.y); ctx.strokeStyle = '#999999'; ctx.lineWidth = 1; ctx.stroke(); // 入手柄 ctx.beginPath(); ctx.arc(point.handleIn.x, point.handleIn.y, 3, 0, Math.PI*2); ctx.fillStyle = selectedElement?.type === 'handleIn' && selectedElement.index === idx ? '#ff0000' : '#cccccc'; ctx.strokeStyle = '#000000'; ctx.fill(); ctx.stroke(); // 出手柄 ctx.beginPath(); ctx.arc(point.handleOut.x, point.handleOut.y, 3, 0, Math.PI*2); ctx.fillStyle = selectedElement?.type === 'handleOut' && selectedElement.index === idx ? '#ff0000' : '#cccccc'; ctx.strokeStyle = '#000000'; ctx.fill(); ctx.stroke(); }); } // 检测是否点击到锚点或手柄 function hitTest(pos) { for (let i = pathPoints.length - 1; i >= 0; i--) { const point = pathPoints[i]; // 检测锚点 const dxAnchor = pos.x - point.anchor.x; const dyAnchor = pos.y - point.anchor.y; if (dxAnchor*dxAnchor + dyAnchor*dyAnchor <= 16) { // 半径4的平方 return { type: 'anchor', index: i }; } // 检测入手柄 const dxIn = pos.x - point.handleIn.x; const dyIn = pos.y - point.handleIn.y; if (dxIn*dxIn + dyIn*dyIn <= 9) { // 半径3的平方 return { type: 'handleIn', index: i }; } // 检测出手柄 const dxOut = pos.x - point.handleOut.x; const dyOut = pos.y - point.handleOut.y; if (dxOut*dxOut + dyOut*dyOut <= 9) { return { type: 'handleOut', index: i }; } } return null; } // 鼠标事件绑定 canvas.addEventListener('mousedown', (e) => { if (!isPenActive) return; const pos = getMousePos(e); lastMousePos = pos; selectedElement = hitTest(pos); if (selectedElement) { isDragging = true; } else { // 添加新锚点 if (pathPoints.length === 0) { pathPoints.push({ anchor: { ...pos }, handleIn: { ...pos }, handleOut: { ...pos } }); } else { // 计算与前一锚点的方向,生成对称手柄 const prevPoint = pathPoints[pathPoints.length - 1]; const dx = pos.x - prevPoint.anchor.x; const dy = pos.y - prevPoint.anchor.y; const length = Math.min(Math.sqrt(dx*dx + dy*dy) / 2, 50); // 手柄长度限制 const angle = Math.atan2(dy, dx); // 更新前一锚点的出手柄 prevPoint.handleOut.x = prevPoint.anchor.x + Math.cos(angle) * length; prevPoint.handleOut.y = prevPoint.anchor.y + Math.sin(angle) * length; // 添加新锚点,入手柄与前一出手柄对称 pathPoints.push({ anchor: { ...pos }, handleIn: { x: pos.x - Math.cos(angle) * length, y: pos.y - Math.sin(angle) * length }, handleOut: { ...pos } }); } } drawPath(); }); canvas.addEventListener('mousemove', (e) => { if (!isPenActive || !isDragging) return; const pos = getMousePos(e); const dx = pos.x - lastMousePos.x; const dy = pos.y - lastMousePos.y; lastMousePos = pos; const { type, index } = selectedElement; const point = pathPoints[index]; if (type === 'anchor') { // 移动锚点时,同步移动两个手柄 point.anchor.x += dx; point.anchor.y += dy; point.handleIn.x += dx; point.handleIn.y += dy; point.handleOut.x += dx; point.handleOut.y += dy; } else if (type === 'handleIn') { point.handleIn.x += dx; point.handleIn.y += dy; // 可选:保持手柄对称(PS的Alt拖拽取消对称) // const dxSym = point.anchor.x - point.handleIn.x; // const dySym = point.anchor.y - point.handleIn.y; // point.handleOut.x = point.anchor.x + dxSym; // point.handleOut.y = point.anchor.y + dySym; } else if (type === 'handleOut') { point.handleOut.x += dx; point.handleOut.y += dy; // 可选:保持手柄对称 // const dxSym = point.handleOut.x - point.anchor.x; // const dySym = point.handleOut.y - point.anchor.y; // point.handleIn.x = point.anchor.x - dxSym; // point.handleIn.y = point.anchor.y - dySym; } drawPath(); }); canvas.addEventListener('mouseup', () => { isDragging = false; selectedElement = null; }); // 右键删除锚点 canvas.addEventListener('contextmenu', (e) => { e.preventDefault(); if (!isPenActive) return; const pos = getMousePos(e); const hit = hitTest(pos); if (hit?.type === 'anchor' && pathPoints.length > 2) { pathPoints.splice(hit.index, 1); drawPath(); } }); // 工具切换 penBtn.addEventListener('click', () => { isPenActive = !isPenActive; penBtn.style.backgroundColor = isPenActive ? '#0099ff' : '#ffffff'; if (!isPenActive) { // 切换工具时可应用遮罩 applyMask(); } }); // 保存路径 saveBtn.addEventListener('click', () => { const pathJson = JSON.stringify(pathPoints); localStorage.setItem('maskPath', pathJson); alert('路径已保存'); }); // 加载路径 loadBtn.addEventListener('click', () => { const savedPath = localStorage.getItem('maskPath'); if (savedPath) { pathPoints = JSON.parse(savedPath); drawPath(); } }); // 应用路径为图像遮罩 function applyMask() { if (pathPoints.length < 3) return; // 假设已有图像对象img // ctx.clearRect(0, 0, canvas.width, canvas.height); // ctx.beginPath(); // ctx.moveTo(pathPoints[0].anchor.x, pathPoints[0].anchor.y); // for (let i = 1; i < pathPoints.length; i++) { // const prev = pathPoints[i-1]; // const curr = pathPoints[i]; // ctx.bezierCurveTo(prev.handleOut.x, prev.handleOut.y, curr.handleIn.x, curr.handleIn.y, curr.anchor.x, curr.anchor.y); // } // ctx.closePath(); // ctx.clip(); // ctx.drawImage(img, 0, 0); } </script>
关键细节说明
- 手柄对称逻辑:示例中默认生成对称手柄,可通过注释的代码实现拖拽手柄时保持对称,或取消对称(类似PS的Alt键功能)
- 性能优化:仅在鼠标事件触发时重绘,避免无意义的Canvas重绘
- 遮罩应用:通过
clip()方法将路径转为剪辑区域,或使用globalCompositeOperation实现图像与路径的合成
内容的提问来源于stack exchange,提问作者animDan
相关产品推荐
相关产品推荐

