圖形編輯器開發(fā):實現(xiàn)縮放圖形

編輯器 github 地址:
https://github.com/F-star/suika
線上體驗:
https://blog.fstars.wang/app/suika/
圖形的屬性
圖形有幾個重要的基礎(chǔ)屬性,會經(jīng)常被用到,我們在實現(xiàn)縮放圖形前需要理清一下它們。
- x / y
- width / height
- rotation
位置和大小
x 和 y 為圖形的左上角位置,注意是旋轉(zhuǎn)前的。
x、y 旋轉(zhuǎn)后我們叫做 rotatedX、rotatedY,屬性面板中會用到。
width 和 height 為圖形的寬高,這個沒什么好說的。
另外,有些圖形有些特殊,它的 x、y、width、height 是要通過其他屬性計算出來的,比如貝塞爾曲線。
旋轉(zhuǎn)
rotation 為圖形的旋轉(zhuǎn)度數(shù),通常使用 弧度單位。
因為弧度是數(shù)學(xué)計算中的常客,各種 API 都是要求提供弧度的,比如內(nèi)置的 Math.sin() 方法。
你存角度自然也是可以,但不推薦,但計算時多了一層多余的單位轉(zhuǎn)換,且丟失一些微小的精度。
當(dāng)然 UI 層還是要展示角度,因為是面向用戶的,對于數(shù)據(jù)和 UI 不統(tǒng)一的問題,在 UI 層做一個轉(zhuǎn)換即可。
旋轉(zhuǎn)度數(shù)通常要配合一個變換中心(origin),這個可以作為一個屬性讓用戶設(shè)置。
但我更建議將 x、y、width、height 形成的 矩形的中點 作為旋轉(zhuǎn)中心,這樣更簡單一些,減少用戶的心智負(fù)擔(dān),也防止出現(xiàn)用戶設(shè)置一些奇怪 origin 的場景。
下圖中,紅色矩形是藍(lán)色矩陣順時針旋轉(zhuǎn) 45 度得到。

旋轉(zhuǎn)度數(shù)還要考慮 旋轉(zhuǎn)方向、基準(zhǔn)角度、取值范圍 問題。
(因為弧度不直觀,后面會用角度來描述,但數(shù)據(jù)層依舊還是用的弧度)
- 旋轉(zhuǎn)方向:設(shè)置旋轉(zhuǎn)后,圖形是會往順時針方向還是逆時針方向旋轉(zhuǎn)。
- 基準(zhǔn)角度:朝向哪里是 0 度。
- 取值范圍:通常為 [0, 360) 和 (-180, 180]。二者其實等價,只是顯示有區(qū)別,后者其實只是前者減去 180 度。
通常這些編輯器自己決定就好。像我的項目,向上表示 0 度,順時針方向為旋轉(zhuǎn)方向,方向取值為 [0, 360)。
一些編輯器是支持用戶自己設(shè)置的,比如 AutoCAD 可通過圖形單位命令,設(shè)置旋轉(zhuǎn)方向和基準(zhǔn)角度。

縮放實現(xiàn)思路
進(jìn)入正題,對圖形進(jìn)行縮放。
接下來會以通過右下角(也叫東南 se 方向) 縮放控制點縮放為例進(jìn)行講解。

