低代码可视化开发平台的实现

285 阅读15分钟

解决的问题

开发者不需要用手写代码的方式进行编程,可以采用图形化拖拽的方式,直接配置参数完成开发

核心就是减少重复的劳动

组成

  • 组件区
  • 编辑区(预览区)
  • 属性/事件区

编辑器布局

editor组件

参数props

data

定义画布样式(container)以及在画布上面所要渲染的组件(blocks)

{
  "container": {
    "width": 550,
    "height": 550
  },
  "blocks": [
 {
  "top": 100,
  "left": 100,
  "zIndex": 1,
  "key": "text",
  "props": {
    "text": "zxj",
    "color": "red",
    "size": "16px"
  }
},
{
  "top": 200,
  "left": 200,
  "zIndex": 2,
  "key": "button",
  "props": {
    "text": "my button",
    "type": "primary",
    "size": ""
  }
}
  ]
}

formData

组件所需要的数据

const formData = ref({
      username: "zxj",
      password: 123,
      start: 0,
      end: 100,
    });

结构

  • editor
    • editor-left 左侧物料区
    • editor-top 顶部快捷按钮
    • editor-right 右侧属性区域
    • editor-container 中间画布区域
<div class="editor">
          <div class="editor-left">
          </div>
          <div class="editor-top">
          </div>
          <div class="editor-right">
          </div>
          <div class="editor-container">
           </div>
</div>

自定义物料组件

editor-config.jsx

在配置文件editor-config.jsx文件中,编写左侧的所有物料

在editor-left元素中遍历所有物料进行渲染


function createEditorConfig() {
  const componentList = []; //左侧渲染组件
  const componentMap = {};  //中间渲染组件


  return {
    componentList,
    componentMap,
    //根据传入的组件的配置,生成一个组件
    register: (component) => {
      componentList.push(component)
      componentMap[component.key] = component
    }
  }
}

export let registerConfig = createEditorConfig()

物料的配置对象属性

label 物料名称
preview  在左侧物料区的渲染函数
render  在中间画布的渲染函数
key  物料的唯一标识
props  物料可配置的属性(在右侧属性去可配置)

文本物料


//文本
registerConfig.register({
  label: '文本',
  preview: () => '预览文本',
  render: ({ props }) => <span style={{ color: props.color, fontSize: props.size }}>{props.text || '渲染文本'}</span>,
  key: 'text',
  props: {
    text: createInputProp('文本内容'),
    color: createColorProp('字体颜色'),
    size: createSelectProp('字体大小', [
      { label: '14px', value: '14px' },
      { label: '15px', value: '15px' },
      { label: '16px', value: '16px' },
      { label: '17px', value: '17px' },
      { label: '18px', value: '18px' },
    ])
  }
})

下拉框物料

//下拉框
registerConfig.register({
  label: '下拉框',
  preview: () => <ElSelect modelValue=""></ElSelect>,
  render: ({ props, model }) => {
    return <ElSelect {...model.default}>
      {
        (props.options || []).map((opt, index) => {
          return <ElOption label={opt.label} value={opt.value} key={index}></ElOption>
        })
      }
    </ElSelect>
  },
  key: 'select',
  props: {  //[{label:'a',value:'1'},{label:'b',value:'2'}]  用户输入label 显示value
    options: createTableProp('下拉选项', {
      options: [
        { label: '显示值', field: 'label' },
        { label: '绑定值', field: 'value' }
      ],
      key: 'label'  //显示给用户的值是label
    })
  },
  model: {
    default: '绑定字段'
  }
})

按钮物料

//按钮
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>,
  key: 'button',
  props: {
    text: createInputProp('按钮内容'),
    color: createColorProp('字体颜色'),
    type: createSelectProp('按钮类型', [
      { label: '基础', value: 'default' },
      { label: '主要', value: 'primary' },
      { label: '成功', value: 'success' },
      { label: '警告', value: 'warning' },
      { label: '危险', value: 'danger' },
      { label: '文本', value: 'info' },
    ]),
    size: createSelectProp('按钮大小', [
      { label: '默认', value: '' },
      { label: '大', value: 'large' },
      { label: '小', value: 'small' },
      // { label: '极小', value: 'mini' }
    ])
  }
})

输入框物料


//输入框
registerConfig.register({
  label: '输入框',
  resize: {
    width: true
  },
  preview: () => <ElInput placeholder="预览输入框"></ElInput>,
  render: ({ model, size }) => <ElInput style={{ width: size.width + 'px' }} placeholder="渲染输入框" {...model.default}></ElInput>,
  key: 'input',
  model: {
    default: '绑定字段'
  }
})

