likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 21

前篇

上一讲 - 低代码 - 可视化拖拽技术分析(2) 中,我们实现了画布元素拖拽移动时的辅助线和吸附功能;本节我们围绕以下几个场景,实现场景操作后的 撤销重做

  1. 向画布中拖拽加入物料组件;
  2. 对画布中的元素进行位置移动;
  3. 删除画布中的元素;
  4. 对画布元素进行置顶;
  5. 对画布元素进行置底;

描述

关于 撤销重做 的操作,这里简单梳理一下两者的特征:

  • 首先,我们会在顶部工具栏区域增加两个操作按钮,分别是撤销和重做;

  • 向画布中拖拽加入物料组件 为例,当拖拽物料组件添加到画布上后,通过点击 撤销,能够将刚刚拖拽的组件从画布中移除;若此时点击 重做,又将上一步 撤销 物料组件恢复到画布中去;

  • 同样,画布元素的位置移动删除置顶置底 这几种情况也同样支持 撤销重做

当然,大多数用户都喜欢用快捷键来做撤销和重做操作,这个也要支持。

下面,我们开始实现 command 命令相关的代码逻辑。

useCommands 定义

考虑到之后还会扩展其它 command 操作,在这里我们统一将命令处理逻辑抽离到公共方法中;

由于方法内涉及到了 React Hooks 相关 API,因此将按照一个以 use 开头的自定义 Hook 去定义和实现:useCommands

useCommands 内需要用到 schema.block 信息,以及处理逻辑中要更新视图,因此接收 schemaforceUpdate 作为参数。

1、command 数据池的定义:

useCommands 中需要有一套数据机制来管理注册的 command,数据结构如下:

// src/useCommands.js
import { useRef } from 'react';

const useCommands = (schema, forceUpdate) => {
  // command 数据池
  const { current: state } = useRef({
    queue: [], // command 队列
    current: -1, // 当前 command 在队列中的位置
    commands: {}, // command 和处理函数 Map 映射表
    commandList: [], // command 集合
    commandDestoryArray: [], // 需要被销毁的 command 集合(存在 init 方法的 command 需要被销毁)
  });
  ...
}

export default useCommands;

这里先有个概念,后面用到时我们再分析每个数据的作用。

2、registry:

这里,我们提供 registry 作为注册 command 的通用方法:

const useCommands = (schema, forceUpdate) => {
  ...
  // 向 command 数据池中注册 command
  const registry = command => {
    if (state.commands[command.name]) return;
    state.commandList.push(command);
    state.commands[command.name] = () => { // 处理函数
      const execute = command.execute();
      const { redo, undo } = execute;
      execute.handle && execute.handle(); // 之所以使用 execute,目的是为了保留 this 指针
      if (!command.pushQueue) return;

      // ...
    }
  }
  ...
}

registry 方法做了两件事情:

  • 将 command 存储到 state.commandList 集合中;
  • 将 command 记录到 state.commands 逻辑处理映射表中,并提供了处理函数;

看到这里,暂且先不关注 registercommand 绑定了哪些处理逻辑,下面我们先熟悉一下,一个要被注册的 command 上包含哪些信息。

3、command 结构信息

从上面 registry 方法内部可以得知一个 command 包含如下几个信息:

  • command 是一个 js 对象,包含一些属性和方法;
  • command 具有一个 name 属性,且唯一;
  • command 具有一个 pushQueue 属性,这不是一个必有属性,当 command 操作需要被加入到队列 queue,用于后续 撤销/重做 时,这个属性值为 true;
  • command 上还存在一个 keyboard 属性,在下面快捷键部分会提到;
  • command 具有一个 execute 方法,通过这个方法可以拿到 handle、redo、undo 三个子方法;
  • handle,作为 command 默认的逻辑处理函数;
  • redo、undo,是需要做撤销、重做功能的 command(如下文的 drag command),提供给 redo command 和 undo command 的处理方法。

这里我们先注册 撤销/重做 command:

const useCommands = (schema, forceUpdate) => {
  // 注册撤销 command
  registry({
    name: 'undo',
    keyboard: 'ctrl+z',
    execute() {
      return {
        handle() {
          console.log('撤销');
          // ...
        }
      }
    }
  });

  // 注册重做 command
  registry({
    name: 'redo',
    keyboard: 'ctrl+y',
    execute() {
      return {
        handle() {
          console.log('重做');
          // ...
        }
      }
    }
  });
}

