大家好,我是前端西瓜哥。今天我们来看看如何为图形编辑器实现工具,例如绘制矩形、选择工具,以及如何管理它们。项目地址,欢迎star:https://github.com/F-star/suika在线体验:https://blog.fstars.wang/app/suika/一个编辑器有两个很重要的方面,一个是性能,一个是另一个是建筑。因为你不知道用户会在画布上绘制多少图形,所以需要在渲染引擎上下功夫,提高绘制的性能。性能决定了编辑器的上限,这也是很多编辑器选择Canvas作为绘图方案的原因。另一个是架构,编辑器很复杂,即使看起来很简单的编辑器。因为里面有很多模块,比如工具管理模块,缩放管理,历史记录,图形树维护,辅助线,标尺,设置,视口管理,热键,游标维护等等,如果模块化不够好,就会导致代码扩展性差,添加功能会很痛苦。今天西瓜哥说说如何设计管理工具,管理不同的工具。工具的交互通常侧重于用户的鼠标操作。比如画一个矩形,按下鼠标确定矩形的x和y值,然后拖动鼠标调整矩形的宽高,最后松开鼠标,矩形的形状为确定,绘制矩形的操作记录在历史操作中。如下图所示:因此,工具类(Tool)设计为:exportinterfaceITool{type:string;//工具类激活:()=>void;//切换到当前工具时调用inactive:()=>void;//切换到其他工具时调用start:(event:PointerEvent)=>void;//鼠标向下拖动:(event:PointerEvent)=>void;//拖拽结束:(event:PointerEvent)=>void;//鼠标释放moveExcludeDrag:(event:PointerEvent)=>void;//拖动外的鼠标移动}classDrawRectToolimplementsITool{//...}有点像我们Rect和Vue中组件的概念。这是因为工具类的本质就是在生命周期中触发一些钩子(hook)来获取一些信息。type表示工具名称,是一个标识符,切换工具时会用到。切换到当前工具时将调用active方法。通常,它会做以下事情:设置光标样式;设置一些监听器,比如画一个矩形监听shift键是否按下,如果按下则画一个正方形;inactive会切换到由其他工具调用,通常是将光标设置为默认值并取消监听。start是鼠标按下事件。这时候需要记录一些初始状态,后续的事件需要根据这个初始状态进行计算。其实我这里并没有使用鼠标事件,而是使用了指针事件,一个适用范围更广的事件。除了鼠标事件,它还支持手写笔和触摸屏等场景。因为大家都习惯了鼠标事件,所以后面我会用鼠标事件来描述。drag是鼠标拖动事件。end是鼠标释放事件。最后还有一个比较特殊的moveExcludeDrag,代表鼠标移动除了拖动场景,比如选择工具,鼠标悬停在一个图形上,我们可以通过这个事件来判断哪个图形被选中并高亮显示。这是最基本的工具类,我们可以在上面进一步封装,比如改变光标样式,我们可以配置一个normalCursor、dragCursor属性,让调用者帮我们统一设置光标样式。这里的调用者是工具管理类。工具管理类工具管理类支持的能力:维护映射表,使用类型映射到对应的工具实例;使用setTool方法切换工具,根据传入的字符串在映射表中找到对应的工具实例,然后调用旧工具的inactive方法,再调用新工具的active方法,然后将this.currentTool设置为新的工具实例;支持事件订阅,监控工具切换,提供给UI层监控。例如,当我们使用快捷键切换工具时,UI层可以通过监听获取到最新的工具标识,并将相应的按钮设置为激活状态;然后将侦听器挂载到DOM元素,将鼠标按下事件挂载到画布上,然后特别是将鼠标移动和鼠标挂载到窗口。为什么不把这些事件挂载到画布上呢,因为我们可能会在拖拽的时候将鼠标移出画布甚至浏览器界面然后松开,这样会导致拖放事件无法触发。监听后会适时调用工具类的start、drag、end等方法。ToolManager的实现如下:classToolManager{toolMap=newMap();当前工具:ITool|空=空;事件发射器:事件发射器;_unbindEvent:()=>void;constructor(privateeditor:Editor){this.eventEmitter=newEventEmitter();//仿nodejs的简单EventEmitter//绑定工具this.toolMap.set(DrawRectTool.type,newDrawRectTool(editor));this.toolMap.set(DrawEllipseTool.type,newDrawEllipseTool(editor)));this.toolMap.set(SelectTool.type,newSelectTool(editor));this.toolMap.set(DragCanvasTool.type,newDragCanvasTool(editor));this.setTool(DrawRectTool.type);this._unbindEvent=this.bindEvent();}getToolName(){返回this.currentTool?.type;}bindEvent(){让isPressing=false;consthandleDown=(e:PointerEvent)=>{if(e.button!==0){//必须是鼠标左键return;}if(!this.currentTool){thrownewError('当前工具未设置');}isPressing=true;日is.currentTool.start(e);};consthandleMove=(e:PointerEvent)=>{if(!this.currentTool){thrownewError('当前工具未设置');}if(isPressing){这个.editor.hostEventManager.disableDragBySpace();this.currentTool.drag(e);}else{this.currentTool.moveExcludeDrag(e);}};consthandleUp=(e:PointerEvent)=>{if(e.button!==0){//必须是鼠标左键return;}if(!this.currentTool){thrownewError('当前工具未设置');}if(isPressing){this.editor.hostEventManager.enableDragBySpace();isPressing=false;this.currentTool.end(e);}};constcanvas=this.editor.canvasElement;canvas.addEventListener('pointerdown',handleDown);window.addEventListener('pointermove',handleMove);窗口.addEventListener('pointerup',handleUp);返回函数unbindEvent(){canvas.removeEventListener('pointerdown',handleDown);window.removeEventListener('pointermove',handleMove);window.removeEventListener('pointerup',handleUp);};}unbindEvent(){this._unbindEvent();this._unbindEvent=noop;}setTool(toolName:string){constprevTool=this.currentTool;constcurrentTool=this.currentTool=this.toolMap.get(工具名称)||无效的;if(!currentTool){thrownewError(`没有与${toolName}对应的工具对象);}prevTool&&prevTool.inactive();currentTool.active();this.eventEmitter.emit('change',currentTool.type);}on(eventName:'change',handler:(toolName:string)=>void){this.eventEmitter.on(eventName,handler);}off(eventName:'change',handler:(toolName:string)=>void){this.eventEmitter.off(eventName,handler);}destroy(){this.currentTool?.inactive();}}端工具管理类的基本设计是这样的。因为它是基于生命周期设计的,所以看起来很像React和Vue组件。