低代码 - 可视化拖拽技术分析(4)
前篇
上一讲 - 低代码 - 可视化拖拽技术分析(3) 中,我们实现了 撤销
与 重做
操作;本节我们继续完善编辑器的功能,包含以下内容:
- 画布元素的放大缩小;
- 画布尺寸、位置自定义调整;
- 保存 Schema;
- Schema 的预览和编辑;
- 右侧设置区域布局;
- 属性设置器;
- 样式设置器。
放大缩小
放大缩小是指:画布中的元素在选中后,可以自由拉拽改变尺寸大小,拉拽的地方可以是元素的 4 个边角,或者上下左右四个方向。
之前我们在元素 focus
选中后为元素添加了边框,现在我们要改造一下这里:在 focus
之后,为元素周围添加可 拉拽 的小圆点(8个),用来放大缩小。
1、添加小圆点
小圆点一共有 8 个,分别代表不同的方向:top、right、bottom、left、leftTop、rightTop、leftBottom、rightBottom。
const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb'];
现在,我们为每个 Block
添加小圆点,当 block 被选中时,显示小圆点:
// src/Block.js
<div
...
className={`editor-block ${block.focus ? 'editor-block-focus' : ''}`}>
{block.focus ? pointList.map(point => (
<div key={point} className="shape-point"}></div>
)) : null}
...
</div>
.shape-point {
position: absolute;
background: #fff;
border: 2px solid #59c7f9;
width: 8px;
height: 8px;
border-radius: 50%;
z-index: 1;
cursor: pointer;
}
现在,选中 block 后,所有的小圆点都在左上角位置,我们需要将每个小圆点分别放置在 8 个方向位置上。
2、计算小圆点位置
这里我们定义 getPointStyle
方法,根据方向计算 left
和 top
:
{block.focus ? pointList.map(point => (
<div key={point} className="shape-point" style={getPointStyle(point)}></div>
)) : null}
block 的四个角很好计算:
- 如果是
leftTop
和leftBottom
,包含 left,则紧贴左侧,left
按照 0 处理;否则紧贴右侧,left
按照 width 处理; - 如果是
leftTop
和rightTop
,包含 top,则紧贴顶部,top
按照 0 处理;否则紧贴底部,top
按照 height 处理。
对于上下两个点,它们的 left
都是 width 的一半;
对于左右两个点,他们的 top
都是 height 的一半;
最后,只需要平移负的小圆点自身的一半,即可达到位置居中。
const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb'];
const cursors = {
t: 'n-resize',
r: 'e-resize',
b: 's-resize',
l: 'w-resize',
lt: 'nw-resize',
rt: 'ne-resize',
lb: 'sw-resize',
rb: 'se-resize',
}
const getPointStyle = point => {
const { width, height } = block;
const hasT = /t/.test(point),
hasB = /b/.test(point),
hasL = /l/.test(point),
hasR = /r/.test(point);
let newLeft = 0, newTop = 0;
// block 的四个角
if (point.length === 2) {
newLeft = hasL ? 0 : width;
newTop = hasT ? 0 : height;
} else {
// 上下两点的点,宽度居中
if (hasT || hasB) {
newLeft = width / 2;
newTop = hasT ? 0 : height;
}
// 左右两边的点,高度居中
if (hasL || hasR) {
newLeft = hasL ? 0 : width;
newTop = Math.floor(height / 2);
}
}
const style = {
marginLeft: '-4px',
marginTop: '-4px',
left: `${newLeft}px`,
top: `${newTop}px`,
cursor: cursors[point],
}
return style;
}
cursors 这里做了方向映射,不同的圆点对应的 cursor 指针样式不同。
有了小圆点,我们就可以通过拖动小圆点改变 block size
。
3、拖动小圆点进行放大缩小
拖动移动,这里我们依然采用 onMouseDown
事件:
{block.focus ? pointList.map(point => (
<div key={point} className="shape-point"
onMouseDown={event => handleMouseDownOnPoint(event, point)}
style={getPointStyle(point)}></div>
)) : null}
梳理一下:
- 首先要阻止冒泡事件,避免触发
Block
的拖拽移动事件; - 在拖动小圆点前,记录当前鼠标位置信息;
- 拖动过程中用最新的位置信息和初始位置信息进行比较,计算差值;
- 根据差值分别计算得出 block:
width
、height
、left
、top
; - 拿底部
button
这个圆点举例:这个点拖动只会改变组件的高度,若差值是正数,说明在往下拉,组件高度增加;若差值是负数,说明在往上拉,组件高度减小。
const handleMouseDownOnPoint = (event, point) => {
event.stopPropagation();
const { clientX: startX, clientY: startY } = event;
const { width, height, top, left } = block;
const handleMouseMove = event => {
let { clientX: moveX, clientY: moveY } = event;
const diffX = moveX - startX;
const diffY = moveY - startY;
const hasT = /t/.test(point),
hasB = /b/.test(point),
hasL = /l/.test(point),
hasR = /r/.test(point);
const newHeight = height + (hasT ? -diffY : hasB ? diffY : 0);
const newWidth = width + (hasL ? -diffX : hasR ? diffX : 0);
block.height = Math.max(newHeight, 0);
block.width = Math.max(newWidth, 0);
block.left = left + (hasL ? diffX : 0);
block.top = top + (hasT ? diffY : 0);
forceUpdate();
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
将改变后的 width
、height
作用于 Block 组件:
const blockStyle = {
top: block.top,
left: block.left,
width: block.width,
height: block.height,
zIndex: block.zIndex,
};
<div
...
style={blockStyle}
className={`editor-block ${block.focus ? 'editor-block-focus' : ''}`}>
...
</div>
另外,由于我们将 style
信息赋予给了 Block 内的 div
盒子,并非对应的 RenderComponent
。
因此,RenderComponent
需要设置 width、height 为 100%
才能实现真正的放大缩小。
我们在 config.js
中为注册的组件改造如下:
// src/config.js
registerConfig.register({
label: '按钮',
preview: () => <button>预览按钮</button>,
render: () => <button style={{ display: 'block', width: '100%', height: '100%' }}>渲染按钮</button>,
type: 'button',
});
画布尺寸、位置自定义调整
1、自定义画布尺寸
画布的默认大小采用 iPhoto 6/7/8 的尺寸:width: 375
和 height: 667
。
为了能够自定义尺寸大小,我们在顶部操作栏位置扩展两个 input
组件:
<div className="editor-header">
...
<div className="editor-header-canvas">
<span>画布尺寸</span>
<input
value={wrapperDragState.width}
onChange={event => setWrapperDragState({ ...wrapperDragState, width: event.target.value })}
onBlur={changeCanvasSize} />
<span>*</span>
<input
value={wrapperDragState.height}
onChange={event => setWrapperDragState({ ...wrapperDragState, height: event.target.value })}
onBlur={changeCanvasSize} />
</div>
</div>
wrapperDragState
是一个 useState,默认读取 schema.container
尺寸信息。
const [wrapperDragState, setWrapperDragState] = useState({
left: 0,
top: 0,
width: schema.container.width,
height: schema.container.height,
});
将 input 每次 change
的数据保存在 wrapperDragState
中;当 input 失焦后,同步到 schema.container
中:
const changeCanvasSize = () => {
schema.container.width = Number(wrapperDragState.width || 0);
schema.container.height = Number(wrapperDragState.height || 0);
forceUpdate();
}
2、自定义画布位置
默认,画布位置显示在 editor-container
容器 水平/垂直 居中位置,我们可以给容器添加 mousemove
事件,来调整画布的位置。
const handleWrapperMouseDown = event => {
// 避免进行右键操作后,出现意外画布移动。(event.button === 0 代表左键)
if (event.button === 2) return;
const { clientX: startX, clientY: startY } = event;
const mousemove = event => {
const diffX = event.clientX - startX;
const diffY = event.clientY - startY;
setWrapperDragState({
...wrapperDragState,
left: wrapperDragState.left + diffX,
top: wrapperDragState.top + diffY,
});
}
const mouseup = () => {
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);
}
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
}
<div className="editor-container" onMouseDown={handleWrapperMouseDown}>
...
</div>
画布应用 wrapperDragState
保存的 left 和 top
信息:
<div
id="canvas-container"
style={{ ...schema.container, transform: `translate(${wrapperDragState.left}px, ${wrapperDragState.top}px)` }}>
...
</div>
保存 Schema
保存的功能非常简单,我们可以拿到 schema
信息,它包含了画布中所有元素的拖拽信息,因此我们只需保存 schema
即可。
通常会将 schema
保存到数据服务器,这里简单保存至本地 sessionStorage
中:
1、操作栏添加保存按钮:
const saveSchema = () => {
sessionStorage.setItem('lc-schema', JSON.stringify(schema));
alert('save success.');
}
const buttons = [
...
{ label: '保存', handler: saveSchema },
];
2、读取缓存 schema
:
import SchemaJSON from './schema.json';
const [schema] = useState(JSON.parse(sessionStorage.getItem('lc-schema')) || SchemaJSON);
Schema 的预览和编辑
Schema
的在线预览和修改后同步编辑器,给编辑器配置带来了灵活性。例如 Ali 低代码引擎就实现了此功能。
一般需要一个类似于 json editor
的 JSON 编辑器来实现此功能。
这里不做过深介绍,简单使用 alert 弹出 Schema 进行预览。
const seeSchema = () => {
alert(JSON.stringify(schema, null, 2));
}
const buttons = [
...
{ label: 'schema', handler: seeSchema },
];
右侧设置区域布局
页面上有了右侧区域的布局渲染,才能继续下面的设置器内容。布局这里我们简单实现一下。
假设我们右侧设置区域,可供设置的画布元素信息类型有以下四类:
const setterTabs = [
{ type: 'props', name: '属性' },
{ type: 'style', name: '样式' },
{ type: 'events', name: '事件' },
{ type: 'animations', name: '动画' },
]
在 Editor .editor-right
位置添加 DOM 结构和样式布局,得到如下代码:
import PropsSetter from './setter/PropsSetter'; // 属性设置器(下面讲)
import StyleSetter from './setter/StyleSetter'; // 样式设置器(下面讲)
<div className="editor-right">
<div className="setter-tabs-list">
// 1、渲染 Tab
{setterTabs.map(tab => (
<div
key={tab.type}
data-type={tab.type}
className={`setter-tab ${activeSetter === tab.type ? 'setter-tab-active' : ''}`}
onClick={() => setSetter(tab.type)}>{tab.name}</div>
))}
</div>
// 2、渲染 activeTab 对应的 View
<div className="setter-content">
{currentBlockIndex.current !== -1 ? (() => {
const block = schema.blocks[currentBlockIndex.current];
if (!block) return null;
switch (activeSetter) {
case 'props':
return <PropsSetter block={block} />;
case 'style':
return <StyleSetter block={block} />
default:
return null;
}
})() : null}
</div>
</div>
同一时间只能显示一个 Tab,因此需要一个 state 变量来标记当前显示的 Tab - activeSetter
:
// src/Editor.js
const [activeSetter, setSetter] = useState('');
另外,为了每次添加到物料组件到画布上时,右侧区域可以默认显示当前 block
的属性设置器内容,在添加到画布上时,记录 currentBlockIndex
即可:
// src/Editor.js
const handleDrop = (event) => {
// ...
currentBlockIndex.current = schema.blocks.length - 1;
setSetter('props');
}
到这里,右侧的设置区域布局完成,下面我们将 属性设置
逻辑加入进来。
属性设置器
设置器,可以理解为修改某一属性的控件组件,比如可以是 Input、Radio、ColorPicker 等;画布元素中,每一个支持修改的属性都会对应一类设置器。
第一步,我们先改造一下 config.js
中注册的组件,为其添加 children prop
:
// src/config.js
registerConfig.register({
label: '文本',
preview: () => '预览文本',
render: ({ children }) => <span>{children}</span>,
type: 'text',
props: {
children: '渲染文本',
}
});
registerConfig.register({
label: '按钮',
preview: () => <button>预览按钮</button>,
render: ({ children }) => <button style={{ display: 'block', width: '100%', height: '100%' }}>{children}</button>,
type: 'button',
props: {
children: '渲染按钮',
}
});
文本和按钮分别接收 props.children
作为渲染文本,第二步,我们改造一下 Block.js
中的逻辑,将 block.props
传递给 RenderComponent
:
// src/Block.js
const { block, ...otherProps } = props;
const component = config.componentMap[block.type];
const RenderComponent = component.render(block.props);
接下来,就需要在将物料组件添加到画布上时,将 第一步
和 第二步
的 props
进行关联:
// src/Editor.js
const handleDrop = (event) => {
const { offsetX, offsetY } = event.nativeEvent;
// 1、取出注册组件时定义的 props
const { type, props, style } = currentMaterial.current;
schema.blocks.forEach(block => block.focus = false);
schema.blocks.push({
type,
alignCenter: true,
focus: true,
style: {...},
// 2、传递给 block props
props,
});
...
}
在上面 右侧属性设置区域布局
已为设置器提供了视图环境,接下来我们编写 PropsSetter
呈现逻辑。
在这里,我们需要做的一件事情就是:将 component.props
和设置器进行关联,去呈现 prop
对应的的设置器。
在这里,组件的 props.children
属于文本类型属性,对应的设置器组件为 Input
。
// src/setter/PropsSetter.js
import { useContext } from 'react';
import EditorContext from '../context';
const PropsSetter = ({ block }) => {
const { forceUpdate } = useContext(EditorContext);
const onChange = (key, value) => {
block.props[key] = value;
forceUpdate(); // 更新画布视图
}
// 根据 block 类型,映射渲染列表,
const propsList = (() => {
switch (block.type) {
case 'button':
// 映射组件属性对应的 Setter 属性设置器类型
return Object.keys(block.props).map(prop => ({ label: prop, value: block.props[prop], setter: 'input' }));
case 'text':
return Object.keys(block.props).map(prop => ({ label: prop, value: block.props[prop], setter: 'input' }));
default:
return [];
}
})();
return (
<div className="setter-props-wrapper">
{propsList.map(prop => (
<div key={prop} className="setter-item">
<span className="setter-item-label">{prop.label}:</span>
<div className="setter-item-control">
{(() => {
switch (prop.setter) {
case 'input':
return <input value={prop.value} onChange={event => onChange(prop.label, event.target.value)} />
default:
return null;
}
})()}
</div>
</div>
))}
</div>
)
}
export default PropsSetter;
上面代码中有一点很关键:设置 block.props
后如何触发视图更新?
const { forceUpdate } = useContext(EditorContext);
const onChange = (key, value) => {
block.props[key] = value;
forceUpdate(); // 更新画布视图
}
这里涉及到一个交互:更改属性设置器后,画布中这个元素视图应该同步更新。
画布视图所在的组件为 Editor
,画布的视图更新可以由 Editor
提供,代码如下:
// src/Editor.js
function Editor() {
const [, forceUpdate] = useReducer(v => v + 1, 0);
return (
<Provider value={{ config, forceUpdate }}>
...
<Provider/>
)
}
现在,我们就可以通过右侧属性设置器,来修改画布中按钮文本。
样式设置器
理解了属性设置器的流程,相信 灵机一动,就可想出样式设置器的实现步骤了。
同样的,我们先从注册组件入手,在 config.js
注册组件时,可以配置 RenderComponent
默认样式:
// src/config.js
registerConfig.register({
type: 'button',
label: '按钮',
render: ({ children }) => <button style={{ display: 'block', width: '100%', height: '100%' }}>{children}</button>,
style: {
width: 100,
height: 34,
zIndex: 1,
},
...
});
接着,在拖动组件添加到画布上时,将提供的默认样式赋值给 block.style
:
const handleDrop = (event) => {
const { type, props, style } = currentMaterial.current;
schema.blocks.push({
style: {
width: undefined,
height: undefined,
left: offsetX,
top: offsetY,
zIndex: 1,
...style
},
...
});
...
}
下面我们实现 StyleSetter
,根据 block.style
遍历渲染设置器,这里目前统一使用了 Input
:
// src/setter/StyleSetter.js
import { useContext } from 'react';
import EditorContext from '../context';
const StyleSetter = ({ block }) => {
const { forceUpdate } = useContext(EditorContext);
const onChange = (key, value) => {
block.style[key] = Number(value);
forceUpdate(); // 更新画布视图
}
return (
<div className="style-setter-wrapper">
{Object.keys(block.style).map(key => (
<div key={key} className="setter-item">
<span className="setter-item-label">{key}:</span>
<div className="setter-item-control">
<input value={block.style[key]} onChange={event => onChange(key, event.target.value)} />
</div>
</div>
))}
</div>
)
}
export default StyleSetter;
关于样式这里,也涉及到一个和画布的交互:当拖动画布元素进行放大缩小时,样式设置器这里的信息也应该同步。
由于右侧设置区域也是在 Editor
组件中,因此 Block
组件可以使用 Editor
提供的视图更新方法 forceUpdate
:
// src/Block.js
- const [, forceUpdate] = useReducer(v => v + 1, 0);
+ const { config, forceUpdate } = useContext(EditorContext);
现在,我们可以去通过设置器去修改属性,并且画布元素的放大缩小,也会将 widht、height 同步在设置器中。
最后
感谢阅读。
如有不足之处,欢迎指正 👏 。
转载自:https://juejin.cn/post/7097913504828489735