低代码可视化平台搭建

650 阅读34分钟

低代码介绍

  • 低代码开发平台(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 收集注册任务 渲染区则需要生成真实可点选的组件元素(比如输入框,但是这里也是不让聚焦的哦,因为还要做选中拖呢),这里按我的思路,需要两部分数据。

  1. 注册组件需要有个组件的key,代表组件类型,比如 text, button 等
  2. 我当前注册过的物料区组件list,我渲染好往那边一放作为预览
  3. 我需要一个物料区组件的 key,还需要一个 key 和真实组件的对应 Map,好用于渲染真实组件
  4. 我需要一个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,该方法做了两件事。

  1. 收集记录了当前鼠标位置和所有选中元素当前坐标
  2. 给当前 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:

  1. 辅助线水平或者垂直方向应该出现辅助线的情况(比如水平方向:顶对顶,顶对底,中对中,底对顶,底对底,纵向一样也是五种)。
  2. 当一个元素距离另一个元素过近,自动水平/垂直吸附至辅助线。
  3. 多选元素,以最后一个选中的元素为准生成辅助线。

实现辅助线的思路:辅助线是依赖未选中元素的位置来生成,所以当我们选中某元素时,就要收集此时场上所有未选中元素(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 这个方法

  1. 该方法保存了一个任务队列 queue,内容是[{ redo, undo } ...],通过下标控制当前在哪个任务上
  2. 加了快捷键来控制执行重做和后退任务
  3. 缓存了镜像 before 和 after 在每个任务的重做&后退函数内存中,合适时机去重置
  4. 任务回退后不删除任务,只改下标,下次执行指令任务(例如再次拖拽生成新的节点)直接根据截取队列,抛弃后退的任务
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

  1. 声明预览和编辑的状态变量,控制能否选择&拖拽元素
  2. 添加预览/编辑标签
  3. 预览状态移除元素伪类样式
        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的数据双向绑定

首先,当我们点击输入框,期望右侧菜单栏有一个可以设置 "绑定" 的输入框,如果输入字段名,则自动在页面填充表单数据中字段的值,同样更改切换到编辑状态,改变输入框的值后,表单的值自动更新。

思考一下,表单的双向绑定分为两步:

  1. 右侧属性栏配置 key,自动取值更新到页面
  2. 页面更新输入框的值,立刻反应到数据。

我们渲染按钮的属性栏的时候用到了一个 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,至此我的低代码拖拽编辑器已经完成。