来自 github.com/wbccb 同学的 PR,下面的内容都是 PR 内容搬运,感兴趣欢迎参与贡献
related: transform相关问题汇总
1. 前言
node 加入rotate
后,可能影响的范围有:
- rotate + resize=>正常 resize 模式
- rotate + resize=>等比例 resize 模式
- rotate + bbox 调整 => 涉及到旋转后辅助线对齐
- rotate + bbox 调整 => 涉及到点是否在某个旋转图形内的判断
- rotate + bbox 调整 => 涉及到 anchor 与 anchor 连线的计算
本次 pr 主要针对 rotate + resize 的正常 resize 模式进行修复
- rotate + resize=>正常 resize 模式
注:由于失误,将control坐标的名称叫成anchorX和anchorY,代码中已经更改,但是这里的pr描述由于篇幅过长,重做比较耗费时间,因此这里没有更改,凡是出现anchor相关单词,都可以替换为control
2.问题发生的原因
在rotate=0
的情况下,坐标转换简单,通过触摸点计算出 dx 和 dy,然后进行整体的 resize,但是在rotate不等于0
的情况下,我们通过触摸点计算出 dx 和 dy 还得经过一层转化才能正确 resize
- 而目前代码中还是延续
rotate=0
的处理,也就是触摸点计算出来的 dx 和 dy 就是实际宽度和高度变化的 dx 和 dy,然后计算出newWidth = oldWidth+dx
- 同时,在 resize 过程中,我们一般会保持对角 anchor 的坐标不变化(在
rotate=0
的情况下拉伸右下角的 anchor,左上角 anchor 保持不变),而由于宽度和高度发生变化,因此中心点 center 也发生变化,因此左上角的 anchor 实际坐标会发生变化,因此这个时候不能简单使用newCenter.x = oldCenter.x + dx
的逻辑
因此在rotate不等于0
的情况下,newWidth
的计算和newCenter
的计算都需要进行调整
3.解决方法
在原来逻辑中,recalcResizeInfo
先处理了PCTResizeInfo
的情况(也就是等比例缩放),返回nextResizeInfo
,然后再处理正常模式下缩放,返回nextResizeInfo
export const recalcResizeInfo = () => {
const nextResizeInfo = cloneDeep(resizeInfo);
let { deltaX, deltaY } = nextResizeInfo;
const { width, height, PCTResizeInfo } = nextResizeInfo;
if (PCTResizeInfo) {
//...处理
return nextResizeInfo;
}
//...处理PCTResizeInfo为空的情况
return nextResizeInfo;
};
因此我们在原来的逻辑上增加PCTResizeInfo为空
并且rotate不等于0
的逻辑处理: recalcRotatedResizeInfo()
,不影响原来等比例缩放
和正常模式缩放+rotate=0
的处理逻辑
export const recalcResizeInfo = () => {
const nextResizeInfo = cloneDeep(resizeInfo);
let { deltaX, deltaY } = nextResizeInfo;
const { width, height, PCTResizeInfo } = nextResizeInfo;
if (PCTResizeInfo) {
//...处理等比例缩放的情况
return nextResizeInfo;
}
if (rotate % (2 * Math.PI) !== 0) {
return recalcRotatedResizeInfo(...);
}
//...处理PCTResizeInfo为空 + rotate等于0的情况
return nextResizeInfo;
};
recalcRotatedResizeInfo()
主要是
- 进行数据的拼接准备
- 通过核心方法
calculateWidthAndHeight()
计算出宽度、高度和新的中心点newCenter
- 然后我们就可以通过
newCenter
和oldCenter
的比较得出deltaX
和deltaY
,因为最终我们是通过BaseNodeModel.resize()
来重置 width、height,以及通过BaseNodeModel.move()
的this.x = this.x+deltaX
来更新中心点的坐标
function recalcRotatedResizeInfo(
pct: number,
resizeInfo: ResizeInfo,
rotate: number,
anchorX: number,
anchorY: number,
oldCenterX: number,
oldCenterY: number,
freezeWidth = false,
freezeHeight = false
) {
// 假设我们触摸的点是右下角的anchor
const { deltaX, deltaY, width: oldWidth, height: oldHeight } = resizeInfo;
const angle = radianToAngle(rotate);
// 右下角的anchor
const startZeroTouchAnchorPoint = {
x: anchorX, // control锚点的坐标x
y: anchorY, // control锚点的坐标y
};
const oldCenter = { x: oldCenterX, y: oldCenterY };
// 右下角的anchor坐标(transform后的-touchStartPoint)
const startRotatedTouchAnchorPoint = calculatePointAfterRotateAngle(
startZeroTouchAnchorPoint,
oldCenter,
angle
);
// 右下角的anchor坐标(transform后的-touchEndPoint)
const endRotatedTouchAnchorPoint = {
x: startRotatedTouchAnchorPoint.x + deltaX,
y: startRotatedTouchAnchorPoint.y + deltaY,
};
// 通过固定点、旧的中心点、触摸点、旋转角度计算出新的宽度和高度以及新的中心点
const {
width: newWidth,
height: newHeight,
center: newCenter,
} = calculateWidthAndHeight(
startRotatedTouchAnchorPoint,
endRotatedTouchAnchorPoint,
oldCenter,
angle,
freezeWidth,
freezeHeight,
oldWidth,
oldHeight
);
// handleResize()会处理isFreezeWidth和deltaX、isFreezeHeight和deltaY,这里不再处理
resizeInfo.width = newWidth * pct;
resizeInfo.height = newHeight * pct;
// BaseNodeModel.resize(deltaX/2, deltaY/2),因此这里要*2
resizeInfo.deltaX = (newCenter.x - oldCenter.x) * 2;
resizeInfo.deltaY = (newCenter.y - oldCenter.y) * 2;
return resizeInfo;
}
如下面代码所示,当我们不考虑freezeWidth
和freezeHeight
时,代码逻辑是非常简单的
下面将通过图示进行思路的讲解
export function calculateWidthAndHeight(
startRotatedTouchAnchorPoint: SimplePoint,
endRotatedTouchAnchorPoint: SimplePoint,
oldCenter: SimplePoint,
angle: number,
freezeWidth = false,
freezeHeight = false,
oldWidth: number,
oldHeight: number
) {
// 假设目前触摸的是右下角的anchor
// 计算出来左上角的anchor坐标,resize过程左上角的anchor坐标保持不变
const freezePoint: SimplePoint = {
x: oldCenter.x - (startRotatedTouchAnchorPoint.x - oldCenter.x),
y: oldCenter.y - (startRotatedTouchAnchorPoint.y - oldCenter.y),
};
// 【touchEndPoint】右下角 + freezePoint左下角 计算出新的中心点
let newCenter = getNewCenter(freezePoint, endRotatedTouchAnchorPoint);
// 得到【touchEndPoint】---angle=0(右下角anchor)的坐标
let endZeroTouchAnchorPoint: SimplePoint = calculatePointAfterRotateAngle(
endRotatedTouchAnchorPoint,
newCenter,
-angle
);
// ---------- 使用transform之前的坐标计算出新的width和height ----------
// 得到左上角---angle=0的坐标
let zeroFreezePoint: SimplePoint = calculatePointAfterRotateAngle(
freezePoint,
newCenter,
-angle
);
// transform之前的坐标的左上角+右下角计算出宽度和高度
const width = Math.abs(endZeroTouchAnchorPoint.x - zeroFreezePoint.x);
const height = Math.abs(endZeroTouchAnchorPoint.y - zeroFreezePoint.y);
// ---------- 使用transform之前的坐标计算出新的width和height ----------
return {
width,
height,
center: newCenter,
};
}
3.1 图解
第1个图=>第2个图
:利用目前已知的坐标oldCenter
、startRotatedTouchAnchorPoint
(也就是 anchor 坐标)、手指触摸的距离(也就是 onDraging 得到的 dx 和 dy),我们可以计算得到的坐标endRotatedTouchAnchorPoint
和freezePoint
第2个图=>第3个图
:利用freezePoint
和endRotatedTouchAnchorPoint
,我们可以得到新的中心点newCenter
第3个图=>第4个图
:自此我们知道了几乎所有点的坐标,我们进行-angle
的翻转得到去掉transform
的图形以及对应的坐标(也就是rotate=0
的图形)第4个图
:利用rotate=0
的图形顺利计算出新的 width 和 height 以及 newCenter 和 oldCenter 之间的偏移量 deltaX 和 deltaY
3.2 freezeWidth 和 freezeHeight
当加入freezeWidth
和freezeHeight
后,情况会变的比较复杂,因为 rotate 不为 0 的时候,recalcRotatedResizeInfo()计算出来的中心点会发生变化,因此我们不能简单限制freezeWidth
,我们就设置为deltaX=0
,我们还是得计算出来deltaX
和deltaY
因此我们在原来的calculateWidthAndHeight()
加入一段修正deltaX
和deltaY
的逻辑
下面将通过图示进行思路的讲解
export function calculateWidthAndHeight(...) {
//...freezeWidth和freezeHeight都为false的坐标获取逻辑
if (freezeWidth) {
// 如果固定width,那么不能单纯使用endZeroTouchAnchorPoint.x=startZeroTouchAnchorPoint.x
// 因为去掉transform的左上角不一定是重合的,我们要保证的是transform后的左上角重合
const newWidth = Math.abs(endZeroTouchAnchorPoint.x - zeroFreezePoint.x);
const widthDx = newWidth - oldWidth;
// 点击的是左边锚点,是+widthDx/2,点击是右边锚点,是-widthDx/2
if (newCenter.x > endZeroTouchAnchorPoint.x) {
// 当前触摸的是左边锚点
newCenter.x = newCenter.x + widthDx / 2;
} else {
// 当前触摸的是右边锚点
newCenter.x = newCenter.x - widthDx / 2;
}
}
if (freezeHeight) {
//...与freezeWidth处理相同
}
if (freezeWidth || freezeHeight) {
// 如果调整过transform之前的坐标,那么transform后的坐标也会改变,那么算出来的newCenter也得调整
// 由于无论如何rotate,中心点都是不变的,因此我们可以使用transform之前的坐标算出新的中心点
const nowFreezePoint = calculatePointAfterRotateAngle(
zeroFreezePoint,
newCenter,
angle
);
// 得到当前新rect的左上角与实际上transform后的左上角的偏移量
const dx = nowFreezePoint.x - freezePoint.x;
const dy = nowFreezePoint.y - freezePoint.y;
// 修正不使用transform的坐标: 左上角、右下角、center
newCenter.x = newCenter.x - dx;
newCenter.y = newCenter.y - dy;
zeroFreezePoint = calculatePointAfterRotateAngle(
freezePoint,
newCenter,
-angle
);
endZeroTouchAnchorPoint = {
x: newCenter.x - (zeroFreezePoint.x - newCenter.x),
y: newCenter.y - (zeroFreezePoint.y - newCenter.y),
};
}
//...利用rotate=0的图形顺利计算出新的width和height
return {
width,
height,
center: newCenter,
};
}
图1->图2
: 当freezeWidth=true
时,我们进行宽度的恢复,得出需要减去的宽度widthDx
图2->图3
: 我们使用减去的宽度widthDx
去修正目前的中心点newCenter.x=newCenter.x-widthDx/2
图3->图4
: 我们旋转angle
得到 transform 后的左上角坐标,此时应该保持一致,因此我们可以得出目前的偏移量,通过偏移量,我们整体平移图形,修正newCenter
的误差图4->图5
: 通过准确的newCenter
以及固定不变的freezePoint
,我们可以计算出新的左上角和右下角,然后计算出width
和height
3.3 其他小优化点
3.3.1 freezeWidth 和 freezeHeight 滑动卡顿问题
当freezeWidth=true
时,minWidth等于maxHeight
,因此nextSize.width
必须等于minWidth
rotate!==0
触发calculateWidthAndHeight()
计算width
和height
,理论计算出来的nextSize.width
必须等于oldWidth
,但是实际上有误差,比如
- oldWidth = 100
- newWidth=100.000000000001
因此在calculateWidthAndHeight()
直接width = oldWidth
,防止误差导致cancelDrag()
发生
export const handleResize = ({...}) => {
//...
const nextSize = recalcResizeInfo(...);
// 限制放大缩小的最大最小范围
if (
nextSize.width < minWidth ||
nextSize.width > maxWidth ||
nextSize.height < minHeight ||
nextSize.height > maxHeight
) {
// this.dragHandler.cancelDrag()
cancelCallback?.();
return;
}
//...
};
export function calculateWidthAndHeight(...) {
//...
if (freezeWidth) {
// 理论计算出来的width应该等于oldWidth
// 但是有误差,比如oldWidth = 100; newWidth=100.000000000001
// 会在handleResize()限制放大缩小的最大最小范围中被阻止滑动
width = oldWidth;
}
if (freezeHeight) {
height = oldHeight;
}
return {
width,
height,
center: newCenter,
};
}
3.3.2 onDragging 没有销毁注册事件
如下面视频所示,有一定小概率会发生,当ResizeControlGroup
销毁时,也就是 Resize 的组件销毁时,此时的DOC.removeEventListener("mousemove")
没有触发,也就是没有销毁注册监听,导致 onDragging 一直触发,造成滑动错乱情况发生
通过代码分析,当<ResizeControlGroup>
在滑动过程中因为某些原因造成销毁,并且在销毁前没有触发handleMouseUp()
事件,也就是跟视频展示一样,在滑动过程中,某些特殊原因导致滑动过程中就触发ResizeControlGroup
销毁时,还没来得及触发handleMouseUp()
时,就会导致window?.document
注册的mousemove
一直存在!
class BaseNode extends Component {
getResizeControl(): h.JSX.Element | null {
const { model, graphModel } = this.props
const {
editConfigModel: { isSilentMode, allowResize },
} = graphModel
const { isSelected, isHitable, resizable, isHovered } = model
// 合并全局 allResize 和节点自身的 resizable 配置,以节点配置高于全局配置
const canResize = allowResize && resizable // 全局开关 > 节点配置
const style = model.getResizeControlStyle()
if (!isSilentMode && isHitable && (isSelected || isHovered) && canResize) {
return (
<ResizeControlGroup
style={style}
model={model}
graphModel={graphModel}
/>
)
}
return null
}
}
export class ResizeControl extends Component<> {
constructor(props: IResizeControlProps) {
//...
// 初始化拖拽工具
this.dragHandler = new StepDrag({
onDragging: this.onDragging,
onDragEnd: this.onDragEnd,
step: graphModel.gridSize,
});
}
handleMouseDown = (e: MouseEvent) => {
const DOC: any = window?.document;
//...
DOC.addEventListener("mousemove", this.handleMouseMove, false);
DOC.addEventListener("mouseup", this.handleMouseUp, false);
//...
};
handleMouseUp = (e: MouseEvent) => {
const DOC = window.document
//...
Promise.resolve().then(() => {
DOC.removeEventListener('mousemove', this.handleMouseMove, false)
DOC.removeEventListener('mouseup', this.handleMouseUp, false)
});
}
因此在ResizeControl
销毁时,应该主动触发一次事件销毁this.dragHandler.cancelDrag()
,才能避免一些错乱事情发生
目前只在
ResizeControl
中加入componentWillUnmount
进行this.dragHandler.cancelDrag()
,按照道理,其它有用到DOC.addEventListener("mousemove")
都应该在组件销毁时显式触发一次事件监听销毁,这里没有深入去排查
export class ResizeControl extends Component<> {
constructor(props: IResizeControlProps) {
//...
// 初始化拖拽工具
this.dragHandler = new StepDrag({
onDragging: this.onDragging,
onDragEnd: this.onDragEnd,
step: graphModel.gridSize,
});
}
componentWillUnmount() {
this.dragHandler.cancelDrag()
}
handleMouseDown = (e: MouseEvent) => {
const DOC: any = window?.document;
//...
DOC.addEventListener("mousemove", this.handleMouseMove, false);
DOC.addEventListener("mouseup", this.handleMouseUp, false);
//...
};
handleMouseUp = (e: MouseEvent) => {
const DOC = window.document
//...
Promise.resolve().then(() => {
DOC.removeEventListener('mousemove', this.handleMouseMove, false)
DOC.removeEventListener('mouseup', this.handleMouseUp, false)
});
}
4. 测试代码和效果视频
4.1 正常模式的 rotate+resize
代码已经上传到examples/feature-examples/src/pages/graph/index.tsx
效果演示视频(点击链接观看): github.com/user-attach…
4.2 freezeWidth 和 freezeHeight
由于代码中好像没有实现maxHeight
和minHeight
的初始化处理,因此我添加了一些测试代码,没有上传
在examples/feature-examples/src/pages/nodes/native/index.tsx
const data = {}
增加测试数据freeWidth矩形
和freeHeight矩形
const config
增加配置allowRotate=true
和allowResize=true
const config = {
//...省略很多数据
allowRotate: true,
allowResize: true,
}
const data = {
nodes: [
//...省略很多数据
{
id: "8",
type: "rect",
x: 350,
y: 400,
rotate: Math.PI * 0.2,
properties: {
width: 100,
height: 100,
minWidth: 100,
maxWidth: 100,
},
text: "freeWidth矩形",
},
{
id: "9",
type: "rect",
x: 550,
y: 400,
rotate: Math.PI * 0.3,
properties: {
width: 100,
height: 100,
minHeight: 100,
maxHeight: 100,
},
text: "freeHeight矩形",
},
],
};
在packages/core/src/model/node/RectNodeModel.ts
的setAttributes()
增加
this.minWidth = minWidth
this.maxWidth = maxWidth
this.minHeight = minHeight
this.maxHeight = maxHeight
setAttributes() {
super.setAttributes()
const { width, height, radius, minWidth, maxWidth, minHeight, maxHeight } =
this.properties
if (!isNil(width)) this.width = width
if (!isNil(height)) this.height = height
// @ts-ignore
if (!isNil(minWidth)) this.minWidth = minWidth
// @ts-ignore
if (!isNil(maxWidth)) this.maxWidth = maxWidth
// @ts-ignore
if (!isNil(minHeight)) this.minHeight = minHeight
// @ts-ignore
if (!isNil(maxHeight)) this.maxHeight = maxHeight
// 矩形特有
if (!isNil(radius)) this.radius = radius
}
效果演示视频(点击链接观看): github.com/user-attach…