You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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应用

  1. 新增工具切换:添加钢笔工具按钮,点击切换到钢笔编辑状态,暂停原遮罩工具的事件监听
  2. 遮罩应用:完成路径编辑后,调用Canvas的clip()方法,或设置globalCompositeOperation = 'source-in',将路径作为遮罩应用到图像上
  3. 状态隔离:确保钢笔工具与原有工具的状态互不干扰,比如切换工具时清空路径或保存当前路径

完整示例代码

<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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 22:45:02