到这里,useCommands 的基础结构搭建完成,上面只是注册了 撤销/重做 入口,下面我们再完善具体工作。

下面,我们先将这两个命令(command 执行函数)与页面按钮进行关联。

顶部栏扩展操作项

接下来在 顶部操作栏 地方增加两个按钮,点击事件会调用上面 useCommands 中注册的命令函数。

// src/Editor.js
import { useReducer } from 'react';
import useCommands from './useCommands';

function Editor() {
  ...
  const [, forceUpdate] = useReducer(v => v + 1, 0);
  const { commands } = useCommands(schema, forceUpdate);
  ...
  const buttons = [
    { label: '撤销', handler: commands.undo },
    { label: '重做', handler: commands.redo },
  ];
  
  return (
    <Provider value={{ config }}>
      <div className="editor-wrapper">
        <div className="editor-header">
          {buttons.map((button, index) => (
            <button key={index} className="editor-header-button" onClick={button.handler}>{button.label}</button>
          ))}
        </div>
        ...
      </div>
    </Provider>
  );
}

这时,点击按钮会在控制台看到相应的打印信息。

接下来我们先从场景一(拖拽物料组件)开始,实现撤销和重做。

物料拖拽的撤销与重做

这里,我们需要在 useCommands 中注册一个 drag command,这个命令用于拖拽物料组件到画布时,向 state.queue 队列中加入一条操作记录;有了这个队列记录,就可以通过点击撤销/重做按钮,实现对应的功能。

registry drag command

我们拥有 registry 方法,注册命令易如反掌:

const useCommands = (schema, forceUpdate) => {
  ...
  // 注册 drag command 撤销/重做
  registry({
    name: 'drag',
    pushQueue: true, // 标识支持操作 queue 的 command
    init() { // 初始化操作,注册时立即执行
      this.before = null; // 拖拽前保存 blocks 副本
      const start = () => this.before = clone(schema.blocks); // 避免地址引用
      const end = () => state.commands.drag();
      events.on('startDrag', start);
      events.on('endDrag', end);
      return () => {
        events.off('startDrag', start);
        events.off('endDrag', end);
      }
    },
    execute() {
      let before = this.before;
      // 目前采用的这种 forceUpdate 更新方式,对于 after 一定要进行深 clone,避免 schema.blocks 出现地址引用
      let after = clone(schema.blocks);
      return {
        undo() { // 撤销
          schema.blocks = before;
          forceUpdate(); // 切勿使用 setSchema,会造成重渲染时,画布元素闪烁
        },
        redo() { // 重做
          schema.blocks = after;
          forceUpdate();
        },
      }
    }
  });
  ...
}

drag command 操作需要向 queue 加入记录,因此 command.pushQueue 是 true;

init 方法会在初次注册命令时就执行,先跳过这里,串联流程后再回来关注这里;

execute 执行后会返回两个方法:redoundo,看这里的代码应该就可以想到思路了:

  • 当执行 undo 时,将 blocks 重置为拖拽前的 blocks 数据,这里用 this.before 保存;
  • 当执行 redo 时,将 blocks 设置为当前 blocks 数据,这里直接访问 schema.blocks 即可;

完善 registry 队列逻辑

注册的 drag command 信息大致有些了解,接下来我们完善进入 registry 完善加入 queue 逻辑:

const registry = command => {
  if (state.commands[command.name]) return;
  state.commandList.push(command);
  state.commands[command.name] = () => { // 处理函数
    const execute = command.execute();
    execute.handle && execute.handle();
    if (!command.pushQueue) return;

    // 每次向队列中加入任务时,根据当前操作的任务,重新计算队列
    // 如,在本次加入此任务前,已发生了撤销操作,即 state.current !== state.queue.length - 1
    if (state.queue.length > 0) {
      state.queue = state.queue.slice(0, state.current + 1);
    }
    const { redo, undo } = execute;
    state.queue.push({ redo, undo }); // 保存前进后退
    state.current = state.current + 1;
  }
}

这里很容易理解,将 drag command 提供的 redo、undo 加入到队列中,并且更新队列索引 state.current;队列中有了任务,点击页面上的 撤销/重做 按钮自然可以去队列中找和自己有关的事情。

