Vue3实现Pixso中的钢笔工具1
一、钢笔工具功能分析先去pixso官网用一下钢笔工具https://pixso.cn/本文会先梳理一下绘制逻辑和简单实现然后再介绍一些具体实现比如用的是Konva框架。导入方式import Konva from konva;1、关键变量定义// 编辑模式 分为idle、creating、halfDone、finalized const editingMode ref(idle) // 临时曲线 const tempP1 ref({ x: 0, y: 0 }); const tempC1 ref({ x: 0, y: 0 }); const tempC2 ref({ x: 0, y: 0 }); const tempP2 ref({ x: 0, y: 0 }); // 鼠标信息 const mousePosition ref({ x: 0, y: 0 }) //预览线是否开启 const previewLine ref(false) //上一个点坐标 const lastPoint ref(null) //长按相关 const longPressTimer ref(null) const isLongPress ref(false) const PRESS_DURATION 300; showHandles.value { // 控制柄显示状态恢复 showBigHandle: false, showTempBezier: false, showBothControlPoints: false, }; const currentPolygon computed(() { if(polygons.value.length 0) { startNewPolygon() } return polygons.value[currentPolygonIndex.value] })idle就是普通添加直线点的模式creating就是长按移动大控制柄的模式后面有解释halfDone是长按移动大控制柄后松开进入的动态曲线绘制模式finalized是完成了动态曲线的绘制2、点击鼠标左键可在画布上添加一个点并且这个点会拉出一条预览线这条预览线的路径是刚添加的点鼠标当前的位置。不断添加点可以绘制直线//点击画布区域的函数const onPenStageClick (event) { // 获取鼠标按钮类型 const button event.button || (event.evt ? event.evt.button : 0); // 检查是否为右键点击改成鼠标左键添加点右键删除点 if (button 2) { // 执行删除点的逻辑 onRightClick(event); return; } if (preventStageClick) { preventStageClick false; // 重置标志位 return; // 阻止普通点添加逻辑 } if (isCtrlPressed.value) { return; } if (editingMode.value creating) { return; } if (editingMode.value halfDone !isLongPress.value) { return; } // 其他点击逻辑添加点、闭合多边形... const stage event.target.getStage(); const position stage.getRelativePointerPosition() const scale stage.scaleX() const { x, y } position; const target event.target; const targetName target.name ? target.name() : ; // 普通点击创建直线点逻辑 if(button 0 || button 1) normalAddPointLogic(x, y, targetName); }//如果形成了封闭区域则新建一个多边形的函数function startNewPolygon() { const newPolygon { points: [], segments: [], closed: false, }; polygons.value.push(newPolygon); currentPolygonIndex.value polygons.value.length - 1; console.log( [startNewPolygon] startNewPolygon: Created a new polygon, index:, currentPolygonIndex.value ); }//普通点击创建直线点的函数function normalAddPointLogic(x, y, targetName) { // 保存当前状态到撤销栈 saveState(); const old_polygon currentPolygon.value; if (old_polygon?.closed) { //如果形成了封闭区域则新建一个多边形 startNewPolygon(); } const polygon currentPolygon.value; const segments polygon?.segments; const points polygon?.points; if (segments?.length 0) { const lastSegment segments[segments.length - 1]; if (segments?.length 3) { const firstSegment segments[0]; const firstPoint getPointById(polygon, firstSegment.startPointId); //已经有点且超过3个点要判断是否闭合 const dist calculateDistance({ x, y }, firstPoint); if (dist 10) { //已形成封闭区域 console.log([normalAddPointLogic] Closing polygon); const closingSegment { type: line, tartPointId: lastSegment.endPointId, endPointId: firstPoint.id, controlPoints: [], }; segments.push(closingSegment); polygon.closed true; previewLine.value false; return; } } console.log([normalAddPointLogic] Adding a new line segment point); //已经有点但是没闭合要添加一段新的点和直线 const newPoint { id: uuidv4(), x, y }; points.push(newPoint); const newSegment { type: line, startPointId: lastSegment.endPointId, endPointId: newPoint.id, controlPoints: [], }; segments.push(newSegment); }else{ console.log([normalAddPointLogic] Adding the first point of polygon); //一个点也没有添加第一个点 const newPoint { id: uuidv4(), x, y }; points.push(newPoint); const newSegment { type: line, startPointId: newPoint.id, endPointId: newPoint.id, //第一个点自己指向自己 controlPoints: [], }; segments.push(newSegment); } //更新视图 polygons.value [...polygons.value]; console.log([normalAddPointLogic] polygons:, polygons.value); }辅助函数function calculateDistance(p1, p2) { const dx p1.x -p2.x const dy p1.y - p2.y; return Math.sqrt(dx * dx dy * dy); } function getPointById(polygon, pointId) { return polygon.points.find((p) p.id pointId) || { x: 0, y: 0 }; } function getLinePoints(polygon, segment) { const startPt getPointById(polygon, segment.startPointId); const endPt getPointById(polygon, segment.endPointId); return [startPt.x, startPt.y, endPt.x, endPt.y]; } function getBezierPoints(polygon, segment) { const P0 getPointById(polygon, segment.startPointId); const P3 getPointById(polygon, segment.endPointId); const C1 segment.controlPoints[0]; const C2 segment.controlPoints[1]; return [P0.x, P0.y, C1.x, C1.y, C2.x, C2.y, P3.x, }3、首先我们整体描述一下绘制曲线的问题。我们先定义一段曲线的首端是P1末端是P2P1的切线是C1P2的切线是C2。长按可以绘制曲线。假设已经有一个点P1然后移动鼠标到某个位置P2开始保持长按并移动鼠标到某一位置tempC1P1和P2之间会形成曲线P1的切线是C1P2的切线是C2。C2-P2-tempC1会形成一个大控制柄这三个点在一条直线上鼠标移动tempC1会对整个大控制板进行移动也就是说C2随着tempC1的移动会跟随移动移动时保持三点一线。为什么称为tempC1我们将大控制板下半段P2-tempC1看做是未来的曲线的首端的切线也就是说这是未来曲线的C1我们记作临时点tempC1。鼠标长按后拉动到tempC1之后就会形成P1和P2的曲线以及C2-P2-tempC1这个大控制柄为什么叫大控制柄因为虽然在保持长按的状态下移动tempC1可以控制整个大控制柄的方向但是其实大控制柄可以看做是两段第一段P2-C2是曲线P1P2的末端小控制柄P2的切线第二段是P2-tempC1是未来曲线tempP1-tempP2的首端小控制板tempP1的切线实际上tempP1就是P2。用户在tempC1松开长按状态后会引出一条动态曲线tempP1-tempP2tempP2是鼠标位置如果用户点击了某处tempP2就固定了如果用户在tempP2长按了则会在tempP2形成新的大控制柄逻辑就跟前面长按是一样的。对于当前步骤来说我们只暂时关注鼠标长按拉到tempC1这个阶段如下图所示。关于用户在tempC1松开长按状态后的逻辑我们在后面的步骤再细说。对于用户鼠标长按这个阶段实际上有两种情况一种是鼠标在P2点原地长按形成曲线P1-P2一种是鼠标从P2点长按并移动到tempC1那么不但形成曲线P1-P2还形成一个大控制柄C2-P2-tempC1。第二种情况其实就是上图的情况第一种情况其实是下图的情况也就是说曲线P1和P2被创建了但它们看起来还是一条直线这是因为直线可以看做是特殊的曲线。我们可以想象一下有一条直线P1-P2向量P1-P2的方向上有一个P1-C1向量P2-P1的方向上有一个P2-C2下图只显示了P1-C1我们定义了直线的情况下两个控制点的位置此时直线可以看做是特殊的曲线具体是为什么直线可以看做是特殊的曲线要了解二阶贝塞尔曲线公式。3.1 长按生成曲线P1-P2//当鼠标点击左键function onPenMouseDown(event) { if (isCtrlPressed.value) { console.log([onPenMouseDown] Ctrl is pressed, skip long press logic.); return; } const polygon currentPolygon.value; if (!polygon || polygon.closed) { // 已闭合就直接 return不再显示预览线 return; } console.log([onPenMouseDown] onMouseDown editingMode:, editingMode.value); // 记录“长按起点”——后面要用它判断是否真的移动了 const stage event.target.getStage(); const startPos stage.getRelativePointerPosition(); // 把它保存在一个响应式 ref或者普通变量里后面计时器能访问 longPressStartPos.value { x: startPos.x, y: starPos.y }; //将tempC1从0,0的初始化位置更新为当前鼠标添加点的位置 if (editingMode.value idle || editingMode.value creating){ tempC1.value longPressStartPos.value } // 当你检测到按住的是你的大控制柄或进入 creating 模式后就标记 isDraggingHandle true if (editingMode.value creating) { // 如果判断是按下了大控制柄或者是进入 creating 状态 isDraggingHandle true; } const targetName event.target.name ? event.target.name() : ; if ( targetName control-point || targetName polygon-point || targetName tempC1-handle ) { console.log([onPenMouseDown] Pressed on a control or polygon point, no long press); return; } // 重置长按标志 isLongPress.value false; pendingBezier.value false; hasDraggedDuringLongPress.value false; pendingSegmentIdx.value null; // 长按计时器只创建点直线段不生成曲线 if ( editingMode.value idle || editingMode.value creating || editingMode.value halfDone || editingMode.value finalized ) { longPressTimer.value setTimeout(() { // 计时器触发创建点 直线段记录下来 console.log([onPenMouseDown] long press triggered → create point straight segment); const segIdx createPointAndStraightSegment(event pendingBezier.value true; // 表示这条新段 *可能* 要改成贝塞尔 pendingSegmentIdx.value segIdx; // 此时不把 editingMode 改成 creating等到真的拖动时再改 }, PRESS_DURATION); } // 普通预览线条显示 if (currentPolygon.value.segments.length 0) { const seg currentPolygon.value.segments.at(-1); const lp getPointById(currentPolygon.value, seg.endPointId); if (lp) { lastPoint.value lp; previewLine.value true; } } }/** * ① 长按计时器触发后调用 * 在当前多边形末端 **添加一个新点** 并 **用线段** 把它连起来。 * 返回新段的索引后面会用它来把段改为贝塞尔如果用户真的拖动。 */ function createPointAndStraightSegment(event) { saveState(); // 与原来保持一致 const polygon currentPolygon.value; const { segments, points } polygon; // 取当前多边形最后一段的终点即前一个点 const lastSeg segments[segments.length - 1]; const stage event.target.getStage(); const { x, y } stage.getRelativePointerPosition(); // 新点Pnew const newPt { id: uuidv4(), x, y }; points.push(newPt); // 用 **直线** 把它接上暂时不算贝塞尔 const newSeg { type: line, // 先写成 line后面如果拖动再改成 bezier startPointId: lastSeg.endPointId, endPointId: newPt.id, }; segments.push(newSeg); // 更新 UI预览线等 previewLine.value true; lastPoint.value newPt; polygons.value [...polygons.value]; // 响应式更新 // 返回新段的索引后面会用它来改成贝塞尔 return segments.length - 1; }3.2 保持长按状态下移动鼠标能够移动大控制柄function onPenMouseMove(event) { const polygon currentPolygon.value; if ((polygon !polygon?.points?.length) || polygon?.closed) { previewLine.value false; // 禁止闭合时显示预览线 return; } const stage event.target.getStage(); const position stage.getRelativePointerPosition() // 如果已经进入正式的 “creating” 状态之前已经改成贝塞尔 // 继续走原来的 C1 / C2 更新逻辑 if ( isLongPress.value editingMode.value creating currentSegmentIndex.value ! null ) { updateC1C2WhileCreating(position); } // 长按后但还没有确认是否要画贝塞尔的阶段 if (pendingBezier.value !hasDraggedDuringLongPress.value) { // 计算从长按起点到当前指针的位移 const moved dist(position, longPressStartPos.value); if (moved MOVE_THRESHOLD) { // 用户真的拖动了就把那条直线段改为贝塞尔段 const segIdx pendingSegmentIdx.value; const seg polygon.segments[segIdx]; const startPt getPointById(polygon, seg.startPointId); const endPt getPointById(polygon, seg.endPointId); //控制点C1的位置是向量P1-P2的方向上的点 const dx endPt.x - startPt.x; //控制点C2的位置是向量P2-P1的方向上的与P1-C1等距的点 const dy endPt.y - startPt.y; //取控制点C1的位置为向量P1-P2的方向上四分之一位置的点 const cp1 { x: lastPt.x dx * 0.25, y: lastPt.y dy * 0.25 }; //取控制点C2的位置为向量P1-P2的方向上四分之三位置的点其实相当于向量P2-P1的方向上四分之一位置的点 const cp2 { x: lastPt.x dx * 0.75, y: lastPt.y: lastPt.y dy * 0.75 }; seg.type bezier; seg.controlPoints [cp1, cp2]; // 进入正式的贝塞尔创建状态 editingMode.value creating; currentSegmentIndex.value segIdx; // 让后面的updateC1C2WhileCreating能找到它 isLongPress.value true; // 真正的长按标记 previewLine.value false; //预览线条 showHandles.value.showBigHandle true; // 显示大控制柄 // 标记已经拖动过后面的 mouseup 不会再走 “直线回退” 分支 hasDraggedDuringLongPress.value true; } } // halfDone 状态临时贝塞尔保持原有逻辑 if (editingMode.value halfDone currentSegmentIndex.value ! null) { // 仅在鼠标不在控制点上时显示动态贝塞尔曲线 if (!isMouseOverControlPoint.value) { showHandles.value.showTempBezier true; } } //更新动态曲线的tempP2为当前鼠标位置 tempP2.value { x: mousePosition.value.x, y: mousePosition.value.y }; //预览直线 if (currentPolygon.value?.segments?.length previewLine.value) { const lastSegment currentPolygon.value.segments[currentPolygon.value.segments.length - 1]; if (lastSegment) { const lastPointObj getPointById( currentPolygon.value, lastSegment.endPointId ); lastPoint.value lastPointObj; } } } //当用户添加了P1之后在P2处长按就会在P1-P2形成新的曲线。 function startCreatingBezierSegment(event) { saveState(); const polygon currentPolygon.value; const segments polygon.segments; const points polygon.points; if (segments.length 0) { const lastSegment segments[segments.length - 1]; //上一个点其实就是P1 const lastPt getPointById(polygon, lastSegment.endPointId); const stage event.target.getStage(); const pos stage.getRelativePointerPosition(); const { x, y } pos; //当前鼠标位置是我们即将要创建的P2 const newPoint { id: uuidv4(), x, y }; points.push(newPoint); //添加一个P2 const dx x - lastPt.x; //控制点C1的位置是向量P1-P2的方向上的点 const dy y - lastPt.y; //控制点C2的位置是向量P2-P1的方向上的与P1-C1等距的点 //取控制点C1的位置为向量P1-P2的方向上四分之一位置的点 const cp1 { x: lastPt.x dx * 0.25, y: lastPt.y dy * 0.25 }; //取控制点C2的位置为向量P1-P2的方向上四分之三位置的点其实相当于向量P2-P1的方向上四分之一位置的点 const cp2 { x: lastPt.x dx * 0.75, y: lastPt.y: lastPt.y dy * 0.75 }; //新建曲线P1-P2 const newSegment { type: bezier, startPointId: lastSegment.endPointId, endPointId: newPoint.id, controlPoints: [cp1, cp2], }; segments.push(newSegment); editingMode.value creating; currentSegmentIndex.value segments.length - 1; tempNewPointId newPoint.id; // 此时显示大控制柄(上一段C2 本段tempC1)初始tempC1与鼠标一致 showHandles.value.showBigHandle true; console.log( [startCreatingBezierSegment] entering creating mode, showHandles:, shoHandles.value ); polygons.value [...polygons.value]; } }// 计算两点欧氏距离 function dist(a, b) { const dx a.x - b.x; const dy a.y - b.y; return Math.sqrt(dx * dx dy * dy); }function updateC1C2WhileCreating(pos) { console.log([updateC1C2WhileCreating]); const polygon currentPolygon.value; const segments polygon.segments; const segment segments[currentSegmentIndex.value]; if (segment segment.type bezier) { tempC1.value { x: pos.x, y: pos.y }; //当前鼠标位置 const P1 getPointById(polygon, segment.startPointId); const P2 getPointById(polygon, segment.endPointId); const C1 segment.controlPoints[0]; const C2 segment.controlPoints[1]; const ratio -0.7; C2.x P2.x (pos.x - P2.x) * ratio; //C2是P2-tempC1反方向就是tempC1-P2 C2.y P2.y (pos.y - P2.y) * ratio; const tangentRatio 0.4; const tangentDX C2.x - P1.x; const tangentDY C2.y - P1.y; C1.x P1.x tangentDX * tangentRatio; //C1是P1-C2的方向上的点 C1.y P1.y tangentDY * tangentRatio; polygons.value [...polygons.value]; } }4、在tempC1处松开以后会出现一段曲线这段曲线会跟随鼠标的位置进行实时绘制即实时绘制P2-当前鼠标位置的曲线function onPenMouseUp(event) { if (isCtrlPressed.value) { console.log([onPenMouseUp] Ctrl is pressed, skip onMouseUp.); return; } console.log([onPenMouseUp] onMouseUp, editingMode:, editingMode.value); const polygon currentPolygon.value; if (!polygon || polygon.closed) { previewLine.value false; // 多边形闭合时关闭预览线 // 已闭合就直接 return不再显示预览线 return; } clearTimeout(longPressTimer.value); // 长按后没有真正拖动hasDraggedDuringLongPress false // 已经在长按计时器里创建了一条直线段直接保留它 if (pendingBezier.value !hasDraggedDuringLongPress.value) { console.log([onPenMouseUp] long press but no drag → keep straight line); // 退出 creating 状态恢复到普通 idle editingMode.value idle; isLongPress.value false; pendingBezier.value null; pendingSegmentIdx.value null; hasDraggedDuringLongPress.value false; // 隐藏所有贝塞尔相关的 UI大柄、绿色临时曲线 showHandles.value.showBigHandle false; showHandles.value.showTempBezier false; tempC1.value null; tempP2.value null; previewLine.value true; // 仍然显示普通的预览直线 return; } // 进入正式的贝塞尔创建已经改成 bezier 且 isLongPress 为 true // 按原来的流程进入 halfDone / finalize if (isLongPress.value) { console.log([onPenMouseUp] was long press with drag → keep bezier flow); if (editingMode.value creating) { editingMode.value halfDone; updateLastPointFromCurrentSegment(); tempC1.value { x: mousePosition.value.x, y: mousePosition.value.y }; showHandles.value.showTempBezier true; } // 重置长按标记防止后续误判 isLongPress.value false; pendingBezier.value false; pendingSegmentIdx.value null; hasDraggedDuringLongPress.value false; previewLine.value true; return; }else{ // 完全没有触发长按普通短点 if (editingMode.value halfDone) { //半完成状态下没有触发长按直接在tempP2短按需要固定动态曲线 previewLine.value false; finalizeBezierSegment(); } } previewLine.value true; }5、1在第4步之后用户有可能鼠标点击某个空白位置以确定这条曲线。在某个位置单击与在某个位置长按但鼠标不移动两者都是下图的效果。function finalizeBezierSegment() { saveState(); editingMode.value finalized; bezierCreationCompleted.value true; const polygon currentPolygon.value; const P1 tempP1.value; const endPoint { id: uuidv4(), x: tempP2.value.x, y: tempP2.value.y }; polygon.points.push(endPoint); const newSegment { type: bezier, startPointId: P1.id, endPointId: endPoint.id, controlPoints: [ { x: tempC1.value.x, y: tempC1.value.y }, { x: tempC2.value.x, y: tempC2.value.y }, ], }; polygon.segments.push(newSegment); currentSegmentIndex.value polygon.segments.length - 1; showHandles.showTempBezier false; showHandles.value.showBigHandle true; polygons.value [...polygons.value]; }2在第4步之后用户有可能还可能在某个空白位置进行长按并且发生移动会出现下面的画面。那这个长按然后移动鼠标松开的操作其实前面已经介绍过了。6、重做const clearCanvas () { brushStrokes.value [] currentBrushStroke.value null polygons.value [] // 清空撤销栈和重做栈 undoStack.value [] redoStack.value [] // 清空预览线条 previewLine.value false lastPoint.value null; mousePosition.value { x: 0, y: 0 }; // 鼠标位置恢复 editingMode.value idle; // 编辑模式恢复 showHandles.value { // 控制柄显示状态恢复 showBigHandle: false, showTempBezier: false, showBothControlPoints: false, }; currentSegmentIndex.value null; // 当前段索引清空 currentPolygonIndex.value -1; // 没有正在编辑的 // 临时贝塞尔点/控制点全部归零 tempP1.value { x: 0, y: 0 } tempC1.value { x: 0, y: 0 } tempC2.value { x: 0, y: 0 } tempP2.value { x: 0, y: 0 } //如果还有显示尺寸/光标等 UI 也同步关闭 cursorLayerConfig.visible false; }// 执行绘制操作将当前状态存入撤销栈 const saveState () { const currentState cloneFullState() undoStack.value.push(currentState) redoStack.value [] } // 保存画布状态 const cloneFullState () { return JSON.parse(JSON.stringify({ polygons: : polygons.value, brushStrokes: brushStrokes.value })) } // 撤销操作 const undo () { if(undoStack.value.length 0) { const lastState undoStack.value.pop(); redoStack.value.push(cloneFullState()); // 将当前状态放入重做栈 restoreState(lastState); currentPolygonIndex.value polygons.value?.length ? polygons.value?.length - 1 : -1; console.log(debug-undo, lastState, currentPolygonIndex.value) } } // 重做操作 const redo () { if(redoStack.value.length 0) { const lastUndoneState redoStack.value.pop(); undoStack.value.push(cloneFullState()); // 当前状态放入撤销栈 restoreState(lastUndoneState) } } const restoreState (state) { polygons.value state.polygons; // 恢复完永久数据后重置临时状态 previewLine.value false; tempC1.value { x: 0, y: 0 }; tempC2.value { x: 0, y: 0 }; tempP1.value { x: 0, y: 0 }; tempP2.value { x: 0, y: 0 }; editingMode.value idle; showHandles.value { showBigHandle: false, showTempBezier: false, showBothControlPoints: false, }; // 根据需要重置其他临时状态 // 重置画笔工具的临时数据 brushStrokes.value state.brushStrokes; currentBrushStroke.value null; isDrawingBrush.value false; }二、模板实现(并不完整)template div classright-wrapper div v-if!isBatch refkonvaContentRef :class[konva-content, headerActiveTool move konva-content-cursor] div v-if![extend].includes(firstTool) classcanvas-wrapper :style{width: stageSize.width || 1500, height: stageSize.height || 900,} v-stage refstageRef :configstageSize mousedownhandleStageMouseDown mousemoveonMouseMove mouseuponMouseUp clickonStageClick :style{zIndex: 1001, cursor: isHideStagePointer ? none : default} wheelhandleDrawStageWheel v-layer refpenLayer !-- 渲染多边形的线 -- template v-for(polygon, polygonIndex) in polygons :keypolygon- polygonIndex template v-for(segment, segmntIndex) in polygon.segments :keysegment- polygonIndex - segmentIndex !-- 直线段 -- v-line v-ifsegment.type line :pointsgetLinePoints(polygon, segment) clickonLineSegmentClick(polygonIndex, segmentIndex, $event) / !-- 贝塞尔段 -- v-line v-ifsegment.type line :pointsgetBezierPoints(polygon, segment) clickonBezierSegmentClick(polygonIndex, segmentIndex, $event) / /template /template !-- 渲染多边形的点 -- template v-for(polygon, polygonIndex) in polygons :keypoints-polygon- polygonIndex template v-forpoint in polygon.points :keypoint- polygonIndex - point.id v-circle :config{ x: point.x, y: point.y, } contextmenuonRightClick(polygonIndex, point.id,, $event) contextmenuonDragMove(polygonIndex, point.id,, $event) mouseenteronPolygonPointMouseEnter mouseenteronPolygonPointMouseLeave / /template /template !-- 预览线条 -- !-- 临时贝塞尔曲线显示半完成状态下 -- v-line v-if editingMode halfDone showHandles.showTempBezier currentSegmentIndex ! null :pointsgetTempBezierPoints() / /v-layer v-layer refcontrolLayer template v-for(polygon, polygonIndex) in polygons :keycontrol-polygon- polygonIndex template v-ifpolygonIndex currentPolygonIndex template v-for(segment, segmntIndex) in polygon.segments :keycontrol-segment- polygonIndex - segmentIndex !-- 如果是贝塞尔段就渲染两条参考线 -- template v-if segment.type bezier (segmentIndex currentSegmentIndex || segmentIndex currentSegmentIndex - 1) template v-for(cp, cpIndex) in segment.controlPoints :keycp- segmentIndex - cpIndex !-- 1) 画控制线 -- v-line v-ifshouldRenderLine(segmentIndex, cpIndex) :points getControlLinePoints( polygon, segment, segmentIndex ) / !-- 2) 画控制点 -- v-rect v-ifshouldRenderControlPoint(segmentIndex, cpIndex) :config{ } :keycprect- segmentIndex - cpIndex dragmove onControlPointDrag( ) mouseenteronControlPointMouseEnter mouseleaveonControlPointMouseLeave / /template /template /template /template !-- 显示大控制柄tempC1与上一段C2的连线和对应的参考线条 -- template v-ifshowHandles.showBigHandle currentSegmentIndex ! null v-line :pointsgetP2toTempC1Line() / v-rect :xtempC1.x - 5 :ytempC1.y - 5 width10 height10 fill#1984ec stroke#1984ec :strokeWidth1 nametempC1-handle draggabletrue dragOnTop: true, dragmoveonTempC1Drag / /template /v-layer /div /div /div /template三、代码逻辑这是一个基于 Vue 3 Konva 实现的绘图板组件支持三种绘图工具画笔、橡皮擦和钢笔工具。1. 工具栏逻辑 (tools - selectTool())工具ID功能brush显示画笔配置悬浮窗颜色/粗细eraser显示橡皮擦配置悬浮窗粗细pen显示钢笔配置悬浮窗直线/曲线颜色/粗细clear清空画布undo撤销操作redo重做操作2. 画笔/橡皮擦工具 (onBrushMouseDown() - onBrushMouseUp())核心数据brushStrokes 数组存储所有笔触每个笔触包含 points 坐标数组、tool 类型、strokeWidth 和 stroke 颜色。关键逻辑通过 brushClipFunc 限定画笔只能在 已闭合的多边形区域内 绘制。橡皮擦使用 destination-out 模式擦除内容。3. 钢笔工具核心逻辑3.1 多边形数据结构 (polygons){ points: [{ id: uuid, x, y }], // 顶点数组 segments: [ // 线段数组 { type: line, startPointId, endPointId, controlPoints: [] }, { type: bezier, startPointId, endPointId, controlPoints: [C1, C2] } ], closed: false }3.2 编辑模式 (editingMode)模式说明idle空闲状态creating正在创建贝塞尔曲线按住 ≥500ms 触发halfDone半完成状态拖动控制柄调整曲线形状finalized已完成状态3.3 鼠标交互流程操作行为短按500ms添加直线点 / 闭合多边形距离首点 10px长按≥500ms进入 creating 模式创建贝塞尔曲线段双击 / Alt点击曲线在曲线上插入新点insertPointOnBezierSegmentAlt点击直线将直线转换为贝塞尔曲线convertLineToBezier()3.4 贝塞尔曲线编辑控制柄联动规则updateC1C2WhileCreating()C2 位于鼠标方向比例为 -0.7C1 位于起点到 C2 的向量上比例为 0.4大控制柄tempC1在 creating 和 halfDone 模式下显示用于调整曲线起点切线方向按住 Alt 键拖动独立控制 C1/C2取消联动De Casteljau 算法splitCubicBezier()用于分割贝塞尔曲线在 insertPointOnBezierSegment() 中调用4. 撤销/重做机制 (undoStack - redo())cloneFullState()深拷贝 polygons 和 brushStrokesrestoreState()恢复状态并重置临时变量如 tempC1、editingMode每次绘图操作前调用 saveState()5. 键盘快捷键按键功能Ctrl恢复多边形点的拖拽自由度Alt独立控制贝塞尔曲线控制柄、在直线上插入点时转换为贝塞尔曲线Shift切换控制柄联动模式activeHandleMode shif6. 渲染层结构层名内容penLayer直线段、贝塞尔曲线、多边形顶点、预览线、临时贝塞尔曲线controlLayer控制点C1/C2、控制线、大控制柄tempC1brushLayer画笔/橡皮擦笔触支持 clipFunc 剪裁关键辅助函数操作行为getPointOnCubicBezier()计算贝塞尔曲线上某点坐标findBezierClosestT()查找曲线上最接近点击点的参数 tpointInPolygon()射线法判断点是否在多边形内calculateDistance()计算两点间距离潜在优化点1.activeHandleMode 是 let 类型而非响应式但模板中未使用2.撤销/重做时临时变量的恢复逻辑可进一步优化3.大量控制台调试日志可移除或分级控制