前言
上一讲 - 低代码 - 可视化拖拽技术分析(1) 中,我们实现了编辑器画布区域的拖拽移动基础操作,接下来我们从使用体验角度,完善画布编辑区的拖动能力,其中包含:
- 为
拖动的 block
靠近其他 block 时显示辅助线, - 让
拖动的 block
靠近其他 block 时具备吸附能力, - 画布中
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 种情况:
- 垂直纵向辅助线 5 种情况:
下面我们从代码层面实现辅助线。
三、辅助线实现
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 被渲染在画布上时,保存其 width
和 height
信息。
// 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 的计算:
五、放大/缩小
放大缩小是指:画布中的元素在选中后,可以自由拉拽改变尺寸大小,拉拽的地方可以是元素的 4 个边角,或者上下左右四个方向。
下面我们实现两种缩放模式:scale 四个边角缩放
和 scaleX 横竖伸缩
,效果图如下:
我们在注册物料组件时,新增 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;
}
最后
到这里,我们优化了编辑器拖拽移动体验:实现辅助线和吸附功能。下节我们实现撤销
和重做
功能。
如有不足之处,欢迎指正 👏 。