交互邏輯:
選擇工具下,當(dāng)光標(biāo)落在右下角的縮放控制點上時,光標(biāo)會變成縮放樣式(這個不是本文核心,不講)。
此時按下鼠標(biāo),然后進(jìn)行拖拽,即可對圖形以左上角為縮放中心,進(jìn)行縮放。
實現(xiàn)思路:更新 width 和 height,然后確定參照點,修正 x 和 y。
按下鼠標(biāo)時,我們要把當(dāng)前圖形的 x、y、width、height、rotation 記錄下來。之后的縮放是基于這個初始狀態(tài)進(jìn)行的。
const mousedown = (e) => {
// ...
// 縮放前圖形的屬性,之后我們會直接更新圖形屬性,導(dǎo)致原來的屬性丟失,所以要記錄下這個快照。
prevElement = {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
rotation: item.rotation ?? 0,
}
}拖拽時,調(diào)用我們將要實現(xiàn)的 movePoint 方法,去更新這個圖形。
const drag = (e) = {
// ...
selectElement.movePoint(
'se', // 縮放控制點類型:右下(或東南)
lastPoint, // 當(dāng)前光標(biāo)位置(基于場景坐標(biāo)系)
prevElement, // 縮放前的屬性快照
);
}下面就是核心方法 movePoint 的實現(xiàn)邏輯了。
更新 width 和 height
首先是更新矩形寬高。
因為有一個旋轉(zhuǎn),所以算法不會這么直觀。
我們要意識到這里有一個變換。看到的圖形,是做過變換(基于矩形中心旋轉(zhuǎn))之后的,但我們需要修改的 width、height、x、y 則是旋轉(zhuǎn)前的。
所以我們需要把光標(biāo)位置給旋轉(zhuǎn)回來,然后再減去 x 和 y 去得到真正的 width 和 height。

看看代碼
class Graph {
// ...
// 根據(jù)縮放點更新圖形
movePoint(type, newPos, oldBox) {
// 1. 計算 width 和 height
// 計算縮放中心(也就是矩形的中點)
const cx = oldBox.x + oldBox.width / 2;
const cy = oldBox.y + oldBox.height / 2;
// 計算反向旋轉(zhuǎn)的光標(biāo)位置
const { x: posX, y: poxY } = transformRotate(
newPos.x,
newPos.y,
-(oldBox.rotation || 0), // 注意這里是負(fù)數(shù)
cx,
cy
);
let width = 0;
let height = 0;
if (type === 'se') {
// 參照點為左上角(x 和 y)
// 新的寬高自然就是光標(biāo)位置減去 x、y
width = posX - oldBox.x;
height = poxY - oldBox.y;
}
// 其他控制點的邏輯暫且省略...
// 2. 計算 x 和 y
// ...
}
}看看只更新寬高的效果。

