圖形編輯器開(kāi)發(fā):實(shí)現(xiàn)縮放圖形
編輯器 github 地址:
https://github.com/F-star/suika
線上體驗(yàn):
https://blog.fstars.wang/app/suika/
圖形的屬性
圖形有幾個(gè)重要的基礎(chǔ)屬性,會(huì)經(jīng)常被用到,我們?cè)趯?shí)現(xiàn)縮放圖形前需要理清一下它們。
- x / y
- width / height
- rotation
位置和大小
x 和 y 為圖形的左上角位置,注意是旋轉(zhuǎn)前的。
x、y 旋轉(zhuǎn)后我們叫做 rotatedX、rotatedY,屬性面板中會(huì)用到。
width 和 height 為圖形的寬高,這個(gè)沒(méi)什么好說(shuō)的。
另外,有些圖形有些特殊,它的 x、y、width、height 是要通過(guò)其他屬性計(jì)算出來(lái)的,比如貝塞爾曲線。
旋轉(zhuǎn)
rotation 為圖形的旋轉(zhuǎn)度數(shù),通常使用 弧度單位。
因?yàn)榛《仁菙?shù)學(xué)計(jì)算中的???,各種 API 都是要求提供弧度的,比如內(nèi)置的 Math.sin() 方法。
你存角度自然也是可以,但不推薦,但計(jì)算時(shí)多了一層多余的單位轉(zhuǎn)換,且丟失一些微小的精度。
當(dāng)然 UI 層還是要展示角度,因?yàn)槭敲嫦蛴脩舻?,?duì)于數(shù)據(jù)和 UI 不統(tǒng)一的問(wèn)題,在 UI 層做一個(gè)轉(zhuǎn)換即可。
旋轉(zhuǎn)度數(shù)通常要配合一個(gè)變換中心(origin),這個(gè)可以作為一個(gè)屬性讓用戶設(shè)置。
但我更建議將 x、y、width、height 形成的 矩形的中點(diǎn) 作為旋轉(zhuǎn)中心,這樣更簡(jiǎn)單一些,減少用戶的心智負(fù)擔(dān),也防止出現(xiàn)用戶設(shè)置一些奇怪 origin 的場(chǎng)景。
下圖中,紅色矩形是藍(lán)色矩陣順時(shí)針旋轉(zhuǎn) 45 度得到。
旋轉(zhuǎn)度數(shù)還要考慮 旋轉(zhuǎn)方向、基準(zhǔn)角度、取值范圍 問(wèn)題。
(因?yàn)榛《炔恢庇^,后面會(huì)用角度來(lái)描述,但數(shù)據(jù)層依舊還是用的弧度)
- 旋轉(zhuǎn)方向:設(shè)置旋轉(zhuǎn)后,圖形是會(huì)往順時(shí)針?lè)较蜻€是逆時(shí)針?lè)较蛐D(zhuǎn)。
- 基準(zhǔn)角度:朝向哪里是 0 度。
- 取值范圍:通常為 [0, 360) 和 (-180, 180]。二者其實(shí)等價(jià),只是顯示有區(qū)別,后者其實(shí)只是前者減去 180 度。
通常這些編輯器自己決定就好。像我的項(xiàng)目,向上表示 0 度,順時(shí)針?lè)较驗(yàn)樾D(zhuǎn)方向,方向取值為 [0, 360)。
一些編輯器是支持用戶自己設(shè)置的,比如 AutoCAD 可通過(guò)圖形單位命令,設(shè)置旋轉(zhuǎn)方向和基準(zhǔn)角度。
縮放實(shí)現(xiàn)思路
進(jìn)入正題,對(duì)圖形進(jìn)行縮放。
接下來(lái)會(huì)以通過(guò)右下角(也叫東南 se 方向) 縮放控制點(diǎn)縮放為例進(jìn)行講解。
交互邏輯:
選擇工具下,當(dāng)光標(biāo)落在右下角的縮放控制點(diǎn)上時(shí),光標(biāo)會(huì)變成縮放樣式(這個(gè)不是本文核心,不講)。
此時(shí)按下鼠標(biāo),然后進(jìn)行拖拽,即可對(duì)圖形以左上角為縮放中心,進(jìn)行縮放。
實(shí)現(xiàn)思路:更新 width 和 height,然后確定參照點(diǎn),修正 x 和 y。
按下鼠標(biāo)時(shí),我們要把當(dāng)前圖形的 x、y、width、height、rotation 記錄下來(lái)。之后的縮放是基于這個(gè)初始狀態(tài)進(jìn)行的。
const mousedown = (e) => {
// ...
// 縮放前圖形的屬性,之后我們會(huì)直接更新圖形屬性,導(dǎo)致原來(lái)的屬性丟失,所以要記錄下這個(gè)快照。
prevElement = {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
rotation: item.rotation ?? 0,
}
}
拖拽時(shí),調(diào)用我們將要實(shí)現(xiàn)的 movePoint 方法,去更新這個(gè)圖形。
const drag = (e) = {
// ...
selectElement.movePoint(
'se', // 縮放控制點(diǎn)類型:右下(或東南)
lastPoint, // 當(dāng)前光標(biāo)位置(基于場(chǎng)景坐標(biāo)系)
prevElement, // 縮放前的屬性快照
);
}
下面就是核心方法 movePoint 的實(shí)現(xiàn)邏輯了。
更新 width 和 height
首先是更新矩形寬高。
因?yàn)橛幸粋€(gè)旋轉(zhuǎn),所以算法不會(huì)這么直觀。
我們要意識(shí)到這里有一個(gè)變換。看到的圖形,是做過(guò)變換(基于矩形中心旋轉(zhuǎn))之后的,但我們需要修改的 width、height、x、y 則是旋轉(zhuǎn)前的。
所以我們需要把光標(biāo)位置給旋轉(zhuǎn)回來(lái),然后再減去 x 和 y 去得到真正的 width 和 height。
看看代碼
class Graph {
// ...
// 根據(jù)縮放點(diǎn)更新圖形
movePoint(type, newPos, oldBox) {
// 1. 計(jì)算 width 和 height
// 計(jì)算縮放中心(也就是矩形的中點(diǎn))
const cx = oldBox.x + oldBox.width / 2;
const cy = oldBox.y + oldBox.height / 2;
// 計(jì)算反向旋轉(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') {
// 參照點(diǎn)為左上角(x 和 y)
// 新的寬高自然就是光標(biāo)位置減去 x、y
width = posX - oldBox.x;
height = poxY - oldBox.y;
}
// 其他控制點(diǎn)的邏輯暫且省略...
// 2. 計(jì)算 x 和 y
// ...
}
}
看看只更新寬高的效果。
可以看到是有問(wèn)題的,因?yàn)樾薷膶捀吆螅匦蔚闹行狞c(diǎn)也發(fā)生了變化,導(dǎo)致縮放中心錯(cuò)誤。所以我們要修正一下 x 和 y。
修正 x 和 y
接著我們就要修正 x 和 y 的值。
重點(diǎn)就一句話:縮放前的參考點(diǎn)和縮放后的參考點(diǎn)的位置要保持一致。這個(gè)參考點(diǎn)其實(shí)就是圖形縮放過(guò)程中的縮放中心。
對(duì)于右下角縮放控制點(diǎn),它的縮放中心就是左上角,即 x 和 y 經(jīng)過(guò)旋轉(zhuǎn)的位置。
class Graph {
// ...
movePoint(type, newPos, oldBox) {
// 1. 計(jì)算 width 和 height
// ...
// 2. 計(jì)算 x 和 y
// 設(shè)置參照點(diǎn),不同縮放類型的參照點(diǎn)不同
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;
}
// 其他縮放類型暫且省略
// 縮放前的參考點(diǎn)位置
const { x: prevRotatedOriginX, y: prevRotatedOriginY } = transformRotate(
prevOriginX,
prevOriginY,
oldBox.rotation || 0,
cx,
cy
);
// 縮放后的參考點(diǎn)位置
const { x: rotatedOriginX, y: rotatedOriginY } = transformRotate(
originX,
originY,
oldBox.rotation || 0,
oldBox.x + width / 2, // 旋轉(zhuǎn)中心是新的
oldBox.y + height / 2
);
// 計(jì)算新舊兩個(gè)參考點(diǎn)的差值,對(duì) 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ù),這里要做一個(gè)標(biāo)準(zhǔn)化,然后賦值給圖形屬性即可。
this.setAttrs(
normalizeRect({
x,
y,
width,
height,
}),
);
其他縮放控制點(diǎn)
對(duì)于其他類型縮放控制點(diǎn),比如左上、右上、左下縮放控制點(diǎn),它們的大框架是一樣的,只是 width 和 height 計(jì)算方式不同,以及參考點(diǎn)不同。
不同類型下 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;
}
新舊參考點(diǎn)設(shè)置:
let prevOriginX = 0;
let prevOriginY = 0;
let originX = 0;
let originY = 0;
if (type === 'se') {
prevOriginX = oldBox.x; // 右下縮放點(diǎn),參考點(diǎn)為左上角
prevOriginY = oldBox.y;
originX = oldBox.x;
originY = oldBox.y;
} else if (type === 'ne') { // 右上縮放點(diǎn),參考點(diǎn)為左下角
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;
}
暫時(shí)沒(méi)實(shí)現(xiàn)正北、正南、正西、正東的邏輯,邏輯大差不差。
鎖定縮放比
按住 shift 可以鎖定縮放比。
做法是對(duì)比新舊圖形寬高比,將 width 和 height 其中一個(gè)進(jìn)行修正即可。注意正負(fù)號(hào)。
方法需要多傳一個(gè) keepRatio 的參數(shù):
class Graph {
// ...
movePoint(type, newPos, oldBox, keepRatio = false) {
// 1. 計(jì)算 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. 計(jì)算 x 和 y
// ...
}
}
貌似沒(méi)考慮除數(shù) height 為 0 的情況..
優(yōu)化點(diǎn)
本文的實(shí)現(xiàn)是考慮的是比較簡(jiǎn)單的縮放圖形場(chǎng)景,一些更復(fù)雜的場(chǎng)景并未實(shí)現(xiàn)。
縮放還有另一種策略,就是會(huì)產(chǎn)生 反向顛倒 的縮放。要實(shí)現(xiàn)這個(gè)效果,需要引入縮放屬性,復(fù)雜度會(huì)提升很多。
另外就是選中多個(gè)圖形,然后縮放的場(chǎng)景我沒(méi)實(shí)現(xiàn)。這種場(chǎng)景下,通常是要鎖定寬高比的。
否則就會(huì)出現(xiàn)圖形的斜切效果,這個(gè)如果要實(shí)現(xiàn),我們還要引入斜切屬性,復(fù)雜度再一次提升。
下面是 Figma 的效果,真是讓人頭扁。
按住 Alt 實(shí)現(xiàn)圖形中心縮放也沒(méi)做,這個(gè)比較簡(jiǎn)單,有空再做。
讀者如果看懂我這篇文章,心里應(yīng)該有思路的:width、height 的計(jì)算要加入圖形中點(diǎn)參數(shù),參照點(diǎn)設(shè)置為圖形中點(diǎn)。