编辑器 github 地址:
https://github.com/F-star/suika
线上体验:
https://blog.fstars.wang/app/suika/
图形的属性
图形有几个重要的基础属性,会经常被用到,我们在实现缩放图形前需要理清一下它们。
- x / y
- width / height
- rotation
位置和大小
x 和 y 为图形的左上角位置,注意是旋转前的。
x、y 旋转后我们叫做 rotatedX、rotatedY,属性面板中会用到。
width 和 height 为图形的宽高,这个没什么好说的。
另外,有些图形有些特殊,它的 x、y、width、height 是要通过其他属性计算出来的,比如贝塞尔曲线。
旋转
rotation 为图形的旋转度数,通常使用 弧度单位。
因为弧度是数学计算中的常客,各种 API 都是要求提供弧度的,比如内置的 Math.sin() 方法。
你存角度自然也是可以,但不推荐,但计算时多了一层多余的单位转换,且丢失一些微小的精度。
当然 UI 层还是要展示角度,因为是面向用户的,对于数据和 UI 不统一的问题,在 UI 层做一个转换即可。
旋转度数通常要配合一个变换中心(origin),这个可以作为一个属性让用户设置。
但我更建议将 x、y、width、height 形成的 矩形的中点 作为旋转中心,这样更简单一些,减少用户的心智负担,也防止出现用户设置一些奇怪 origin 的场景。
下图中,红色矩形是蓝色矩阵顺时针旋转 45 度得到。
旋转度数还要考虑 旋转方向、基准角度、取值范围 问题。
(因为弧度不直观,后面会用角度来描述,但数据层依旧还是用的弧度)
- 旋转方向:设置旋转后,图形是会往顺时针方向还是逆时针方向旋转。
- 基准角度:朝向哪里是 0 度。
- 取值范围:通常为 [0, 360) 和 (-180, 180]。二者其实等价,只是显示有区别,后者其实只是前者减去 180 度。
通常这些编辑器自己决定就好。像我的项目,向上表示 0 度,顺时针方向为旋转方向,方向取值为 [0, 360)。
一些编辑器是支持用户自己设置的,比如 AutoCAD 可通过图形单位命令,设置旋转方向和基准角度。
缩放实现思路
进入正题,对图形进行缩放。
接下来会以通过右下角(也叫东南 se 方向) 缩放控制点缩放为例进行讲解。
交互逻辑:
选择工具下,当光标落在右下角的缩放控制点上时,光标会变成缩放样式(这个不是本文核心,不讲)。
此时按下鼠标,然后进行拖拽,即可对图形以左上角为缩放中心,进行缩放。
实现思路:更新 width 和 height,然后确定参照点,修正 x 和 y。
按下鼠标时,我们要把当前图形的 x、y、width、height、rotation 记录下来。之后的缩放是基于这个初始状态进行的。
const mousedown = (e) => {
// ...
// 缩放前图形的属性,之后我们会直接更新图形属性,导致原来的属性丢失,所以要记录下这个快照。
prevElement = {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
rotation: item.rotation ?? 0,
}
}
拖拽时,调用我们将要实现的 movePoint 方法,去更新这个图形。
const drag = (e) = {
// ...
selectElement.movePoint(
'se', // 缩放控制点类型:右下(或东南)
lastPoint, // 当前光标位置(基于场景坐标系)
prevElement, // 缩放前的属性快照
);
}
下面就是核心方法 movePoint 的实现逻辑了。
更新 width 和 height
首先是更新矩形宽高。
因为有一个旋转,所以算法不会这么直观。
我们要意识到这里有一个变换。看到的图形,是做过变换(基于矩形中心旋转)之后的,但我们需要修改的 width、height、x、y 则是旋转前的。
所以我们需要把光标位置给旋转回来,然后再减去 x 和 y 去得到真正的 width 和 height。
看看代码
class Graph {
// ...
// 根据缩放点更新图形
movePoint(type, newPos, oldBox) {
// 1. 计算 width 和 height
// 计算缩放中心(也就是矩形的中点)
const cx = oldBox.x + oldBox.width / 2;
const cy = oldBox.y + oldBox.height / 2;
// 计算反向旋转的光标位置
const { x: posX, y: poxY } = transformRotate(
newPos.x,
newPos.y,
-(oldBox.rotation || 0), // 注意这里是负数
cx,
cy
);
let width = 0;
let height = 0;
if (type === 'se') {
// 参照点为左上角(x 和 y)
// 新的宽高自然就是光标位置减去 x、y
width = posX - oldBox.x;
height = poxY - oldBox.y;
}
// 其他控制点的逻辑暂且省略...
// 2. 计算 x 和 y
// ...
}
}
看看只更新宽高的效果。
可以看到是有问题的,因为修改宽高后,矩形的中心点也发生了变化,导致缩放中心错误。所以我们要修正一下 x 和 y。
修正 x 和 y
接着我们就要修正 x 和 y 的值。
重点就一句话:缩放前的参考点和缩放后的参考点的位置要保持一致。这个参考点其实就是图形缩放过程中的缩放中心。
对于右下角缩放控制点,它的缩放中心就是左上角,即 x 和 y 经过旋转的位置。
class Graph {
// ...
movePoint(type, newPos, oldBox) {
// 1. 计算 width 和 height
// ...
// 2. 计算 x 和 y
// 设置参照点,不同缩放类型的参照点不同
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, // 旋转中心是新的
oldBox.y + height / 2
);
// 计算新旧两个参考点的差值,对 x、y 进行补正
const dx = rotatedOriginX - prevRotatedOriginX;
const dy = rotatedOriginY - prevRotatedOriginY;
const x = oldBox.x - dx;
const y = oldBox.y - dy;
}
}
width 和 height 可能为负数,这里要做一个标准化,然后赋值给图形属性即可。
this.setAttrs(
normalizeRect({
x,
y,
width,
height,
}),
);
其他缩放控制点
对于其他类型缩放控制点,比如左上、右上、左下缩放控制点,它们的大框架是一样的,只是 width 和 height 计算方式不同,以及参考点不同。
不同类型下 width 和 height 的设置:
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;
}
新旧参考点设置:
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;
}
暂时没实现正北、正南、正西、正东的逻辑,逻辑大差不差。
锁定缩放比
按住 shift 可以锁定缩放比。
做法是对比新旧图形宽高比,将 width 和 height 其中一个进行修正即可。注意正负号。
方法需要多传一个 keepRatio 的参数:
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
// ...
}
}
貌似没考虑除数 height 为 0 的情况..
优化点
本文的实现是考虑的是比较简单的缩放图形场景,一些更复杂的场景并未实现。
缩放还有另一种策略,就是会产生 反向颠倒 的缩放。要实现这个效果,需要引入缩放属性,复杂度会提升很多。
另外就是选中多个图形,然后缩放的场景我没实现。这种场景下,通常是要锁定宽高比的。
否则就会出现图形的斜切效果,这个如果要实现,我们还要引入斜切属性,复杂度再一次提升。
下面是 Figma 的效果,真是让人头扁。
按住 Alt 实现图形中心缩放也没做,这个比较简单,有空再做。
读者如果看懂我这篇文章,心里应该有思路的:width、height 的计算要加入图形中点参数,参照点设置为图形中点。