范围选择器物料

//范围选择器
registerConfig.register({
  label: '范围选择器',
  key: 'range',
  preview: () => <Range></Range>,
  render: ({ 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>
  },
  model: {
    start: '开始字段',
    end: '结束字段'
  }
})

editor.jsx

在editor-left元素中遍历componentList,调用每个物料的preview渲染函数,进行渲染

<div class="editor-left">
  {/* 根据注册列表,渲染对应的内容  可以实现拖拽*/}
  {config.componentList.map(component => {
    return <div class="editor-left-item"
      draggable
      onDragstart={e => dragstart(e, component)}
      onDragend={dragend}>
      <span>{component.label}</span>
      <div>{component.preview()}</div>
    </div>
  })}
</div>

实现拖拽物料组件(useMenuDragger)

使用html5的拖拽属性

给每个物料添加draggable属性实现可拖拽

监听物料的dragStart和dragEnd事件

触发物料的dragStart

  • 监听画布dragenter dragover dragleave 和 drop事件

触发物料的dragEnd

  • 取消对画布的事件监听

画布的dragenter事件

修改鼠标的图标-可移动

const dragenter = (e) => {
  e.dataTransfer.dropEffect = 'move' //增加图标
}

画布的dragover事件

阻止默认事件

  const dragover = (e) => {
    e.preventDefault()
  }

画布的dragleave事件

修改鼠标的图标-不可移动

  const dragleave = (e) => {
    e.dataTransfer.dropEffect = 'none'
  }

画布的drop事件

将当前拖拽的物料加入到data中

由于data是响应式的,所以一旦修改data,页面就会重新渲染

  const drop = (e) => {

    let blocks = data.value.blocks  //内部已经渲染的组件
    data.value = {
      ...data.value,
      blocks: [
        ...blocks,
        {
          top: e.offsetY,
          left: e.offsetX,
          zIndex: 1,
          key: currentComponent.key,
          alignCenter: true,//希望松手的时候可以居中
          props: {},
          model: {}
        }
      ]
    }
    currentComponent = null
  }

editor-container的渲染

在editor-container内会遍历用户传入的data对象中的blocks数组(这个数组存储要在画布上面渲染的组件的配置对象)

用户的组件配置对象的属性

组件位置
top 
left
zIndex
key 标识这是哪一个物料
props 组件属性

eg
{
  "top": 100,
  "left": 100,
  "zIndex": 1,
  "key": "text",
  "props": {
    "text": "zxj",
    "color": "red",
    "size": "16px"
  }
}

editor-block组件

遍历之后将每个组件配置对象传给editor-block组件,专门用它来渲染

          <div class="editor-container">
            {/* 负责产生滚动条*/}
            <div class="editor-container-canvas">
              {/* 内容区域 */}
              <div class="editor-container-canvas-content"
                style={containerStyles.value}
                ref={containerRef}
                onMousedown={containerMousedown}
              >
                {
                  (data.value.blocks.map((block, index) => {
                    return <EditorBlock
                      block={block}
                      formData={props.formData}
                      onMousedown={e => blockMousedown(e, block, index)}
                      onContextmenu={(e) => onContextMenuBlock(e, block)}
                      class={[block.focus ? 'eidtor-block-focus' : '', previewRef.value ? 'editor-block-preview' : '']}
                    ></EditorBlock>
                  }))
                }
                {markLine.x !== null && <div class="line-x" style={{ left: markLine.x + 'px' }}></div>}
                {markLine.y !== null && <div class="line-y" style={{ top: markLine.y + 'px' }}></div>}

              </div>

            </div>
          </div>

编辑器画布元素获取焦点(useFocus)

调用useFocus函数,得到的返回值

{
    focusData,  //记录选中组件和未选中组件
    blockMousedown, //处理组件的点击事件
    containerMousedown, //处理画布的点击事件
    lastSelectBlock, //最后点击的组件
    clearBlockFocus //将所有组件变成未选中状态
  }

监听在画布中组件的mousedown事件

调用blockMousedown函数

修改focus属性

每个渲染的组件根据自己的focus属性决定是否要添加选中框的样式

还能实现多选

用户按下shift进行点击 就能多选

所以通过mousedown的事件对象的shiftKey属性判断是否按下了shift

  // 5) 处理画布中的组件的点击事件
  const blockMousedown = (e, block, index) => {
    // 1. 如果是在预览状态,则不能点击
    if (previewRef.value) {
      return
    }
    // 2. 阻止默认事件和冒泡事件
    e.preventDefault()
    e.stopPropagation()
    // 3. 在block上定义一个属性focus, 获取焦点后就就将focus变为true

    //3.1 判断是否按下了shift  实现多选效果
    if (e.shiftKey) {
      if (focusData.value.focus.length <= 1) {
        block.focus = true
      } else {
        block.focus = !block.focus
      }

      // 3.2 单选
      //把其他人变成未选中状态,自己变成选中状态
    } else if (!block.focus) {
      clearBlockFocus()
      block.focus = true  //清空其他人
    } //当自己已经被选中了,再次点击就不会取消选择,只有点击外面元素才能取消选中

    // 4. 不断更新最后被点击的元素
    selectIndex.value = index

    // 5. 选中完成之后调用传进来的回调函数
    callback(e)
  }

编辑器画布元素拖动(useBLockDragger)

调用useBlockDragger函数,得到返回值

{
    mousedown, //选中组件之后触发
    markLine //x,y轴线的位置
  }

点击组件之后调用mousedown函数

调用useFocus传入的回调函数中,调用了mousedown函数

记录拖拽组件的宽高

获取到未被选中的组件

定义lines对象,属性为x数组和y数组

存储所有辅助线的位置和被拖拽的组件能够使辅助线出现的位置

遍历未被选中的组件,得到他们的位置和宽高

记录辅助线的10种情况

将每个未选中的组件对应的10条辅助线加入到x数组和y数组中

监听鼠标移动和鼠标放开事件

  // 1. 点击组件之后触发 
  const mousedown = (e) => {
    // 1) 记录拖拽元素的宽高
    const { width: BWidth, height: BHeight } = lastSelectBlock.value
    // 2) 记录 x,y轴辅助线的10中情况(当前移动的组件位置和基准线应该在的位置)
    dragState = {
      //记录刚点击的时候鼠标的位置
      startX: e.clientX,
      startY: e.clientY,

      //只是点击,不是在拖拽
      dragging: false,

      //记录刚点击的时候被点击元素的位置
      startLeft: lastSelectBlock.value.left,
      startTop: lastSelectBlock.value.top,

      //记录每一个选中所在的位置
      startPos: focusData.value.focus.map(({ top, left }) => ({ top, left })),
      lines: (() => {
        const { unfocused } = focusData.value  //获取其他没选中的以他们的位置做辅助线

        let lines = { x: [], y: [] }  //y用来存放所有横线的位置和拖动元素的位置  x用来存放所有竖线的位置和拖动元素的位置

        //最外面的容器也得作为参照物
        const unfocused1 = [...unfocused, {
          top: 0,
          left: 0,
          width: data.value.container.width,
          height: data.value.container.height
        }]
        unfocused1.forEach(block => {
          const { top: ATop, left: ALeft, width: AWidth, height: AHeight } = block

          //在移动B的时候就看B的top和left是否满足线出现的条件
          //-----------------横线Y
          //showTop是横线的位置,top是被拖动元素的top
          //顶(A不动元素)对顶(B拖动元素)
          lines.y.push({ showTop: ATop, top: ATop })
          // 顶对底
          lines.y.push({ showTop: ATop, top: ATop - BHeight })
          //中对中
          lines.y.push({ showTop: ATop + (AHeight / 2), top: ATop + (AHeight / 2) - (BHeight / 2) })
          //底对顶
          lines.y.push({ showTop: ATop + AHeight, top: ATop + AHeight })
          //底对底
          lines.y.push({ showTop: ATop + AHeight, top: ATop + AHeight - BHeight })

          //-------------------竖线
          //左对左
          lines.x.push({ showLeft: ALeft, left: ALeft })
          //左对右
          lines.x.push({ showLeft: ALeft, left: ALeft - BWidth })
          //中对中
          lines.x.push({ showLeft: ALeft + (AWidth / 2), left: ALeft + (AWidth / 2) - (BWidth / 2) })
          //右对右
          lines.x.push({ showLeft: ALeft + AWidth, left: ALeft + AWidth - BWidth })
          //右对左
          lines.x.push({ showLeft: ALeft + AWidth, left: ALeft + AWidth })
        })
        return lines
      })()
    }
    // 3) 监听鼠标移动和鼠标放开事件
    document.addEventListener('mousemove', mousemove)
    document.addEventListener('mouseup', mouseup)

  }

mousemove

根据鼠标移动的距离修改被拖拽组件的left和top

根据最新的left和top去x,y数组里面找是否有符合的辅助线

如果left和top与规定的left和top之间的差值小于5px,就可以显示基准线

将被拖拽组件的Left和top修改成规定的Left和top,就可以实现吸附效果

  const mousemove = (e) => {
    let { clientX: moveX, clientY: moveY } = e

    //当前正在拖
    if (!dragState.dragging) {
      dragState.dragging = true
      //触发事件  就会记住触发前的位置
      events.emit('start')
    }

    //计算当前元素最新的Left和top,去lines数组里面找是否有符合的
    //(鼠标移动后-鼠标移动前) =>鼠标移动位置 +元素最初的left
    let left = moveX - dragState.startX + dragState.startLeft
    let top = moveY - dragState.startY + dragState.startTop


    //显示横线  当top跟lines数组中的top差值小于5px的时候,就显示这根线
    let y = null
    for (let i = 0; i < dragState.lines.y.length; i++) {
      const { top: t, showTop: s } = dragState.lines.y[i]
      if (Math.abs(top - t) < 5) {  //显示这根线
        y = s//线显示的位置

        //接近的时候两个元素之间快速贴合
        moveY = dragState.startY - dragState.startTop + t


        break
      }
    }

    //显示竖线
    let x = null
    for (let i = 0; i < dragState.lines.x.length; i++) {
      const { left: l, showLeft: s } = dragState.lines.x[i]
      if (Math.abs(left - l) < 5) {  //显示这根线
        x = s//线显示的位置

        //接近的时候两个元素之间快速贴合
        moveX = dragState.startX - dragState.startLeft + l

        break
      }
    }

    //响应式数据,x和y更新了会导致视图更新
    markLine.x = x
    markLine.y = y

    //更新选中元素的位置
    let durX = moveX - dragState.startX  //横向移动的距离
    let durY = moveY - dragState.startY  //纵向移动的距离

    focusData.value.focus.forEach((block, index) => {
      block.top = dragState.startPos[index].top + durY
      block.left = dragState.startPos[index].left + durX
    })
  }

editor-container渲染辅助线

{markLine.x !== null && <div class="line-x" style={{ left: markLine.x + 'px' }}></div>}
{markLine.y !== null && <div class="line-y" style={{ top: markLine.y + 'px' }}></div>}

mouseup

取消监听

将 辅助线的值设置为null


  const mouseup = (e) => {
    document.removeEventListener('mousemove', mousemove)
    document.removeEventListener('mouseup', mouseup)
    markLine.x = null
    markLine.y = null
    if (dragState.dragging) {
      events.emit('end')
    }
  }

实现辅助线

(与画布元素拖动功能一起实现)

每个未被选中的组件都对应10条辅助线

根据拖拽组件的位置选择渲染哪条辅助线

实现重做和撤销的功能以及快捷(useCommands)

定义命令状态

  const state = {
    //前进后退需要指针(索引值)
    current: -1,
    queue: [],  //用一个栈存放操作过的命令,能够实现前进后退
    commands: {}, //命令和执行函数的映射表
    commandsArray: [],//存放所有命令
    destoryArray: [], //销毁命令的函数
  }

注册命令的函数registry

  const registry = (command) => {
    //1) 把注册的命令对象放入数组中
    state.commandsArray.push(command)
    //2) 把命令的执行函数重写之后放到映射表中
    state.commands[command.name] = (...args) => {  //重写命令对应的执行函数,为了传递参数
      // 3) 命令 的 原来的执行函数会返回 redo函数和undo函数
      const { redo, undo } = command.execute(...args)
      // 4) 默认执行一次redo函数,进行正常操作
      redo()

      // 5) 判断当前这个命令能否实现前进和后退
      if (!command.pushQueue) {
        return
      }
      let { queue, current } = state

      //防止在放置的过程中有撤销操作
      if (queue.length > 0) {
        //根据当前最新的current 求出新的queue
        queue = queue.slice(0, current + 1)
        state.queue = queue
      }

      // 6) 可以则将命令的redo和undo函数压入栈中
      queue.push({ redo, undo })
      // 7) 修改当前的current指针
      state.current = current + 1
    }
  }

注册命令

重做

  registry({
    name: 'redo', //命令名称
    keyboard: 'ctrl+y', //快捷键
    execute() { //执行函数
      return {
        redo() {
          // 1. 取出栈中current指针指向的命令的上面一个命令
          let item = state.queue[state.current + 1]
          if (item) {
            // 2. 执行这个命令的redo函数
            item.redo && item.redo()
            // 3. 更新current指针
            state.current++
          }
        }
      }
    }
  })

撤销

  //撤销
  registry({
    name: 'undo',
    keyboard: 'ctrl+z',
    execute() {
      return {
        redo() {
          // 1. 根据current指针判断是否有命令能够被撤销
          // 2  没有撤销操作,什么也不做
          if (state.current === -1) {
            return
          }
          // 3  有,current指针指向的命令就是需要被撤销的
          let item = state.queue[state.current]  //不会操作队列 只是修改了current指针
          if (item) {
            // 4. 执行这个命令的undo函数
            item.undo && item.undo()
            // 5. 更新current的值
            state.current--
          }
        }
      }
    }
  })

拖拽

  //拖拽
  registry({  //如果希望将操作放到队列中,可以增加一个属性,标识等会操作要放到队列中
    name: 'drag',
    pushQueue: true, //能够重做和撤销
    init() {  //初始化操作 (监听拖拽开始事件和结束事件),一开始就执行
      // 1.1 开始拖拽,就先把拖拽之前的状态保存起来
      this.before = null
      const start = () => {
        this.before = deepcopy(data.value.blocks)
      }
      // 2.1 拖拽结束,就去命令映射表获取drag命令的执行函数去执行
      const end = () => {
        state.commands.drag()  //执行drag命令的redo方法
      }

      // 1. 监听开始拖拽事件
      events.on('start', start)  //拖动的时候会触发
      // 2. 监听拖拽结束事件
      events.on('end', end)      //拖动结束之后会触发

      //关闭监听的函数
      return () => {
        events.off('start', start)
        events.off('end', end)
      }
    },
    //2.1.1 拖拽结束之后被触发
    execute() {
      // 1) 获取到拖拽之前的状态
      let before = this.before
      // 2) 获取到当前状态
      let after = data.value.blocks
      return {
        // 2.1 执行redo 正常操作,把当前状态
        redo() {  //默认松手,就把当前的事情做了
          data.value = { ...data.value, blocks: after }
        },
        // 1.1 执行undo 用之前的状态修改data 
        undo() { //撤回操作
          data.value = { ...data.value, blocks: before }
        }
      }
    }
  })

导入

//导入指令1(根据新导入的JSON数据-data重新渲染整个画布)  更新整个容器 (data.value)
  registry({
    name: 'updateContainer',
    pushQueue: true,
    execute(newValue) {
      let state = {
        before: data.value,
        after: newValue
      }
      return {
        redo() {
          data.value = state.after
        },
        undo() {
          data.value = state.before
        }
      }
    }
  })

  // 导入指令2(重新渲染单个组件)  更新单个block (data.value.blocks)
  registry({
    name: 'updateBlock',
    pushQueue: true,
    execute(newBlock, oldBlock) {
      let state = {
        before: data.value.blocks,
        after: () => {
          let blocks = [...data.value.blocks]  //拷贝一份用于新的blocks
          let index = data.value.blocks.indexOf(oldBlock)  //在老的里面找修改的是哪一个,把他替换掉
          if (index > -1) {
            blocks.splice(index, 1, newBlock)
          }

          return blocks
        }
      }


      return {
        redo() {
          data.value = { ...data.value, blocks: state.after() }
        },
        undo() {
          data.value = { ...data.value, blocks: state.before }
        }
      }
    }
  })

置顶

  //置顶
  registry({
    name: 'placeTop',
    pushQueue: true,
    execute() {
      let before = deepcopy(data.value.blocks) //需要深拷贝 因为在vue3中如果修改前后的对象是同一个对象,就不会触发更新
      let after = () => {
        let { focus, unfocused } = focusData.value

        //1.在未选中组件中 找到ZIndex最大的
        let maxZIndex = unfocused.reduce((pre, block) => {
          return Math.max(pre, block.zIndex)
        }, -Infinity)

        //2. 然后把当前选中的组件的ZIndex改成比他更大
        focus.forEach(block => block.zIndex = maxZIndex + 1)
        return data.value.blocks
      }
      return {
        redo: () => {
          data.value = { ...data.value, blocks: after() }
        },
        undo: () => {
          data.value = { ...data.value, blocks: before }
        }
      }
    }
  })

置底

  //置底
  registry({
    name: 'placeBottom',
    pushQueue: true,
    execute() {
      let before = deepcopy(data.value.blocks) //需要深拷贝 因为在vue3中如果修改前后的对象是同一个对象,就不会触发更新
      let after = () => {
        let { focus, unfocused } = focusData.value

        //1.在未选中组件中.找到ZIndex最小的
        let minZIndex = unfocused.reduce((pre, block) => {
          return Math.min(pre, block.zIndex)
        }, Infinity)

        //不能是负值,不然可能看不到
        //如果是负值,则让没选中的增加,自己变成0
        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: () => {
          data.value = { ...data.value, blocks: after() }
        },
        undo: () => {
          data.value = { ...data.value, blocks: before }
        }
      }
    }
  })

删除

  //删除
  registry({
    name: 'delete',
    pushQueue: true,
    execute() {
      let before = data.value.blocks
      let after = focusData.value.unfocused //选中的都删除了
      return {
        redo() {
          data.value = { ...data.value, blocks: after }
        },
        undo() {
          data.value = { ...data.value, blocks: before }
        }
      }
    }
  })

初始化函数

执行所有命令的初始化函数

  //3.执行所有命令的初始化函数
  function first() {
    //监听键盘事件
    state.destoryArray.push(keyboardEvent()())
    state.commandsArray.forEach(command => state.destoryArray.push(command.init && command.init()))
  }
  first()

实现顶部菜单栏

在按钮数组中定义每个按钮的配置对象

配置对象的属性

label 按钮名称
icon 显示的图标类名
handler 点击按钮之后的处理函数

按钮数组

撤销,重做,置顶,置底,删除

这些按钮的处理函数都是直接去命令映射表中执行对应命令的执行函数

导入

点击之后调用$dialog函数弹出输入框

参数为配置对象

配置对象的属性包括

title 输入框标题
content 默认显示的内容
footer 是否显示确认取消按钮
onConfirm 点击确认按钮之后的回调函数

在回调函数中,调用导入命令的执行函数

$dialog函数


let vm = null
export function $dialog(options) {
  //手动挂载组件

  //不需要多次重复创建
  if (!vm) {
    //1. 创建一个div元素
    let el = document.createElement('div')

    //2. 得到组件的虚拟节点
    vm = createVNode(DialogComponent, { options })

    //3. 把虚拟节点变成真实节点, 将真实节点渲染到div元素上
    render(vm, el)

    //4. 将div 渲染到页面上
    document.body.appendChild(el)
  }

  //得到显示对话框的方法
  let { showDialog } = vm.component.exposed
  showDialog(options)
}

Dialog组件

const DialogComponent = defineComponent({
  props: {
    options: {
      type: Object
    }
  },
  setup(props, ctx) {
    const state = reactive({
      isShow: false,
      options: props.options  //用户传入的属性
    })

    //在全局暴露出修改对话框显示或者隐藏的方法
    ctx.expose({
      showDialog(options) {
        //更新用户传入的属性,保证每次都是最新的
        state.options = options
        state.isShow = true
      }
    })
    const onCancel = () => {
      state.isShow = false
    }
    const onConfirm = () => {
      state.isShow = false
      state.options.onConfirm && state.options.onConfirm(state.options.content)
    }
    const render = () => {
      return <ElDialog v-model={state.isShow} title={state.options.title}>
        {{
          default: () => <ElInput
            type="textarea"
            v-model={state.options.content}
            rows={10}
          ></ElInput>,
          footer: () => state.options.footer && <div>
            <ElButton onClick={onConfirm}>确定</ElButton>
            <ElButton type="primary" onClick={onCancel}>取消</ElButton>
          </div>
        }
        }
      </ElDialog >
    }

    return render
  }
})

切换编辑和预览

用一个previewRef变量来控制样式

//预览的时候,组件不能再移动了   ,但是可以点击/输入内容 
 const previewRef = ref(false)
 
 //修改组件样式
 <EditorBlock
              block={block}
              formData={props.formData}
              onMousedown={e => blockMousedown(e, block, index)}
              onContextmenu={(e) => onContextMenuBlock(e, block)}
              class={[block.focus ? 'eidtor-block-focus' : '', previewRef.value ? 'editor-block-preview' : '']}
    ></EditorBlock>

点击按钮的时候,就对这个变量进行取反

取消所有的选中状态

  {
        label: () => previewRef.value ? '编辑' : '预览',
        icon: () => previewRef.value ? 'icon-edit' : 'icon-browse',
        handler: () => {
          previewRef.value = !previewRef.value
          clearBlockFocus()
        }
   }

退出

用一个editorRef变量控制页面显示的内容是整个编辑器的内容,还是只显示画布

//当前是否为编辑状态,如果是则正常显示,不是则是渲染页面的状态,只显示面板上的内容
 const editorRef = ref(true)
 
  const render = () => {
      return !editorRef.value ? <>
        <div class="editor-container-canvas-content"
          style={[containerStyles.value, "margin:0"]}
        >
        </div>
      </>
        : <div class="editor">
          <div class="editor-left">
          </div>
          <div class="editor-top">
          </div>
          <div class="editor-right">
          </div>
          <div class="editor-container">
            {/* 负责产生滚动条*/}
            <div class="editor-container-canvas">
              </div>
            </div>
          </div>
        </div>
    }

取消所有的选中状态

    const buttons = [
      { label: '撤销', icon: 'icon-back', handler: () => commands.undo() },
      { label: '重做', icon: 'icon-forward', handler: () => commands.redo() },
      //弹出对话框, 输入JSON数据,把JSON数据转化到画布上面
      {
        label: '导入', icon: 'icon-import', handler: () => {
          $dialog({
            title: '导入JSON使用',
            content: '',
            footer: true,
            onConfirm: (text) => {
              //data.value = JSON.parse(text)  //这样更改无法保留历史记录
              commands.updateContainer(JSON.parse(text))
            }
          })
        }
      },
      //弹出对话框, 把组件转化为json数据
      {
        label: '导出', icon: 'icon-export', handler: () => {
          $dialog({
            title: '导出JSON使用',
            content: JSON.stringify(data.value),
            footer: true
          })
        }
      },
      //置顶
      { label: '置顶', icon: 'icon-place-top', handler: () => commands.placeTop() },
      { label: '置底', icon: 'icon-place-bottom', handler: () => commands.placeBottom() },
      { label: '删除', icon: 'icon-delete', handler: () => commands.delete() },
      //编辑/预览之间进行切换
      {
        label: () => previewRef.value ? '编辑' : '预览',
        icon: () => previewRef.value ? 'icon-edit' : 'icon-browse',
        handler: () => {
          previewRef.value = !previewRef.value
          clearBlockFocus()
        }
      },
      //关闭按钮 将画布渲染到屏幕上
      {
        label: '关闭', icon: 'icon-close', handler: () => {
          editorRef.value = false
          clearBlockFocus()
        }
      },

    ]

实现右侧属性操作栏

editor-operator组件

接受的参数

block 最后选中的组件
data 用户设置的组件信息
updateContainer  更新整个画布的函数
updateBlock 更新单个组件的函数

使用

  <div class="editor-right">
            <EditorOperator
              block={lastSelectBlock.value}
              data={data.value}
              updateContainer={commands.updateContainer}
              updateBlock={commands.updateBlock}
            ></EditorOperator>
</div>

布局

如果没有点击组件,默认显示的是画布的属性

如果点击了组件,根据组件的类型去物料映射表中获取需要显示的属性

提供不同类型的表单给用户去修改组件的属性

使用v-model实现数据的双向绑定


    const render = () => {
      // 1. 存放最终渲染的内容的数组
      let content = []
      // 2. 没有点击组件,默认显示的是画布的属性
      if (!props.block) {
        content.push(<>
          <ElFormItem label="容器宽度">
            <ElInputNumber v-model={state.editData.width}></ElInputNumber>
          </ElFormItem>
          <ElFormItem label="容器高度">
            <ElInputNumber v-model={state.editData.height}></ElInputNumber>
          </ElFormItem>
        </>)
      } else {
        // 3. 处理容器  文本  按钮物料
        // 4. 在物料映射表中获取当前物料需要显示的属性
        // 5. 使用v-model实现属性值之间的双向绑定
        let component = config.componentMap[props.block.key]
        if (component && component.props) {
          content.push(Object.entries(component.props).map(([propName, propConfig]) => {
            return <ElFormItem label={propConfig.label}>
              {{
                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>,
                table: () => <tableEditor propConfig={propConfig} v-model={state.editData.props[propName]}></tableEditor>
              }[propConfig.type]()}
            </ElFormItem>
          }))
        }

        // 6. 处理输入框物料
        if (component && component.model) {
          content.push(Object.entries(component.model).map(([modelName, label]) => {
            return <ElFormItem label={label}>
              <ElInput v-model={state.editData.model[modelName]}></ElInput>
            </ElFormItem>
          }))
        }
      }



      return <ElForm labelPosition="top">
        {content}
        <ElFormItem>
          <ElButton type="primary" onClick={() => apply()}>应用</ElButton>
          <ElButton onClick={() => reset()}>重置</ElButton>

        </ElFormItem>

      </ElForm>
    }

实现范围选择器物料

实现下拉菜单物料

实现调整组件大小功能

editor-block组件

在editor-block组件中除了渲染组件本身,还需要判断是否需要渲染调整大小的组件

    return <div class="editor-block" style={blockStyles.value} ref={blockRef}>
        {renderComponent}

        {/* 传递block的目的是为了修改当前block的宽高  传component是为了获取是修改宽度还是高度 */}
        {props.block.focus && (width || height) && <BlockResize
          block={props.block}
          component={component}
        ></BlockResize>}
      </div>

BlockResize组件

接受两个参数

block  用户定义的组件配置对象--为了修改宽高
component 默认的配置对象---为了判断是能修改宽还是高

布局

根据能否修改宽高的条件,设置不同位置的拖动点,

根据当前选中的组件的位置,设置这些拖动点的样式

    const render = () => {
      const { width, height } = props.component.resize || {}
      return <>
        {/* 能修改宽  左右两个点*/}
        {width && <>
          <div class="block-resize block-resize-left" onMousedown={e => onmousedown(e, {
            horizontal: 'start',
            vertical: 'center'
          })}></div>
          <div class="block-resize block-resize-right" onMousedown={e => onmousedown(e, {
            horizontal: 'end',
            vertical: 'center'
          })}></div>
        </>}
        {/* 能修改高  上下两个点*/}
        {height && <>
          <div class="block-resize block-resize-top" onMousedown={e => onmousedown(e, {
            horizontal: 'center',
            vertical: 'start'
          })}></div>
          <div class="block-resize block-resize-bottom" onMousedown={e => onmousedown(e, {
            horizontal: 'center',
            vertical: 'end'
          })}></div>
        </>}
        {/* 宽高都能修改  左上 左下 右上 右下 四个点*/}
        {width && height && <>
          <div class="block-resize block-resize-top-left" onMousedown={e => onmousedown(e, {
            horizontal: 'start',
            vertical: 'start'
          })}></div>
          <div class="block-resize block-resize-top-right" onMousedown={e => onmousedown(e, {
            horizontal: 'end',
            vertical: 'start'
          })}></div>
          <div class="block-resize block-resize-bottom-left" onMousedown={e => onmousedown(e, {
            horizontal: 'start',
            vertical: 'end'
          })}></div>
          <div class="block-resize block-resize-bottom-right" onMousedown={e => onmousedown(e, {
            horizontal: 'end',
            vertical: 'end'
          })}></div>
        </>}
      </>
    }

监听拖动元素的mousedown事件

收集拖之前的信息

鼠标位置

组件宽高

组件位置

监听mousemove和mouseup事件

    const onmousedown = (e, direction) => {
      e.stopPropagation()

      // 1. 保存拖拽之前的信息
      data = {
        //鼠标位置
        startX: e.clientX,
        startY: e.clientY,
        //组件宽高
        startWidth: props.block.width,
        startHeight: props.block.height,
        //组件位置
        startLeft: props.block.left,
        startTop: props.block.top,
        //拖动点的位置
        direction
      }

      //2. 监听mousemove和mouseup事件
      document.body.addEventListener('mousemove', onmousemove)
      document.body.addEventListener('mouseup', onmouseup)

    }

mousemove事件

计算鼠标移动距离

  • 拖水平中间的点, 只改变高度,宽度不能改变
    • 将鼠标在x轴方向的移动距离设置为0
  • 拖垂直中间的点 ,只改变宽度, 高度不能改变
    • 将鼠标在y轴方向的移动距离设置为0

计算组件宽高

  • 如果拖动的是左中 左上 左下 中中这四个点,需要改变组件的top或者是left

更新组件宽高

    const onmousemove = (e) => {
      let { clientX, clientY } = e
      let { startX, startY, startWidth, startHeight, startLeft, startTop, direction } = data

      // 1.1 拖水平中间的点, 只改变高度,宽度不能改变
      // 将鼠标在x轴方向的移动距离设置为0
      if (direction.horizontal == 'center') {
        clientX = startX
      }

      // 1.2 拖垂直中间的点 ,只改变宽度,  高度不能改变
      // 将鼠标在y轴方向的移动距离设置为0
      if (direction.vertical === 'center') {
        clientY = startY
      }

      // 1. 计算鼠标移动的距离,求出移动后的宽高
      let durX = clientX - startX
      let durY = clientY - startY

      // 2.1 如果移动的是开始点,则应该改变 left 和 top (左中 左上 左下 中中)
      if (direction.vertical == 'start') {
        durY = -durY
        props.block.top = startTop - durY
      }

      if (direction.horizontal == 'start') {
        durX = -durX
        props.block.left = startLeft - durX
      }

      // 2. 计算移动之后的宽高
      const width = startWidth + durX
      const height = startHeight + durY

      // 3. 更新组件的宽高
      props.block.width = width
      props.block.height = height
      props.block.hasResize = true
    }