纯canvas无依赖,400多行代码,实现类似ps,figma,mastergo等设计软件的钢笔工具

目录

复制html可以直接运行:支持:


钢笔工具

纯canvas实现无依赖,400多行代码,实现类似ps,figma,mastergo等设计软件的钢笔工具

代码设计的比较简单,但逻辑很清晰。适合对这方面感兴趣的研究

复制html可以直接运行:
支持:

  1. 锚点添加点没有控制柄,新增时和选中时可以按住不放拖动,生成控制柄。选中时,按住ctrl可以移动锚点。
  2. 控制柄支持平滑曲线和自由曲线,按住alt键可以对单个控制柄控制
  3. 左键添加,右键取消选中,选中最后一个锚点才会下个锚点的位置预览,选中时只会显示当前曲线的控制柄,
  4. 鼠标划过曲线会自动高亮显示锚点占位预览
  5. 双击锚点可以删除
  6. 如果选中最后的锚点,再单点其它锚点会闭合路径,选中最后一个锚点,鼠标移动时会有指示锚点
<!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>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值