目录
钢笔工具
纯canvas实现无依赖,400多行代码,实现类似ps,figma,mastergo等设计软件的钢笔工具
代码设计的比较简单,但逻辑很清晰。适合对这方面感兴趣的研究
复制html可以直接运行:
支持:
- 锚点添加点没有控制柄,新增时和选中时可以按住不放拖动,生成控制柄。选中时,按住ctrl可以移动锚点。
- 控制柄支持平滑曲线和自由曲线,按住alt键可以对单个控制柄控制
- 左键添加,右键取消选中,选中最后一个锚点才会下个锚点的位置预览,选中时只会显示当前曲线的控制柄,
- 鼠标划过曲线会自动高亮显示锚点占位预览
- 双击锚点可以删除
- 如果选中最后的锚点,再单点其它锚点会闭合路径,选中最后一个锚点,鼠标移动时会有指示锚点
<!DOCTYPE html>
<html>
<head>
<style>
canvas {
background-color: #efefef;
}
</style>
</head>
<body>
<script>
class PenTool {
static fromWH(width, height, dpr) {
const instance = new this()
instance.canvas.width = width * dpr
instance.canvas.height = height * dpr
instance.dpr = dpr
instance.canvas.style.width = width + 'px'
instance.canvas.style.height = height + 'px'
return instance
}
// 曲线样式
fillStyle = '#ff0000'
lineStrokeColor = '#000'
lineWidth = 1
previewAnchorStrokeColor = '#0000ff'
// 锚点样式
anchorStrokeWidth = 1
anchorStrokeColor = '#0000ff'
anchorFillColor = '#fff'
anchorSelectedFillColor = '#0000ff'
anchorSize = 3
// 控制柄样式
handleSize = 3
handleStrokeWidth = 1
handleStrokeColor = '#0000ff'
handleFillColor = '#fff'
handleSelectedFillColor = '#0000ff'
// 控制柄与锚点的线段
handleLineSelectedColor = '#0000ff'
handleLineWidth = 0.5
handleLineColor = '#000'
isPathClosed = false
dpr = 1
drawing = false
constructor(canvas) {
this.canvas = canvas || document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.anchors = []; // 锚点数组
this.previewAnchor = null
this.isDragging = false; // 是否拖拽
this.currentAnchor = null; // 当前选中锚点
this.selectedHandle = null; // 选中类型,锚点还是控制柄
this.isPathClosed = false;
this.lastClickTime = 0; // 拦截mousedown双击
this.hoverHighlight = null // 当鼠标移动曲线上,显示的高亮层
// 初始化事件监听
this.canvas.addEventListener('contextmenu', e => {
e.preventDefault()
});
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
this.canvas.addEventListener('dblclick', this.onDoubleClick.bind(this));
}
onMouseDown(e) {
// 右键取消选中和不显示预览
if (e.button === 2) {
this.currentAnchor = null
this.selectedHandle = null
this.requestDraw()
e.preventDefault()
return
}
const now = Date.now();
if (now - this.lastClickTime < 300) return; // 防止双击触发单击
this.lastClickTime = now;
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 检测控制柄点击
const hitTarget = this.hitTest(mouseX, mouseY, 'all')
if (hitTarget) {
const anchor = hitTarget.target
if (hitTarget.type === 'anchor') {
// 按住ctrl键是选中
if (!this.previewAnchor) {
this.selectedHandle = 'anchor';
this.currentAnchor = anchor;
this.isDragging = true;
} else {
// 创建闭合路径
this.anchors.push(this.createAnchors(anchor.point.x, anchor.point.y))
this.selectedHandle = '';
this.currentAnchor = null;
this.isDragging = false;
}
this.requestDraw();
return;
}
if (hitTarget.type === 'handleIn') {
this.selectedHandle = 'in';
this.currentAnchor = anchor;
this.isDragging = true;
this.requestDraw();
return;
}
if (hitTarget.type === 'handleOut') {
this.selectedHandle = 'out';
this.currentAnchor = anchor;
this.isDragging = true;
this.requestDraw();
return;
}
if (hitTarget.type === 'segment') {
// 插入点
const newAnchor = this.createAnchors(anchor.point.x, anchor.point.y)
this.anchors.splice(anchor.index + 1, 0, newAnchor)
this.selectedHandle = 'anchor';
this.currentAnchor = newAnchor;
this.isDragging = true;
this.requestDraw();
return
}
}
const newAnchor = this.createAnchors(mouseX, mouseY)
this.anchors.push(newAnchor);
this.currentAnchor = newAnchor;
this.selectedHandle = 'anchor'
this.isDragging = true;
this.requestDraw();
}
createAnchors(x, y) {
// 添加新锚点
const newAnchor = {
point: { x: x, y: y },
handleIn: null,
handleOut: null,
isSmooth: true
};
return newAnchor
}
/**
* 命中交互层
* @param {'all'|'anchorOrHandle'|'segment'} checkType
*/
hitTest(mouseX, mouseY, checkType = 'all') {
// 检测控制柄点击
if (checkType === 'all' || checkType === 'anchor') {
for (const anchor of this.anchors) {
if (this.isNearPoint(mouseX, mouseY, anchor.point, Math.max(8, this.anchorSize))) {
return {
type: 'anchor',
target: anchor
}
}
if (anchor.handleIn && this.isNearPoint(mouseX, mouseY, anchor.handleIn, Math.max(8, this.handleSize))) {
return {
type: 'handleIn',
target: anchor
}
}
if (anchor.handleOut && this.isNearPoint(mouseX, mouseY, anchor.handleOut, Math.max(8, this.handleSize))) {
return {
type: 'handleOut',
target: anchor
}
}
}
}
if (checkType === 'all' || checkType === 'segment') {
const hitTarget = !this.previewAnchor && this.findNearestAnchors({ x: mouseX, y: mouseY })
if (hitTarget) {
return {
type: "segment",
target: hitTarget
}
}
}
return null
}
_mouseEvent = null
_moving = false
onMouseMove(e) {
this._mouseEvent = e;
if (this._moving) {
return
}
this._moving = true;
requestAnimationFrame(() => {
this._onMouseMove(this._mouseEvent)
this._moving = false
})
}
_onMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
this.hoverHighlight = null
if (!this.isDragging || !this.currentAnchor) {
const hitTarget = !this.previewAnchor && this.hitTest(mouseX, mouseY)
if (hitTarget && hitTarget.type === 'segment') {
this.hoverHighlight = hitTarget.target
}
// 当选中最后一个时,显示预览锚点
if (this.currentAnchor === this.anchors[this.anchors.length - 1]) {
this.previewAnchor = this.previewAnchor || this.createAnchors(mouseX, mouseY)
this.previewAnchor.point.x = mouseX
this.previewAnchor.point.y = mouseY
} else {
this.previewAnchor = null
}
this.draw()
return
}
this.previewAnchor = null
// 按Alt键切换为角点模式
const isAltPressed = e.altKey;
const isCtrlPressed = e.ctrlKey
// 选中锚点、控制柄的操作
if (this.selectedHandle === 'anchor') {
let x = this.currentAnchor.point.x
let y = this.currentAnchor.point.y
let dx = mouseX - x
let dy = mouseY - y
// 不按下ctrl键时,创建鼠标拖拽点与当前点的线段方向控制柄
if (!isCtrlPressed) {
this.currentAnchor.handleIn = {
x: x - dx,
y: y - dy
}
this.currentAnchor.handleOut = {
x: x + dx,
y: y + dy
}
} else {
// 移动控制柄和当前锚点
this.currentAnchor.point = { x: mouseX, y: mouseY }
if (this.currentAnchor.handleIn) {
this.currentAnchor.handleIn.x += dx
this.currentAnchor.handleIn.y += dy
}
if (this.currentAnchor.handleOut) {
this.currentAnchor.handleOut.x += dx
this.currentAnchor.handleOut.y += dy
}
}
} else if (this.selectedHandle === 'in') {
this.currentAnchor.handleIn = { x: mouseX, y: mouseY };
if (this.currentAnchor.isSmooth && !isAltPressed) {
const dx = this.currentAnchor.point.x - mouseX;
const dy = this.currentAnchor.point.y - mouseY;
this.currentAnchor.handleOut = {
x: this.currentAnchor.point.x + dx,
y: this.currentAnchor.point.y + dy
};
}
} else if (this.selectedHandle === 'out') {
this.currentAnchor.handleOut = { x: mouseX, y: mouseY };
if (this.currentAnchor.isSmooth && !isAltPressed) {
const dx = mouseX - this.currentAnchor.point.x;
const dy = mouseY - this.currentAnchor.point.y;
this.currentAnchor.handleIn = {
x: this.currentAnchor.point.x - dx,
y: this.currentAnchor.point.y - dy
};
}
}
this.draw();
}
onMouseUp() {
this.isDragging = false;
}
onDoubleClick(e) {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 删除锚点
this.anchors = this.anchors.filter(anchor => {
return !this.isNearPoint(mouseX, mouseY, anchor.point, Math.max(8, this.anchorSize));
});
this.requestDraw();
}
isNearPoint(x, y, point, radius = 8) {
return Math.sqrt((x - point.x) ** 2 + (y - point.y) ** 2) < radius;
}
requestDraw = () => {
if (this.drawing) {
return
}
this.drawing = true;
requestAnimationFrame(() => {
this.draw()
this.drawing = false
})
}
draw() {
const ctx = this.ctx;
ctx.save()
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.scale(this.dpr, this.dpr)
// 绘制描边
ctx.lineWidth = this.lineWidth
ctx.strokeStyle = this.lineStrokeColor
ctx.fillStyle = this.fillStyle
// 闭合路径
let start, end;
// 预览点存在就追加
if (this.anchors.length > 1) {
ctx.beginPath()
for (let i = 0; i < this.anchors.length - 1; i++) {
start = this.anchors[i];
end = this.anchors[i + 1];
if (i === 0) {
ctx.moveTo(start.point.x, start.point.y)
}
this.drawCubicBezier(start.handleOut ?? start.point, end.handleIn ?? end.point, end.point);
}
ctx.stroke()
}
// 渲染预览点
// 当前最后锚点是选中状态时,显示预览
if (this.previewAnchor && this.currentAnchor === this.anchors[this.anchors.length - 1]) {
if (this.anchors.length > 0) {
start = end
end = this.previewAnchor
ctx.beginPath()
ctx.strokeStyle = this.previewAnchorStrokeColor
if (!start) {
start = this.anchors[0]
}
ctx.moveTo(start.point.x, start.point.y)
this.drawCubicBezier(start.handleOut ?? start.point, end.handleIn ?? end.point, end.point);
ctx.stroke()
}
this.drawAnchor(this.previewAnchor, false)
}
// 显示高亮
if (this.hoverHighlight) {
let d = this.hoverHighlight
ctx.beginPath()
ctx.strokeStyle = this.previewAnchorStrokeColor
ctx.moveTo(d.p0.x, d.p0.y)
this.drawCubicBezier(d.p1, d.p2, d.p3);
ctx.stroke()
this.drawAnchor(d, false);
}
// 绘制控制点
this.anchors.forEach((anchor, i) => {
const selected = anchor === this.currentAnchor
this.drawAnchor(anchor, selected && this.selectedHandle === 'anchor');
if (selected || this.anchors[i - 1] === this.currentAnchor) {
this.drawHandle(anchor.handleIn, anchor.point, selected && this.selectedHandle === 'in');
this.drawHandle(anchor.handleOut, anchor.point, selected && this.selectedHandle === 'out');
}
});
ctx.restore()
}
drawCubicBezier(p1, p2, p3) {
const ctx = this.ctx;
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y)
}
drawCircle(point, r, lineWidth, fillColor, strokeColor) {
const ctx = this.ctx;
ctx.lineWidth = lineWidth
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor
ctx.beginPath();
ctx.arc(point.x, point.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke()
}
drawSquare(point, r, lineWidth, fillColor, strokeColor) {
const ctx = this.ctx;
ctx.lineWidth = lineWidth
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor
ctx.save()
ctx.translate(point.x, point.y)
ctx.rotate(45 / 180 * Math.PI)
ctx.translate(-point.x, -point.y)
ctx.beginPath();
ctx.rect(point.x - r, point.y - r, r * 2, r * 2)
ctx.fill();
ctx.stroke()
ctx.restore()
}
drawAnchor(anchor, active) {
const fillColor = active ? this.anchorSelectedFillColor : this.anchorFillColor
this.drawCircle(anchor.point, this.anchorSize, this.anchorStrokeWidth, fillColor, this.anchorStrokeColor)
}
drawHandle(handle, anchor, active) {
if (!handle) {
return
}
const ctx = this.ctx;
ctx.lineWidth = this.handleLineWidth;
ctx.strokeStyle = active ? this.handleLineSelectedColor : this.handleLineColor
ctx.beginPath();
ctx.moveTo(anchor.x, anchor.y);
ctx.lineTo(handle.x, handle.y);
ctx.stroke();
this.drawSquare(handle, this.handleSize, this.handleStrokeWidth, active ? this.handleSelectedFillColor : this.handleFillColor, this.handleStrokeColor);
}
cubicBezierPoint(p0, p1, p2, p3, t) {
const u = 1 - t;
const tt = t * t;
const uu = u * u;
const uuu = uu * u;
const ttt = tt * t;
return {
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y
};
}
getClosestOnCubicBezier(p0, p1, p2, p3, p, steps = 100) {
for (let i = 0; i <= steps; i++) {
let t = i / steps
let target = this.cubicBezierPoint(p0, p1, p2, p3, t)
const dx = p.x - target.x, dy = p.y - target.y
const dist = dx * dx + dy * dy
if (dist < 10) {
return target
}
}
return null
}
findNearestAnchors(p) {
loop:
for (let i = 0; i < this.anchors.length - 1; i++) {
const start = this.anchors[i]
const end = this.anchors[i + 1]
const p0 = start.point
const p1 = start.handleOut ?? start.point
const p2 = end.handleIn ?? end.point
const p3 = end.point
const target = this.getClosestOnCubicBezier(p0, p1, p2, p3, p)
if (target) {
return {
p0,
p1,
p2,
p3,
point:target,
index: i,
};
}
}
return null
}
}
// 初始化工具
const penTool = PenTool.fromWH(500, 500, window.devicePixelRatio)
document.body.appendChild(penTool.canvas)
</script>
</body>
</html>