可以看到是有問題的,因為修改寬高后,矩形的中心點也發(fā)生了變化,導(dǎo)致縮放中心錯誤。所以我們要修正一下 x 和 y。
修正 x 和 y
接著我們就要修正 x 和 y 的值。
重點就一句話:縮放前的參考點和縮放后的參考點的位置要保持一致。這個參考點其實就是圖形縮放過程中的縮放中心。
對于右下角縮放控制點,它的縮放中心就是左上角,即 x 和 y 經(jīng)過旋轉(zhuǎn)的位置。
class Graph {
// ...
movePoint(type, newPos, oldBox) {
// 1. 計算 width 和 height
// ...
// 2. 計算 x 和 y
// 設(shè)置參照點,不同縮放類型的參照點不同
let prevOriginX = 0;
let prevOriginY = 0;
let originX = 0;
let originY = 0;
if (type === "se") {
prevOriginX = oldBox.x;
prevOriginY = oldBox.y;
originX = oldBox.x;
originY = oldBox.y;
}
// 其他縮放類型暫且省略
// 縮放前的參考點位置
const { x: prevRotatedOriginX, y: prevRotatedOriginY } = transformRotate(
prevOriginX,
prevOriginY,
oldBox.rotation || 0,
cx,
cy
);
// 縮放后的參考點位置
const { x: rotatedOriginX, y: rotatedOriginY } = transformRotate(
originX,
originY,
oldBox.rotation || 0,
oldBox.x + width / 2, // 旋轉(zhuǎn)中心是新的
oldBox.y + height / 2
);
// 計算新舊兩個參考點的差值,對 x、y 進(jìn)行補(bǔ)正
const dx = rotatedOriginX - prevRotatedOriginX;
const dy = rotatedOriginY - prevRotatedOriginY;
const x = oldBox.x - dx;
const y = oldBox.y - dy;
}
}width 和 height 可能為負(fù)數(shù),這里要做一個標(biāo)準(zhǔn)化,然后賦值給圖形屬性即可。
this.setAttrs(
normalizeRect({
x,
y,
width,
height,
}),
);其他縮放控制點
對于其他類型縮放控制點,比如左上、右上、左下縮放控制點,它們的大框架是一樣的,只是 width 和 height 計算方式不同,以及參考點不同。
不同類型下 width 和 height 的設(shè)置:
let width = 0;
let height = 0;
if (type === 'se') { // 右下
width = posX - oldBox.x;
height = poxY - oldBox.y;
} else if (type === 'ne') { // 右上
width = posX - oldBox.x;
height = oldBox.y + oldBox.height - poxY;
} else if (type === 'nw') {
width = oldBox.x + oldBox.width - posX;
height = oldBox.y + oldBox.height - poxY;
} else if (type === 'sw') {
width = oldBox.x + oldBox.width - posX;
height = poxY - oldBox.y;
}新舊參考點設(shè)置:
let prevOriginX = 0;
let prevOriginY = 0;
let originX = 0;
let originY = 0;
if (type === 'se') {
prevOriginX = oldBox.x; // 右下縮放點,參考點為左上角
prevOriginY = oldBox.y;
originX = oldBox.x;
originY = oldBox.y;
} else if (type === 'ne') { // 右上縮放點,參考點為左下角
prevOriginX = oldBox.x;
prevOriginY = oldBox.y + oldBox.height;
originX = oldBox.x;
originY = oldBox.y + height;
} else if (type === 'nw') {
prevOriginX = oldBox.x + oldBox.width;
prevOriginY = oldBox.y + oldBox.height;
originX = oldBox.x + width;
originY = oldBox.y + height;
} else if (type === 'sw') {
prevOriginX = oldBox.x + oldBox.width;
prevOriginY = oldBox.y;
originX = oldBox.x + width;
originY = oldBox.y;
}暫時沒實現(xiàn)正北、正南、正西、正東的邏輯,邏輯大差不差。
鎖定縮放比
按住 shift 可以鎖定縮放比。
做法是對比新舊圖形寬高比,將 width 和 height 其中一個進(jìn)行修正即可。注意正負(fù)號。
方法需要多傳一個 keepRatio 的參數(shù):
class Graph {
// ...
movePoint(type, newPos, oldBox, keepRatio = false) {
// 1. 計算 width 和 height
// ...
if (keepRatio) {
const ratio = oldBox.width / oldBox.height;
const newRatio = Math.abs(width / height);
if (newRatio > ratio) {
height = (Math.sign(height) * Math.abs(width)) / ratio;
} else {
width = Math.sign(width) * Math.abs(height) * ratio;
}
}
// 2. 計算 x 和 y
// ...
}
}貌似沒考慮除數(shù) height 為 0 的情況..
優(yōu)化點
本文的實現(xiàn)是考慮的是比較簡單的縮放圖形場景,一些更復(fù)雜的場景并未實現(xiàn)。
縮放還有另一種策略,就是會產(chǎn)生 反向顛倒 的縮放。要實現(xiàn)這個效果,需要引入縮放屬性,復(fù)雜度會提升很多。
另外就是選中多個圖形,然后縮放的場景我沒實現(xiàn)。這種場景下,通常是要鎖定寬高比的。
否則就會出現(xiàn)圖形的斜切效果,這個如果要實現(xiàn),我們還要引入斜切屬性,復(fù)雜度再一次提升。
下面是 Figma 的效果,真是讓人頭扁。

按住 Alt 實現(xiàn)圖形中心縮放也沒做,這個比較簡單,有空再做。
讀者如果看懂我這篇文章,心里應(yīng)該有思路的:width、height 的計算要加入圖形中點參數(shù),參照點設(shè)置為圖形中點。





































