低代码 - 可视化拖拽技术分析(2)

2,755 阅读11分钟

前言

上一讲 - 低代码 - 可视化拖拽技术分析(1) 中,我们实现了编辑器画布区域的拖拽移动基础操作,接下来我们从使用体验角度,完善画布编辑区的拖动能力,其中包含:

  1. 拖动的 block 靠近其他 block 时显示辅助线,
  2. 拖动的 block 靠近其他 block 时具备吸附能力,
  3. 画布中 block 放大/缩小 实现。

一、描述

1、关于辅助线:
当画布中存在两个或多个 block,拖动其中一个 block 至另一个 block(参照物)周围时,能够显示出上下,或者居中的参考线,便于对两个 block 进行对齐排版。

2、关于吸附:
当拖动 block 的移动位置非常接近另一个 block 的位置时(比如两个 block 的相差距离 < 5px),能够直接让它们紧挨在一起(吸附),而非手动去拖动实现精准对齐。这是一个很好的体验!

3、关于放大/缩小:
通过拖动 block 周围的圆点、线条,调整元素的尺寸大小。

二、辅助线思路

辅助线显示的规则是拿选中的 blocks其余未选中的 blocks,在拖动移动过程中进行位置比对

在拖动一个或多个 block(focus blocks)前,画布上其余的 block(unfocused blocks)需要提供自身周围的参考线(即辅助线,下文中的 lines);

每次拖动 focus blocks 时,会根据当前移动到的位置,与 unfocused blocks 提供的辅助线集合(lines)中的位置进行比较,若满足临近条件,显示其辅助线;

每一个 unfocused blocks 周围都可能存在10种辅助线,横向、纵向分别各5条。

下面绘制了两张图便于理解这里的思路:

  • 水平横向辅助线 5 种情况:

截屏2022-04-23 下午4.47.00.png

  • 垂直纵向辅助线 5 种情况:

截屏2022-04-23 下午5.13.34.png

下面我们从代码层面实现辅助线。

三、辅助线实现

1、渲染视图

首先,我们需要两条线:水平位置线和垂直位置线,插入到 DOM 树上。

<div className="editor-container">
  <div
    ...
    id="canvas-container" style={{ ...schema.container }}>
    ...
    {markLine.x !== null && <div className="editor-line-x" style={{ left: markLine.x }}></div>}
    {markLine.y !== null && <div className="editor-line-y" style={{ top: markLine.y }}></div>}
  </div>
</div>

.editor-line-x{
  position: absolute;
  top: 0;
  bottom: 0;
  border-left: 1px dashed #1890ff;
}

.editor-line-y{
  position: absolute;
  left: 0;
  right: 0;
  border-top: 1px dashed #1890ff;
}

2、定义数据状态

这里需要用到两个 Hook

  • currentBlockIndex:useRef,记录当前选中拖动的 block 索引;
  • markLine:useState,记录水平、垂直辅助线显示的位置,因为涉及到视图更新,这里使用 state 存储。
// 记录当前选中拖动的 block 索引
const currentBlockIndex = useRef(-1);
// 水平、垂直辅助线显示的位置
const [markLine, setMarkLine] = useState({ x: null, y: null });

currentBlockIndex 的记录时机,发生在选中 block 时,这样当在拖动移动时,可以从 schema.blocks 中拿到对应的 block

{schema.blocks.map((block, index) => (
  <Block key={index} block={block} onMouseDown={e => handleMouseDown(e, block, index)}></Block>
))}

const handleMouseDown = (e, block, index) => {
  ...
  currentBlockIndex.current = index;
  // 进行移动
  handleBlockMove(e);
}

markLine 的记录时机发生在拖动移动过程中,下文介绍。

3、记录 block 宽高

由于下面 收集 lines 时需要用到 block 尺寸信息,所以我们在 block 被渲染在画布上时,保存其 widthheight 信息。

// src/Block.js
useEffect(() => {
  const { offsetWidth, offsetHeight } = blockRef.current;
  const { style } = block;
  // block 初渲染至画布上时,记录一下尺寸大小,用于辅助线显示
  style.width = offsetWidth;
  style.height = offsetHeight;
  ...
}, [block]);

