暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

svg实现图形编辑器系列四:吸附&辅助线

原创 wpp 2023-03-21
948

# 用svg实现图形编辑器系列二:精灵的开发和注册 文章中,我们介绍了图形编辑器基础的 移动缩放旋转 等编辑能力,做到了三个操作代码隔离,并且在旋转后缩放修复了位置偏移问题。

本文会继续丰富以下编辑能力:

  • 移动靠近其他精灵时吸附上去,并显示辅助线
  • 缩放靠近其他精灵时吸附上去,并显示辅助线
  • 画布上显示网格,精灵在画布上拖拽时可以吸附在网格上

Demo体验链接:图形编辑器在线Demo

一、矩形之间靠近吸附

1. 原理介绍

我们把画布上的一个个精灵想像成一个个大小位置不同的矩形,当 矩形1 靠近 矩形2 时,计算他们上下左右方向各自的间距当间距小于设置的阈值时,就记录下来显示这些辅助线,并将矩形位置移动到间距最小的一个位置上。

  • 矩形中参与吸附计算的位置示意图

image.png

  • 水平方向上的部分吸附演示

image.png

  • 吸附效果

1adsorb.gif

  • 参与计算的矩形位置线(水平方向)
源矩形 目标矩形
left left
left centerX
left right
centerX left
centerX centerX
centerX right
right left
right centerX
right right
  • 垂直方向同理:
源矩形 目标矩形
top top
top centerY
top bottom
centerY top
centerY centerY
centerY bottom
bottom top
bottom centerY
bottom bottom

2. 计算元素之间靠近时的对其辅助线,以及吸附的修正距离


/** * 计算元素之间靠近时的对其辅助线,以及吸附的修正距离 * @param rect 选中矩形区域 * @param spriteList 未选中的与元素列表 * @param activeSpriteList 选中的元素 * @returns 辅助线数组和吸附定位 */
export const getAuxiliaryLine = ( adsorbLine: IAdsorbLine, spriteRect: ISizeCoordinate, rectList: ISizeCoordinate[], canvasSize: ISize, // 四个方向上是否禁止计算吸附线,例如正在拖动右侧,则左侧就不用计算了 disableAdsorbSide: Record<string, boolean>, adsorbCanvas = true, ) => {
  // 正在拖拽中的矩形的各个边信息
  const rectLeft = spriteRect.x;
  const rectRight = spriteRect.x + spriteRect.width;
  const rectTop = spriteRect.y;
  const rectBottom = spriteRect.y + spriteRect.height;
  const rectCenterX = (rectLeft + rectRight) / 2;
  const rectCenterY = (rectTop + rectBottom) / 2;

  const dis = adsorbLine.distance || 5;
  // 判断接近
  const closeTo = (a: number, b: number, d = dis) => Math.abs(a - b) < d;
  const rectList = [...rectList];
  // 增加一个和舞台同样大小的虚拟元素,用来和舞台对齐
  if (adsorbCanvas) {
    const canvasBackground: ISizeCoordinate = { x: 0, y: 0, ...canvasSize };
    rectList.push(canvasBackground);
  }
  let dx = Infinity;
  let dy = Infinity;
  const sourcePosSpaceMap: Record<string, any> = {};
  for (const rect of rectList) {
    // 矩形的各个边信息
    const left = rect.x;
    const right = rect.x + rect.width;
    const top = rect.y;
    const bottom = rect.y + rect.height;
    const centerX = (left + right) / 2;
    const centerY = (top + bottom) / 2;

    // x和y方向各自取开始、中间、结束三个位置,枚举出共18种情况
    const array = [
      { pos: 'x', sourcePos: 'left', source: rectLeft, target: left },
      { pos: 'x', sourcePos: 'left', source: rectLeft, target: centerX },
      { pos: 'x', sourcePos: 'left', source: rectLeft, target: right },

      { pos: 'x', sourcePos: 'centerX', source: rectCenterX, target: left },
      { pos: 'x', sourcePos: 'centerX', source: rectCenterX, target: centerX },
      { pos: 'x', sourcePos: 'centerX', source: rectCenterX, target: right },

      { pos: 'x', sourcePos: 'right', source: rectRight, target: left },
      { pos: 'x', sourcePos: 'right', source: rectRight, target: centerX },
      { pos: 'x', sourcePos: 'right', source: rectRight, target: right },

      { pos: 'y', sourcePos: 'top', source: rectTop, target: top },
      { pos: 'y', sourcePos: 'top', source: rectTop, target: centerY },
      { pos: 'y', sourcePos: 'top', source: rectTop, target: bottom },

      { pos: 'y', sourcePos: 'centerY', source: rectCenterY, target: top },
      { pos: 'y', sourcePos: 'centerY', source: rectCenterY, target: centerY },
      { pos: 'y', sourcePos: 'centerY', source: rectCenterY, target: bottom },

      { pos: 'y', sourcePos: 'bottom', source: rectBottom, target: top },
      { pos: 'y', sourcePos: 'bottom', source: rectBottom, target: centerY },
      { pos: 'y', sourcePos: 'bottom', source: rectBottom, target: bottom },
    ];

    const minX = Math.min(left, rectLeft);
    const maxX = Math.max(right, rectRight);
    const minY = Math.min(top, rectTop);
    const maxY = Math.max(bottom, rectBottom);

    // 对正在拖拽的矩形来说,每个方向上选出一个最近的辅助线即可
    array.forEach((e: any) => {
      if (closeTo(e.source, e.target)) {
        const space = e.target - e.source;
        // 选出距离更小的
        if (
          !sourcePosSpaceMap[e.sourcePos] ||
          Math.abs(sourcePosSpaceMap[e.sourcePos].space) < Math.abs(space)
        ) {
          if (e.pos === 'x') {
            dx = space;
          } else {
            dy = space;
          }
          sourcePosSpaceMap[e.sourcePos] = {
            space,
            line: {
              x1: e.pos === 'x' ? e.target : minX,
              x2: e.pos === 'x' ? e.target : maxX,
              y1: e.pos === 'y' ? e.target : minY,
              y2: e.pos === 'y' ? e.target : maxY,
            },
          };
        }
      }
    });
  }
  return {
    lines: Object.values(sourcePosSpaceMap).map(e => e.line),
    dx,
    dy,
  };
};

