新聞中心
大家好,我是前端西瓜哥。今天我們看看對于一款圖形編輯器,應該怎么去實現(xiàn)工具,比如繪制矩形、選中工具,以及如何去管理它們的。

創(chuàng)新互聯(lián)建站專注于企業(yè)成都營銷網(wǎng)站建設(shè)、網(wǎng)站重做改版、洛隆網(wǎng)站定制設(shè)計、自適應品牌網(wǎng)站建設(shè)、成都h5網(wǎng)站建設(shè)、成都商城網(wǎng)站開發(fā)、集團公司官網(wǎng)建設(shè)、成都外貿(mào)網(wǎng)站制作、高端網(wǎng)站制作、響應式網(wǎng)頁設(shè)計等建站業(yè)務,價格優(yōu)惠性價比高,為洛隆等各大城市提供網(wǎng)站開發(fā)制作服務。
項目地址,歡迎 star:
https://github.com/F-star/suika
線上體驗:
https://blog.fstars.wang/app/suika/
一款編輯器,有兩個很重要的方面,一個是性能,另一個是架構(gòu)。
因為不知道用戶會在畫布上畫上多少圖形,所以需要在渲染引擎上下很大的功夫,去提高繪制的性能。性能決定了編輯器的上限,這也是為什么很多編輯器選擇了 Canvas 作為繪制方案。
另一個則是架構(gòu),編輯器很復雜,即便是看上去很簡單編輯器。因為里面的模塊非常多,比如工具管理模塊、縮放管理、歷史記錄、圖形樹維護、輔助線、標尺、設(shè)置、視口管理、熱鍵、光標維護等等。如果模塊化不夠好,就會導致代碼擴展性差,加功能會非常痛苦。
今天西瓜哥談談如何設(shè)計管理工具類,管理不同的工具。
工具類
工具的交互,通常會集中于用戶的鼠標操作。
比如繪制矩形,按下鼠標,會確定矩形的 x、y 值,然后拖拽鼠標,調(diào)整矩形的寬高,最后放開鼠標,矩形的形狀就確認好了,并將這個繪制矩形的操作記錄到歷史操作中。如下圖:
所以,工具類(Tool)的設(shè)計為:
export interface ITool {
type: string; // 工具類
active: () => void; // 切換為當前工具時調(diào)用
inactive: () => void; // 切換為其他工具時調(diào)用
start: (event: PointerEvent) => void; // 鼠標按下
drag: (event: PointerEvent) => void; // 拖拽
end: (event: PointerEvent) => void; // 鼠標釋放
moveExcludeDrag: (event: PointerEvent) => void; // 拖拽之外的鼠標移動
}
class DrawRectTool implements ITool {
// ...
}
有點像我們 Rect 和 Vue 中的組件的概念。這是因為工具類本質(zhì)也是 在生命周期內(nèi)觸發(fā)一些鉤子(hook),拿到一些信息。
type 表示工具名稱,是一個標識符,切換工具時會用到。
active 方法會在切換為當前工具時調(diào)用,通常會做的事情有:
- 設(shè)置光標樣式;
- 設(shè)置一些監(jiān)聽器,比如繪制矩形監(jiān)聽 shift 鍵是否按下,如果按下,就繪制方形;
inactive 會在切換為其他工具時調(diào)用,通常就是將光標設(shè)置為默認值,取消監(jiān)聽器。
start 是鼠標按下事件,此時要記錄一些初始狀態(tài),后面的事件需要基于這個初始狀態(tài)進行計算。這里其實我沒用鼠標事件,而是用了 pointer 指針事件,一種適用范圍更廣的事件,除了鼠標事件,還支持觸控筆和觸摸屏幕等場景。因為大家習慣鼠標事件,后面我都用鼠標事件來描述。
drag 就是鼠標拖拽事件。end 是鼠標釋放事件。
最后是比較特殊的 moveExcludeDrag,代表除了拖拽場景的鼠標移動,比如選擇工具,懸停在一個圖形上,我們就可以用這個事件來判斷是哪個圖形被選中,對它進行高亮。
這就是最基本的工具類,在此上我們可以進行進一步地封裝,比如更改光標樣式,我們可以配個 normalCursor、dragCursor 屬性,讓調(diào)用者幫我們統(tǒng)一設(shè)置光標樣式。
這里的調(diào)用者就是工具管理類。
工具管理類
工具管理類支持的能力:
- 維護映射表,用 type 映射到對應工具實例;
- 使用 setTool 方法切換工具,會根據(jù)傳入的字符串在映射表中找到對應工具實例,然后調(diào)用舊的工具的 inactive 方法,再調(diào)用新工具的 active 方法,然后設(shè)置 this.currentTool 為新工具實例;
- 支持事件訂閱,監(jiān)聽工具的切換,提供給 UI 層去監(jiān)聽。比如我們用快捷鍵切換工具時,UI 層就能通過監(jiān)聽獲得最新工具標識符,將對應按鈕設(shè)置為激活狀態(tài);
- 然后是給 DOM 元素掛載監(jiān)聽器,canvas 上掛載鼠標按下事件,然后是特殊的,給 window 掛載鼠標移動和鼠標。為什么不給 canvas 掛載這些事件,這是因為我們可能會在拖拽時將鼠標移出 canvas 甚至瀏覽器界面然后釋放,會導致拖拽、釋放事件沒能觸發(fā)。監(jiān)聽后,就會在何時的時機調(diào)用工具類的 start、drag、end 等方法。
ToolManager 實現(xiàn)如下:
class ToolManager {
toolMap = new Map();
currentTool: ITool | null = null;
eventEmitter: EventEmitter;
_unbindEvent: () => void;
constructor(private editor: Editor) {
this.eventEmitter = new EventEmitter(); // 模仿 nodejs 的簡易版 EventEmitter
// 綁定 tool
this.toolMap.set(DrawRectTool.type, new DrawRectTool(editor));
this.toolMap.set(DrawEllipseTool.type, new DrawEllipseTool(editor));
this.toolMap.set(SelectTool.type, new SelectTool(editor));
this.toolMap.set(DragCanvasTool.type, new DragCanvasTool(editor));
this.setTool(DrawRectTool.type);
this._unbindEvent = this.bindEvent();
}
getToolName() {
return this.currentTool?.type;
}
bindEvent() {
let isPressing = false;
const handleDown = (e: PointerEvent) => {
if (e.button !== 0) { // 必須是鼠標左鍵
return;
}
if (!this.currentTool) {
throw new Error('未設(shè)置當前使用工具');
}
isPressing = true;
this.currentTool.start(e);
};
const handleMove = (e: PointerEvent) => {
if (!this.currentTool) {
throw new Error('未設(shè)置當前使用工具');
}
if (isPressing) {
this.editor.hostEventManager.disableDragBySpace();
this.currentTool.drag(e);
} else {
this.currentTool.moveExcludeDrag(e);
}
};
const handleUp = (e: PointerEvent) => {
if (e.button !== 0) { // 必須是鼠標左鍵
return;
}
if (!this.currentTool) {
throw new Error('未設(shè)置當前使用工具');
}
if (isPressing) {
this.editor.hostEventManager.enableDragBySpace();
isPressing = false;
this.currentTool.end(e);
}
};
const canvas = this.editor.canvasElement;
canvas.addEventListener('pointerdown', handleDown);
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
return function unbindEvent() {
canvas.removeEventListener('pointerdown', handleDown);
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
}
unbindEvent() {
this._unbindEvent();
this._unbindEvent = noop;
}
setTool(toolName: string) {
const prevTool = this.currentTool;
const currentTool = this.currentTool = this.toolMap.get(toolName) || null;
if (!currentTool) {
throw new Error(`沒有 ${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();
}
}
結(jié)尾
工具管理類基礎(chǔ)的設(shè)計就是這樣。因為是基于生命周期去設(shè)計的,所以看起來挺像 React、Vue 的組件寫法的。
標題名稱:圖形編輯器:工具管理和切換
標題路徑:http://www.5511xx.com/article/dpcojes.html


咨詢
建站咨詢