!!! 注意,有时候拖拽元素的宽高尺寸带有小数点,如果使用 offsetWidth 只能拿到向下取整的整数,因为这一点偏差,导致拖拽到画布上后,元素会出现换行现象。

针对这种情况,可以换成 getBoundingClientRect 获取元素的准确尺寸,保留两位小数或者是向上取整:

useEffect(() => {
  let { width, height } = blockRef.current.getBoundingClientRect();
  const offsetWidth = Math.ceil(width), offsetHeight = Math.ceil(height);
  const { style } = block;
  // block 初渲染至画布上时,记录一下尺寸大小,用于辅助线显示
  style.width = offsetWidth;
  style.height = offsetHeight;
  ...
}, [block]);

4、收集 lines

这里我们可以拟定两个“对象”(非 JS 对象数据结构,只是称呼):

  • B:代表了当前选中拖动的 block,即 currentBlockIndex block
  • A:代表了画布中剩余未选中的 blocks,每一个未选中的 block 都代表一个 A

那么,接下来 lines 的收集,就是将每一个 A 周围的 10种 辅助线的位置进行存储。(这里可以结合代码与上文中的绘图,一起结合理解

可以暂且先理解为"埋雷",当拖动 B 移动到"埋雷"的位置时,显示辅助线条。

  • lines.x:存储以 left 为坐标的 editor-line-x 线条辅助线信息;
    • lines.x.showLeft:垂直(竖)辅助线显示时所在的位置;
    • lines.x.left:对应到「拖拽元素」所要显示的位置;(解释:当拖拽元素移动到 left 位置时,会让垂直辅助线显示出来,并显示在 showLeft 位置)

  • lines.y:存储以 top 为坐标的 editor-line-y 线条辅助线信息;
    • lines.x.showTop:水平(横)辅助线显示时所在的位置;
    • lines.x.top:对应到「拖拽元素」所要显示的位置;(解释:当拖拽元素移动到 top 位置时,会让垂直辅助线显示出来,并显示在 showTop 位置)
const handleBlockMove = (e) => {
  const { focus, unfocused } = blocksFocusInfo();
  const lastSelectBlock = schema.blocks[currentBlockIndex.current];
  // 我们声明:B 代表最近一个选中拖拽的元素,A 则是对应的参照物,对比两者的位置
  const { width: BWidth, height: BHeight, left: BLeft, top: BTop } = lastSelectBlock.style;

  dragState.current = {
    // 用于实现 block 在画布上进行移动
    startX: e.clientX,
    startY: e.clientY,
    startPos: focus.map(({ top, left }) => ({ top, left })),
    
    // 用于实现 block 在画布上的辅助线
    startLeft: BLeft,
    startTop: BTop,
    
    // 找到其余 A block(unfocused)作为参照物时,参照物周围可能出现的 lines
    lines: (() => {
      const lines = { x: [], y: [] }; // 计算横线的位置使用 y 存放;纵线的位置使用 x 存放。
      
      // 收集 B 移动到每个 unfocused 周围时,要显示的 10 条线信息
      unfocused.forEach(block => {
        const { top: ATop, left: ALeft, width: AWidth, height: AHeight } = block.style;
        
        // 水平横线显示的 5 种情况:(可以对着上图来看)
        lines.y.push({ showTop: ATop, top: ATop }); // 情况一:A 和 B 顶和顶对其。当拖拽元素移动到 ATop(lines.y.top) 位置时,显示这根辅助线,辅助线的位置是 ATop(lines.y.showTop)
        lines.y.push({ showTop: ATop, top: ATop - BHeight }); // 情况二:A 和 B 顶对底
        lines.y.push({ showTop: ATop + AHeight / 2, top: ATop + AHeight / 2 - BHeight / 2 }); // 情况三:A 和 B 中对中
        lines.y.push({ showTop: ATop + AHeight, top: ATop + AHeight }); // 情况四:A和B 底对顶
        lines.y.push({ showTop: ATop + AHeight, top: ATop + AHeight - BHeight }); // 情况四:A和B 底对底

        // 垂直纵线显示的 5 种情况:(可以对着上图来看)
        lines.x.push({ showLeft: ALeft, left: ALeft }); // A 和 B 左对左,线条显示在 A 的左边
        lines.x.push({ showLeft: ALeft + AWidth, left: ALeft + AWidth }); // A 和 B 右对左,线条显示在 A 的右边
        lines.x.push({ showLeft: ALeft + AWidth / 2, left: ALeft + AWidth / 2 - BWidth / 2 }); // A 和 B 中对中,线条显示在 A 的中间
        lines.x.push({ showLeft: ALeft + AWidth, left: ALeft + AWidth - BWidth }); // A 和 B 右对右,线条显示在 A 的右边
        lines.x.push({ showLeft: ALeft, left: ALeft - BWidth }); // A 和 B 左对右,线条显示在 A 的左边
      });
      
      return lines;
    })()
  }

  ...
}

5、查找 line

有了 lines 集合(所有可能会被渲染的辅助线 list),接下来根据当前拖动 block 移动的位置,去比对附近是否有 line 要被显示;

line 满足显示条件(比如:接近 5 像素距离时显示辅助线),调用 setMarkLine 去更新 markLine 的坐标,将 DOM 上的辅助线渲染在视图上。

const handleBlockMove = (e) => {
  ...

  const blockMouseMove = (e) => {
    let { clientX: moveX, clientY: moveY } = e;

    ----------------------------------------------
    // 计算鼠标拖动后,B block 最新的 left 和 top 值
    let left = moveX - dragState.current.startX + dragState.current.startLeft;
    let top = moveY - dragState.current.startY + dragState.current.startTop;
    let x = null, y = null;

    // 将当前 B block 移动的位置,和上面记录的 lines 进行一一比较,如果移动到的范围内有 A block 存在,显示对应的辅助线
    for (let i = 0; i < dragState.current.lines.x.length; i ++) {
      const { left: l, showLeft: s } = dragState.current.lines.x[i];
      if (Math.abs(l - left) < 5) { // 接近 5 像素距离时显示辅助线
        x = s;
        break;
      }
    }
    for (let i = 0; i < dragState.current.lines.y.length; i ++) {
      const { top: t, showTop: s } = dragState.current.lines.y[i];
      if (Math.abs(t - top) < 5) { // 接近 5 像素距离时显示辅助线
        y = s;
        break;
      }
    }

    setMarkLine({ x, y });
    ----------------------------------------------

    const durX = moveX - dragState.current.startX;
    const durY = moveY - dragState.current.startY;

    focus.forEach((block, index) => {
      block.style.top = dragState.current.startPos[index].top + durY;
      block.style.left = dragState.current.startPos[index].left + durX;
    })
    
    forceUpdate();
  }

  const blockMouseUp = () => {
    ...
  }

  document.addEventListener('mousemove', blockMouseMove);
  document.addEventListener('mouseup', blockMouseUp);
}

此时,画布辅助线功能实现完成!

6、画布中心点的辅助线

如果想支持:拖拽一个物料组件(block)到画布的中央点,我们也可以将画布看作是 A block

lines: (() => {
  const lines = { x: [], y: [] };
  
  [...unfocused, {
    // 画布中心辅助线
    style: {
      top: 0,
      left: 0,
      width: schema.container.width,
      height: schema.container.height,
    }
  }].forEach(block => {
    ...
  });
  
  return lines;
})()

7、重置数据状态

当然,别忘记了在特定时机对记录做初始化操作。如:

点击画布的空白处时,重置 currentBlockIndex

const handleClickCanvas = event => {
  event.stopPropagation();
  currentBlockIndex.current = -1;
  cleanBlocksFocus(true);
}

在拖动结束后,重置 markLine

const blockMouseUp = () => {
  document.removeEventListener('mousemove', blockMouseMove);
  document.removeEventListener('mouseup', blockMouseUp);
  setMarkLine({ x: null, y: null });
}

四、吸附实现

吸附的实现思路可以理解为:当移动 B 非常接近 A 时,立刻让 B 去贴近 A,从而达到吸附效果。

比如我们可以定义相差 <= 5px 时,让拖动元素和另一个参照元素贴紧。

有了上面的 辅助线实现,只需要添加两行代码,将以 B 为目标的鼠标移动位置(moveX、moveY),改成以 A 为参考目标。

const handleBlockMove = (e) => {
  ...

  const blockMouseMove = (e) => {
    let { clientX: moveX, clientY: moveY } = e;
    
    for (let i = 0; i < dragState.current.lines.x.length; i ++) {
      const { left: l, showLeft: s } = dragState.current.lines.x[i];
      if (Math.abs(l - left) < 5) { // 接近 5 像素距离时显示辅助线
        x = s;
        // 实现吸附
*       moveX = dragState.current.startX - dragState.current.startLeft + l;
        break;
      }
    }
    for (let i = 0; i < dragState.current.lines.y.length; i ++) {
      const { top: t, showTop: s } = dragState.current.lines.y[i];
      if (Math.abs(t - top) < 5) { // 接近 5 像素距离时显示辅助线
        y = s;
        // 实现吸附
*       moveY = dragState.current.startY - dragState.current.startTop + t;
        break;
      }
    }
    
    ...
  }
}

下面绘制了一张图来理解 moveX 的计算:

image.png

五、放大/缩小

放大缩小是指:画布中的元素在选中后,可以自由拉拽改变尺寸大小,拉拽的地方可以是元素的 4 个边角,或者上下左右四个方向。

下面我们实现两种缩放模式:scale 四个边角缩放scaleX 横竖伸缩,效果图如下:

image.png

我们在注册物料组件时,新增 focusShape 字段来区分两种缩放模式:

registerConfig.register({
  label: '文本',
  preview: () => '四个角度缩放',
  render: () => '四个角度缩放',
  type: 'text',
* focusShape: 'scaleDot',
});

registerConfig.register({
  label: '按钮',
  preview: () => <button>横纵伸缩</button>,
  render: () => <button>横纵伸缩</button>,
  type: 'button',
* focusShape: 'scaleLine',
});

1、结构定义

缩放一共涉及八个方向,这里我们定义如下:

const scaleDotPoints = ['lt', 'rt', 'lb', 'rb']; // 四个脚边放大缩小
const scaleLinePoints = ['l', 'r', 't', 'b']; // 横纵伸缩

下面我们为画布上选中的 block 应用这两种缩放视图,scaleDotPoints 对应四个小圆点,scaleLinePoints 对于四个横线,DOM 结构定义如下:

// Block
<div className={`editor-block`} style={blockStyle} ref={blockRef} {...otherProps}>  
  {RenderComponent}
  
  {block.focus ? (
    <div className="block-focus-shape">
      {block.focusShape === 'scaleLine' ? (
        scaleLinePoints.map(point => (
          <span 
            key={point} 
            className="block-focus-shape__line" 
            onMouseDown={event => handleMouseDownPoint(event, point)}
            style={getShapeLineStyle(point)}></span>
        ))
      ) : (
        scaleDotPoints.map(point => (
          <span 
            key={point} 
            className="block-focus-shape__dot" 
            onMouseDown={event => handleMouseDownPoint(event, point)}
            style={getShapeDotStyle(point)}></span>
        ))
      )}
    </div>
  ) : null}
</div>

CSS 样式如下:

.block-focus-shape{
    &__dot{
      position: absolute;
      width: 9px;
      height: 9px;
      margin-left: -5px;
      margin-top: -5px;
      border-radius: 50%;
      background: #3297FC;
      cursor: pointer;
    }
    &__line{
      position: absolute;
      background: #3297FC;
      box-shadow: 0px 1px 6px 0px rgba(0,0,0,0.1);
      border-radius: 2px;
    }
}

2、位置计算

getShapeDotStyle 用于设置四个小圆角的位置:

const getShapeDotStyle = (point) => {
  const { width, height } = block.style;
  const hasL = /l/.test(point), hasT = /t/.test(point);
  let left = hasL ? 0 : width, top = hasT ? 0 : height;
  const style: React.CSSProperties = {
    left,
    top,
    cursor: `${cursorPoints[point]}-resize`,
  };
  return style;
}

getShapeLineStyle 用于设置四个线条的位置:

const getShapeLineStyle = (point) => {
  const linePointStyles = {
    l: { width: 3, height: 16, left: -3, top: '50%', marginTop: -8 },
    r: { width: 3, height: 16, right: -3, top: '50%', marginTop: -8, },
    t: { width: 16, height: 3, top: -3, left: '50%', marginLeft: -8 },
    b: { width: 16, height: 3, bottom: -3, left: '50%', marginLeft: -8 },
  }
  return {
    ...linePointStyles[point],
    cursor: `${cursorPoints[point]}-resize`,
  };
}

cursorPoints 定义了每一个方向要使用的 css cursor 鼠标指针:

const cursorPoints = {
  lt: 'nw',
  rt: 'ne',
  lb: 'sw',
  rb: 'se',
  l: 'w',
  r: 'e',
  t: 'n',
  b: 's',
}

3、按住圆点、线条进行 放大缩小

这里我们监听 onMouseDown 鼠标按下后,监听 document 鼠标移动事件,去更改 block 的宽高和位置信息,核心实现如下:

const handleMouseDownPoint = (event, point) => {
  event.stopPropagation();
  const { clientX: startX, clientY: startY } = event;
  const { width, height, left, top } = block.style;

  const pointMouseMove = (event) => {
    const hasL = /l/.test(point), hasT = /t/.test(point), hasR = /r/.test(point), hasB = /b/.test(point);
    let { clientX: moveX, clientY: moveY } = event;
    const durX = moveX - startX, durY = moveY - startY;
    block.style = {
      ...block.style,
      width: Math.max(10, width + (hasL ? -durX : hasR ? durX : 0)), // 不存在 l 和 r,说明纵向缩放,width 不动
      height: Math.max(10, height + (hasT ? -durY : hasB ? durY : 0)), // 不存在 t 和 b,说明横向缩放,height 不动
      left: Math.min(left + width, left + (hasL ? durX : 0)), // 从左向右拖,不能超过 right,从右往左拖,left 不动
      top: Math.min(top + height, top + (hasT ? durY : 0)), // 从上往下拖,不能超过 bottom,从下往上拖,top 不懂
    }
  }

  const pointMouseUp = () => {
    document.removeEventListener('mousemove', pointMouseMove);
    document.removeEventListener('mouseup', pointMouseUp);
  }

  document.addEventListener('mousemove', pointMouseMove);
  document.addEventListener('mouseup', pointMouseUp);
}

提示:四个圆角可以同时进行 width/height 调整,四个线条只能修改 width/height 其中一个。

扩展:如果想统一两种模式的交互方式:四个边角、四个方向都采用圆角方式,只需改造下 getShapeDotStyle

const scaleDotPoints = ['lt', 'rt', 'lb', 'rb', 'l', 'r', 't', 'b']; // 放大缩小

const getShapeDotStyle = (point: TPointKey) => {
  const { width, height } = block.style;
  const hasL = /l/.test(point), hasT = /t/.test(point), hasR = /r/.test(point), hasB = /b/.test(point);
  let left = 0, top = 0;
  
  // block 的四个角
  if (point.length === 2) {
    left = hasL ? 0 : width;
    top = hasT ? 0 : height;
  } else {
    // 上下两点的点,宽度居中
    if (hasT || hasB) {
      left = width / 2;
      top = hasT ? 0 : height;
    }
    // 左右两边的点,高度居中
    if (hasL || hasR) {
      left = hasL ? 0 : width;
      top = Math.floor(height / 2);
    }
  }

  const style: React.CSSProperties = {
    left,
    top,
    cursor: `${cursorPoints[point]}-resize`,
  };
  return style;
}

最后

到这里,我们优化了编辑器拖拽移动体验:实现辅助线和吸附功能。下节我们实现撤销重做功能。

低代码 - 可视化拖拽技术分析(3)- 撤销与重做

如有不足之处,欢迎指正 👏 。

借鉴:谭志光 - 可视化拖拽组件库一些技术要点原理分析