完善 redo command 和 undo command

现在,我们回过头来再看下注册 redo 和 undo command 时未完成的逻辑:

// 注册撤销 command
handle() {
  console.log('撤销');
  let item = state.queue[state.current];
  if (item) {
    item.undo && item.undo();
    state.current --;
  }
}

// 注册重做 command
handle() {
  console.log('重做');
  let item = state.queue[state.current + 1];
  if (item) {
    item.redo && item.redo();
    state.current ++;
  }
}

对于这两个操作,本质上就是读取任务队列,根据 current 指针,执行队列中任务的撤销或重做逻辑。

执行 command.init

上面虽然注册了 drag command,但是 command.init 始终没有执行,我们也明确,它是要在注册完 command 时执行,可以放在 useEffect 内:

import { useRef, useEffect } from 'react';

const useCommands = (schema, forceUpdate) => {
  ...
  useEffect(() => {
    state.commandList.forEach(command => command.init && state.commandDestoryArray.push(command.init()));
    return () => {
      state.commandDestoryArray.forEach(fn => fn && fn());
    }
  }, []);
}

init 方法执行时使用到了 events 对象,即发布订阅,它会订阅 startDragendDrag 动作。

  • 当收到 startDrag 通知时,记录 schema.blockscommand.before 上;
  • 当收到 endDrag 通知时,执行注册的 drag command,加入任务到队列中;
  • 有订阅自然是有销毁,init 返回的函数用作销毁订阅事件;
  • events 是一个手写的简单发布订阅:EventEmitter,这里不做过多描述。
init() { // 初始化操作,注册时立即执行
  this.before = null; // 拖拽前保存 blocks 副本
  const start = () => this.before = clone(schema.blocks);
  const end = () => state.commands.drag();
  events.on('startDrag', start);
  events.on('endDrag', end);
  return () => {
    events.off('startDrag', start);
    events.off('endDrag', end);
  }
},

触发 drag command

那,何处来执行 events 发布 startDragendDrag 呢?

在拖拽物料组件开始时(onDragStart) 会通过 events 发布 startDrag

那能否使用拖拽物料组件的 onDragEnd 事件调用 events 发布 endDrag 呢?

考虑到拖动物料组件,但并未将组件放置在画布上,因此 material.onDragEnd 用在这里不太合适,可以通过 canvas.onDrop 来处理。

// src/Editor.js

const handleDragStart = (component) => {
  currentMaterial.current = component;
  events.emit('startDrag');
}

const handleDrop = (event) => {
  ...
  events.emit('endDrag');
}

<div className="editor-left">
  {config.componentList.map(component => (
    <div 
      ...
      draggable
      onDragStart={() => handleDragStart(component)}>
    </div>
  ))}
</div>

<div className="editor-container" onMouseDown={handleWrapperMouseDown}>
  <div
    id="canvas-container"
    onDrop={handleDrop}
    ...>
    ...
  </div>
</div>

至此,物料拖拽的撤销与重做实现完成。

小结

梳理一下,

  • 在开始拖拽物料组件时,发布 events.startDrag 事件将当前 schema.blocks 保存到 command.before 上;
  • 在将物料组件添加到画布上后,发布 events.endDrag 事件去执行 drag command,将执行逻辑封装成任务加入到 state.queue 队列中;
  • 队列中有了任务,在点击 撤销和重做 时,就可以根据 state.currentstate.queue 中查找并执行任务。

画布元素位置移动的撤销与重做

这个场景可以复用 物料拖拽的撤销与重做 逻辑,也是通过 event.startDragevent.endDrag 结合 drag command 去使用。

首先在拖动画布元素进行移动前,定义一个 drag flag:dragging

// src/Editor.js
const handleBlockMove = (e) => {
  dragState.current = {
    // drag flag
    dragging: false,
    ...
  }
  ...
}

在移动事件(blockMouseMove)中,触发 events.startDrag 通知 drag command 开始拖动:

const blockMouseMove = (e) => {
  if (!dragState.current.dragging) {
    dragState.current.dragging = true;
    events.emit('startDrag'); // 记录开始拖拽移动
  }
}

