大家好,我是前端西瓜哥。
今天来介绍如何实现图形绘制工具,实现绘制任意的图形。
编辑器 github 地址:
线上体验:
我之前讲过如何实现工具类管理类的:
《图形编辑器:工具管理和切换》
对应的工具类的实现会围绕用户的 按下鼠标、拖拽、释放 这 3 个行为,图形绘制工具同样如此。
整体框架:
//?绘制图形工具类(这里用了抽象类,后面会说为什么)abstract?class?DrawGraphTool?{??//?工具被激活??active()?{????//?通常是设置光标,或是绑定一些事件,比如键盘事件??}??//?工具失活??inactive()?{????//?通常是解绑一些事件??}????//?鼠标按下??start()?{?/*?TODO?*/?}??//?鼠标拖拽??drag()?{?/*?TODO?*/?}??//?鼠标释放??end()?{?/*?TODO?*/?}}
类似 React / Vue 的生命周期 hook。
模板模式
图形有很多种,矩形、椭圆、三角形、五角星等等。每个图形都实现一遍未免有点繁琐。
西瓜哥我一开始是分别去实现绘制矩形和椭圆的,然后发现有很多相同的逻辑。当又要加一个新的图形时,又要复制粘贴,然后修改少量的不一样的地方,这不利于代码维护。
为解决这个问题,我们要实现一个 绘制图形基类,将共用逻辑放到里面,不同的部分则交给子类去实现。
这个在设计模式上叫做 模板模式。
所谓模板模式,就是在方法中定义一个 “算法” 骨架,继承的子类在不改变算法整体结构的情况下,重写其中某些步骤(有些步骤有默认实现,可不重写)。
模板模式的具体实现,就是用 抽象类(abstract class) 去实现这个基类。
抽象类是一种不能被实例化的特殊类,继承的子类才能实例化。
抽象类的方法可以是普通方法,也可以是只定义了方法类型签名的抽象方法。
子类继承抽象类时,必须提供抽象类的抽象方法的具体实现。
TypeScript 支持抽象类。下面是一个例子。
//?抽象类abstract?class?AbstractClass?{??say()?{????if?(this.shoudISaySomething())?{??????console.log('前端西瓜哥')????}??}??//?抽象方法(不能用?private,因为子类要重写它)??protected?abstract?shoudISaySomething():?boolean}class?A?extends?AbstractClass?{??shoudISaySomething()?{????//?...假设这里一堆判断????return?true??}}
子类不实现抽象方法的话,TS 编译会报错:
如果你用 JavaScript,虽然不能做编译时的检验,但还可以做运行时的检测。
将需要子类继承实现的方法,加入抛出错误的实现。这样子类如果没实现,就会通过原型链的方式,执行基类的方法,然后报错提示给开发者。
class?AbstractClass?{??say()?{????if?(this.shoudISaySomething())?{??????console.log('前端西瓜哥')????}??}??shoudISaySomething()?{????throw?new?Error('请实现?shoudISaySomething?方法')??}}class?A?extends?AbstractClass?{??shoudISaySomething()?{????//?...假设这里一堆逻辑????return?true??}}图形绘制工具的实现
我们回到绘制图形的业务逻辑。
我们在鼠标按下时确定起始坐标,拖拽时调整终点坐标,鼠标释放确认终点坐标。
这里产生了一个矩形框,得到 x、y、width、height,通过它们可以确定了一个图形的位置和大小。
当要加一个新的图形时,只要它能够通过 x、y、width、height 这几个属性确定绘制效果,那就可以使用这个基类。
如果这个图形还有其他属性,我们可以在绘制后通过其他方式(比如控制点或者面板修改值)去修改。
鼠标按下
首先是鼠标按下的逻辑。逻辑很少,主要是记录起始点。
abstract?class?DrawGraphTool?{??commandDesc?=?'Add?Graph';?//?历史记录的命令描述??protected?drawingGraph:?Graph?|?null?=?null;?//?被绘制的图形对象??????start(e:?PointerEvent)?{????//?这里将光标的视口坐标转成场景坐标????this.startPoint?=?this.editor.getSceneCursorXY(e);????????//?重置一些状态????this.drawingGraph?=?null;??}}鼠标拖拽
拖拽的时候,会判断 this.drawingGraph 是否为 null。
如果是,就会创建一个新的图形对象。如果不是,那就更新 this.drawingGraph 的 x、y、 width、height 属性。
abstract?class?DrawGraphTool?{??private?lastDragPoint!:?IPoint;????drag(e:?PointerEvent)?{????//?记录终点坐标????this.lastDragPoint?=?this.editor.getSceneCursorXY(e);????this.updateRect();??}????//?更新矩形选框,并对图形对象进行操作??private?updateRect()?{????const?{?x,?y?}?=?this.lastDragPoint;????const?sceneGraph?=?this.editor.sceneGraph;????const?{?x:?startX,?y:?startY?}?=?this.startPoint;????const?width?=?x?-?startX;?//?这个可能是负数,还没做标准化????const?height?=?y?-?startY;?//?同上????const?rect?=?{??????x:?startX,??????y:?startY,??????width,??????height,????};????//?按住shift键,通过算法把矩形变成方形。????if?(this.editor.hostEventManager.isShiftPressing)?{??????this.adjustSizeWhenShiftPressing(rect);????}????if?(this.drawingGraph)?{??????//?(1)更新图形逻辑??????this.updateGraph(rect);????}?else?{??????//?(2)创建图形逻辑??????const?element?=?this.createGraph(rect)!;??????sceneGraph.addItems([element]);??????this.drawingGraph?=?element;????}????//?设置选中对象,并渲染????this.editor.selectedElements.setItems([this.drawingGraph]);????sceneGraph.render();??}}创建图形
创建图形对象的方法是 createGraph(),要返回一个图形对象,保存到 this.drawingGraph。
这个图形对象需要子类来提供。所以写成抽象方法:
protected?abstract?createGraph(rect:?IRect,?noMove?:?boolean):?Graph?|?null;
我们的矩形绘制工具,实现如下。
export?class?DrawRectTool?extends?DrawGraphTool?implements?ITool?{?//?...????//?这里提供实现创建图形对象??protected?createGraph(rect:?IRect)?{????rect?=?normalizeRect(rect);????return?new?Rect({??????...rect,??????fill:?[cloneDeep(this.editor.setting.get('firstFill'))],????});??}}
这里用 normalizeRect 对 rect 对象做了标准化,原来 width 和 height 可能为负数,标准化就是改变 x、y,并让 width 和 height 变回正数,变成一个常规的 rect 对象。
这样我们拿到了图形对象通用属性:x、y、width、height,然后这里再补上了一个默认的填充色。
如果要实现绘制直线,就不要提供填充色,而是要补一个默认描边。
更新图形
更新图形通常就是更新一下图形的 x、y、width、height 属性,所以基类会提供一个默认实现。
/**?*?这个是通用逻辑,直接更新?x、y、width、height?*/protected?updateGraph(rect:?IRect)?{??//?对矩形标准化??rect?=?normalizeRect(rect);??const?drawingShape?=?this.drawingGraph!;??drawingShape.x?=?rect.x;??drawingShape.y?=?rect.y;??drawingShape.width?=?rect.width;??drawingShape.height?=?rect.height;}
当然有些图形并不是这样的逻辑,那子类就需要重写 updateGraph 方法。
比如绘制直线就比较特殊,它更新的是 width 和 rotation,height 则永远是 0,需要另写一个算法去实现转换。
Shift 模式
这里有个比较特别的效果,就是按住 Shift,会让 图形的宽高比保持一比一。
绘制正方形:
绘制圆形:
实现就是找 width 和 height 绝对值大的那一个,然后符号保持不变,两者的绝对值都变成这个最大值。
protected?adjustSizeWhenShiftPressing(rect:?IRect)?{??//?pressing?Shift?to?draw?a?square??const?{?width,?height?}?=?rect;????const?size?=?Math.max(Math.abs(width),?Math.abs(height));??// Math.sign()?方法可能会返回?0,所以要兜底为 1??rect.height?=?(Math.sign(height)?||?1)?*?size;??rect.width?=?(Math.sign(width)?||?1)?*?size;}
子类如果比较特殊(没错说的就是你,直线工具),可重写该方法。
顺带一提,还有一种 Alt 模式,会将起始点作为图形的中心点进行绘制,这个我还没去实现。
鼠标释放
鼠标释放时,主要逻辑是将新的状态保持到历史记录中。
end(e:?PointerEvent)?{??if?(this.drawingGraph)?{????//?记录新的状态????this.editor.commandManager.pushCommand(??????new?AddShapeCommand(this.commandDesc,?this.editor,?[this.drawingGraph]),????);??}}结尾
模板模式的优点是复用和扩展。相同的主体框架逻辑不变,暴露几个方法让子类实现,有些是必须实现,有些是可实现可不实现(不实现用默认算法),对我们实现一种通用的绘制图形工具很有帮助。
实现了这个图形绘制基类后,我们理论上就可以绘制任何图形了,甚至用户自定义的图形,只要这些图形对象使用 x、y、 width、height。
我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。
立即咨询: 13716188458 / 18588225959,助您抢占市场先机。项目经理在线