Vue3实现Pixso中的钢笔工具2——整体逻辑

发布时间:2026/7/13 18:20:26
Vue3实现Pixso中的钢笔工具2——整体逻辑 文章目录一、前言二、实现Pixso中的钢笔代码逻辑 功能概述 模板结构1. **顶部工具栏**2. **主画布区域**3. **左侧面板**4. **右侧面板** 逻辑流程1. 初始渲染阶段2. 用户交互流程2.1 普通单击添加点2.2 长按添加点并转换为贝塞尔段2.3 拖拽控制点更新贝塞尔曲线2.4 画笔/橡皮擦绘制流程2.5 缩放与拖拽2.6 删除点3. 特殊状态和逻辑3.1 编辑模式转换3.2 贝塞尔曲线处理3.3 clipFunc画笔裁剪函数3.4 键盘快捷键3.5 缩放逻辑三、贝塞尔曲线相关函数实现1. convertLineToBezier()将直线段转换为贝塞尔曲线2. splitBezier()使用 De Casteljau 算法分割贝塞尔曲线3. insertPointOnBezierSegment()在贝塞尔曲线上插入点四、其他部分函数实现一、前言上一篇是关注的主要是如何控制鼠标的点击、松开来实现钢笔工具。这篇是描述完整代码的整体逻辑并补充了一些函数实现但因为整个代码过多两篇加起来也并没有完全展现所有函数的实现代码。二、实现Pixso中的钢笔代码逻辑 功能概述该组件是图像编辑器的核心画布区域提供以下功能支持钢笔工具pen创建直线段/贝塞尔曲线段、插入控制点、闭合路径支持橡皮擦/画笔涂抹操作支持图像缩放/拖拽wheel缩放 拖拽支持批量编辑模式batch和单图编辑模式提供撤销/重做功能支持图像扩展extend和图像替换/增强等AI后处理功能 模板结构1.顶部工具栏退出批量编辑按钮v-if“isBatch”工具切换按钮选择、移动缩放比例下拉菜单下载/收藏按钮2.主画布区域v-stageKonva画布主体v-layer多个penLayer多边形线段、控制点渲染区域controlLayer控制点、控制线渲染区域brushLayer画笔/橡皮擦渲染区域使用cllipFunc裁剪coverLayerRef生成动画遮罩层stageRefKonva Stage节点3.左侧面板BatchImageList批量图像列表组件4.右侧面板el-dialog结果预览弹窗 逻辑流程1. 初始渲染阶段onMounted后初始化stageSize、extendStageConfig、extendRectConfig等创建brushStrokes和polygons的初始状态注册brushStrokes的 watch 监听第1318-1358行根据firstTool设置activeTool和headerActiveTool初始化tempC1,tempP1,tempP2,tempC2等临时变量设置editingMode idle、showHandles { showBigHandle: false, showTempBezier: false, showBothControlPoints: false }2. 用户交互流程2.1 普通单击添加点// normalAddPointLogic(x, y, targetName)// - 调用 saveState() 保存当前状态// - 判断 polygon.closed 状态// - 添加新点和直线段// - 添加新点和直线段// - 更新 polygons.value [...polygons.value] 以触发响应式更新2.2 长按添加点并转换为贝塞尔段长按计时器触发createPointAndStraightSegment(event)被调用添加新点和直线段进入creating模式显示临时贝塞尔曲线预览如果用户拖动继续进入halfDone模式鼠标离开/松开onPenMouseUp被调用如果是长按且有拖动 → 进入finalized模式否则回到idle模式2.3 拖拽控制点更新贝塞尔曲线用户拖拽tempC1控制点调用onTempC1Drag(event)根据editingMode和isAltPressed更新tempC1.value.x/ysegment.controlPoints[0/1].x/y联动上一段的C2点如果有的话2.4 画笔/橡皮擦绘制流程mousedown开始绘制保存起点mousemove追加点mouseup结束绘制保存为笔触注意如果当前不是brush/eraser工具则不进入此流程2.5 缩放与拖拽handleDrawStageWheel检查是否按下 Ctrl计算新的scale和position更新stage.scaleX()和stage.y()等关键同步更新cursorLayerConfig的radius和strokeWidth2.6 删除点onRightClick(polygonIndex, pointId, event)检查event.button 2右键调用preventStageClick true防止误触发普通点击删除对应polygon.points中的点删除相关segments更新editingMode和showHandles3. 特殊状态和逻辑3.1 编辑模式转换idle→creating长按计时器触发creating→halfDone拖动超过阈值MOVE_THRESHOLD 3pxhalfDone→finalized用户点击确认或松开鼠标finalized用户可以删除点/插入点/转换为贝塞尔3.2 贝塞尔曲线处理convertLineToBezier()将直线段转换为贝塞尔曲线splitBezier()使用 De Casteljau 算法分割贝塞尔曲线insertPointOnBezierSegment()在贝塞尔曲线上插入点3.3 clipFunc画笔裁剪函数使用Path2D判断点是否在多边形内使用ctx.isPointInPath(path, x, y)进行裁剪3.4 键盘快捷键Ctrl创建/删除点时使用Alt按住可独立移动 C1/C2 控制点Shift未实现3.5 缩放逻辑ZOOM_IN_FACTOR 1.08放大 8%ZOOM_OUT_FACTOR 0.92缩小 8%限制最小/最大缩放比例为minScale 0.2,maxScale 5三、贝塞尔曲线相关函数实现1.convertLineToBezier()将直线段转换为贝塞尔曲线functionconvertLineToBezier(polygonIndex,segmentIndex){ConvertedALineToBezierflag1;constpolygonpolygons.value[polygonIndex];if(!polygon)return;constsegmentpolygon.segments[segmentIndex];if(!segment||segment.type!line){// 若它本身就是bezier段或者不存在就不处理return;}// 1. 找到线段首末端坐标conststartPtgetPointById(polygon,segment.starPointId);constendPtgetPointById(polygon,segment.endPointId);// 2. 将 type 改为 beziersegment.typebezier;// 3. 设置 controlPoints 数组constdxendPt.x-startPt.x;constdyendPt.y-startPt.y;constt10.25;constt20.75;// C1constc1xstartPt.xt1*dx;constc1ystartPt.yt1*dy;segment.controlPoints[{x:c1x,y:c1y},{x:c2x,y:c2y},};// 4. 如果你有“当前选中段”的概念可以改一下currentSegmentIndex.valuesegmentIndex;// 5. 设置编辑模式/显示控制柄等editingMode.valuefinalized;showHandles.value.showBothControlPointstrue;// 6. 触发Vue的响应式更新polygons.value[...polygons.value];}2.splitBezier()使用 De Casteljau 算法分割贝塞尔曲线functionsplitBezier(polygon,segment,t){// 1. 先拿到实际坐标constP0getPointById(polygon,segment.startPointId);constP3getPointById(polygon,segment.endPointId);constC1segment.controlPoints[0];constC2segment.controlPoints[1];// 2. 调用de Casteljau拆分const{leftPoints,rightPoints}splitCubicBezier(P0,C1,C2,P3,t);const[L0,L1,L2,M]leftPoints;const[R0,R1,R2,R3]rightPoints;// 如果需要“对称”执行一下矫正// 1. 计算向量 v (L2 - M)constvxL2.x-M.x;//M是分割点constvyL2.y-M.y;// 2. 让右侧的 (R1 - M) -(vx, vy)即方向相反、长度相同R1.xM.x-vx;R1.yM.y-vy;tempC1.value.xR1.x;tempC1.value.yR1.y;// 3. newAnchor(锚点)也就是 MconstnewAnchorIduuidv4();constnewAnchor{id:newAnchorId,x:M.x,y:M.y};// 4. 构造两条新的 segmentconstleftSegment{type:bezier,startPointId:segment.startPointId,// 复用原先的起点idendPointId:newAnchorId,controlPoints:[{x:L1.x,y:L1.y},// C1{x:L2.x,y:L2.y},// C2];};constrightSegment{type:bezier,startPointId:newAnchorId,endPointId:segment.endPointId,// 复用原先的终点idcontrolPoints:[{x:R1.x,y:R1.y},// C1{x:R2.x,y:R2.y},// C2],};// 5. 返回给外部让它往 polygon 里添加 newAnchor// 并把原segment替换成 [leftSegment, rightSegment].return{left:leftSegment,right:rightSegment,newAnchor};}3.insertPointOnBezierSegment()在贝塞尔曲线上插入点functioninsertPointOnBezierSegment(polygonIndex,segmentIndex,event){constpolygonpolygons.value[polygonIndex];if(!polygon)return;constsegmentpolygon.segments[segmentIndex];if(!segment||segment.type!bezier)return;// 1. 找到点击坐标conststageevent.target.getStage();constpointerPosstage.getPointerPosition();const{x,y}pointerPos;// 2. 计算曲线上t (0~1)// 这里要做投影到贝塞尔 或最近点搜索。为简化假设有个函数:consttfindBezierClosestT(polygon,segment,x,y);// 这个实现过程略复杂需要De Casteljau 或迭代逼近具体实现请自行Google// 3. 用 de Casteljau 或别的方法把这条三次贝塞尔拆成2条// splitBezier返回:// {// left: { startPointId, endPointId, controlPoints: [C1L, C2L] },// right: { startPointId, endPointId, controlPoints: [C1R, C2R] },// newAnchor: { id: xxx, x: something, y: something }// }const{left,right,newAnchor}splitBezier(polygon,segment,t);// 4. 在 polygon.points 里加入 newAnchorpolygon.points.push(newAnchor);// 5. 删除原 segment, 用新的2段替换polygon.segments.splice(segmentIndex,1,left,right);// 6. 让前端知道“我是刚插入的点”currentSegmentIndex.valuesegmentIndex1;// 指向right段// 你也可以选中left或both// 7. Vue 响应式更新polygons.value[...polygons.value];}四、其他部分函数实现functiononTempC1Drag(event){// 隐藏动态曲线showHandles.value.showTempBezierfalse;// 拖动tempC1点逻辑constposevent.target.position();// 不管是 creating, halfDone, 还是 finalized都一样先把 tempC1.value postempC1.value.xpos.x;tempC1.value.ypos.y;constpolygoncurrentPolygon.value;constsegmentpolygon.segments[currentSegmentIndex.value];if(!segment||segment.type!bezier)return;console.log([onTempC1Drag] segment:,segment);// 如果按住 Alt 只移动 C1否则 连杆 (联动更新 C2) if(!isAltPressed.value){// 没按 Alt - 连杆逻辑if(editingMode.valuecreating||editingMode.valuehalfDone){updateC1C2WhileCreating(pos);}elseif(editingMode.valuefinalized){// 这里可以写一套 finalized 下的连杆公式linkC1C2Finalized(segment,pos);}}else{// 按住 Alt - 独立移动 C1// 只更新 segment.controlPoints[0]if(editingMode.valuefinalized){segment.controlPoints[0].xpos.x;segment.controlPoints[0].ypos.y;}}polygons.value[...polygons.value];// 保证响应式更新}functiononRightClick(polygonIndex,pointId,event){preventStageClicktrue;// 设置标志位阻止 onSta onStageClick 添加点console.log(执行删除点操作阻止添加点操作preventStageClick,preventStageClick);if(eventevent.evt){event.evt.preventDefault();event.evt.stopPropagation();}elseif(eventevent.preventDefault){event.preventDefault();event.stopPropagation();}constplygenpolygons.value[polygonIndex];if(!polygon)return;constpointIndexpolygon.points.findIndex((p)p.idpointId);if(pointIndex-1)return;// 如果删除的是多边形的第一个点则关闭预览线if(pointIndex0){previewLine.valuefalse;console.log(删除的是第一个点关闭预览线,previewLine.value);}// 保存被删除的点用于找到相邻点constdeletedPoint{...polygon.points[pointIndex]};polygon.points.splice(pointIndex,1);polygon.segmentspolygon.segments.filter((segment){returnsegment.startPointId!pointIdsegment.endPointId!pointId;});if(polygon.closed){constuniquePointIdsnewSet(polygon.points.map((p)p.id));constcanStillClosepolygon.segments.every((segment)uniquePointIds.has(segment.startPointId)uniquePointIds.has(segment.endPointId));if(!canStillClose){polygon.closedfalse;}}console.log(多边形,polygon.points);// 删除完点和线段后如果没有剩余段需要编辑了或者你想让编辑恢复到 idle 状态// 那就把 tempC1 / editingMode / showHandles 里的相关值重置editingMode.valueidle;showHandles.value.showBigHandlefalse;showHandles.value.showTempBezierfalse;tempC1.value{x:0,y:0};preventStageClickfalse;// 如果多边形剩余的点数不足两个则关闭预览线并重置 lastPointif(polygon.points.length2){previewLine.valuefalse;console.log(多边形剩余的点数不足两个关闭预览线,previewLine.value);lastPoint.value{x:0,y:0};}polygons.value[...polygons.value];}