低代码介绍
- 低代码开发平台(LCDP):开发者不需要传统的手写代码的方式进行变成,采用图形化拖拽的方式,配置参数完成开发工作。
- 低代码核心:降低重复劳动(营销中心,中后台系统等)。
- 组成:组件区(物料)、编辑/预览区,属性区/事件区、通过拖拽组件生成JSON,通过JSON渲染出页面(采用 JsonSchema 描述页面)。
我这里是通过 Vue3 来实现的低代码平台。
step1:项目初始化
- 通过 vue/cli 创建项目
vue create xyy-visual-editor
版本选择 vue3.x,选择scss,然后回车默认生成即可。
删除 assets 文件夹 删除 Hello.vue 组件 清空 app.vue
<template>
</template>
<script>
</script>
<style lang="scss">
</style>
step2:创建editor组件
创建 src/data.json对象,它作为一个描述对象,用来约束拖拽编辑器容器和注册的组件的一些行为,比如大小,颜色等待,作为本次编辑器的一种统一风格配置叭。
{
"container": {
"width": 550,
"height": 550
},
"blocks": [ // 拖拽生成的元素,有要渲染区的位置信息和层级,目前我们写死啦
{
"top": 100,
"left": 100,
"zIndex": 1,
"key": "text"
},
{
"top": 200,
"left": 200,
"zIndex": 1,
"key": "button"
},
{
"top": 300,
"left": 300,
"zIndex": 1,
"key": "input"
}
]
}
创建 packages/editor.jsx,此即为拖拽编辑器生成区域的显示内容。
这里作为一个比较复杂的组件,为了更灵活,我选用了jsx。
创建 packages/editor.jsx
import { defineComponent } from 'vue';
import './editor.scss';
export default defineComponent({
props: {
modelValue: {
type: Object,
}
},
setup(props) {
return () => {
return <div className="editor">
<div className="editor-left">左侧物料区</div>
<div className="editor-top">顶部菜单栏</div>
<div className="editor-right">属性控制栏</div>
<div className="editor-container">
{/* 负责产生滚动条 */}
<div className="editor-container-canvas">
{/* 内容区 */}
<div className="editor-container-canvas_content">
这是页面内容区
</div>
</div>
</div>
</div>
}
}
});
创建 editor.scss
.editor {
width: 100%;
height: 100%;
&-left, &-right {
position: absolute;
width: 270px;
background: red;
top: 0;
bottom: 0;
}
&-left {
left: 0;
}
&-right {
right: 0;
}
&-top {
position: absolute;
right: 280px;
left: 280px;
height: 80px;
background: blue;
}
&-container {
padding: 80px 270px 0;
height: 100%;
box-sizing: border-box;
&-canvas {
overflow: scroll;
height: 100%;
&_content {
margin: 20px auto;
width: 1000px;
height: 1000px;
background: yellow;
}
}
}
}
修改app.vue
<template>
<div class="app">
// 这里v-model相当于两个属性
// 一个:modelValue="state"
// 一个@update:modelValue="xxx"
<Editor v-model="state"></Editor>
</div>
</template>
<script>
import data from './data.json';
import Editor from './packages/editor';
import { ref } from 'vue';
export default {
components: {
Editor
},
setup() { // 导出响应式数据源
const state = ref(data);
return { state };
}
}
</script>
<style lang="scss">
.app {
position: fixed;
top: 20px;
left: 20px;
right: 20px;
bottom: 20px;
}
</style>
此时页面应该长这样:
step3:创建假数据根据拖放的位置渲染内容
创建组件 packages/editor-block.jsx,该组件用于遍历 blocks 属性列表,循环生成 dom 结构,组件接受了一个 block 配置,并根据配置动态设置 css,这就是我们内容区组件排版的方式啦
packages/editor-block.jsx
import { computed, defineComponent } from "vue";
export default defineComponent({
props:{
block: { type: Object }
},
setup(props) {
const blockStyles = computed(() => {
return {
top: `${ props.block.top }px`,
left: `${ props.block.left }px`,
zIndex: props.block.zIndex,
}
})
return () => {
return <div class="editor-block" style={ blockStyles.value }>这是一个代码块</div>
}
}
});
引入 block 组件,取配置对象并传入 block 组件,交由组件内部处理。
修改 packages/editor.jsx
+ import EditorBlock from './editor-block';
...
setup(props) {
+ const data = computed({
+ get() {
+ return props.modelValue;
+ }
+ });
+ const containerStyles = computed(() => {
+ return {
+ width: data.value.container.width + 'px',
+ height: data.value.container.height + 'px',
+ };
+ });
}
return () => {
...
<div className="editor-container-canvas">
+ {/* 内容区 根据配置调整内容区容器大小*/}
+ <div className="editor-container-canvas_content" style={ containerStyles.value }>
+ {/* 循环blocks */}
+ {
+ (data.value.blocks.map((block, index) => (
+ <EditorBlock block={ block }></EditorBlock>
+ )))
+ }
</div>
</div>
</div>
</div>
}
修改 editor.css,给组件 block 添加样式,并干掉原有内容区写死的宽高。
packages/editor.css
...
&-container {
padding: 80px 270px 0;
height: 100%;
box-sizing: border-box;
&-canvas {
overflow: scroll;
height: 100%;
&_content {
margin: 20px auto;
- width: 1000px;
- height: 1000px;
background: yellow;
+ position: relative;
}
}
+ .editor-block {
+ position: absolute;
+ }
此时,页面长这样:
step4:根据元素类型,生成物料区&渲染区真实元素
引入 element-plus
这里我们使用了 element-plus 组件库,这也是符合我们业务组件特色的组件库,这里可以自行调整。
npm install element-plus --save-dev
main.js 引入组件样式
...
import { createApp } from 'vue'
+ import 'element-plus/theme-chalk/index.css';
import App from './App.vue'
createApp(App).mount('#app')
配置组件之间的映射关系
组件在页面显示分为物料区和渲染区,物料区只能展示和拖动(不能输入或者点击,"假元素"),还需要按照注册顺序依次渲染,考虑 list 收集注册任务 渲染区则需要生成真实可点选的组件元素(比如输入框,但是这里也是不让聚焦的哦,因为还要做选中拖呢),这里按我的思路,需要两部分数据。
- 注册组件需要有个组件的key,代表组件类型,比如 text, button 等
- 我当前注册过的物料区组件list,我渲染好往那边一放作为预览
- 我需要一个物料区组件的 key,还需要一个 key 和真实组件的对应 Map,好用于渲染真实组件
- 我需要一个label,因为物料区左上角我想显示一个tag
so,我需要一个这样结构的对象。
// preview 和 render 都是一个 render 函数。
{
label: '输入框',
key: 'input'
preview: () => <ElButton>预览按钮</ElButton>,
render: () => <ElButton>渲染按钮</ElButton>
}
那我就把注册的对象格式定义成这样,就好了。
创建 utils/editor-config.jsx
// 本方法生成列表区可以显示所有的物料和 key 对应的组件映射关系
import { ElButton, ElInput } from 'element-plus';
/**
* 组件注册方法
* @returns { Function }
*/
function createEditorConfig() {
// 物料区的预览组件列表,使用 List 可以保证按注册顺序显示物料区元素
const componentList = [];
// 渲染用的组件映射关系
const componentMap = {};
return { // 返回列表和映射表
componentList,
componentMap,
register(component) {
componentList.push(component);
componentMap[component.key] = component; // 渲染映射表
}
}
}
// 这里导出
export let registerConfig = createEditorConfig();
// 传入一个要注册的映射表,用来注册我们左侧的物料,想注册多少个就注册多少个
registerConfig.register({
label: '文本',
preview: () => '预览文本', // 左侧物料区展示的预览
render: () => '渲染文本', // 内容区即将渲染的内容元素, 放在block组件
key: 'text' // 这里的key,要和通用配置中key一致,才可应用其样式
});
registerConfig.register({
label: '按钮',
preview: () => <ElButton>预览按钮</ElButton>,
render: () => <ElButton>渲染按钮</ElButton>,
key: 'button'
});
registerConfig.register({
label: '按钮',
preview: () => <ElInput placeholder="预览input"></ElInput>,
render: () => <ElInput placeholder="渲染input"></ElInput>,
key: 'input'
});
注入映射关系到全局
修改 App.vue,注入 registerConfig 到全局
+ import { registerConfig as config } from './utils/editor-config';
export default {
components: {
Editor
},
setup() { // 导出响应式数据源
...
+ // 将物料区组件&渲染区key对应组件的Map注册到全局 以便block 渲染物料区和绘制内容
+ provide('config', config);
...
}
渲染内容区
在block组件内接受并根据当前组件key去渲染
setup(props) {
...
+ const config = inject('config');
return () => {
+ // 通过key获取到对应的组件
+ const component = config.componentMap[props.block.key];
+ // 拿到渲染后的元素
+ const RenderComponent = component.render();
return <div class="editor-block" style={ blockStyles.value }>
+ { RenderComponent }
</div>
}
}
渲染物料区
修改 editor.jsx
+ import { computed, defineComponent, inject } from 'vue';
setup() {
+ inject('config');
+ return () => {
+ return <div className="editor">
+ <div className="editor-left">
+ {/* 物料区 */}
+ {
+ config.componentList.map(component => {
+ return <div className="editor-left-item">
+ <span>{ component.label }</span>
+ <div>{ component.preview() }</div>
+ </div>
+ })
+ }
+ </div>
+
+ ...
}
修改 editor.scss
.editor{
&-left {
left: 0;
+ &-item {
+ width: 250px;
+ margin: 20px auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: #fff;
+ padding: 20px;
+ box-sizing: border-box;
+ cursor: move;
+ user-select: none;
+ min-height: 100px;
+ position: relative;
+
+ > span {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: rgb(96, 205, 224);
+ color: #fff;
+ padding: 4px;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #ccc;
+ opacity: 0.2;
+ }
}
}
}
这回看起来舒服多了,现在页面它长这样
至此,我们实现了物料区组件和mock数据生成真实dom,接下来,该弄拖拽了。
step5:拖拽的实现
拖拽呢,分为两部分,一部分是物料区往内容区拖,一部分就是内容区自己的拖拽啦,我们先来思考物料区的拖拽。
物料区拖动
拖拽本身不难,不过是设置个 draggable, 就能拖拽了,ondragstart,ondragmove, ondragleave,onDragend, drop 这些 API 而已。
那怎么往内容区渲染呢?其实很简单,因为我们上面已经实现了通过 blocks 和 映射 Map 往内容区生成真实标签,那么就是说,谁在我内容区松手(内容区的 drop 事件),我只需要往 blocks 中插入点值,并通知 App.vue data 更新了,这事儿不就完了,很巧,我们传递编辑器配置文件的时候,使用了 v-model,v-model 默认为我们绑定了一个 @update:modelValue 事件,我们通过 editor 组件去 emit change,这事儿看起来就完了,说到底,只是数据改变,触发视图更新,这也是我选 Vue 来做这件事的原因。
修改 editor 组件
packages/editor.jsx
+ import { computed, defineComponent, inject, ref } from 'vue';
import './editor.scss';
import EditorBlock from './editor-block';
+ import deepcopy from 'deepcopy';
+ import useMenuDragger from './useMenuDragger';
export default defineComponent({
props: {
modelValue: {
type: Object,
}
},
setup(props, ctx) {
const data = computed({
get() {
return props.modelValue;
},
+ set(newValue) {
+ // 这里触发前面 v-model 绑定的事件
+ ctx.emit('update:modelValue', deepcopy(newValue));
+ }
});
const containerStyles = computed(() => {
return {
width: data.value.container.width + 'px',
height: data.value.container.height + 'px',
};
});
const config = inject('config');
+ const containerRef = ref(null); // 获取内容区节点
+ const { dragstart, dragend } = useMenuDragger(containerRef, data); // 实现物料菜单的拖拽功能
return () => {
return <div className="editor">
<div className="editor-left">
+ {/* 物料区 这里要实现h5的拖拽*/}
+ {
+ config.componentList.map(component => (
+ <div
+ className="editor-left-item"
+ draggable="true"
+ ondragstart={ e => dragstart(e, component) }
+ onDragend={dragend}
+ >
+ <span>{ component.label }</span>
+ <div>{ component.preview() }</div>
+ </div>
+ ))
+ }
+ </div>
<div className="editor-top">顶部菜单栏</div>
<div className="editor-right">属性控制栏</div>
<div className="editor-container">
{/* 负责产生滚动条 */}
<div className="editor-container-canvas">
{/* 内容区 */}
+ <div className="editor-container-canvas_content" style={ containerStyles.value } ref={ containerRef }>
...
新增文件 packages/useMenuDragger.js
export default function useMenuDragger(containerRef, data) {
let currentComponent = null;
const dragenter = (e) => {
e.dataTransfer.dropEffect = 'move';
}
const dragover = (e) => {
e.preventDefault();
}
const dragleave = (e) => {
e.dataTransfer.dropEffect = 'none';
}
const drop = (e) => {
console.log('松手了', currentComponent);
// 拷贝一个元素放到拖拽的位置
let blocks = data.value.blocks; // 拿到所有注册的元素
// 插入一个新的拖拽元素到 blocks 中,这样写触发 set 更新
data.value = {
...data.value,
blocks: [
...blocks,
{
top: e.offsetY,
left: e.offsetX,
zIndex: 1,
key: currentComponent.key,
alignCenter: true // 松手居中
}
]
}
currentComponent = null;
}
const dragstart = (e, component) => {
// console.log(containerRef.value);
// dragenter 进入内容区 需要添加一个移动的小图标
// dragover 在目标元素上经过 必须要阻止默认行为 否则不能触发 drop
// dragleave 离开内容区时,取消移动的小图标
// drop 在内容区松手时候 根据拖拽的位置 在当前位置添加一个该组件
currentComponent = component;
containerRef.value.addEventListener('dragenter', dragenter);
containerRef.value.addEventListener('dragover', dragover);
containerRef.value.addEventListener('dragleave', dragleave);
containerRef.value.addEventListener('drop', drop);
}
const dragend = (e) => {
containerRef.value.removeEventListener('dragenter', dragenter);
containerRef.value.removeEventListener('dragover', dragover);
containerRef.value.removeEventListener('dragleave', dragleave);
containerRef.value.removeEventListener('drop', drop);
}
return {
dragstart,
dragend
}
}
为新渲染的元素居中
packages/editor-block.js
+ import { computed, defineComponent, inject, onMounted, ref } from "vue";
export default defineComponent({
setup(props) {
const config = inject('config');
+ const blockRef = ref(null);
+ onMounted(() => { // 如果有居中属性 让其居中(希望元素生成后,鼠标位置在元素中心)
+ let { offsetWidth, offsetHeight } = blockRef.value;
+ if (props.block.alignCenter === true) {
+ // 说明是拖拽松手后渲染的元素 修改 props.block 触发更新
+ props.block.left = props.block.left - offsetWidth / 2;
+ props.block.top = props.block.top - offsetHeight / 2;
+ props.block.alignCenter = false; // 重置为false
+ }
+ });
return () => {
// 通过key获取到对应的组件
const component = config.componentMap[props.block.key];
// 拿到渲染后的元素
const RenderComponent = component.render();
+ return <div class="editor-block" style={ blockStyles.value } ref={ blockRef }>
{ RenderComponent }
</div>
}
});
完事儿
内容区拖拽
选中/多选元素
内容区拖拽的第一步是先选中元素,甚至多选元素(按住 shift 多选),选中的时候可以给个线圈,然后一起拖拽。
ok,这个很简单,生成元素的时候来个 onMousedown(e, block) 事件就行了,不过我们需要阻止元素的默认行为,给所有元素添加一个 after 蒙层,不能再获取 input button 等元素焦点了。
修改 packages/editor.scss
.editor-block {
position: absolute;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #ccc;
+ opacity: 0.2;
+ }
+}
+
+.editor-block-focus {
+ &::after {
+ border: 5px dashed red;
+ }
}
选中事件,我们可以给内容区元素本身添加一个 omMousedown(e, block),点到元素后给元素添加一个属性 focus (先禁止元素默认行为,比如 input 的聚焦,而且阻止冒泡)。而点击元素外面的内容区,则取消全部选中,如果按着 shift,可以连续选中,而不是选中我取消你。
修改 packages/editor.jsx
+ const { blockMouseDown, containerMousedown, focusData } = useFocus(data);
{/* 负责产生滚动条 */}
<div className="editor-container-canvas">
{/* 内容区 */}
+ <div className="editor-container-canvas_content" style={containerStyles.value} ref={containerRef} onMousedown={containerMousedown}>
{/* 循环blocks */}
{
(data.value.blocks.map((block, index) => (
<EditorBlock
class={block.focus ? 'editor-block-focus' : ''}
block={block}
+ onMousedown={e => blockMouseDown(e, block)}
>
</EditorBlock>
)))
}
</div>
创建一个工具方法,packages/useFocus.js
import { computed } from "vue";
// 该方法处理内容区选中焦点相关
export default function useFocus(data, callback) {
const clearBlockFocus = () => {
data.value.blocks.forEach(block => {
block.focus = false;
});
}
const blockMouseDown = (e, block) => {
// 阻止元素默认行为
e.preventDefault();
// 禁止事件冒泡
e.stopPropagation();
// 我们计划在内容区每个元素上定义一个属性叫 focus,获取焦点就变为 true
if (e.shiftKey) { // 按住 shift 可以多选
// 如果只有一个选中,则不需要来回切换当前元素的选中状态了
block.focus = focusData.value.focus.length <= 1 ? true : !block.focus;
} else {
if (!block.focus) {
// 清除其他focus
clearBlockFocus();
block.focus = true;
}
}
callback(e); // 按钮处理完后,立马统计一下选中的元素
}
const containerMousedown = (e) => { // 点击内容区 清空所有选中
clearBlockFocus();
}
// 计算哪些被选中了,对 focusData 取值时会计算
const focusData = computed(() => {
let focus = [];
let unfocused = [];
data.value.blocks.forEach(block => (block.focus ? focus : unfocused).push(block));
return {
focus,
unfocused
}
})
return {
blockMouseDown,
containerMousedown,
focusData
}
}
注意,focusData 是一个计算属性,我们对 focusData 的取值,能拿到当前哪些元素被选中了,这很好理解,我要批量移动或者单独移动的话,需要知道当前哪些元素被选中,然后批量对 x ,y 坐标做更改。
至此,点选元素功能就实现了,还实现了统计点选的元素 focusData。
元素拖拽
其实元素的拖拽就好实现很多了,因为在元素上按下鼠标的时候,我们目前实现的是选中元素,那还有可能是马上拖动元素呢?所以我们要在点选元素的时候,记录下当前所有选中元素的坐标,以便鼠标移动后,所有元素跟着动。所以我们传入一个 cb,在处理元素点击的时候,执行这个 cb,为了方便,我们给 cb 也起名 mousedownCb,该方法做了两件事。
- 收集记录了当前鼠标位置和所有选中元素当前坐标
- 给当前 document 添加 mousemove 和 mouseup 事件。
在 mousemove 事件中,我们不停的计算当前鼠标位置跟之前的鼠标位置做对比,根据鼠标移动的轨迹去改变选中元素的 top 和 left 就完事了。而在 mouseup 的时候,我们需要销毁事件,否则事件注册的很多。
修改 packages/editor.jsx
setup(props, ctx) {
...
+ const { blockMouseDown, containerMousedown, focusData } = useFocus(data, (e) => { // 实现选中组件获取焦点功能
+ // 每次选中都会执行次mousedown回调,里面触发 focusData 取值,而且判断移动坐标
+ mousedownCb(e);
+ });
+ const { mousedownCb } = useBlockDragger(focusData);
...
修改 packages/useFocus.js,每次点击内容区元素后,都执行 mouseCb
const blockMouseDown = (e, block) => {
// 阻止元素默认行为
e.preventDefault();
// 禁止事件冒泡
e.stopPropagation();
// 我们计划在内容区每个元素上定义一个属性叫 focus,获取焦点就变为 true
if (e.shiftKey) { // 按住 shift 可以多选
// 如果只有一个选中,则不需要来回切换当前元素的选中状态了
block.focus = focusData.value.focus.length <= 1 ? true : !block.focus;
} else {
if (!block.focus) {
// 清除其他focus
clearBlockFocus();
block.focus = true;
}
}
+ callback(e); // 记录当前所有选中元素的坐标和鼠标当前位置,并注册move和up事件,为移动元素做准备
}
新增工具方法 packages/useBlockDragger.js,该方法处理移动相关,暴露一个 mouseCb
export default function useBlockDragger(focusData) {
let dragState = { // 记录鼠标按下时坐标和所有选中元素的位置列表
stateX: 0,
stateY: 0
}
const mousedownCb = (e) => {
// 记录鼠标位置和所有选中元素原始坐标
dragState = {
startX: e.clientX,
startY: e.clientY,
startPos: focusData.value.focus.map(({top, left}) => ({top, left})) // 所有选中元素的坐标列表
}
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
}
const mousemove = (e) => {
let { clientX: curX, clientY: curY } = e;
let moveX = curX - dragState.startX; // 鼠标移动距离
let moveY = curY - dragState.startY;
focusData.value.focus.forEach((block, idx) => { // 给选中的元素赋值
let { top, left } = dragState.startPos[idx];
block.top = top + moveY;
block.left = left + moveX;
})
}
const mouseup = (e) => {
document.removeEventListener('mousemove', mousemove)
document.removeEventListener('mouseup', mouseup)
}
return {
mousedownCb
}
}
ok,至此,我们完成了内容区元素选中和拖拽,现在拖拽对齐全凭手感,接下来内容区我们会添加辅助线。
step6:实现拖拽的辅助线功能
辅助线相对较复杂,而辅助线的核心是跟其他元素对齐。so:
- 辅助线水平或者垂直方向应该出现辅助线的情况(比如水平方向:顶对顶,顶对底,中对中,底对顶,底对底,纵向一样也是五种)。
- 当一个元素距离另一个元素过近,自动水平/垂直吸附至辅助线。
- 多选元素,以最后一个选中的元素为准生成辅助线。
实现辅助线的思路:辅助线是依赖未选中元素的位置来生成,所以当我们选中某元素时,就要收集此时场上所有未选中元素(A元素们)的辅助线位置以及其对应的拖动目标位置的元素(B元素),当拖动元素来到目标位置附近,就显示对应辅助线位置。
// 收集的数据
{
x: [{ 纵线横坐标1, 目标拖动left1 }, { 纵线横坐标2, 拖动目标left2 }, ...], // 纵线
y: [{ 横线纵坐标1, 目标拖动top1 }, { 横线纵坐标2, 拖动目标top2 }, ...], // 横线
}
如果拖动的元素 B 的 left 来到了拖动目标的 left2 附近,此时要显示 纵线横坐标2 处的竖线辅助线,换言之,如果拖动的元素 B 的 top 来到了拖动目标的 top2 附近,此时要显示 横线纵坐标2 处的横线辅助线
其实整个过程都是在用未选中元素去确定辅助线位置(所以每个元素宽高也需要记录),又用辅助线位置去确定拖动的目标位置,然后拖动元素的时候不停的判断拖动元素位置和目标位置去作对比,看是否显示和显示哪条辅助线。
首先我们修改 packages/useFocus.js,收集最后一个选中的元素
+import { computed, ref } from "vue";
// 该方法处理内容区选中焦点相关
export default function useFocus(data, callback) {
+ const selectIndex = ref(-1); // 表示没有任何一个被选中
+ const lastSelectBlock = computed(() => data.value.blocks[selectIndex.value]); // 最后一个选中的元素
+
const clearBlockFocus = () => {
data.value.blocks.forEach(block => {
block.focus = false;
});
}
- const blockMouseDown = (e, block) => {
+ const blockMouseDown = (e, block, index) => {
...
+ // 记录当前选中的元素索引,多选则为最后一个选中的元素索引
+ selectIndex.value = index;
callback(e); // 记录当前所有选中元素的坐标和鼠标当前位置
}
const containerMousedown = (e) => { // 点击内容区 清空所有选中
clearBlockFocus();
+ selectIndex.value = -1; // 重置 index
}
// 计算哪些被选中了,对 focusData 取值时会计算
return {
blockMouseDown,
containerMousedown,
- focusData
+ focusData,
+ lastSelectBlock
}
}
因为计算辅助线需要用到元素宽高信息,修改 packages/editor-block.jsx,在 block 中添加元素宽高
export default defineComponent({
const config = inject('config');
const blockRef = ref(null);
onMounted(() => { // 如果有居中属性 让其居中(希望元素生成后,鼠标位置在元素中心)
let { offsetWidth, offsetHeight } = blockRef.value
+ // 当前元素的 block 信息记录当前元素的宽高
+ props.block.width = offsetWidth;
+ props.block.height = offsetHeight;
});
...
修改 packages/editor.jsx,引入 lastSelectBlock,并根据辅助线位置信息绘制辅助线
export default defineComponent({
setup(props, ctx) {
...
+ // 实现选中组件获取焦点功能
+ const { blockMouseDown, containerMousedown, focusData, lastSelectBlock } = useFocus(data, (e) => {
// 每次选中都会执行次mousedown回调,里面触发 focusData 取值,而且判断移动坐标
mousedownCb(e);
});
+ // 实现组件拖拽功能, markline 为拖动时不停计算的辅助线位置信息
+ const { mousedownCb, markLine } = useBlockDragger(focusData, lastSelectBlock, data);
return () => {
return <div className="editor">
...
+ { markLine.x !== null && <div className="line-x" style={{ left: markLine.x + 'px' }}></div> }
+ { markLine.y !== null && <div className="line-y" style={{ top: markLine.y + 'px' }}></div> }
...
辅助线润色,修改 packages/editor.scss
+// 竖辅助线
+.line-x {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ border-left: 2px dashed red;;
+}
+
+// 横辅助线
+.line-y {
+ position: absolute;
+ left: 0;
+ right: 0;
+ border-top: 2px dashed red;;
}
修改 packages/useBlockDragger.js,实现辅助线计算,接收 lastSelectBlock,当做B元素去做操作。
+import { reactive } from 'vue';
+/**
+ * 处理内容区拖拽的方法
+ * @param {Object} focusData 选中的内容区元素列表
+ * @param {Object} lastSelectBlock 最后一个选中的元素 block 信息
+ * @returns { Object }
+ */
+export default function useBlockDragger(focusData, lastSelectBlock, data) {
let dragState = { // 记录鼠标按下时坐标和所有选中元素的位置列表
stateX: 0,
stateY: 0
}
const mousedownCb = (e) => {
+ const { width: BWidth, height: BHeight } = lastSelectBlock.value; // 最后一个选中元素的宽高
+ // 记录鼠标位置和所有选中元素原始坐标
dragState = {
startX: e.clientX,
startY: e.clientY,
+ lastSelectBlockStartLeft: lastSelectBlock.value.left,
+ lastSelectBlockStartTop: lastSelectBlock.value.top,
+ startPos: focusData.value.focus.map(({top, left}) => ({top, left})),
+ lines: (() => {
+ // 获取其他没选中的元素,以他们的位置做辅助线
+ // 可以看出,辅助线位置是根据没选中的元素来计算的
+ // 而拖拽元素的位置用来判断显不显示辅助线
+ const { unfocused } = focusData.value;
+ // 计算辅助线横线的位置,y存储横向线的纵坐标和拖拽元素的top
+ let lines = { x: [], y: [] };
+
+ [...unfocused, { // 让整个容器也参与计算,这样可以让拖拽元素到达容器边缘/中间时,也能显示辅助线
+ top: 0,
+ left: 0,
+ width: data.value.container.width,
+ height: data.value.container.height
+ }].forEach(block => { // 每次拖拽计算10根辅助线的位置
+ // A系列为没选中的任意元素,为参照物
+ const { top: ATop, left: ALeft,
+ width: AWidth, height: AHeight } = block;
+
+ // 拖拽元素B拖到和A元素top一致的时候,辅助线的坐标就是ATop
+ // showTop: 辅助线的top值 top: 预设要显示辅助线的B元素的top值
+ // B顶对A顶
+ lines.y.push({ showTop: ATop, top: ATop });
+ // B顶对A底
+ lines.y.push({ showTop: ATop, top: ATop - BHeight });
+ // B中对A中
+ lines.y.push({ showTop: ATop + AHeight/2,
+ top: ATop + AHeight/2 - BHeight/2 });
+ // B底对A顶
+ lines.y.push({ showTop: ATop + AHeight,
+ top: ATop + AHeight });
+ // B底对A底
+ lines.y.push({ showTop: ATop + AHeight,
+ top: ATop + AHeight - BHeight });
+
+ // 当此元素(拖拽元素统称为B)拖到和A元素left一致的时候(左对左)
+ // 辅助线的坐标就是 ALeft
+ // showLeft: 辅助线left值 left: 预设显示辅助线的B元素left值
+ // B左对A左
+ lines.x.push({ showLeft: ALeft, left: ALeft });
+ // B左对A右
+ lines.x.push({ showLeft: ALeft, left: ALeft - BWidth });
+ // B中对A中
+ lines.x.push({ showLeft: ALeft + AWidth/2,
+ left: ALeft + AWidth/2 - BWidth/2 });
+ // B右对A左
+ lines.x.push({ showLeft: ALeft + AWidth,
+ left: ALeft + AWidth });
+ // B右对A右
+ lines.x.push({ showLeft: ALeft + AWidth,
+ left: ALeft + AWidth - BWidth });
+ });
+
+ return lines;
+ })()
+ }
+
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
}
+
+ let markLine = reactive({ // 绘制辅助线的数据
+ x: null,
+ y: null
+ });
+ const mousemove = (e) => {
+ let { clientX: curX, clientY: curY } = e;
+
+ // 计算当前拖拽元素最新的 left 和 top,去线列表里面找
+ // 看是否触发了显示辅助线(5px就提前显示)
+ let newBLeft = dragState.lastSelectBlockStartLeft +
+ curX - dragState.startX;
+ let newBTop = dragState.lastSelectBlockStartTop +
+ curY - dragState.startY;
+
+ let y = null; // 横线显示的位置
+ let x = null;
+ // 先计算横线 距离参照物还有5像素的时候 显示这根线
+ for (let i = 0; i < dragState.lines.y.length; i++) {
+ let { showTop, top } = dragState.lines.y[i];
+
+ // 元素位置和要显示辅助线的预设元素位置差5px以内,就显示辅助线
+ if (Math.abs(top - newBTop) < 5) {
+ y = showTop;
+
+ // 迅速贴到线的位置,curY为鼠标当前Y位置
+ // 当前Y位置 = 鼠标开始Y位置 + 元素和目标元素top补差值
+ // 改变当前鼠标位置实现的迅速对齐
+ curY = dragState.startY +
+ (top - dragState.lastSelectBlockStartTop);
+
+ break; // 找到一根线就停止
+ }
+ }
+
+ // 再计算竖线 距离参照物还有5像素的时候 显示这根线
+ for (let i = 0; i < dragState.lines.x.length; i++) {
+ let { showLeft, left } = dragState.lines.x[i];
+
+ if (Math.abs(left - newBLeft) < 5) {
+ x = showLeft;
+ curX = dragState.startX +
+ (left - dragState.lastSelectBlockStartLeft);
+ break;
+ }
+ }
+
+ // markline 是一个响应式数据,x,y 更新了会导致视图更新
+ markLine.x = x;
+ markLine.y = y;
+
+ console.log(markLine);
+
+ let moveX = curX - dragState.startX; // 鼠标移动距离
+ let moveY = curY - dragState.startY;
+
+ focusData.value.focus.forEach((block, idx) => { // 给选中的元素赋值
+ let { top, left } = dragState.startPos[idx];
+ block.top = top + moveY;
+ block.left = left + moveX;
+ })
+ }
const mouseup = (e) => {
document.removeEventListener('mousemove', mousemove)
document.removeEventListener('mouseup', mouseup)
+ // 抬起鼠标的时候,清空辅助线
+ markLine.x = null;
+ markLine.y = null;
+ }
return {
mousedownCb,
+ markLine
}
}
实现效果如下,而且还有吸附效果哦:
step7:重做和撤销、快捷键的实现
现在内容区可以拖动了嘛,如果我拖错了,回不去了咋整,我希望有个按钮回退/重做功能,或者 ctrl+z / ctrl+y。
我的思路是备份一个操作(拖动)前的镜像,在拖动结束再保存一个镜像,分别对应 before 和 after。正常会进入 after 状态,如果回退,进入 before 状态,再次重做又回到 after 状态,听起来很复杂,其实只需要我们维护一个任务队列即可,队列里有两个方法,分别是回退和重做,两个函数内部通过闭包保存 before 和 after 即可。
function task() {
let before = x
let after = x
return {
redo() { // 这一步也是默认执行的 调用drag就执行redo
console.log('重做', after);
data.value = { ...data.value, blocks: after };
},
undo() { // 这步是回退的时候执行的
console.log('回退', before)
data.value = { ...data.value, blocks: before };
}
}
}
queue = [{ redo1, undo1 }, { redo2, undo2 }]
可能我还需要一个通知体系,来确保操作前和操作后,更新备份,这里我选用 mitt, 它是一个发布订阅的库
npm i mitt
import { events } from './events';
event.eimt('start')
event.end('end')
event.on('start', () => brfore = deepcopy(data.value.blocks));
event.on('end', () => brfore = data.value.blocks);
当然有个问题,难点是我并不能在回退的时候删除任务,否则任务里面的"重做"方法也会被干掉,所以我选择回退时候只移动 queue 下标,而在执行下次指令(这里只有 drag)的时候,去判断截取掉已经回退的任务。
if(queue.length > 0) {
queue = queue.slice(0, current + 1);
queue = queue;
}
OK,到这里,思路就清晰了,我们只需要注册一些指令,指令提供重做(redo)和撤销(undo)方法,默认会走重做方法到下一步。
首先,我们在编辑器顶部操作区加上撤销和重做的按钮,并引入指令模块(还没写😁),该方法提供一个 commands 可以供我们调用某些指令。
setup() {
...
+ const { commands } = useCommand(data); // 处理撤销重做命令
+ const buttons = [
+ {
+ label: '撤销',
+ icon: 'icon-back',
+ handler: () => {
+ commands.undo();
+ }
+ },
+ {
+ label: '重做',
+ icon: 'icon-forward',
+ handler: () => {
+ commands.redo();
+ }
+ }
+ ];
...
- <div className="editor-top">顶部菜单栏</div>
+ <div className="editor-top">
+ {
+ buttons.map((btn, idx) => {
+ return <div className="editor-top-button" onClick={btn.handler}>
+ <i class={btn.icon}></i>
+ <span>{btn.label}</span>
+ </div>
+ })
+ }
+ </div>
修改 packages/editor.scss,我这里引入了字体图标
.editor {
&-left, &-right {
- background: red;
}
&-top {
position: absolute;
right: 280px;
left: 280px;
height: 80px;
- background: blue;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ &-button{
+ width:60px;
+ height:60px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;;
+ background: rgba(0,0,0,.3);
+ user-select: none;
+ cursor: pointer;
+ color:#fff;
+ &+&{
+ margin-left:3px
+ }
+ }
+ }
}
页面长这样:
增加 packages/events.js 文件
import mitt from 'mitt';
export const events = mitt(); // 导出一个发布订阅的对象
因为我们需要在操作前备份一个内容区 blocks 镜像,那么就要在合适的时机通知。有两处需要改,一个是物料区拖拽前后,一个是内容区鼠标按下到鼠标抬起。
修改 packages/useMenuDragger.js
+import { events } from './events';
const dragstart = (e, component) => {
// console.log(containerRef.value);
// dragenter 进入内容区 需要添加一个移动的小图标
// dragover 在目标元素上经过 必须要阻止默认行为 否则不能触发 drop
// dragleave 离开内容区时,取消移动的小图标
// drop 在内容区松手时候 根据拖拽的位置 在当前位置添加一个该组件
currentComponent = component;
containerRef.value.addEventListener('dragenter', dragenter);
containerRef.value.addEventListener('dragover', dragover);
containerRef.value.addEventListener('dragleave', dragleave);
containerRef.value.addEventListener('drop', drop);
+ events.emit('start'); // 发布一个拖拽前的事件 start
}
const dragend = (e) => {
containerRef.value.removeEventListener('dragenter', dragenter);
containerRef.value.removeEventListener('dragover', dragover);
containerRef.value.removeEventListener('dragleave', dragleave);
containerRef.value.removeEventListener('drop', drop);
console.log('onend', data.value);
+ events.emit('end'); // 发布一个拖拽后的事件 end
}
修改 packages/useBlockDragger.js,这个文件不一样的地方是,可能点了元素没拖,所以我们备份是要在 mousemove 中进行。
export default function useBlockDragger(focusData, lastSelectBlock, data) {
let dragState = { // 记录鼠标按下时坐标和所有选中元素的位置列表
stateX: 0,
stateY: 0,
+ dragging: false, // 是否正在拖拽
}
const mousedownCb = (e) => {
const { width: BWidth, height: BHeight } = lastSelectBlock.value;
// 记录鼠标位置和所有选中元素原始坐标
dragState = {
+ dragging: false,
...
}
const mousemove = (e) => {
let { clientX: curX, clientY: curY } = e;
+ if (!dragState.dragging) { // 点击不会走这里哦
+ dragState.dragging = true; // 如果非正在拖拽,就变为正在拖拽
+ events.emit('start'); // 拖拽之前通知备份当前状态
+ }
...
}
const mouseup = (e) => {
...
+ if (dragState.dragging){ // 如果只是点击就不会触发
+ events.emit('end');
+ }
}
最后来实现 packages/useCommand.js 这个方法
- 该方法保存了一个任务队列 queue,内容是[{ redo, undo } ...],通过下标控制当前在哪个任务上
- 加了快捷键来控制执行重做和后退任务
- 缓存了镜像 before 和 after 在每个任务的重做&后退函数内存中,合适时机去重置
- 任务回退后不删除任务,只改下标,下次执行指令任务(例如再次拖拽生成新的节点)直接根据截取队列,抛弃后退的任务
import { onUnmounted } from "vue";
import { events } from './events';
import deepcopy from 'deepcopy';
export default function useCommand(data) {
const state = {
current: -1, // 前进后退的索引
queue: [], // 存放所有的操作命令
commands: {}, // 命令和执行功能的一个映射表 redo: ( )=> {}
commandArray: [], // 指令列表
destroyArray: [] // 销毁列表
}
const registry = command => { // 注册命令
state.commandArray.push(command);
state.commands[command.name] = (...args) => {
const { redo, undo } = command.execute(...args);
redo();
// 收集当前指令提供的撤销重做命令
if (!command.pushQueue) {
return;
}
let { queue, current } = state;
// 如果前进后退的索引不是最后一个,则删除后面的操作(截取掉已经撤回的任务)
// 可能 A->B->C->D放了四个组件,我撤回了两个(只修正下标),那么C,D就不应该存在于任务队列了
// 插入A 撤销BC,插入D,这时候再撤销应该只有A了
// 重做/撤销不走这里 这里是指令执行(比如drag),重做撤销是把方法拿出去操作了
if(queue.length > 0) {
queue = queue.slice(0, state.current + 1);
state.queue = queue;
}
// 队列维护了操作,redo 即为默认执行的操作,undo 为撤销的操作
// 保存指令的前进后退
queue.push({ redo, undo });
state.current = current + 1;
};
}
registry({ // 注册重做命令
name: 'redo',
keyboard: 'ctrl+y',
execute() {
return {
redo() {
let itm = state.queue[state.current + 1]; // 下一步的操作
if (itm) {
// 当再次执行指令操作时
itm.redo && itm.redo();
state.current++;
}
}
}
}
});
registry({ // 注册撤销命令
name: 'undo',
keyboard: 'ctrl+z',
execute() {
return {
redo() {
if (state.current == -1) return; // 撤销完啦
// 取的指令自己的撤销方法 比如 drag 指令提供的 undo 方法
let itm = state.queue[state.current];
if (itm) {
// 往上一步的撤销操作,此时不删除队列,因为可能还要前进
itm.undo && itm.undo();
state.current--; // 指针前移
}
}
}
}
});
// 每次拖拽都在队列里参生一条任务 带属性 pushQueue 的都会被放入队列
registry({
name: 'drag',
pushQueue: true,
init() { // 默认会执行的初始化操作
this.before = null;
// 监控拖拽开始事件,保存状态
const start = () => {
this.before = deepcopy(data.value.blocks)
};
// 拖拽之后需要触发对应的指令
const end = () => {
state.commands.drag()
};
events.on('start', start);
events.on('end', end);
// 返回一个卸载函数
return () => {
events.off('start', start);
events.off('end', end);
}
},
execute() {
let before = this.before; // 备份的之前的数据
let after = data.value.blocks; // 拖拽之后的data数据
return {
redo() { // 这一步也是默认执行的 调用drag就执行redo
console.log('重做', after);
data.value = { ...data.value, blocks: after };
},
undo() { // 这步是回退的时候执行的
console.log('回退', before)
data.value = { ...data.value, blocks: before };
}
}
}
});
const keyboardEvent = (() => {
const keyCodes = {
90: 'z',
89: 'y'
}
const onKeydown = (e) => {
const { ctrlKey, keyCode } = e;
let keyString = [];
// 如果摁住了ctrl,那么就是组合键,往keyString里面添加ctrl
if (ctrlKey) keyString.push('ctrl');
keyString.push(keyCodes[keyCode]);
keyString = keyString.join('+'); // 看看是不是 ctrl+z / ctrl+y
// 找到对应的指令
state.commandArray.forEach(({ keyboard, name }) => {
if (!keyboard) return; // 没有键盘事件
if (keyboard === keyString) {
state.commands[name]();
// 禁用默认行为,防止触发浏览器的默认行为
e.preventDefault();
}
})
}
const init = () => { // 初始化事件
window.addEventListener('keydown', onKeydown);
return () => { // 销毁事件
window.removeEventListener('keydown', onKeydown);
}
}
return init;
})();
(() => {
// 监听键盘事件,实现ctrl+z撤销,ctrl+y重做
// // 调用键盘初始化并把销毁键盘事件塞进销毁队列
state.destroyArray.push(keyboardEvent());
state.commandArray.forEach(command => {
if (command.init) {
// 执行所有事件的初始化操作 并收集返回的卸载函数
state.destroyArray.push(command.init());
}
});
})();
onUnmounted(() => { // 组件销毁的时候执行卸载函数
state.destroyArray.forEach(fn => fn && fn());
});
return state;
}
至此,我们实现了重做&撤销功能。
step8:实现 json 的导入导出功能
导入导出功能其实很好做,导出只要把当前的页面数据显示上去就好了,导入只需要替换页面数据就好了,这里有个坑
data.value = newJson; ❎
// 如此导入数据会导致无法回退(没有镜像),我们针对整个页面更新操作写一个带有 pushQueue 的指令就好了
commands.updateContainer(JSON.parse(newJson)); ✅
因为我们需要自定义弹框,包含内容区,按钮方法,为了以后扩展,我们对 Dialog 进行更灵活的改造,自己去挂载我们包装后的新的组件 components/Dialog.jsx
- 创建弹框,内部有 textarea,点击确定按钮,通过 cb 把新 json 传递出去。
import { defineComponent, createVNode, render, reactive } from "vue";
import { ElDialog, ElInput, ElButton } from "element-plus";
const DialogComponent = defineComponent({
props: {
option: { type: Object }
},
setup(props, ctx) {
const state = reactive({
option: props.option,
isShow: false
});
ctx.expose({ // 向外界暴露方法
showDialog(option) {
state.option = option; // 更新弹窗内容
state.isShow = true;
}
});
const onConfirm = () => {
state.isShow = false;
state.option.onConfirm
&& state.option.onConfirm(state.option.content);
}
return () => {
return <ElDialog v-model={ state.isShow } title={ state.option.title }>
{/* 插槽内容 */}
{{
default: () => <ElInput type="textarea" v-model={ state.option.content } rows={10}></ElInput>,
footer:() => state.option.footer && <div>
<ElButton onClick={ () => state.isShow = false }>取消</ElButton>
<ElButton type="primary" onClick={ () => onConfirm() }>确定</ElButton>
</div>
}}
</ElDialog>
}
}
})
let vm; // 我们自定义组件的虚拟节点
export function $dialog(option) {
let el = document.createElement('div');
if (!vm) { // 如果没有创建过,就创建一个
vm = createVNode(DialogComponent, { option }); // 创建组件的虚拟节点
document.body.appendChild((render(vm, el), el)); // 挂载到元素上后插入页面
}
// 从虚拟节点上获取组件暴露出的方法
let { showDialog } = vm.component.exposed;
showDialog(option);
}
修改 packages/editor.jsx,添加导入/导出按钮,引入弹框
+import { $dialog } from '../components/Dialog';
export default defineComponent({
setup() {
...
const { commands } = useCommand(data); // 处理撤销重做命令
const buttons = [
{ label: '撤销', icon: 'icon-back', handler: () => commands.undo() },
{ label: '重做', icon: 'icon-forward', handler: () => commands.redo() },
+ { label: '导出', icon: 'icon-import', handler: () => {
+ $dialog({
+ title: '导出 json',
+ content: JSON.stringify(data.value),
+ footer: true,
+ onConfirm: (json) => {
+
+ }
+ })
+ }},
+ { label: '导入', icon: 'icon-export', handler: () => {
+ $dialog({
+ title: '导入 json',
+ content: '',
+ footer: true,
+ onConfirm: newJson => {
+ // 这里需要维持后退功能,所以不能直接赋值,这里我们注册个命令
+ commands.updateContainer(JSON.parse(newJson));
+ }
+ })
+ }},
+ ];
}
}
修改 packages/useCommand.js,新注册一个命令,该命令带有 pushQueue 属性,可以将其提供的 redo 和 undo 方法丢进 queue,用于回退和重做,函数作用域通过闭包保存了 before 和 after, 注意默认执行 redo 的时候才触发了页面更新,所以在 execute 顶部赋值 before 时候,是操作前备份的镜像。
+ // 导入数据 更新整个容器 整个data全部更新,因为传入的json可能连物料区&容器都改了
+ registry({
+ name: 'updateContainer',
+ pushQueue: true,
+ execute(newValue) {
+ let state = {
+ before: data.value, // 当前的值
+ after: newValue // 新的值
+ }
+
+ return {
+ redo() {
+ console.log('重做', state.after);
+ data.value = state.after;
+ },
+ undo() { // 这步是回退的时候执行的
+ console.log('回退', state.before)
+ data.value = state.before;
+ }
+ }
+ }
+ });
+
此时拖动内容区的元素,点击导出按钮。
然后刷新页面,点击导入,发现页面内容改变
同时可以回退操作,至此导入导出搞定了。
step9:置顶、置底、删除、预览
我们来实现操作栏上的功能。
实现置顶和置底
置顶,置底肯定也需要实现前进后退,所以也需要指令啦,而指令的功能就是操作 zIndex,只是我们操作 zIndex 时,要规避 zIndex 负值的情况。当选中元素 zIndex 为 0 且再次置底,那我们给其他未选中元素 zIndex 太高而非继续调低选中元素的 zIndex,否则就不能显示在画布上啦。
注意,使用指令的原因是我们针对指令做了个操作队列,可以实现前进后退功能
修改 packages/editor.jsx,把按钮添加到页面上
export default defineComponent({
setup() {
...
+ // 传入 focusData 用于计算最大最小未选中元素 zIndex
+ const { commands } = useCommand(data, focusData); // 处理撤销重做命令
const buttons = [
...
+ { label: '置顶', icon: 'icon-place-top', handler: () => commands.placeTop() },
+ { label: '置底', icon: 'icon-place-bottom', handler: () => commands.placeBottom() },
];
}
})
修改 packages/useCommand.js,实现两个指令
+export default function useCommand(data, focusData) {
+ // 置顶操作
+ registry({
+ name: 'placeTop',
+ pushQueue: true,
+ execute() {
+ // 因为更改 zIndex 并不会导致 blocks 的变化,所以这里需要深拷贝一份 blocks 用于重新赋值
+ let before = deepcopy(data.value.blocks); // 备份的之前的数据
+ let after = (() => {
+ // 置顶就是找到没选中元素最大的 zIndex,然后+1
+ let { focus, unfocused } = focusData.value;
+ let maxZIndex = Math.max(...unfocused.map(item => item.zIndex));
+
+ console.log(maxZIndex," maxZIndex");
+ // 选中的元素 zindex 在最大的基础上+1
+ focus.forEach(block => {
+ block.zIndex = maxZIndex + 1;
+ });
+
+ return data.value.blocks; // 返回置顶后的数据
+ })()
+
+ return {
+ redo() {
+ console.log('置顶')
+
+ data.value = { ...data.value, blocks: after };
+ },
+ undo() {
+ console.log('取消置顶')
+ data.value = { ...data.value, blocks: before };
+ }
+ }
+ }
+ });
+
+
+ // 置底操作
+ registry({
+ name: 'placeBottom',
+ pushQueue: true,
+ execute() {
+ // 因为更改 zIndex 并不会导致 blocks 的变化,所以这里需要深拷贝一份 blocks 用于重新赋值
+ let before = deepcopy(data.value.blocks); // 备份的之前的数据
+ let after = (() => {
+ // 置顶就是找到没选中元素最小的 zIndex,然后 - 1
+ let { focus, unfocused } = focusData.value;
+ // 把减一提到前面了
+ let minZIndex = Math.min(...unfocused.map(item => item.zIndex)) - 1;
+
+ // 选中的元素 zindex 在最大的基础上 -1
+ // 如果即将变成负值,就变成 0,并让其他元素 +1
+ if (minZIndex < 0) {
+ const dur = Math.abs(minZIndex);
+ minZIndex = 0;
+ unfocused.forEach(block => block.zIndex += dur);
+ }
+
+ focus.forEach(block => {
+ block.zIndex = minZIndex;
+ });
+
+ return data.value.blocks; // 返回置顶后的数据
+ })()
+
+ return {
+ redo() {
+ console.log('置底')
+ data.value = { ...data.value, blocks: after };
+ },
+ undo() {
+ console.log('取消置底')
+ data.value = { ...data.value, blocks: before };
+ }
+ }
+ }
+ });
这就实现了置顶置底功能。
实现删除
删除就简单多了,不就是把内容区 blocks 换成未选中元素的 blocks 么。不过要支持回退,我们也得写一个指令来实现它。
修改 packages/editor.jsx,把删除添加到页面上
export default defineComponent({
setup() {
...
+ // 传入 focusData 用于计算最大最小未选中元素 zIndex
+ const { commands } = useCommand(data, focusData); // 处理撤销重做命令
const buttons = [
...
+ { label: '删除', icon: 'icon-delete', handler: () => commands.delete() },
];
}
})
修改 packages/useCommand.js,实现删除指令
+ // 删除操作
+ registry({
+ name: 'delete',
+ pushQueue: true,
+ execute() {
+ let state = {
+ before: deepcopy(data.value.blocks), // 当前的值
+ after: focusData.value.unfocused // 没选中的
+ }
+
+ return {
+ redo() {
+ console.log('删除')
+ data.value = { ...data.value, blocks: state.after };
+ },
+ undo() {
+ console.log('取消删除')
+ data.value = { ...data.value, blocks: state.before };
+ }
+ }
+ }
+ });
OK,就这么简单。
实现预览
我们希望预览的时候元素可以点击,聚焦、输入等,但是不能再拖动了,这时候我们需要一个预览的变量,去控制预览和编辑页面行为和显示。
修改 packages/editor.jsx
- 声明预览和编辑的状态变量,控制能否选择&拖拽元素
- 添加预览/编辑标签
- 预览状态移除元素伪类样式
setup(props, ctx) {
+ // 预览的时候内容不能再操作了,但是可以点击输入内容,方便看效果
+ // 1. 获取焦点时,若为非预览模式,就不该选中了
+ const previewRef = ref(false);
...
+ // 传入 previewRef,如果是预览状态元素不能选中
+ const { blockMouseDown, containerMousedown, focusData, lastSelectBlock, clearBlockFocus } = useFocus(data, previewRef, (e) => { // 实现选中组件获取焦点功能
const buttons = [
...
+ { label: () => previewRef.value ? '预览' : '编辑', icon: () => previewRef.value ? 'icon-edit' : 'icon-browse', handler: () => {
+ previewRef.value = !previewRef.value; // 切换状态
+ clearBlockFocus(); // 清空所有选中
+ }},
];
return () => {
return <div className="editor">
...
<div className="editor-top">
{
buttons.map((btn, idx) => {
+ const icon = typeof btn.icon === 'function' ? btn.icon() : btn.icon;
+ const label = typeof btn.label === 'function' ? btn.label() : btn.label;
return <div className="editor-top-button" onClick={btn.handler}>
+ <i class={icon}></i>
+ <span>{label}</span>
</div>
})
}
</div>
<div className="editor-right">属性控制栏</div>
<div className="editor-container">
{/* 负责产生滚动条 */}
<div className="editor-container-canvas">
{/* 内容区 */}
<div className="editor-container-canvas_content" style={containerStyles.value} ref={containerRef} onMousedown={containerMousedown}>
{/* 循环blocks */}
{
(data.value.blocks.map((block, index) => (
<EditorBlock
class={block.focus ? 'editor-block-focus' : ''}
+ class={ previewRef.value ? 'editor-block-preview' : ''}
block={block}
onMousedown={e => blockMouseDown(e, block, index)}
>
修改 packages/editor.scss,预览状态移除伪类样式
+// 元素能点 能输入
+.editor-block-preview {
+ &::after {
+ display: none;
+ }
+}
修改 packages/useFocus.js,预览状态使元素不能点选,也就不能拖动了
+export default function useFocus(data, previewRef, callback) {
const blockMouseDown = (e, block, index) => {
+ if (previewRef.value) return;
// 阻止元素默认行为
e.preventDefault();
// 禁止事件冒泡
...
}
const containerMousedown = (e) => { // 点击内容区 清空所有选中
+ if (previewRef.value) return;
clearBlockFocus();
selectIndex.value = -1; // 重置 index
...
}
return {
blockMouseDown,
containerMousedown,
focusData,
lastSelectBlock,
+ clearBlockFocus // 清空所有选中的状态
}
至此我们实现了预览/编辑功能。
关闭编辑功能,纯净预览
我想有个关闭编辑的功能,实现操作区全部隐藏,仿真实预览,而且提供了继续编辑的按钮。
修改 packages/editor.js,添加关闭按钮,关闭后展示纯净的 Editor 组件,所有拖拽点击的处理全部干掉。
...
setup(props, ctx) {
// 预览的时候内容不能再操作了,但是可以点击输入内容,方便看效果
// 获取焦点时,若为非预览模式,就不该选中了
const previewRef = ref(false);
+ const editRef = ref(true); // 是否为编辑状态,关闭后会显示真实页面预览状态,默认编辑状态
const buttons = [
+ { label: '关闭', icon: 'icon-close', handler: () => {
+ editRef.value = false;
+ clearBlockFocus(); // 清空所有选中
+ }},
];
+ return () => !editRef.value ? <>
+ {/* 内容区 */}
+ <div className="editor-container-canvas_content" style={containerStyles.value} style="margin: 0">
+ {/* 循环blocks */}
+ {
+ (data.value.blocks.map((block, index) => (
+ <EditorBlock block={block} class="editor-block-preview">
+ </EditorBlock>
+ )))
+ }
+ </div>
+ <div>
+ <ElButton type="primary" onClick={ () => editRef.value = true }>继续编辑</ElButton>
+ </div>
+ </> : <div className="editor">
<div className="editor-left">
...原本的内容
}
额,好像有点丑。。
step10:实现右键菜单
我希望可以右键操作我的元素,单个点选太难受了。
我们知道,H5 提供的 onContextmenu 事件能捕获鼠标右键,如果阻止了默认的弹出,就可以自定义弹窗了,这里我用到自定义的 dropdown 作为右键弹窗,因为比较好自定义。
创建 components/Dropdown.jsx,这里我自定义了 dropdown 组件
components/Dropdown.jsx
import { provide, inject, computed, createVNode, render, defineComponent, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
export const DropdownItem = defineComponent({
props: {
label: {
type: String,
default: ''
},
icon: {
type: String,
default: ''
},
},
setup(props) {
let { label, icon } = props;
let hide = inject('hide');
return () => <div class="dropdown-item" onClick={ hide }>
<i class={ icon }></i>
<span>{ label }</span>
</div>
}
});
const DropdownComponent = defineComponent({
props: {
option: { type: Object }
},
setup(props, ctx) {
const state = reactive({
option: props.option,
isShow: false,
top: 0,
left: 0
});
ctx.expose({
showDropdown: (option) => {
state.option = option;
state.isShow = true;
let { top, left, height} = option.el.getBoundingClientRect(); // 获取元素的位置信息
state.top = top + height;
state.left = left;
}
});
provide('hide', () => { // 用于给子组件调用,隐藏下拉菜单
state.isShow = false;
});
const classes = computed(() => {
return ['dropdown', { 'dropdown-isShow': state.isShow }];
});
const styles = computed(()=>({
top: state.top + 'px',
left: state.left + 'px'
}))
const el = ref(null);
const onmousedownDocument = e => {
if (el.value && !el.value.contains(e.target)) { // 如果点击是dropdown内部,就什么都不做, 否则就隐藏
state.isShow = false;
}
}
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onmousedownDocument);
});
onMounted(() => {
// 事件的传播行为是先捕获,再冒泡
// 之前为了阻止事件传播,给 block 都增加了阻止冒泡,所以只能在外面的(这里用的body)捕获阶段去接收事件了
document.body.addEventListener('mousedown', onmousedownDocument, true);
});
return () => {
return <div class={classes.value} style={ styles.value } ref={el}>
{ state.option.content() }
</div>
}
}
});
let vm; // 我们自定义组件的虚拟节点
export function $dropdown(option) {
let el = document.createElement('div');
if (!vm) { // 如果没有创建过,就创建一个
vm = createVNode(DropdownComponent, { option }); // 创建组件的虚拟节点
document.body.appendChild((render(vm, el), el)); // 挂载到元素上后插入页面
}
// 从虚拟节点上获取组件暴露出的方法
let { showDropdown } = vm.component.exposed;
showDropdown(option);
}
packages/editor.js 引入组件,并定义菜单内容
+import { ElButton } from 'element-plus';
+import { $dropdown, DropdownItem } from '../components/Dropdown';
setup(){
...
+ const onContextMenuBlock = (e, block) => {
+ e.preventDefault();
+
+ $dropdown({
+ el: e.target, // 以哪个元素为准产生一个 dropdown
+ content: () => <>
+ <DropdownItem label="删除" icon="icon-delete" onClick={() => { commands.delete() }}></DropdownItem>
+ <DropdownItem label="置顶" icon="icon-place-top" onClick={() => { commands.placeTop() }}></DropdownItem>
+ <DropdownItem label="置底" icon="icon-place-bottom" onClick={() => { commands.placeBottom() }}></DropdownItem>
+ <DropdownItem label="查看" icon="icon-browse" onClick={() => {
+ $dialog({
+ title: '查看节点数据',
+ content: JSON.stringify(block)
+ })
+ }}></DropdownItem>
+ <DropdownItem label="导入" icon="icon-import" onClick={() => {
+ $dialog({
+ title: '导入节点数据',
+ content: '',
+ footer: true,
+ onConfirm: newJson => {
+ commands.updateBlock(block, JSON.parse(newJson));
+ }
+ })
+ }}></DropdownItem>
+ </>
+ });
+ }
+
return () => !editRef.value ? <>
...
{/* 负责产生滚动条 */}
<div className="editor-container-canvas">
{/* 内容区 */}
<div className="editor-container-canvas_content" style={containerStyles.value} ref={containerRef} onMousedown={containerMousedown}>
{/* 循环blocks */}
{
(data.value.blocks.map((block, index) => (
<EditorBlock
class={block.focus ? 'editor-block-focus' : ''}
class={ previewRef.value ? 'editor-block-preview' : ''}
block={block}
onMousedown={e => blockMouseDown(e, block, index)}
// 右键菜单
+ onContextmenu = { e => onContextMenuBlock(e, block)}
>
</EditorBlock>
)))
}
{ markLine.x !== null && <div className="line-x" style={{ left: markLine.x + 'px' }}></div> }
{ markLine.y !== null && <div className="line-y" style={{ top: markLine.y + 'px' }}></div> }
</div>
</div>
</div>
</div>
}
}
);
定义单个元素的导入指令,修改 packages/useCommand.js,添加 updateBlock 指令。
+ // 更新某节点数据
+ registry({
+ name: 'updateBlock',
+ pushQueue: true,
+ execute(oldBlock, newBlock) {
+ let state = {
+ before: data.value.blocks,
+ after: (() => {
+ let blocks = deepcopy(data.value.blocks); // 拷贝一份新的去操作
+ let index = data.value.blocks.indexOf(oldBlock);
+
+ if (index > -1) {
+ blocks.splice(index, 1, newBlock); // 替换
+ }
+
+ return blocks;
+ })()
+ }
+
+ return {
+ redo() {
+ console.log('重做', state.after);
+ data.value = { ...data.value, blocks: state.after };
+ },
+ undo() { // 这步是回退的时候执行的
+ console.log('回退', state.before)
+ data.value = { ...data.value, blocks: state.before };
+ }
+ }
+ }
+ });
修改 packages/editor.scss,添加 dropdown 样式
+.dropdown {
+ display: none;
+ position: absolute;
+ background: #fff;
+ box-shadow: 2px 2px #ccc;
+}
+
+.dropdown-isShow{
+ display: block;
+}
+
+.dropdown-item{
+ line-height: 30px;
+ width: 100px;
+ padding: 5px 10px;
+ box-sizing: border-box;
+ border-bottom: 1px solid #ccc;
+ text-align: center;
+ user-select: none;
+ cursor: pointer;
+}
此时菜单就做好了。
也可以更改单个节点属性,例如我这里就把 buton 改成了 text,页面正常显示而且可以 ctrl+z,ctrl+y。
step11:实现右侧属性操作栏
渲染操作栏&操作栏更改属性
我希望我点选某个元素后,能针对该元素生成特定的操作栏目,可以更改它的值,背景色,颜色等等。如果未选中任何元素,则修改容器属性。
OK,首先,我需要让渲染在画布上的元素带有一些默认属性,比如字体大小,颜色,按钮大小,按钮类型等,这里我们先不处理 Input 类型,因为它涉及双向绑定,我们拿出来单说。
"blocks": [
{
"top": 100,
"left": 100,
"zIndex": 1,
"key": "text",
+ "props": {
+ "text": "默认文案",
+ "color": "#ff0000",
+ "size": "14px"
}
},
...
]
其次,因为我组件点选后,右侧操作栏要展示不同的操作,所以我注册元素时,要提供一个属性操作的清单。
registerConfig.register({
label: '文本',
preview: () => '预览文本',
// 这里需要根据上面元素默认配置,挂载到组件本身
+ render: ({ props }) => <span style={{color: props.color, fontSize: props.size }}>{ props.text || '渲染文本' }</span>,
key: 'text',
+ props: { // 这里是右侧属性配置区的配置
+ text: createInputProp('请输入文本内容'),
+ color: createColorProp('字体颜色')
+ }
});
那么双向绑定就好做了,在我点击元素时显示元素的当前属性到右侧操作栏,右侧操作栏更改属性时,再应用到选中的元素或者整个container,这样的指令我们已经实现了,就是 commands.updateContainer(newValue) 和 commands.updateBlock(oldValue, newValue)。这就成啦。
首先修改 data.json,为 blocks 中的元素增加一些默认属性,该属性用于设定渲染到画布元素的默认字体大小,颜色,按钮大小等。当然也是为了点选元素后,放到右边菜单栏的默认属性。
{
...
"blocks": [
{
"top": 100,
"left": 100,
"zIndex": 1,
"key": "text",
+ "props": {
+ "text": "杨帅",
+ "color": "#ff0000",
+ "size": "14px"
+ }
},
{
"top": 200,
"left": 200,
"zIndex": 1,
"key": "button",
+ "props": {
+ "text": "我的按钮",
+ "type": "primary",
+ "size": "small"
+ }
}
]
}
修改 packages/useMenuDragger.js,拖拽生成的元素也要加上一个空的默认项
const drop = (e) => {
...
data.value = {
...data.value,
blocks: [
...blocks,
{
top: e.offsetY,
left: e.offsetX,
zIndex: 1,
key: currentComponent.key,
alignCenter: true, // 松手居中
+ props: {} // 组件属性默认配置信息
}
]
}
currentComponent = null;
}
修改 packages/editor-block.jsx, 给我们自己定义的渲染函数(根据 key 生成真实节点的函数)传入这些默认属性属性
export default defineComponent({
setup(props) {
...
return () => {
// 通过key获取到对应的组件
const component = config.componentMap[props.block.key];
+ // console.log(props.block.props);
+ const RenderComponent = component.render({
+ props: props.block.props // 把渲染的属性传递过去
+ });
return <div class="editor-block" style={ blockStyles.value } ref={ blockRef }>
{ RenderComponent }
</div>
}
}
});
修改 utils/editor-config.jsx,自定义 render 函数生成真实 dom 时候,挂载默认属性
function createEditorConfig() {
...
+const createInputProp = label => ( { type: 'input', label } );
+const createColorProp = label => ( { type: 'color', label } ); // 颜色选择器
+const createSelectProp = (label, options) => ( { type: 'select', label, options } ); // 下拉选择器
// 传入一个要注册的映射表,用来注册我们左侧的物料,想注册多少个就注册多少个
registerConfig.register({
label: '文本',
preview: () => '预览文本', // 左侧物料区展示的预览
+ render: ({ props }) => <span style={{color: props.color, fontSize: props.size }}>{ props.text || '渲染文本' }</span>, // 内容区即将渲染的内容元素, 放在block组件
key: 'text', // 这里的key,要和通用配置中key一致,才可应用其样式
+ props: { // 这里是右侧属性配置区的配置
+ text: createInputProp('请输入文本内容'),
+ color: createColorProp('字体颜色'),
+ size: createSelectProp('字体大小', [
+ { label: '14px', value: '14px' },
+ { label: '20px', value: '20px' },
+ { label: '24px', value: '24px' },
+ ]),
}
});
registerConfig.register({
label: '按钮',
preview: () => <ElButton>预览按钮</ElButton>,
+ render: ({ props }) => <ElButton type={ props.type } size={props.size}>{ props.text || '渲染按钮' }</ElButton>,
key: 'button',
+ props: { // 这里是右侧属性配置区的配置
+ text: createInputProp('按钮内容'),
+ type: createSelectProp('按钮类型', [
+ { label: '基础', value: 'primary' },
+ { label: '成功', value: 'success' },
+ { label: '警告', value: 'warning' },
+ { label: '危险', value: 'danger' },
+ { label: '文本', value: 'text' },
+ ]),
+ size: createSelectProp('按钮尺寸', [
+ { label: '默认', value: '' },
+ { label: '大', value: 'large' },
+ { label: '中等', value: 'medium' },
+ { label: '小', value: 'small' }
+ ]),
}
});
...
我们准备新建一个组件 packages/editor-operator.jsx,传入当前所有的 data(可用于更新整个容器) 和 lastSelectBlock(用于更新最后选中的元素),以及更新容器和更新元素的方法。 修改 packages/editor.jsx,引入该组件
<div className="editor-right">
+ {/* 右侧属性区 */}
+ <EditorOperator lastBlock={ lastSelectBlock.value }
+ data= { data.value }
+ updateContainer={ commands.updateContainer }
+ updateBlock={ commands.updateBlock }
+ >
+ </EditorOperator>
</div>
新建组件 packages/editor-operator.jsx
- 判断当前选中元素与否,选中则 editorData 为当前选中元素的 block,否则,为整个 data (用于更新和双向绑定)。
- 右侧属性栏根据 props 和 是否选中元素 动态生成,未选中元素则为修改容器宽高。
- 更新时调用更新容器和更新元素的指令,传入对应的新值即可。
import { defineComponent, inject, watch, reactive } from "vue";
import { ElForm, ElFormItem, ElButton, ElInputNumber, ElInput, ElColorPicker, ElSelect, ElOption } from 'element-plus';
import deepcopy from 'deepcopy';
export default defineComponent({
name: "EditorOperator",
props: {
lastBlock: { type: Object }, // 用户最后选中的元素
data: { type: Object }, // 当前所有的数据
updateContainer: { type: Function },
updateBlock: { type: Function }
},
setup(props, ctx) {
const config = inject('config'); // 注册的组件配合信息
const state = reactive({
editData: {},
});
const reset = () => {
if (!props.lastBlock) { // 说明要绑定的是容器的宽度和高度
state.editData = deepcopy(props.data.container);
} else {
state.editData = deepcopy(props.lastBlock);
}
}
const apply = () => {
if (!props.lastBlock) { // 更新容器
props.updateContainer({ ...props.data, container: state.editData });
} else { // 更新 block
// oldValue, newValue
console.log('更新节点');
props.updateBlock(props.lastBlock, state.editData);
}
}
// 如果 lastBlock 发生变化,立即触发一次 reset
watch(() => props.lastBlock, reset, { immediate: true })
return () => {
let content = []
if (!props.lastBlock) {
// 没有点选元素,则控制的是整个容器
content.push(<>
<ElFormItem label="容器宽度">
<ElInputNumber v-model={ state.editData.width }></ElInputNumber>
</ElFormItem>
<ElFormItem label="容器高度">
<ElInputNumber v-model={ state.editData.height }></ElInputNumber>
</ElFormItem>
</>)
} else {
let component = config.componentMap[props.lastBlock.key];
if (component && component.props) { // {text:{type:'xxx', label: 'xxx'},size:{type:'xxx', label:'xxx', options: []},color:{}}
content.push(Object.entries(component.props).map(([propName, propConfig]) => {
// text {type: 'input', label: '按钮内容'}
// size {type: 'select', label: '按钮大小', options: Array(4)}
return <ElFormItem label={propConfig.label}>
{{
// 取最后点击元素的props[Name]属性作为原始值
input: () => <ElInput v-model={ state.editData.props[propName] }></ElInput>,
color: () => <ElColorPicker v-model={ state.editData.props[propName] }></ElColorPicker>,
select: () => <ElSelect v-model={ state.editData.props[propName] }>
{ propConfig.options.map(opt => {
return <ElOption label={opt.label} value={opt.value}></ElOption>
})}
</ElSelect>
}[propConfig.type]()}
</ElFormItem>
}))
}
}
return <ElForm labelPosition="top" style="padding:30px">
{content}
<ElFormItem>
<ElButton type="primary" onClick={ () => apply() }>应用</ElButton>
<ElButton onClick={reset}>重置</ElButton>
</ElFormItem>
</ElForm>
}
}
});
现在我们就实现了右侧菜单的基础功能。
实现input的数据双向绑定
首先,当我们点击输入框,期望右侧菜单栏有一个可以设置 "绑定" 的输入框,如果输入字段名,则自动在页面填充表单数据中字段的值,同样更改切换到编辑状态,改变输入框的值后,表单的值自动更新。
思考一下,表单的双向绑定分为两步:
- 右侧属性栏配置 key,自动取值更新到页面
- 页面更新输入框的值,立刻反应到数据。
我们渲染按钮的属性栏的时候用到了一个 props,注册时候所带的 props 用于渲染右侧属性栏,而按钮本身属性上带的 props 我们用它渲染页面真实节点。
在这里,我们再新加一个数据 model,注册所带的 model 用于收集用户输入的 key 值,用户输入key后。将其挂载到对应input真实节点上,block.model.xxxx,用这个做真实双向绑定的 key。这个 key 去做 v-model 的两件事,生成 modelValue 和 updatemodelValue,去挂载到真实的 input 节点上做展示和双向绑定,
App.vue中声明 formData,用于取值测试
+ <Editor v-model="state" :formData="formData"></Editor>
setup() {
...
+ const formData = ref({
+ username: 'ys',
+ password: 234
+ })
+
+ return { state, formData };
}
packages/editor.jsx 接收这些值,并向下子组件 EditorBlock(元素组件) 传递,预览和编辑都要传
+ formData: {
+ type: Object
+ }
return () => !editRef.value ? <>
{/* 内容区 */}
<div className="editor-container-canvas_content" style={containerStyles.value} style="margin: 0">
{/* 循环blocks */}
{
(data.value.blocks.map((block, index) => (
+ <EditorBlock block={block} class="editor-block-preview" formData={ props.formData }>
</EditorBlock>
)))
}
</div>
<div>
<ElButton type="primary" onClick={ () => editRef.value = true }>继续编辑</ElButton>
+ {JSON.stringify(props.formData) } // 这里打印用于后续测试双向绑定
</div>
</> :
...
<EditorBlock
class={block.focus ? 'editor-block-focus' : ''}
class={ previewRef.value ? 'editor-block-preview' : ''}
block={block}
+ formData={ props.formData }
onMousedown={e => blockMouseDown(e, block, index)}
// 右键菜单
onContextmenu = { e => onContextMenuBlock(e, block)}
>
</EditorBlock>
修改 utils/editor-config.jsx,增加 input 框配置,有此配置右侧操作栏显示输入绑定字段的输入框,同时,我们展开了一个对象作为 ElInput 的属性值,这个对象应包含 modelValue 和 updatemodelValue
registerConfig.register({
label: '输入框',
preview: () => <ElInput placeholder="预览input"></ElInput>,
- render: () => <ElInput placeholder="渲染input"></ElInput>,
- key: 'input'
+ render: ({ model }) => <ElInput placeholder="渲染输入框" { ...model.default } ></ElInput>, // { modelValue: xx, onUpdate:modelValue: xx }
+ key: 'input',
+ model: { // 输入的值挂载到 dafult 这个 key 上
+ default: '绑定字段'
+ }
**修改 data.json,给 input 类型增加 model 防止报错 **
{
"top": 300,
"left": 300,
"zIndex": 1,
"key": "input",
+ "props": {
+
+ },
+ "model": {
+
+ }
}
同理,packages/useMenuDragger.js 拖拽生成元素时,给个默认的 model
data.value = {
...data.value,
blocks: [
...blocks,
{
top: e.offsetY,
left: e.offsetX,
zIndex: 1,
key: currentComponent.key,
alignCenter: true, // 松手居中
props: {}, // 组件属性默认配置信息
+ model: {}, // input 需要的配置
}
]
}
修改packages/editor-operator.jsx,显示 input 的操作框
setup() {
let content = [];
return () => {
if (!props.lastBlock) {
// 渲染容器操作栏
...
} else {
// 渲染按钮 文本操作栏
...
+ if (component && component.model) { // 带 model 说明是为 input 渲染操作栏
+ // modelName 即为我们注册时放上的 model 数据的 key
+ // 这里循环是为了兼容 model 中有多个变量,比如 { start: xx, end: xx }
+ content.push(Object.entries(component.model).map(([modelName, label]) => {
+ return <ElFormItem label={label}>
+ {/* { defult: '输入内容' } */}
+ {/* 这里输入input,会修改 block 内部的 model,key 为注册时提供的key(比如 default) */}
+ {/* v-model={key} 值根据key来取*/}
+ <ElInput v-model={ state.editData.model[modelName] }></ElInput>
+ </ElFormItem>
+ }));
}
}
}
}
注意,这里输入完,修改了 block.model.xxx,这个 XXX 就是用户自己定义的 key 值。我们需要根据 block 上的这个 key 值来做内容读取和双向绑定。
修改 /packages/editor-block.jsx,根据 key 组装 model,实现 v-model 功能
props: {
+ formData: { type: Object }
},
setup(props) {
return () => {
// 通过key获取到对应的组件
const component = config.componentMap[props.block.key];
// console.log(props.block.props);
// 拿到渲染后的元素 这是我们自己定义的 render 方法哦 根据注册的 key 生成真实元素
const RenderComponent = component.render({
props: props.block.props, // 把渲染的属性传递过去
+ model: Object.keys(component.model || {}).reduce((prev, modelName) => {
+ let propName = props.block.model[modelName]; // 'username'
+
+ // { default :{modelValue: undefined, onUpdate:modelValue: ƒ }}
+ // 输入 username 后 渲染元素再次走到此方法
+ // { default :{modelValue: ys, onUpdate:modelValue: ƒ }} 这样把 prev.dafault 展开挂载到真实内容区 input 上就好了
+ prev[modelName] = {
+ modelValue: props.formData[propName],
+ "onUpdate:modelValue": v=> props.formData[propName] = v
+ }
+ return prev;
+ }, {})
+ });
return <div class="editor-block" style={ blockStyles.value } ref={ blockRef }>
{ RenderComponent }
</div>
}
}
因为前面我们已经在注册时,给渲染的 input 做了处理,放上了我们经过输入的 key 计算得来的 modelValue 和 updatemodelValue。此时已经大功告成了。
step12:实现范围选择物料
其实有了上面对于输入框双向绑定的基础,范围选择物料就好办了。
新建 Range 组件 src/components/Range.jsx,该组件需要一个 start 和 end 参数
import { defineComponent,computed } from "vue";
export default defineComponent({
props: {
start: { type: Number },
end: { type: Number }
},
emits: ['update:start', 'update:end'],
setup(props, ctx) {
const start = computed({
get(){
return props.start
},
set(newValue){
ctx.emit('update:start',newValue)
}
})
const end = computed({
get(){
return props.end
},
set(newValue){
ctx.emit('update:end',newValue)
}
})
return ()=> {
return <div class="range">
<input type="text" v-model={start.value} />
<span>~</span>
<input type="text" v-model={end.value} />
</div>
}
}
})
注册组件 src/utils/editor-config.jsx
+import Range from '../components/Range';
+
+registerConfig.register({
+ label: '范围选择器',
+ preview: () => <Range placeholder="预览input"></Range>,
+ render: ({ model }) => {
+ console.error(model)
+ return <Range {...{
+ start: model.start.modelValue, // @update:start
+ end: model.end.modelValue,
+ 'onUpdate:start': model.start['onUpdate:modelValue'], // 注意这里的事件转换
+ 'onUpdate:end': model.end['onUpdate:modelValue']
+ }}></Range>
+ },
+ key: 'range',
+ model: {
+ start: '开始范围字段',
+ end: '结束范围字段'
+ }
+});
修改 App.vue,表单项增加 start 和 end
const formData = ref({
username: 'ys',
+ password: 234,
+ start: 0,
+ end: 100
})
修改 src/packages/editor.scss,把范围选择框搞好看点
+.range {
+ display: inline-flex;
+ width: 220px;
+ min-height: 30px;
+ align-items: center;
+
+ input{
+ flex:1;
+ width:100%;
+ }
+
+ span{
+ margin:0 4px;
+ display: inline-flex;
+ }
+}
这样就实现啦,以后扩展也会很简单。
step13:实现下拉框物料
其实实现下拉框物料比较简单,针对每个下拉框自定义 options 每一项的 key 和 value,再设置个选中的字段就好了,目标效果如下:
修改 src/utils/editor-config.jsx,首先注册物料,以前都写过,就不想详细说了,注意注册的格式
+import { ElButton, ElInput, ElSelect, ElOption } from 'element-plus';
+const createTableProp = (label, table) => ({ type: 'table', label, table }) // 表格options选择器
+registerConfig.register({
+ label: '下拉框选项',
+ preview: () => <ElSelect placeholder="预览select" modelValue=""></ElSelect>,
+ render: ({ props, model }) => {
+ // block.props.options = [{label: '用户填写', value: '用户填写'} ...]
+ return <ElSelect { ...model.default }>
+ {(props.options || []).map((opt, index) => {
+ return <ElOption label={opt.label} value={opt.value} key={index}></ElOption>
+ })}
+ </ElSelect>
+ },
+ key: 'select',
+ props: { // 用来渲染右侧属性面板
+ options: createTableProp('下拉框选项', {
+ options: [
+ { label: '显示值', field: 'label', },
+ { label: '绑定值', field: 'value' }
+ ],
+ key: 'label' // 内容区显示给用户的值,当配置完,展示在页面上给用户看的 tag 标签
+ })
+ },
+ model: {
+ default: '绑定字段'
+ }
+});
修改 packages/editor-operator.jsx,针对 select 提供的 type = table 注册信息去生成操作栏
+// propName propConfig => options optionsArray
+table: () => <TableEditor propConfig={ propConfig } v-model={ state.editData.props[propName] }></TableEditor> // table组件,当下拉框元素在右侧属性区添加下拉选项时,弹出表格
新建 table 组件,将接收到的配置信息 modelValue,做成计算属性,并传入下级组件 tableDialog,而且提供了更新 data,触发 update:modelValue 的 onConfirm 方法,此方法执行后,修改后的 data 数据会同步至 TableEditor 组件的接收的参数 state.editData 中,该数据用于替换元素 block。
src/components/table-editor.jsx
import deepcopy from "deepcopy";
import { ElButton, ElTag } from "element-plus";
import { computed, defineComponent } from "vue";
import { $tableDialog } from "./TableDialog";
export default defineComponent({
props: {
propConfig: { type: Object },
modelValue: { type: Array },
},
emits: ['update:modelValue'],
setup(props, ctx) {
const data = computed({
get() {
return props.modelValue || [];
},
set(newValue) {
ctx.emit('update:modelValue', deepcopy(newValue));
}
})
const add = () => {
$tableDialog({
config: props.propConfig,
data: data.value, // 传入计算属性,更新 data 的值时,把更新事件发射出去
onConfirm: (newData) => { // 点击确认的时候,更新 data
data.value = newData;
}
});
}
return () => {
return <div>
{
// 此下拉框没有任何数据 直接显示一个按钮即可
(!data.value || data.value.length === 0) && <ElButton onClick={ add }>添加</ElButton>
}
{
// 根据配置中的展示给用户看的key 显示配置的 options tag content,这里我配置的是 label
(data.value || []).map(item => <ElTag onClick={ add }>{ item[props.propConfig.table.key] }</ElTag>)
}
</div>
}
}
})
新建 tableDialog 组件,提供表单功能,将修改后的值同步出去,用于更新
src/components/TableDialog.jsx
import deepcopy from "deepcopy";
import { createVNode, render, defineComponent, reactive } from "vue";
import { ElDialog, ElButton, ElTable, ElTableColumn, ElInput } from 'element-plus';
const TableComponent = defineComponent({
props: {
option: { type: Object },
},
setup(props, ctx) {
const state = reactive({
option: props.option,
isShow: false,
editData: [] // 编辑的数据
});
let method = {
show(option) {
state.option = option; // 保存用户配置
state.isShow = true; // 更改显示状态
state.editData = deepcopy(option.data)// 拷贝一份,这里不会直接修改外面 data 的数据,等待调用传入的 confim 方法时,才会修改
}
}
ctx.expose(method);
const onCancel = () => {
state.isShow = false;
}
const onConfirm = () => {
state.option.onConfirm(state.editData);
state.isShow = false;
}
const add = () => {
state.editData.push({});
}
return () => {
// console.error(state.option.config.table.options)
return state.isShow && <ElDialog v-model={ state.isShow } title={state.option.config.label}>
{{
default: () => {
return <div>
<div>
<ElButton onClick={ add }>添加</ElButton>
<ElButton>重置</ElButton>
<ElTable data={ state.editData }>
{
state.option.config.table.options.map((item, index) => {
return <ElTableColumn label={ item.label }>
{{
default: ({ row }) => {
// 相当于 row.label = 'xxx' row.value = 'xxx' 而且带有 update 方法 row 就是editorData[index] 会修改 editData
// 当 confirm 时,调用外面传来的 onConfirm 方法,把 editData 传出去赋值给外面计算属性 data,外面开始触发更新事件 updata:modelValue
// 更新弹框组件 TableEditor 绑定的 modelValue,也就是 Editor-operator 组件中的 state.editData,此时点击应用,就可以实现数据更改,内容区真实组件接收到该事件后 更新视图
return <ElInput v-model={ row[item.field] }></ElInput>
}
}}
</ElTableColumn>
})
}
<ElTableColumn label="操作">
<ElButton type="danger">删除</ElButton>
</ElTableColumn>
</ElTable>
</div>
</div>
},
footer: () => <>
<ElButton onClick={onCancel}>取消</ElButton>
<ElButton onClick={onConfirm}>确定</ElButton>
</>
}}
</ElDialog>
}
}
})
let vm;
export const $tableDialog = (option) => {
if (!vm) {
const el = document.createElement('el');
vm = createVNode(TableComponent, { option }); // 创建虚拟节点并传入参数
let r = render(vm, el);
document.body.appendChild(el);
}
let { show } = vm.component.exposed;
show(option);
}
这样就完成了下拉组件物料~
step13:实现手动拉伸组件尺寸
更改配置不够精细,也不够方便,我期望实现手动拉伸更改属性,这里分为横向拉,纵向拉,斜向拉,不同的元素有不同的操作方式,比如 input 我只想横向拉,按钮我想怎么拉就怎么啦。所以我们还是要更改我们的配置文件,这里以按钮和input框为例,预期效果如下:
思路:我们点击元素的时候,需要出框选锚点,这里锚点需要根据配置是否能改变宽或高属性来生成。
修改 utils/editor-config.jsx,增加框选配置,并接收一个 size 属性,作用到内容区元素上
registerConfig.register({
label: '按钮',
+ resize: {
+ width: true, // 可以更改横向大小
+ height: true
+ },
preview: () => <ElButton>预览按钮</ElButton>,
+ render: ({ props, size }) => <ElButton style={{ height: size.height + 'px', width: size.width + 'px' }} type={ props.type } size={props.size}>{ props.text || '渲染按钮' }</ElButton>,
});
registerConfig.register({
label: '输入框',
+ resize: {
+ width: true, // 可以更改横向大小
+ },
preview: () => <ElInput placeholder="预览input"></ElInput>,
+ render: ({ model, size }) => <ElInput style={{ width: size.width + 'px' }} placeholder="渲染输入框" { ...model.default } ></ElInput>, // { modelValue: xx, onUpdate:modelValue: xx }
key: 'input',
model: { // 输入的值挂载到 dafult 这个 key 上
default: '绑定字段'
}
});
创建一个元素锚点组件 block-resize.jsx,实现框选锚点样式,根据拖动方向更改位置和宽高逻辑等。
import { defineComponent } from "vue";
export default defineComponent({
props: {
block: { type: Object }, // 当前元素的属性信息
component: { type: Object }, // 注册组件信息
},
setup(props) {
const { width, height } = props.component.resize || {};
let data = {}; // 备份之前的位置信息
const onmousemove = (e) => {
let { clientX, clientY } = e;
let { startX, startY, startWidth, startHeight, startLeft, startTop, direction } = data;
if (direction.hirizontal == 'center') { // 如果拉伸方向是垂直方向的 那么只需要改变高度 宽度不能改变
clientX = startX;
}
if (direction.vertical == 'center') { // 如果拉伸方向是水平方向的 那么只需要改变宽度 高度不能改变
clientY = startY;
}
// 算出鼠标之前之后位置差
let moveX = clientX - startX;
let moveY = clientY - startY;
// 针对一些反向拖拽的锚点(操作top拉或者left拉),先挪动,再给宽高,符合视觉效果
// 因为 left top 不变的话,所有在 left top 方向的操作都不对劲,左锚点->左不对,左锚点->右也不对,上锚点->上不对,上锚点->下也不对
if (direction.hirizontal == "start") { // 针对水平方向开始位置,这里针对的是左侧上中下三个位置
moveX = -moveX;
props.block.left = startLeft - moveX;
}
if (direction.vertical == 'start') { // 针对垂直方向开始位置 这里其实只针对水平方向的往上拉的中心锚点,因为往下是符合渲染规则的(被宽高往下堆叠top值变大)
moveY = -moveY;
props.block.top = startTop - moveY;
}
const width = startWidth + moveX;
const height = startHeight + moveY;
// 拖拽 改变元素宽高
props.block.width = width;
props.block.height = height;
props.block.hasResize = true; // 说明改变了宽高,需要渲染到页面
}
const onmouseup = (e) => {
document.body.removeEventListener('mousemove', onmousemove);
document.body.removeEventListener('mouseup', onmouseup);
}
const onmousedown = (e, direction) => {
e.stopPropagation(); // 阻止冒泡 防止元素被拖走
data = {
startX: e.clientX, // 鼠标位置
startY: e.clientY,
startWidth: props.block.width,
startHeight: props.block.height,
startLeft: props.block.left, // 元素默认位置
startTop: props.block.top,
direction
}
document.body.addEventListener('mousemove', onmousemove);
document.body.addEventListener('mouseup', onmouseup);
}
return () => <>
{/* 有宽度则显示元素垂直边中心横向的两个操作锚点 */}
{ width && <>
<div class="block-resize block-resize-left" onMousedown={ e => onmousedown(e, { hirizontal: 'start', vertical: 'center' })}></div>
<div className="block-resize block-resize-right" onMousedown={ e => onmousedown(e, { hirizontal: 'end', vertical: 'center' })}></div>
</> }
{/* 高度能修改的话显示水平边中心的两个操作锚点 */}
{ height && <>
<div class="block-resize block-resize-top" onMousedown={ e => onmousedown(e, { hirizontal: 'center', vertical: 'start' })}></div>
<div className="block-resize block-resize-bottom" onMousedown={ e => onmousedown(e, { hirizontal: 'center', vertical: 'end' })}></div>
</> }
{/* 宽高都能修改的话显示四个角的操作锚点 */}
{ width && height && <>
<div class="block-resize block-resize-top-left" onMousedown={ e => onmousedown(e, { hirizontal: 'start', vertical: 'start' })}></div>
<div className="block-resize block-resize-top-right" onMousedown={ e => onmousedown(e, { hirizontal: 'end', vertical: 'start' })}></div>
<div className="block-resize block-resize-bottom-left" onMousedown={ e => onmousedown(e, { hirizontal: 'start', vertical: 'end' })}></div>
<div className="block-resize block-resize-bottom-right" onMousedown={ e => onmousedown(e, { hirizontal: 'end', vertical: 'end' })}></div>
</> }
</>
}
})
修改组件样式 packages/editor.scss
+.block-resize {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: rgb(15, 65, 204);
+ z-index: 1000;
+ user-select: none;
+}
+
+.block-resize-top {
+ top: -4px;
+ left: calc(50% - 4px)
+}
+
+.block-resize-bottom {
+ bottom: -4px;
+ left: calc(50% - 4px)
+}
+
+.block-resize-left {
+ top: calc(50% - 4px);
+ left: -4px
+}
+
+.block-resize-right {
+ top: calc(50% - 4px);
+ right: -4px
+}
+
+.block-resize-top-left {
+ top: -4px;
+ left: -4px;
+}
+
+.block-resize-top-right {
+ top: -4px;
+ right: -4px;
+}
+
+.block-resize-bottom-left {
+ bottom: -4px;
+ left: -4px;
+}
+
+.block-resize-bottom-right {
+ bottom: -4px;
+ right: -4px;
+}
+
+.el-button, .el-input{
+ transition: none;
+}
修改 packages/editor-block.jsx, 引入组件锚点,并传递 size 给真实元素
import { computed, defineComponent, inject, onMounted, ref } from "vue";
+import BlockResize from "./block-resize";
export default defineComponent({
// console.log(props.block.props);
// 拿到渲染后的元素 这是我们自己定义的 render 方法哦 根据注册的 key 生成真实元素
const RenderComponent = component.render({
+ size: props.block.hasResize ? { width: props.block.width, height: props.block.height } : {},
props: props.block.props, // 把渲染的属性传递过去
model: Object.keys(component.model || {}).reduce((prev, modelName) => {
...
}, {})
});
+ const { width, height } = component.resize || {};
+
return <div class="editor-block" style={ blockStyles.value } ref={ blockRef }>
{ RenderComponent }
+
+ {/* 选中状态 而且有 resize 属性 增加元素操作锚点框 block.props 存放了元素的属性信息 component 为注册的组件信息 存放了宽和高是否能修改 */}
+ { props.block.focus && (width || height) && <BlockResize block={ props.block } component={ component }></BlockResize>}
+ {props.block.focus && (width || height) && <BlockResize
+ block={props.block}
+ component={component}
+ ></BlockResize>}
</div>
}
}
OK,至此我的低代码拖拽编辑器已经完成。