在松开鼠标移动结束后,触发 events.endDrag 通知 drag command 拖拽结束:

const blockMouseUp = () => {
  ...
  if (dragState.current.dragging) {
    events.emit('endDrag');
  }
}

绑定快捷键

撤销与重做对应的键盘快捷键是:ctrl + zctrl + y,mac 环境下使用 command 按键 代替 ctrl;

当按下组合键时,会在 state.commandList 中查找具有 keyboard 属性的 command,如:undo commandredo command,去执行对应的操作。

import { useRef, useEffect, useMemo } from 'react';

const useCommands = (schema, forceUpdate) => {
  ...
  const keyboardEvent = useMemo(() => {
    const keyCodes = {
      90: 'z',
      89: 'y'
    }
    const onKeydown = (e) => {
      // 避免执行撤销时,和右侧设置器内 input 等输入控件默认支持的命令冲突
      const activeElement = document.activeElement;
      if (activeElement !== document.querySelector('body')) return;
      
      const { ctrlKey, metaKey, keyCode } = e;
      let keyString = [];
      if (ctrlKey || metaKey) keyString.push('ctrl'); // 兼容 windows ctrl 和 mac command
      keyString = [...keyString, keyCodes[keyCode]].join('+');
      state.commandList.forEach(({ keyboard, name }) => {
        if (!keyboard) return; // 没有键盘事件
        if (keyboard === keyString) {
          state.commands[name]();
          e.preventDefault();
        }
      });
    }
    const init = () => {
      document.addEventListener('keydown', onKeydown);
      return () => {
        document.removeEventListener('keydown', onKeydown);
      }
    }
    return init;
  }, []);
}

需要注意:一些原生元素自身具备撤销重做特性,如 input 表单类元素;为避免与之发生冲突,可根据 document.activeElement 进行判定。

快捷键绑定同样提供 init 方法,和 command.init 统一处理。

const useCommands = (schema, forceUpdate) => {
  ...
  useEffect(() => {
    state.commandDestoryArray.push(keyboardEvent());
    state.commandList.forEach(command => command.init && state.commandDestoryArray.push(command.init()));
    return () => {
      state.commandDestoryArray.forEach(fn => fn && fn());
    }
  }, []);
}

删除操作

  1. 思路: 将画布中选中的元素从 schema.blocks 中移除掉。

  2. 顶部导航栏添加删除按钮:

const buttons = [
  ...
  { label: '删除', handler: commands.delete },
];
  1. useCommands 中注册 delete command
// 注册 delete command 撤销/重做
registry({
  name: 'delete',
  pushQueue: true,
  execute() {
    const before = clone(schema.blocks);
    // 将选中的都删除掉,剩余的就是未选中的
    const after = blocksFocusInfo(schema.blocks).unfocused;
    return {
      handle() {
        this.redo(); // 进行删除,删除画布中选中的元素
      },
      undo() {
        schema.blocks = before;
        forceUpdate();
      },
      redo() {
        schema.blocks = after;
        forceUpdate();
      }
    }
  }
});

blocksFocusInfo 是一个工具方法,对画布中 blocks 进行 focusunfocused 分类:

// src/utils.js
export const blocksFocusInfo = blocks => {
  let focus = [], unfocused = [];
  blocks.forEach(block => (block.focus ? focus : unfocused).push(block));
  return { focus, unfocused };
}

置顶操作

  1. 思路: 置顶(placeTop),是要作用于画布中 focus 的元素,若找到画布里 unfocused 中最大的一个 block 层级值 maxZIndex,只需让 focus 的元素层级高于 maxZIndex 即可;

  2. 顶部导航栏添加置顶按钮:

const buttons = [
  ...
  { label: '置顶', handler: commands.placeTop },
];
  1. useCommands 中注册 placeTop command
// 注册 placeTop command 撤销/重做
registry({
  name: 'placeTop',
  pushQueue: true,
  execute() {
    const before = clone(schema.blocks);
    const after = (() => {
      const { focus, unfocused } = blocksFocusInfo(schema.blocks);
      const maxZIndex = unfocused.reduce((prev, block) => {
        return Math.max(prev, block.style.zIndex);
      }, -Infinity); // 负无穷大
      focus.forEach(block => block.style.zIndex = maxZIndex + 1); // 将所有选中的 block zIndex 基于最大层级 + 1
      return schema.blocks;
    })();

    return {
      handle() {
        this.redo(); // 进行删除,删除画布中选中的元素
      },
      undo() {
        schema.blocks = before;
        forceUpdate();
      },
      redo() {
        schema.blocks = after;
        forceUpdate();
      }
    }
  }
});

