開發(fā)者理解底層,理解一個圖形是先有基本的物理信息(x、y、width、height),然后再做變換(旋轉(zhuǎn)、縮放等)后得到新的坐標(biāo)再進(jìn)行繪制。

大家好,我是前端西瓜哥。圖形編輯器的一個需求,就是可以通過屬性面板的輸入框設(shè)置選中元素的屬性值。
項目地址,歡迎 star:
https://github.com/F-star/suika
線上體驗:
https://blog.fstars.wang/app/suika/
最終效果如下:

元素對象的結(jié)構(gòu):
interface IGraph {
x: number;
y: number;
width: number;
height: number;
rotation: number; // 旋轉(zhuǎn)角度,單位為弧度
}
設(shè)置 x / y
UI 界面顯示上說的 x / y,指的是旋轉(zhuǎn)后的 x(即 rotatedX / rotatedY)。

為什么不是對應(yīng)真正的 x 和 y 呢?因為需要對應(yīng)用戶的視角。
開發(fā)者理解底層,理解一個圖形是先有基本的物理信息(x、y、width、height),然后再做變換(旋轉(zhuǎn)、縮放等)后得到新的坐標(biāo)再進(jìn)行繪制。
而用戶看到的則是直觀的繪制出來的圖形,并希望圖形的左上角坐標(biāo)能夠?qū)ι纤O(shè)置的坐標(biāo)。旋轉(zhuǎn)前的 x 和 y 是無法直觀體現(xiàn)在畫布上的,用戶也不會在意。
OK,先看看怎么修改 rotatedX。圖形對象上沒有 rotatedX 屬性,本質(zhì)還是要修改 x 值。
先看看 rotatedX 和 rotatedY 是怎么計算出來的,其實就是計算 x 和 y 基于圖形的中點旋轉(zhuǎn)后的結(jié)果:
// 對坐標(biāo)做旋轉(zhuǎn)
function transformRotate(x, y, radian, cx ,cy) {
if (!radian) {
return [x, y];
}
const cos = Math.cos(radian);
const sin = Math.sin(radian);
return [
(x - cx) * cos - (y - cy) * sin + cx,
(x - cx) * sin + (y - cy) * cos + cy,
];
}
// 計算旋轉(zhuǎn)后的 x 和 y
const [rotatedX, rotatedY] = transformRotate(x, y, rotation, cx, cy);
計算一個元素 rotatedX / rotatedY 的方法實現(xiàn):
// 計算中點
function getRectCenterPoint({x, y, width, height}) {
return [x + width / 2, y + height / 2];
}
// 計算 rotatedX / rotatedY
export function getElementRotatedXY(element) {
const [cx, cy] = getRectCenterPoint(element);
return transformRotate(element.x, element.y, element.rotation || 0, cx, cy);
}
所以,設(shè)置新的 rotatedX,其實就是加上一個移動前后 rotatedX 的偏移值,將其加到 x 上就行了。
class Graph {
// ...
setRotatedX(rotatedX) {
const [prevRotatedX] = getElementRotatedXY(this);
const dx = rotatedX - prevRotatedX;
this.x += dx;
}
}
rotatedY 同理:
class Graph {
// ...
setRotatedY(rotatedY: number) {
const [, prevRotatedY] = getElementRotatedXY(this);
const dy = rotatedY - prevRotatedY;
this.y += dy;
}
}
設(shè)置 width / height
首先修改width 和 height。
但是這樣會導(dǎo)致 rotatedX 和 rotatedY 發(fā)生偏移,我們需要修正一下。
修正方式有兩種思路:
思路 1:計算修改 width 前后的 rotatedX / rotatedY 之間的差值,給元素進(jìn)行修正。
const [preRotatedX, preRotatedY] = getElementRotatedXY(el); // 修改 width 前的
el.width = width;
const [rotatedX, rotatedY] = getElementRotatedXY(el); // 修改 width 后的
const dx = rotatedX - preRotatedX;
const dy = rotatedY - preRotatedY;
el.x -= dx; // "-" 是因為要復(fù)原狀態(tài)
el.y -= dy;
思路 2:確定后最終的 rotatedX / rotatedY,然后對之前的 transformRotate 方法中的等式,進(jìn)行逆推導(dǎo),通過 rotatedX、rotatedY、radian、width、height 計算出對應(yīng)的 x 和 y。這個思路比上一個思路有點復(fù)雜。
const [rotatedX, rotatedY] = getElementRotatedXY(el);
el.width = width;
const [x, y] = getOriginXY(
rotatedX,
rotatedY,
el.rotation || 0,
width,
el.height
);
el.x = x;
el.y = y;
/**
* 計算旋轉(zhuǎn)前的 x、y
* transformRotate 的反推
*/
function getOriginXY(rotatedX, rotatedY, radian, width, height) {
if (!radian) {
return [rotatedX, rotatedY];
}
const cos = Math.cos(radian);
const sin = Math.sin(radian);
const halfWidth = width / 2;
const halfHeight = height / 2;
return [
rotatedX - halfWidth - halfHeight * sin + halfWidth * cos,
rotatedY - halfHeight + halfHeight * cos + halfWidth * sin,
];
}
我一開始用的思路 2 實現(xiàn)的,后面寫這篇文章梳理時,相處了思路 1 的解法,因為更簡單更好理解,就換成思路 1 的實現(xiàn)了。
修改 rotation
修改 rotation 就很簡單了,直接改就好了。
但需要注意將度數(shù)轉(zhuǎn)成弧度,以及通過取余來限定弧度范圍。
// 角度轉(zhuǎn)弧度
function degree2Radian(degree: number) {
return (degree * Math.PI) / 180;
}
/**
* 標(biāo)準(zhǔn)化角度
*/
const PI_DOUBLE = 2 * Math.PI;
export const normalizeAngle = (angle) => {
return angle % PI_DOUBLE;
};
element.rotation = normalizeAngle(degree2Radian(rotation));
結(jié)尾
算法實現(xiàn)上并不復(fù)雜。