低代码 - 可视化拖拽技术分析(3)
前篇
上一讲 - 低代码 - 可视化拖拽技术分析(2) 中,我们实现了画布元素拖拽移动时的辅助线和吸附功能;本节我们围绕以下几个场景,实现场景操作后的 撤销
与 重做
。
- 向画布中拖拽加入物料组件;
- 对画布中的元素进行位置移动;
- 删除画布中的元素;
- 对画布元素进行置顶;
- 对画布元素进行置底;
描述
关于 撤销
和 重做
的操作,这里简单梳理一下两者的特征:
-
首先,我们会在顶部工具栏区域增加两个操作按钮,分别是撤销和重做;
-
拿
向画布中拖拽加入物料组件
为例,当拖拽物料组件添加到画布上后,通过点击撤销
,能够将刚刚拖拽的组件从画布中移除;若此时点击重做
,又将上一步撤销
物料组件恢复到画布中去; -
同样,
画布元素的位置移动
、删除
、置顶
和置底
这几种情况也同样支持撤销
和重做
。
当然,大多数用户都喜欢用快捷键来做撤销和重做操作,这个也要支持。
下面,我们开始实现 command
命令相关的代码逻辑。
useCommands 定义
考虑到之后还会扩展其它 command 操作,在这里我们统一将命令处理逻辑抽离到公共方法中;
由于方法内涉及到了 React Hooks
相关 API,因此将按照一个以 use
开头的自定义 Hook 去定义和实现:useCommands
;
useCommands
内需要用到 schema.block
信息,以及处理逻辑中要更新视图,因此接收 schema
和 forceUpdate
作为参数。
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
逻辑处理映射表中,并提供了处理函数;
看到这里,暂且先不关注 register
为 command
绑定了哪些处理逻辑,下面我们先熟悉一下,一个要被注册的 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
执行后会返回两个方法:redo
、undo
,看这里的代码应该就可以想到思路了:
- 当执行 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
对象,即发布订阅,它会订阅 startDrag
和 endDrag
动作。
- 当收到
startDrag
通知时,记录schema.blocks
到command.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 发布 startDrag
和 endDrag
呢?
在拖拽物料组件开始时(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.current
去state.queue
中查找并执行任务。
画布元素位置移动的撤销与重做
这个场景可以复用 物料拖拽的撤销与重做
逻辑,也是通过 event.startDrag
和 event.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 + z
和 ctrl + y
,mac 环境下使用 command 按键
代替 ctrl;
当按下组合键时,会在 state.commandList
中查找具有 keyboard
属性的 command,如:undo command
和 redo 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());
}
}, []);
}
删除操作
-
思路: 将画布中选中的元素从
schema.blocks
中移除掉。 -
顶部导航栏添加删除按钮:
const buttons = [
...
{ label: '删除', handler: commands.delete },
];
- 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
进行 focus
和 unfocused
分类:
// src/utils.js
export const blocksFocusInfo = blocks => {
let focus = [], unfocused = [];
blocks.forEach(block => (block.focus ? focus : unfocused).push(block));
return { focus, unfocused };
}
置顶操作
-
思路: 置顶(placeTop),是要作用于画布中
focus
的元素,若找到画布里unfocused
中最大的一个block
层级值maxZIndex
,只需让focus
的元素层级高于maxZIndex
即可; -
顶部导航栏添加置顶按钮:
const buttons = [
...
{ label: '置顶', handler: commands.placeTop },
];
- 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();
}
}
}
});
置底操作
- 思路:
置底(placeBottom),是要作用于画布中
focus
的元素,若找到画布里unfocused
中最小的一个block
层级值minZIndex
,只需让focus
的元素层级低于minZIndex
即可;
注意:ele.zIndex 若是一个负数,元素会被隐藏掉,这里会做特殊判断。
- 顶部导航栏添加置底按钮:
const buttons = [
...
{ label: '置底', handler: commands.placeBottom },
];
- 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();
}
}
}
});
右键菜单
有时候,我们习惯对画布元素进行右键操作,来删除画布元素。下面我们定制几个右键菜单项。
- 添加右键菜单项的 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;
}
- hooks 定义如下:
const contextMenuRef = useRef(null);
const [visible, setVisible] = useState(false);
- 为
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>
- 关闭右键菜单显示:
当在非
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]);
最后
到这里,我们完成了拖拽物料组件、移动画布元素两个场景的 撤销和重做
操作,同时支持了键盘快捷键。下节我们实现 画布尺寸调整
、画布元素放大缩小
。
如有不足之处,欢迎指正 👏 。
转载自:https://juejin.cn/post/7097913024974946317