Appearance
期望篇-可视化自定义独特的简历模版
前言
本章节并不会教你去实现一份可视化自定义的简历模版,授人以鱼不如授人以渔
,阿宽希望你阅读完此章节之后,能散发思维,自己动手实现,举一隅不以三隅反,则不复也。
我们要做一个什么样的东西呢?看下图你大致也能猜到,没错,好听点叫做可视化低代码平台,但实际上,懂得都懂。抛开所有东西不说,我们仅为了学习,来了解一下其中某些功能是如何实现。
⚠️ 本章节不会手把手再带大家去实现这个平台了,工作量与阅读量较多,但如果你相信阿宽,你可以结合文章的核心内容,再去 dom-ui-toolbar 结合代码阅读,最后动手实操。纸上得来终觉浅,绝知此事要躬行!
本项目代码在:✨ dom-ui-toolbar,感兴趣的小伙伴可以前往阅读源码
期望你看完本章节之后,能结合线上代码,动手实操,甚至于能完成可视化自定义独特的简历模版~
00. 前期准备
如上图所示,我们的页面布局共分为三处:
- 左侧:物料市场
- 中间:内容画布
- 右侧:操作区
一个操作流程分为:从左侧物料市场拖拽一个组件至画布,激活当前选中的组件,可通过右侧修改数值以达到组件样式的实时更新。
- 初始状态
- 组件拖拽进画布
- 组件自由移动、层级覆盖
- 组件内容更新,样式修改
01. 布局实现
接下来均是实现过程的一些思考,具体动手实操请结合github 仓库。接下来都不需要小伙伴们写代码,小伙伴们只需要理解即可~
我们先在 dom-ui-toolbar 项目的 src/container
文件夹下,建好三个文件夹,对外导出相应组件。
ts
// 左侧物料市场
export default function Material() {
return (
<div styleName="material">
<div styleName="logo">物料市场</div>
</div>
);
}
ts
// 中间组件画布
export default function WinCenter() {
return <div styleName="content">中间画布</div>;
}
ts
// 右侧操作区
export default function Toolbar() {
return <div styleName="toolbar">操作区</div>;
}
现在共有 Material 物料市场
、Toolbar 操作区
、WinCenter 中心画布
三大组件,那么我们的布局组件是这样的。源代码点击这里
ts
// https://github.com/PDKSophia/dom-ui-toolbar/blob/master/example/src/index.tsx
import Material from './container/Material';
import Toolbar from './container/Toolbar';
import WinCenter from './container/WinCenter';
function Main() {
const toolbarRef = useRef(null);
const materialRef = useRef(null);
return (
<React.Fragment>
<div ref={materialRef} styleName="material">
<Material />
</div>
<div styleName="content">
<WinCenter />
</div>
<div ref={toolbarRef} styleName="action">
<Toolbar />
</div>
</React.Fragment>
);
}
现在我们完成了布局,左侧物料市场,中间画布,右侧操作区,闭上眼睛,脑补一下,此时页面上分三大块,但都还是空白,接下来我们一步步讲解其中的功能点。
在这之前,请允许我给大家看一个大致的流程图
02. 物料市场的设计
搞清楚了流程之后,接下来的开发就比较简单了。
我们在 Material 文件夹下,新增了两个物料组件:Button
与 Text
,
大家思考一下,在 Material/index.tsx
中,我们该如何写这段代码呢?第一时间撸下了这段代码
javascript
import Button from './components/Button';
import Text from './components/Text';
function Material() {
return (
<div styleName="material">
<div styleName="logo">物料市场</div>
<div styleName="flex">
<Button />
<Text />
</div>
</div>
);
}
但这样写会有什么问题?第一,如果你想做拖拽组件效果,那么你需要在每一个组件的代码实现中,写一段重复的代码,如下,以 Button
组件为例
ts
function Button() {
return (
<div
styleName="btn"
draggable={true}
onDragStart={(e) => e.dataTransfer.setData('componentName', 'Button')}
>
基础按钮
</div>
);
}
export default Button;
第二,当你往 Material/components
新增物料组件时,你还需要到根组件将新增的组件引入。意味着,新增/删除一个物料,你都需要修改 Material/index.tsx
入口文件。有没有什么办法解决?
我们可以这样写,将所有物料统一经过 index.ts
管理,对外导出
ts
export { default as Button } from './Button';
export { default as Text } from './Text';
然后我们在物料市场的Material/index.tsx
入口文件稍微修改
ts
import React from 'react';
import './index.less';
import * as ComponentsList from './components';
import MyScrollBox from '@components/Base/MyScrollBox';
function Material() {
const height = document.body.clientHeight;
return (
<div styleName="material">
<MyScrollBox maxHeight={height}>
<div styleName="logo">物料市场</div>
<div styleName="flex">
{Object.keys(ComponentsList).map(
(componentName: string, index: number) => {
// 👇 这个是需要渲染的组件实例
const RenderComponent = ComponentsList[componentName];
return (
<div styleName="item" key={`${componentName}_${index}`}>
<RenderComponent
key={`${componentName}_${index}`}
draggable={true}
onDragStart={(e: React.DragEvent<HTMLDivElement>, componentRefs?: HTMLDivElement) => {}}
/>
</div>
);
}
)}
</div>
</MyScrollBox>
</div>
);
}
export default Material;
以上是物料市场的实现(还没有实现 onDragStart()
方法),接下来我们来实现一下中间画布。
03. 组件画布设计实现
中间区域就是一个“画布”,它并不是我们所理解的 canvas
,它是一个大的 div
元素,相对定位,所有拖拽进来的组件在该画布上,都是绝对定位。
画布维护一套 editorComponentList
数组,每次从物料市场拖拽组件过来,都会 push 进该数组。画布进行数组遍历,从而展示。下面看一下基本代码
ts
function WinCenter() {
const { editorComponentList, dispatchAddComponentAction } = useEditorStoreModel();
return (
<div styleName="winCenter">
<div
styleName="editor-canvas"
onDrop={(e: React.DragEvent<HTMLDivElement>) => {}}
onDragOver={(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}}
>
<Editor />
</div>
</div>
);
}
我们来看看 <Editor />
组件主要做了什么事情
function Editor() {
const { editorComponentList } = useEditorStoreModel();
// 组件随便移动
function handleOnMouseDown(e: React.MouseEvent, componentIndex: number) {}
// 点击选中一个组件
function handleOnComponentClick(e: React.MouseEvent, componentIndex: number) {}
// 点击画布空白区域
function handleUnComponentAreaClick(e: React.MouseEvent) {}
return (
<div styleName="editor" onMouseDown={handleUnComponentAreaClick}>
// step1. 遍历所有加入的组件数组
{editorComponentList.length > 0 &&
editorComponentList.map((EditComponent: Types.IStoreComponentProps, componentIndex: number) => {
return (
<div
styleName="wrapper-component"
key={EditComponent.componentId}
style={pickStyle(EditComponent.style, ['left', 'top', 'zIndex', 'cursor'])}
// step2. 元素拖拽,鼠标按下之后的回调事件
onMouseDown={(e: React.MouseEvent) => {
handleOnMouseDown(e, componentIndex);
}}
// step3. 点击选中当前需要操作的组件
onClick={(e: React.MouseEvent) => {
handleOnComponentClick(e, componentIndex);
}}
>
{EditComponent.componentInstance && (
<EditComponent.componentInstance
componentKey={EditComponent.componentId}
componentInnerText={EditComponent.componentInnerText}
componentStyles={omitStyle(EditComponent.style, ['left', 'top', 'zIndex', 'cursor'])}
/>
)}
</div>
);
})}
</div>
);
}
export default Editor;
可能小伙伴们还是有些懵,我们来捋一捋。首先,我们在 Redux 中存储着一个组件数组,取名为 editorComponentList
,数组的每一项均符合 IStoreComponentProps
类型声明
ts
export interface IStoreComponentProps {
componentId: string; // 组件id
componentInstance: any; // 组件实例
style: React.CSSProperties; // 组件样式
}
然后我们在物料市场拖拽至画布时,我们执行 onDragStart()
中,写下了这段代码
ts
onDragStart={(e: React.DragEvent<HTMLDivElement>, componentRefs?: HTMLDivElement) => {
// 👉 step1. 传递拖拽组件名
e.dataTransfer.setData('componentName', componentName);
if (componentRefs) {
const componentStore = {
...initComponentStyleStore.Base,
...initComponentStyleStore[componentName]
};
// 👉 step2. 得到拖拽组件的 style
const styles = getDomStyle(componentRefs, componentStore);
// 👉 step3. 传递拖拽组件的文本内容
e.dataTransfer.setData('componentInnerText', componentRefs.innerText);
// 👉 step4. 传递拖拽组件的样式
e.dataTransfer.setData('componentDomStyle', JSON.stringify(styles));
}
}}
当组件进入到画布,鼠标松开的那一瞬间,我们执行了 onDrop()
方法,来看看做了什么
ts
onDrop={(e: React.DragEvent<HTMLDivElement>) => {
// 👉 step1. 阻止冒泡
e.preventDefault();
e.stopPropagation();
// 👉 step2. 得到拖拽组件名
const componentName = e.dataTransfer.getData('componentName');
// 👉 step3. 得到拖拽组件的文本内容
const componentInnerText = e.dataTransfer.getData('componentInnerText');
// 👉 step4. 得到拖拽组件的样式
let componentDomStyle: React.CSSProperties = {};
try {
componentDomStyle = JSON.parse(e.dataTransfer.getData('componentDomStyle'));
} catch (err) {
componentDomStyle = {};
}
// 💥 👉 step5. 通过 dispatch action 的方式,将拖拽组件存到 redux 中
dispatchAddComponentAction(componentName, componentInnerText, {
...componentDomStyle,
left: e.nativeEvent.offsetX,
top: e.nativeEvent.offsetY,
// 👇 注意这里,因为组件都是绝对定位,所以组件间的层级关系
// 👇 通过z-index进行修改,先加入的 z-index 低,后加入的 z-index 高
zIndex: editorComponentList.length
});
}
上面注释写的非常清楚,这时候我们通过 dispatch 了一个 action,将拖拽组件追加到 redux 的 editorComponentList
,来看看这个 dispatchAddComponentAction()
做了什么。
ts
// 👉 step1.导入所有的物料,目的是拿到实例
import * as ComponentsList from '@container/Material/components';
/**
* 添加一个组件
* @param componentName 组件名
* @param componentInnerText 组件文本内容
* @param componentStyles 组件自定义样式
*/
const dispatchAddComponentAction = (
componentName: string,
componentInnerText?: string,
componentStyles?: React.CSSProperties
) => {
// 👉 step2. 得到组件实例,目的是为了渲染
const componentInstance = ComponentsList[componentName];
// 👉 step3. 拷贝一份之前的 editorComponentList
let nextStore = cloneDeep(editorComponentList);
// 👉 step4. 追加一条数据
nextStore.push({
componentId: createUUid(),
componentName,
componentInstance,
componentInnerText,
style: componentStyles,
});
setEditorComponentList(nextStore);
};
此时我们只需要在画布中将 editorComponentList
取出来渲染即可。
ts
{
editorComponentList.length > 0 &&
editorComponentList.map(
(EditComponent: Types.IStoreComponentProps, componentIndex: number) => {
return (
<div
styleName="wrapper-component"
key={EditComponent.componentId}
style={pickStyle(EditComponent.style, ['left', 'top', 'zIndex', 'cursor'])}
onMouseDown={(e: React.MouseEvent) => {
handleOnMouseDown(e, componentIndex);
}}
onClick={(e: React.MouseEvent) => {
handleOnComponentClick(e, componentIndex);
}}
>
{EditComponent.componentInstance && (
<EditComponent.componentInstance
componentKey={EditComponent.componentId}
componentInnerText={EditComponent.componentInnerText}
componentStyles={omitStyle(EditComponent.style, ['left', 'top', 'zIndex', 'cursor'])}
/>
)}
</div>
);
}
);
}
以上我们就完成了从物料市场拖拽组件到画布渲染的主流程之一。请注意,因为我们的画布定位是 relative
,组件在画布下的定位是 absolute
,通过 left、top
进行位置的偏移,通过 z-index
实现后拖拽的组件能覆盖在之前拖拽的组件。
04. 操作区的实现
上面是完成了 物料市场 \-> 画布
的实现,接下来我们继续完成一下操作区的实现。操作区我们可以看看共分为五大块。
- 布局
- 填充
- 文字
- 内容
- 代码
ts
const ToolbarMemo = useMemo(() => {
if (currentEditorComponent) {
return (
<MyScrollBox maxHeight={height}>
<Layout styles={currentEditorComponent?.style || {}} onUpdateStyles={onUpdateStyles} />
<Fill styles={currentEditorComponent?.style || {}} onUpdateStyles={onUpdateStyles} />
{ResourceContentList.includes(currentEditorComponent.componentName) && (
<Font styles={currentEditorComponent?.style || {}} onUpdateStyles={onUpdateStyles} />
)}
{ResourceContentList.includes(currentEditorComponent.componentName) && (
<Content
componentInnerText={currentEditorComponent?.componentInnerText || ''}
onUpdateInnerText={onUpdateInnerText}
/>
)}
{currentEditorComponent?.style && (
<Code styles={currentEditorComponent?.style} />
)}
</MyScrollBox>
);
} else {
return <MyEmpty full={false} description="暂无选中组件" />;
}
}, [currentEditorComponent]);
上面的代码想必小伙伴们都能理解,那么 currentEditorComponent
是什么呢?这里指的是我们在画布中鼠标点击选中的组件。我们前面说了,画布中的组件渲染是遍历 editorComponentList
,而 editorComponentList
数组是在我们从物料市场拖拽进画布时,push 到数组中的。
当我们选中一个组件 currentEditorComponent
后,将其数据存至 redux 中,然后 <Toolbar />
组件直接从 redux 中取数据,做对应的渲染。
ts
/**
* editorComponentList 在遍历时,注册 onClick 事件
* 触发点击事件,表示选中当前组件,记录当前组件
*/
const dispatchSetCurrentEditorComponentAction = (componentIndex: number) => {
setCurrentEditorComponentIndex(componentIndex);
setCurrentEditorComponent(editorComponentList[componentIndex]);
};
05. 选中组件动态修改 style
如果你仔细看代码,上面的代码你会发现一个叫做 onUpdateStyles()
的方法。让我们再来捋一捋。
首先我们在画布众多组件中,点击选中了一个组件,将其存到 redux,此时此刻的 currentEditorComponent
是存在数据的。在操作组件里,我们从 redux 中取出 currentEditorComponent
数据,进行展示。
由于我们的操作区均是由 Input
输入框组成,当输入框内容 onChange
之后,我们将修改后的数据覆盖原有的数值,从而达到动态修改 style 的功能。
ts
const dispatchUpdateComponentStylesAction = (componentStyles: React.CSSProperties) => {
const nextStore = cloneDeep(editorComponentList);
const updateComponent = {
...nextStore[currentEditorComponentIndex],
style: { ...componentStyles },
};
nextStore[currentEditorComponentIndex] = updateComponent;
setEditorComponentList(nextStore);
setCurrentEditorComponent(updateComponent);
};
06. 组件在画布中随意移动
上边我们实现了 物料市场 \-> 拖拽组件 \-> 画布 \-> 选中组件 \-> 操作区修改
的主逻辑,接下来我们来实现组件的随意移动。
试想一哈,你拖拽组件时,手一抖,心一慌,不小心松开了鼠标,组件在画布上出现了位置偏差,本来你想拖到右边的,不小心拖到了左边。咋整?
所以支持组件在画布中随意移动是重中之重,接下来我们来实现一下此功能。为了更好的直观表达,阿宽花了一张图
如上图所示,组件从位置 A 移动到 A'
我们只需要知道移动之后, left'
和 top'
的值,然后修改该组件的 style 即可
这是一道数学题,已知 A 的 clientX 和 clientY,以及 left 和 top,并且现在我们还能知道 A' 的 clientX' 与 clientY',求 A' 的 left' 和 top',思考一下,该如何计算?
ts
// 组件
<div styleName="editor" onMouseDown={handleUnComponentAreaClick}>
{editorComponentList.length > 0 &&
editorComponentList.map(
(EditComponent: Types.IStoreComponentProps, componentIndex: number) => {
return (
<div
styleName="wrapper-component"
key={EditComponent.componentId}
onMouseDown={(e: React.MouseEvent) => {
handleOnMouseDown(e, componentIndex);
}}
>
// ...
</div>
);
}
)}
</div>
上面的伪代码应该看得懂,重点在于 onMouseDown
做了什么事,来看看 handleOnMouseDown
具体干了啥?
ts
export default function () {
const { editorComponentList, dispatchUpdateComponentPositionAction } = useEditorStoreModel();
return (componentIndex: number, e: React.MouseEvent) => {
const currentEditComponent: Types.IStoreComponentProps = editorComponentList[componentIndex];
if (e.button != 0) {
// 屏蔽左键以外的按键
return;
}
// 获得最开始,鼠标按下时的客户端区域的坐标
const x = e.clientX;
const y = e.clientY;
// 获得元素之前的定位偏移量
const top = Number(currentEditComponent?.style?.top) || 0;
const left = Number(currentEditComponent?.style?.left) || 0;
// 是否鼠标按下
let isMouseDown = false;
// 设置手势
const cursor = 'move';
const mouseMove = (moveEvent: MouseEvent) => {
isMouseDown = true;
// 获得元素移动过程中的客户端区域坐标
const currentX = moveEvent.clientX;
const currentY = moveEvent.clientY;
const repaintStyle = {
...currentEditComponent.style,
top: currentY - y + top,
left: currentX - x + left,
cursor,
};
dispatchUpdateComponentPositionAction(componentIndex, repaintStyle);
};
const mouseUp = () => {
isMouseDown = false;
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseUp);
};
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseUp);
};
}
代码几乎都有注释,我就不过多赘述了。
07. 清除画布
这很简单,点个按钮,将 redux 中的 editorComponentList
数据清空即可。
ts
/**
* 清空画布,清除所有组件
*/
const dispatchClearTotalComponentAction = () => {
setEditorComponentList([]);
setCurrentEditorComponentIndex(-1);
setCurrentEditorComponent(null);
};
08. 删除某一组件
目前只支持选中一个组件,点击删除按钮进行删除。
ts
/**
* 从数组中删除组件
*/
const dispatchDeleteComponentAction = (componentIndex: number) => {
let nextStore = cloneDeep(editorComponentList);
nextStore.splice(componentIndex, 1);
setEditorComponentList(nextStore);
};
如果你有兴趣,你可以做多个组件的删除,比如当我摁住 commandOrControl
键,再点击鼠标,这时候可以选中多个进行删除。
再或者你可以鼠标右键进行快捷删除。没有做不到的,只有想不到的。
09. 撤销
撤销是什么呢?说白了你反悔了。那如何实现呢?不好意思,阿宽还没实现此功能,但有过一些方案的对比。有两种方式实现:
- 快照方式
- 动作记录方式
快照方式
以快照方式,存储当前画布的完整信息,记录每次操作后的快照,撤销则以上一次快照为准进行恢复,特点是:
- 数据存储的是所有画布数据
- 在乎的是最终的结果,以结果进行的快照存储
那有什么问题?举个例子,存储的快照只有 ABC 三个数据,期间我做了添加 DEF 数据的操作,此时撤销,恢复快照,则只会恢复 ABC,但我的预期是恢复到 ABCDE。可能小伙伴会觉得,为什么只能恢复到 ABC 呢?因为我们需要考虑快照的存储时长。究竟是 n/ms
还是 n/s
都需要经过考虑。
它的优点是什么呢?相对简单,适用本地,不适用于多人协作(虽然这里我们不考虑多人协同)
动作记录方式
以动作记录方式,存储的是当前的每一次动作,定义一个栈,每次的动作均入栈。在我们执行撤销操作时,出栈,执行对应的撤销事件响应,如:
- 我们删除了一个组件,对应的,在撤销时,我们执行添加组件事件,把删除的加回来,以达到恢复原状态
- 再比如我们把组件宽度变大,对应的,在撤销时,我们执行缩小组件宽度,把宽度改回原来的大小,以达到恢复原状态
- ......
这种方式的特点主要是:
- 存储的是动作,可以细化到你做了什么,在乎的是过程,相对快照方式更加颗粒化
- 比较复杂,但可以做到协同编辑,可以多人协作(但还需要考虑更多,比较是否会冲突等)
以上就是撤销实现的两种方式,虽然我还没实现,但这两种思路,希望能给小伙伴们带来一些帮助,感兴趣的可以延伸一波,扩宽一下思路,比如额外查阅资料看看如何实现协同编辑,多人协作。
10. 调整组件层级
我们的画布是 position: relative
,而组件是 position: absolute
,所以我们的组件可以在画布中任何布局定位,但组件之间的层级关系是通过 z-index
来实现的。
第一个组件被拖入画布,此时 editorComponentList
长度为 1,该组件的 z-index
为 1;第二个组件被拖入,editorComponentList
长度为 2,该组件的 z-index
为 2;以此类推,但人总是不满足于现状。说白了就是 jian,我就想把第一个组件的 z-index
层级提升,该如何处理?
很简单,只需要处理 z-index
即可,那么如何提升?这就得你自己实现了。
比如 editorComponentList = [a, b, c],对应的层级关系就是 zIndex = [a, b, c],你想把 a 的层级提升,你可以把数组顺序改变一下,变成 [b, c, a],当然这只是其中的一种方案。
11. 标线
类似上述效果,就是 A 组件不动,我们拖拽 B 组件,当 B 组件跟 A 组件左对齐、右对齐、上对齐、下对齐等,我们就会出现一条辅助线。
那么如何实现呢?
很简单,当 A 组件固定,B 组件拖拽过程中,我们其实都能知道每个组件的 x、y,我们只需要添加一个判断。判断它们是否存在对齐。
javascript
// 左对齐
A.x = B.x
// 右对齐
A.x + A.width = B.x + B.width
// 上对齐
A.y = B.Y
// 下对齐
A.y + A.height = B.y + B.height
如果复合条件,那么就现实一条辅助线在该 (x, y) 上即可。
如果你还有疑问,那么再看看,再想想,捋一捋是不是这么个意思。
12. 吸附
如图,此时我 B 组件继续往左边拖(图一),那么会出现 B 组件紧吸附在 A 组件上(图二),那这是如何实现的呢?
我们预先定好一个值 distance = 5
,表示这两个组件在距离小于 distance
时,就需要自动吸附。
假设我们现在 A 和 B 的 x、y 均为 0,宽度均为 50,此时它们重叠在了一块。
ts
// A 组件
A = (0, 0);
// B 组件
B = (0, 0);
当我们拖拽组件 B 时,B 的坐标变为 (54, 0)
。
ts
// A 组件
A = (0, 0);
// B 组件
B = (54, 0);
这时候按道理来讲,A 和 B 的位置应该如上图的左边图一所示,中间留有 4 像素的距离,但此时,由于 B.x \- A.width <= distance
,也就是 54 \- 50 <= 5
,我们就能认为这两个组件靠的很近了,应当实现吸附效果。所以我们手动将 B 组件的坐标改为 B = (50, 0)
,这样就将 AB 组件吸附在一块,不分离了。
最后
还有很多功能点值得去讲,比如 组件拉伸放大缩小
、组件旋转
等,再更多的可以扩展,去做离线缓存,协同编辑。
离线缓存:比如你做的是一个知识图谱(或者是一些笔记等),那么如何设计将数据存储在本地设备中,你可能采用本地文件读写存储、FIleSystem API、IndexDB 等方案,如何择选如何设计?数据结构如何定义?连入网络之后的本地数据同步到云端?云储存同步方案如何实现?同步过程出现冲突,如何解决?
协同编辑:两个人同时操作如何处理,比如 A 在处理图谱时,B 的整个画布被禁止不允许操作还是说 B 也能同时处理图谱?如果禁止 B 操作则体验不佳,允许 B 操作那么该如何处理操作范围?比如图谱两个同级节点,A 处理 Node1,那么 B 是也能处理 Node1 还是 Node1 被锁置灰,只能处理 Node2?同步远端数据出现冲突如何处理?
我认为这些都是值得去探索去研究的,即使最后我们没能做出来,或者做了一个 bug 很多半成品,但在过程中我们学到了很多东西。
最后,我没有讲解如何去实现一份可视化自定义的简历模版,我认为最核心重要的是如何实现最基础的流程,万变不离其宗。
希望小伙伴们看完这章节之后,能结合 ✨ dom-ui-toolbar 搭一个简单的小平台,去折腾,去玩一下。