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

3,065 阅读8分钟

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

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

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

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

系列文章汇总

一、矩形之间靠近吸附

1. 原理介绍

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

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

image.png

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

image.png

  • 吸附效果

1adsorb.gif

  • 参与计算的矩形位置线(水平方向)
源矩形目标矩形
leftleft
leftcenterX
leftright
centerXleft
centerXcenterX
centerXright
rightleft
rightcenterX
rightright
  • 垂直方向同理:
源矩形目标矩形
toptop
topcenterY
topbottom
centerYtop
centerYcenterY
centerYbottom
bottomtop
bottomcenterY
bottombottom

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} />
  );
};


总结

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

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

  • 锚点功能,如圆角矩形调整圆角大小的锚点、扇形调整扇形角度的锚点等
  • 连接线功能:在精灵上定义端口,可以用连接线把精灵彼此相连,当其中一个精灵发生变化时,连接线也会持续变化保持连接。

系列文章汇总

  1. svg实现图形编辑器系列一:精灵系统
  2. svg实现图形编辑器系列二:精灵的开发和注册
  3. svg实现图形编辑器系列三:移动、缩放、旋转
  4. svg实现图形编辑器系列四:吸附&辅助线
  5. svg实现图形编辑器系列五:辅助编辑锚点
  6. svg实现图形编辑器系列六:链接线、连接桩
  7. svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退
  8. svg实现图形编辑器系列八:多选、组合、解组
  9. svg实现图形编辑器系列九:精灵的编辑态&开发常用精灵
  10. svg实现图形编辑器系列十:工具栏&配置面板(最终篇)