圖形編輯器開發(fā):繪制圖形工具
大家好,我是前端西瓜哥。
今天來介紹如何實現(xiàn)圖形繪制工具,實現(xiàn)繪制任意的圖形。
編輯器 github 地址:
https://github.com/F-star/suika
線上體驗:
https://blog.fstars.wang/app/suika/
我之前講過如何實現(xiàn)工具類管理類的:
《圖形編輯器:工具管理和切換》
對應(yīng)的工具類的實現(xiàn)會圍繞用戶的 按下鼠標、拖拽、釋放 這 3 個行為,圖形繪制工具同樣如此。
整體框架:
// 繪制圖形工具類(這里用了抽象類,后面會說為什么)
abstract class DrawGraphTool {
// 工具被激活
active() {
// 通常是設(shè)置光標,或是綁定一些事件,比如鍵盤事件
}
// 工具失活
inactive() {
// 通常是解綁一些事件
}
// 鼠標按下
start() { /* TODO */ }
// 鼠標拖拽
drag() { /* TODO */ }
// 鼠標釋放
end() { /* TODO */ }
}
類似 React / Vue 的生命周期 hook。
模板模式
圖形有很多種,矩形、橢圓、三角形、五角星等等。每個圖形都實現(xiàn)一遍未免有點繁瑣。
西瓜哥我一開始是分別去實現(xiàn)繪制矩形和橢圓的,然后發(fā)現(xiàn)有很多相同的邏輯。當又要加一個新的圖形時,又要復(fù)制粘貼,然后修改少量的不一樣的地方,這不利于代碼維護。
為解決這個問題,我們要實現(xiàn)一個 繪制圖形基類,將共用邏輯放到里面,不同的部分則交給子類去實現(xiàn)。
這個在設(shè)計模式上叫做 模板模式。
所謂模板模式,就是在方法中定義一個 “算法” 骨架,繼承的子類在不改變算法整體結(jié)構(gòu)的情況下,重寫其中某些步驟(有些步驟有默認實現(xiàn),可不重寫)。
模板模式的具體實現(xiàn),就是用 抽象類(abstract class) 去實現(xiàn)這個基類。
抽象類是一種不能被實例化的特殊類,繼承的子類才能實例化。
抽象類的方法可以是普通方法,也可以是只定義了方法類型簽名的抽象方法。
子類繼承抽象類時,必須提供抽象類的抽象方法的具體實現(xiàn)。
TypeScript 支持抽象類。下面是一個例子。
// 抽象類
abstract class AbstractClass {
say() {
if (this.shoudISaySomething()) {
console.log('前端西瓜哥')
}
}
// 抽象方法(不能用 private,因為子類要重寫它)
protected abstract shoudISaySomething(): boolean
}
class A extends AbstractClass {
shoudISaySomething() {
// ...假設(shè)這里一堆判斷
return true
}
}
子類不實現(xiàn)抽象方法的話,TS 編譯會報錯:
如果你用 JavaScript,雖然不能做編譯時的檢驗,但還可以做運行時的檢測。
將需要子類繼承實現(xiàn)的方法,加入拋出錯誤的實現(xiàn)。這樣子類如果沒實現(xiàn),就會通過原型鏈的方式,執(zhí)行基類的方法,然后報錯提示給開發(fā)者。
class AbstractClass {
say() {
if (this.shoudISaySomething()) {
console.log('前端西瓜哥')
}
}
shoudISaySomething() {
throw new Error('請實現(xiàn) shoudISaySomething 方法')
}
}
class A extends AbstractClass {
shoudISaySomething() {
// ...假設(shè)這里一堆邏輯
return true
}
}
圖形繪制工具的實現(xiàn)
我們回到繪制圖形的業(yè)務(wù)邏輯。
我們在鼠標按下時確定起始坐標,拖拽時調(diào)整終點坐標,鼠標釋放確認終點坐標。
這里產(chǎn)生了一個矩形框,得到 x、y、width、height,通過它們可以確定了一個圖形的位置和大小。
當要加一個新的圖形時,只要它能夠通過 x、y、width、height 這幾個屬性確定繪制效果,那就可以使用這個基類。
如果這個圖形還有其他屬性,我們可以在繪制后通過其他方式(比如控制點或者面板修改值)去修改。
鼠標按下
首先是鼠標按下的邏輯。邏輯很少,主要是記錄起始點。
abstract class DrawGraphTool {
commandDesc = 'Add Graph'; // 歷史記錄的命令描述
protected drawingGraph: Graph | null = null; // 被繪制的圖形對象
start(e: PointerEvent) {
// 這里將光標的視口坐標轉(zhuǎn)成場景坐標
this.startPoint = this.editor.getSceneCursorXY(e);
// 重置一些狀態(tài)
this.drawingGraph = null;
}
}
鼠標拖拽
拖拽的時候,會判斷 this.drawingGraph 是否為 null。
如果是,就會創(chuàng)建一個新的圖形對象。如果不是,那就更新 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; // 這個可能是負數(shù),還沒做標準化
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)創(chuàng)建圖形邏輯
const element = this.createGraph(rect)!;
sceneGraph.addItems([element]);
this.drawingGraph = element;
}
// 設(shè)置選中對象,并渲染
this.editor.selectedElements.setItems([this.drawingGraph]);
sceneGraph.render();
}
}
創(chuàng)建圖形
創(chuàng)建圖形對象的方法是 createGraph(),要返回一個圖形對象,保存到 this.drawingGraph。
這個圖形對象需要子類來提供。所以寫成抽象方法:
protected abstract createGraph(rect: IRect, noMove?: boolean): Graph | null;
我們的矩形繪制工具,實現(xiàn)如下。
export class DrawRectTool extends DrawGraphTool implements ITool {
// ...
// 這里提供實現(xiàn)創(chuàng)建圖形對象
protected createGraph(rect: IRect) {
rect = normalizeRect(rect);
return new Rect({
...rect,
fill: [cloneDeep(this.editor.setting.get('firstFill'))],
});
}
}
這里用 normalizeRect 對 rect 對象做了標準化,原來 width 和 height 可能為負數(shù),標準化就是改變 x、y,并讓 width 和 height 變回正數(shù),變成一個常規(guī)的 rect 對象。
這樣我們拿到了圖形對象通用屬性:x、y、width、height,然后這里再補上了一個默認的填充色。
如果要實現(xiàn)繪制直線,就不要提供填充色,而是要補一個默認描邊。
更新圖形
更新圖形通常就是更新一下圖形的 x、y、width、height 屬性,所以基類會提供一個默認實現(xiàn)。
/**
* 這個是通用邏輯,直接更新 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,需要另寫一個算法去實現(xiàn)轉(zhuǎn)換。
Shift 模式
這里有個比較特別的效果,就是按住 Shift,會讓 圖形的寬高比保持一比一。
繪制正方形:
繪制圓形:
實現(xiàn)就是找 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 模式,會將起始點作為圖形的中心點進行繪制,這個我還沒去實現(xiàn)。
鼠標釋放
鼠標釋放時,主要邏輯是將新的狀態(tài)保持到歷史記錄中。
end(e: PointerEvent) {
if (this.drawingGraph) {
// 記錄新的狀態(tài)
this.editor.commandManager.pushCommand(
new AddShapeCommand(this.commandDesc, this.editor, [this.drawingGraph]),
);
}
}
結(jié)尾
模板模式的優(yōu)點是復(fù)用和擴展。相同的主體框架邏輯不變,暴露幾個方法讓子類實現(xiàn),有些是必須實現(xiàn),有些是可實現(xiàn)可不實現(xiàn)(不實現(xiàn)用默認算法),對我們實現(xiàn)一種通用的繪制圖形工具很有幫助。
實現(xiàn)了這個圖形繪制基類后,我們理論上就可以繪制任何圖形了,甚至用戶自定義的圖形,只要這些圖形對象使用 x、y、 width、height。