置底操作

  1. 思路: 置底(placeBottom),是要作用于画布中 focus 的元素,若找到画布里 unfocused 中最小的一个 block 层级值 minZIndex,只需让 focus 的元素层级低于 minZIndex 即可;

注意:ele.zIndex 若是一个负数,元素会被隐藏掉,这里会做特殊判断。

  1. 顶部导航栏添加置底按钮:
const buttons = [
  ...
  { label: '置底', handler: commands.placeBottom },
];
  1. useCommands 中注册 placeBottom command
// 注册 placeBottom command 撤销/重做
registry({
  name: 'placeBottom',
  pushQueue: true,
  execute() {
    const before = clone(schema.blocks);
    const after = (() => {
      const { focus, unfocused } = blocksFocusInfo(schema.blocks);

      let minZIndex = unfocused.reduce((prev, block) => {
        return Math.min(prev, block.style.zIndex);
      }, Infinity) - 1;
      // 如果 zIndex < 0,出现了负值,元素则会被隐藏,所以限制不能出现负数
      if (minZIndex < 0) {
        minZIndex = 0;
        const dur = Math.abs(minZIndex);
        unfocused.forEach(block => block.style.zIndex += dur);
      }

      focus.forEach(block => block.style.zIndex = minZIndex);
      return schema.blocks;
    })();
    return {
      handle() {
        this.redo(); // 进行删除,删除画布中选中的元素
      },
      undo() {
        schema.blocks = before;
        forceUpdate();
      },
      redo() {
        schema.blocks = after;
        forceUpdate();
      }
    }
  }
});

右键菜单

有时候,我们习惯对画布元素进行右键操作,来删除画布元素。下面我们定制几个右键菜单项。

  1. 添加右键菜单项的 DOM 呈现:
<ul className={`editor-context-menu ${visible ? 'editor-context-menu__active' : ''}`} ref={contextMenu}>
  <li className="context-menu-item" onClick={commands.placeTop}>置顶</li>
  <li className="context-menu-divider"></li>
  <li className="context-menu-item" onClick={commands.placeBottom}>置底</li>
  <li className="context-menu-divider"></li>
  <li className="context-menu-item" onClick={commands.delete}>删除</li>
</ul>

这里用到了两个 hook 变量:

  • visible 会在用户对 画布 block 进行右键操作后,设置为 true;
  • contextMenuRef 用于保存 Menu DOM 实例;

visible = true 时,会向 Menu DOM 追加 editor-context-menu__active 让菜单显示:

.editor-context-menu__active{
  opacity: 1;
  visibility: visible;
}
  1. hooks 定义如下:
const contextMenuRef = useRef(null);
const [visible, setVisible] = useState(false);
  1. block 绑定右键菜单事件:
const handleContextMenu = event => {
  event.stopPropagation();
  event.preventDefault();
  contextMenuRef.current.style.left = event.clientX + 'px';
  contextMenuRef.current.style.top = event.clientY + 5 + 'px';
  setVisible(true);
}

<Block 
  key={index} 
  block={block} 
  onMouseDown={e => handleMouseDown(e, block, index)}
  onContextMenu={handleContextMenu}></Block>
  1. 关闭右键菜单显示: 当在非 block 区域点击或者进行右键操作时,需要重置 visible 来隐藏 Menu。可以借助 useEffect 去为 window 顶层元素绑定事件:
useEffect(() => {
  const closeContextMenu = () => {
    if (!visible) return;
    setVisible(false)
  };
  window.addEventListener('click', closeContextMenu);
  window.addEventListener('contextmenu', closeContextMenu);
  return () => {
    window.removeEventListener('click', closeContextMenu);
    window.removeEventListener('contextmenu', closeContextMenu);
  }
}, [visible]);

最后

到这里,我们完成了拖拽物料组件、移动画布元素两个场景的 撤销和重做 操作,同时支持了键盘快捷键。下节我们实现 画布尺寸调整画布元素放大缩小

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