复制代码

3. 处理吸附的修正距离作用与矩形上

将计算出来的吸附修正距离应用在矩形的位置和高宽上

注意: 平移和缩放都可以用下面的函数,平移和缩放同时生效时,可以将各自计算出的dx dy修正距离对比,选出绝对值更小的进行计算即可。


// 处理吸附的修正距离作用与矩形上
export const handleAdsorb = ({ // 正在编辑的矩形 rect, // 吸附计算出来的x和y方向的变更 dx, dy, // 移动还是缩放 mode, // 正在缩放的锚点名 resizePos = '', // 缩放是否移动到了反向,例如把右侧缩放锚点移动到矩形左侧 reverse = {}, }: { rect: ISizeCoordinate; dx: number; dy: number; mode: IChangeMode; resizePos?: string; reverse?: any; }) => {
  const spriteRect = { ...rect };
  const {
    leftReverse = false,
    rightReverse = false,
    bottomReverse = false,
    topReverse = false,
  } = reverse;
  if (mode === 'move') {
    spriteRect.x += dx;
    spriteRect.y += dy;
  } else if (mode === 'resize') {
    if (resizePos.includes('right')) {
      if (rightReverse) {
        spriteRect.x += dx;
        spriteRect.width -= dx;
      } else {
        spriteRect.width += dx;
      }
    }
    if (resizePos.includes('left')) {
      if (leftReverse) {
        spriteRect.width += dx;
      } else {
        spriteRect.x += dx;
        spriteRect.width -= dx;
      }
    }
    if (resizePos.includes('top')) {
      if (topReverse) {
        spriteRect.height += dy;
      } else {
        spriteRect.y += dy;
        spriteRect.height -= dy;
      }
    }
    if (resizePos.includes('bottom')) {
      if (bottomReverse) {
        spriteRect.y += dy;
        spriteRect.height -= dy;
      } else {
        spriteRect.height += dy;
      }
    }
  }
  return spriteRect;
};
复制代码

4. 吸附线渲染

export const AuxiliaryLine = ({ lineList = [] }: { lineList: Line[] }) => {
  return (
    <g> {/* 辅助线 */} {lineList.map((line: Line) => ( <line key={JSON.stringify(line)} {...line} stroke={'#0068ee'} strokeDasharray="4 4"></line> ))} </g>
  );
}
复制代码

二、网格

网格能力会在很多图形编辑产品里出现,例如流程图、UML图等等,本章节结合如何实现体验较好的网格能力

网格线吸附

除了矩形之间相互靠近时的吸附功能,有时我们也希望按照画布的网格来进行吸附约束

1. 参数解释

我们通过一下几个参数控制吸附的行为,可以设置网格单元格的高宽,以及在高宽方向上的吸附距离阈值,通过这几个参数就可以针对不同场景下的不同要求进行灵活定制,实现出体验较好的网格功能。

参数 含义
gridCellWidth 网格单元格的宽度
gridCellHeight 网格单元格的高度
adsorbWidth 水平方向的吸附距离阈值
adsorbHeight 垂直方向的吸附距离阈值

2. 网格吸附效果案例

2.1 单元格宽度大于吸附距离

例如单元格为 50 * 50 ,吸附距离均为5,效果如下:

  • 此时适用于对宽高细节调整要求较高的情况

1grid-adsorb.gif

2.2 吸附距离大于等于单元格宽度

例如单元格为 50 * 50 ,吸附距离均为50,效果如下:

  • 此时适用于对宽高细节调整要求不高,希望严格吸附在网格上
  • 也可以将单元格调小一些提升网格的精度来做细节调整

1strict-grid-adsorb.gif

2.3 在严格吸附网格情况下,使拖拽更加平滑

  • 增加一个虚线框,显示放手时的位置,矩形跟着鼠标实时位置

1-pinghua-grid-adsorb.gif

3. 计算网格吸附的源码

这里和矩形之间吸附的原理类似,可以把网格线类比理解为矩形的边线。主要区别是可能有需要根据单元格大小进行四舍五入计算的情况。

缩放时以移动右边为例说明

  • 当吸附距离小于宽度的一半时,比较 右边 的 x 和网格的 x 的差,如果小于吸附阈值 并且绝对值小于已经记录的边距,就更新;
  • 当吸附距离大于等于宽度的一半时,根据网格宽度进行四舍五入,使边一直落在网格上即可;

移动 时以水平方向为例:

  • 同时计算左边、右边与网格的边距,如果小于吸附阈值并且绝对值小于已经记录的边距,就更新;
// 网格吸附
export const handleGridAdsorb = ( rect: IRect, gridCellWidth: number, gridCellHeight: number, adsorbWidth = defaultGridAdsorbWidth, adsorbHeight = defaultGridAdsorbHeight, // 移动还是缩放 changeMode: string, // 缩放时需要计算吸附的边 adsorbSides: Record<string, boolean> = {}, ) => {
  const { x, y, width, height } = rect;
  const spriteRect = { ...rect };
  // 组件左或下方向被激活
  let leftActivated = true;
  let topActivated = true;
  if (changeMode === 'resize') {
    // resize的场景下,正在操作哪个方向的锚点就激活哪个方向
    leftActivated = adsorbSides.left;
    topActivated = adsorbSides.top;
  } else {
    // move的场景下,距离那哪边近就激活哪个方向
    leftActivated =
      minDisWithGrid(x, gridCellWidth) <
      minDisWithGrid(x + width, gridCellWidth);
    topActivated =
      minDisWithGrid(y, gridCellHeight) <
      minDisWithGrid(y + height, gridCellHeight);
  }
  if (leftActivated) {
    spriteRect.x = roundingUnitize(x, gridCellWidth, adsorbWidth);
  } else {
    spriteRect.x =
      roundingUnitize(x + width, gridCellWidth, adsorbWidth) - width;
  }
  if (topActivated) {
    spriteRect.y = roundingUnitize(y, gridCellHeight, adsorbHeight);
  } else {
    spriteRect.y =
      roundingUnitize(y + height, gridCellHeight, adsorbHeight) - height;
  }
  return {
    dx: spriteRect.x - rect.x || Infinity,
    dy: spriteRect.y - rect.y || Infinity,
  };
};


// 距离网格的最小距离
export const minDisWithGrid = (n: number, unit: number) =>
  Math.min(n % unit, unit - (n % unit));

// 四舍五入网格取整
export const roundingUnitize = (n: number, unit: number, adsorbDis = 4) => {
  // 余数
  const remainder = Math.abs(n % unit);
  const closeToStart = remainder <= adsorbDis;
  const closeToEnd = Math.abs(unit - (n % unit)) <= adsorbDis;
  if (closeToStart || closeToEnd) {
    const m = Math.floor(n / unit); // 是单位长度的几倍
    if (remainder <= unit / 2) {
      // 靠近单元格开始位置
      return m * unit;
    } else {
      // 靠近单元格结束位置
      return (m + 1) * unit;
    }
  }
  // 都不靠近,直接返回原本位置
  return n;
};
复制代码

网格线渲染


interface IProps {
  width: number;
  height: number;
  spacing?: number;
}
// 计算网格线
const getLines = (width: number, height: number, spacing: number) => {
  const getLineList = (size: number, spacing: number) => {
    const length = Math.floor(size / spacing);
    return new Array(length)
      .fill(1)
      .map((_: any, index: number) => [0, size, index * spacing]);
  };
  const xLines = getLineList(height, spacing).map((arr: number[]) => ({
    x1: 0,
    y1: arr[2],
    x2: width,
    y2: arr[2],
  }));
  const yLines = getLineList(width, spacing).map((arr: number[]) => ({
    x1: arr[2],
    y1: 0,
    x2: arr[2],
    y2: height,
  }));
  return { xLines, yLines };
};
// 把网格线转化成path路径
const getPath = (line: Line) => `M${line.x1},${line.y1} L${line.x2},${line.y2}`;

export default (props: IProps) => {
  const {
    width = 0,
    height = 0,
    spacing = 50,
  } = props;
  const [d, setD] = useState('');
  const style = {
    stroke: '#f8f8f8',
    strokeWidth: 1,
    strokeDasharray: 'none',
  };
  // 计算网格线
  useEffect(() => {
    const { xLines, yLines } = getLines(width, height, spacing);
    const lines = [...xLines, ...yLines];
    const path = lines.map((line: Line) => getPath(line)).join(' ');
    setD(path);
  }, [width, height, spacing]);

  return (
    <path d={d} {...style} />
  );
};


复制代码

总结

本文介绍了两种吸附功能:矩形之间靠近吸附网格吸附,可以针对不同的场景下提升用户体验。

接下来我们会继续强化编辑能力,如:

  • 锚点功能,如圆角矩形调整圆角大小的锚点、扇形调整扇形角度的锚点等
  • 连接线功能:在精灵上定义端口,可以用连接线把精灵彼此相连,当其中一个精灵发生变化时,连接线也会持续变化保持连接。
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论