给大家分享一个优质的 PR - 解决节点旋转后的缩放问题

196 阅读9分钟

来自 github.com/wbccb 同学的 PR,下面的内容都是 PR 内容搬运,感兴趣欢迎参与贡献

related: transform相关问题汇总

fix: github.com/didi/LogicF…

fix: github.com/didi/LogicF…

fix: github.com/didi/LogicF…

fix: github.com/didi/LogicF…

1. 前言

node 加入rotate后,可能影响的范围有:

  1. rotate + resize=>正常 resize 模式
  2. rotate + resize=>等比例 resize 模式
  3. rotate + bbox 调整 => 涉及到旋转后辅助线对齐
  4. rotate + bbox 调整 => 涉及到点是否在某个旋转图形内的判断
  5. rotate + bbox 调整 => 涉及到 anchor 与 anchor 连线的计算

本次 pr 主要针对 rotate + resize 的正常 resize 模式进行修复

  1. 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的计算都需要进行调整

image.png

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
  • 然后我们就可以通过newCenteroldCenter的比较得出deltaXdeltaY,因为最终我们是通过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;
}

如下面代码所示,当我们不考虑freezeWidthfreezeHeight时,代码逻辑是非常简单的

下面将通过图示进行思路的讲解

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个图:利用目前已知的坐标oldCenterstartRotatedTouchAnchorPoint(也就是 anchor 坐标)、手指触摸的距离(也就是 onDraging 得到的 dx 和 dy),我们可以计算得到的坐标endRotatedTouchAnchorPointfreezePoint
  • 第2个图=>第3个图:利用freezePointendRotatedTouchAnchorPoint,我们可以得到新的中心点newCenter
  • 第3个图=>第4个图:自此我们知道了几乎所有点的坐标,我们进行-angle的翻转得到去掉transform的图形以及对应的坐标(也就是rotate=0的图形)
  • 第4个图:利用rotate=0的图形顺利计算出新的 width 和 height 以及 newCenter 和 oldCenter 之间的偏移量 deltaX 和 deltaY

image.png

3.2 freezeWidth 和 freezeHeight

当加入freezeWidthfreezeHeight后,情况会变的比较复杂,因为 rotate 不为 0 的时候,recalcRotatedResizeInfo()计算出来的中心点会发生变化,因此我们不能简单限制freezeWidth,我们就设置为deltaX=0,我们还是得计算出来deltaXdeltaY

因此我们在原来的calculateWidthAndHeight()加入一段修正deltaXdeltaY的逻辑

下面将通过图示进行思路的讲解

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,我们可以计算出新的左上角和右下角,然后计算出widthheight

image.png

3.3 其他小优化点

3.3.1 freezeWidth 和 freezeHeight 滑动卡顿问题

freezeWidth=true时,minWidth等于maxHeight,因此nextSize.width必须等于minWidth rotate!==0触发calculateWidthAndHeight()计算widthheight,理论计算出来的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 一直触发,造成滑动错乱情况发生

github.com/user-attach…

通过代码分析,当<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

由于代码中好像没有实现maxHeightminHeight的初始化处理,因此我添加了一些测试代码,没有上传

examples/feature-examples/src/pages/nodes/native/index.tsx

  • const data = {}增加测试数据freeWidth矩形freeHeight矩形
  • const config增加配置allowRotate=trueallowResize=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.3properties: {
        width: 100,
        height: 100,
        minHeight: 100,
        maxHeight: 100,
      },
      text: "freeHeight矩形",
    },
  ],
};

packages/core/src/model/node/RectNodeModel.tssetAttributes()增加

  • 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…