可视化拖拽页面编辑器 | 项目复盘

11,283 阅读9分钟

前言

在去年闲暇时间开发了一个可视化的页面编辑器,这次看到掘金有项目复盘的活动,正好可以拿出来写篇文章,和大家分享下。不知道还能不能赶上活动了~

在开始文章之前你可以先体验下:

在线预览

GitHub 地址

1.png

编辑器主要功能

  • 元素自由拖拽,放大,缩小,旋转
  • 可添加图片,文本,矩形,背景。多种编辑功能(字体,背景,大小,边距等)
  • 组件自动吸附,实时参考线(组件可以和画布,自定义参考线以及其他组件进行自动吸附对齐,并显示实时参考线,拖动过程中按下 alt 键可暂时关闭)
  • 标尺,参考线,可自定义参考线(在标尺上点击即可生成参开线,可拖动参考线更改位置,双击删除参考线)
  • 撤销,重做(支持快捷键,可配置撤销的步数)
  • 组件复制,粘贴,锁定,隐藏等
  • ctrl + 拖动组件可快速复制组件
  • 右键菜单,菜单可配置,可针对组件当前状态灵活生成(即不同的组件可产生不同的菜单)
  • 图层面板,可拖拽更改组件图层,可重命名,可在图层面板快速锁定,删除,隐藏组件
  • 同时选中多组件(按 ctrl + 左键),可进行多组件对齐
  • 数据备份,通过 indexDB 数据库保存在本地(可自动备份,手动备份),并可从备份中恢复数据
  • 一键生成 h5 代码
  • 编辑画布大小
  • 多种快捷键
  • 设置中心,可设置撤销功能,备份功能等
  • 可通过插件系统二次开发

由于里面的细节比较多,肯定不能将所有点都讲到,我就挑几个主要的写写,有些可能写的比较简略,具体的实现可以看 GitHub 上的源码。

整体架构

2.png

这种编辑器一般都是分为 3 个区域,左中右,在左边添加组件,中间操作,右侧可以编辑组件的一些属性,我这个是参考易企秀的设计,中部和右部有一个快捷操作栏,有一些常用的设置。

这些不同的区域对应不同的功能,那么在代码里我们也要将这些不同的功能区域分开:

<!-- index.vue -->
<template>
  <div class="poster-editor" :class="{ 'init-loading': initLoading }">
    <div class="base">
      <!-- 左侧添加组件栏 -->
      <left-side />
      <!-- 主要操作区域 -->
      <main-component ref="main" />
      <!-- 常用功能栏 -->
      <extend-side-bar />
      <!-- 组件编辑区域 -->
      <control-component />
    </div>
    <!-- 图层面板 -->
    <transition name="el-zoom-in-top">
      <layer-panel v-show="layerPanelOpened" />
    </transition>
  </div>
</template>

然后还要有数据,这数据包含了画布属性,组件属性,当前编辑器状态等等,存在 vuex 中:

const state = {
  activityId: '',
  pageConfigId: '',
  pageTitle: '',
  canvasSize: {
    width: 338,
    height: 600
  },
  canvasPosition: {
    top: null,
    left: null
  },
  background: null,
  posterItems: [], // 组件列表
  activeItems: [], // 当前选中的组件
  assistWidgets: [], // 辅助组件
  layerPanelOpened: true, // 是否打开图层面板
  referenceLineOpened: true, // 是否打开参考线
  copiedWidgets: null, // 当前复制的组件 WidgetItem[]
  referenceLine: {
    // 参考线,用户定义的参考线
    row: [],
    col: []
  },
  matchedLine: null, // 匹配到的参考线 {row:[],col:[]}
  mainPanelScrollY: 0,
  isUnsavedState: false // 是否处于未保存状态
}

这里最主要的就是posterItems属性,是保存当前所有组件的数组。添加组件就是往这个数组中 push 数据,然后遍历posterItems将组件放在画布上,进而可以编辑这个组件。

组件实现

因为一共有很多种组件,并且这些组件都有一些相同属性,比如位置大小信息,是否锁定,是否隐藏等等,这些相同的属性不可能每个组件都写一遍,所以要就要实现一个基础的组件,这个基础组件包含了所有组件通用的属性,然后其他组件就通过这个组件扩展,我这里通过 class 的方式实现:

const defaultWidgetConfig = () => {
  return {
    id: '', // 组件id
    type: '', // 类型
    typeLabel: '', // 类型标签
    componentName: '', // 动态component的name
    icon: '', // 图标class
    wState: {}, // 组件内部状态数据,样式属性等信息
    dragInfo: { w: 100, h: 100, x: 0, y: 0, rotateZ: 0 }, // 组件的位置、大小、旋转角度
    rename: '', // typeLabel重命名
    lock: false, // 是否处于锁定状态
    visible: true, // 是否可见
    initHook: null, // Function 组件初始化时候(created)执行
    layerPanelVisible: true, // 是否在图片面板中可见
    replicable: true, // 是否可复制
    isCopied: false, // 是否是复制的组件(通过复制操作获得的组件)
    removable: true, // 是否可删除
    couldAddToActive: true, // 是否可被添加进activeItems
    componentState: null // Function 复制组件时有效,返回结果为为复制时原组件内部的data;componentState.count为复制的次数

    /**
     * @property {Int} _copyCount 复制的次数
     * @property {String} _copyFrom 复制来源 command | drag
     * @property {Boolean} _isBackup 是否是通过备份恢复的组件
     * @property {Int} _widgetCountLimit 该组件的数量限制
     * @property {Int} _sort 组件图层排序
     */
  }
}

// 组件父类
export default class Widget {
  constructor(config) {
    const item = _merge(defaultWidgetConfig(), config, {
      id: uniqueId(config.typeLabel + '-')
    })
    // this._config = item
    Object.keys(item).forEach((key) => {
      this[key] = item[key]
    })
  }

  // 组件mixin
  static widgetMixin(options) {
    // ...一会讲
  }
}

defaultWidgetConfig是组件的配置项,Widget其实就是初始化这些配置,然后其他组件继承Widget就可以了,比如我们要实现一个文本组件:

// 文本Widget
export default class TextWidget extends Widget {
  constructor(config) {
    config = _merge(
      {
        type: 'text',
        typeLabel: '文本',
        componentName: 'text-widget',
        icon: 'icon-text',
        lock: false,
        visible: true,
        wState: {
          text: '双击编辑文本',
          style: {
            margin: '10px',
            wordBreak: 'break-all',
            color: '#000',
            textAlign: 'center',
            fontSize: '14px', // px
            padding: 0, // px
            borderColor: '#000',
            borderWidth: 0, // px
            borderStyle: 'solid',
            lineHeight: '100%', // %
            letterSpacing: 0, // %
            backgroundColor: '',
            fontWeight: '',
            fontStyle: '',
            textDecoration: ''
          }
        }
      },
      config
    )
    super(config)
  }
}

这个构造函数里面就可以初始化一些配置,然后使用时就 new 一个,然后添加到posterItems中,简化后大概是这样的:

// 添加文本组件
store.dispatch('poster/addItem', new TextWidget())

const actions = {
  addItem(state, item) {
    if (item instanceof Widget) {
      state.posterItems.push(item)
    }
  }
}

添加完之后,按照上文所说,编辑器中部的操作区域是遍历这个posterItems的:

<component
  v-for="item in posterItems"
  :key="item.id"
  :item="item"
  :is="item.componentName"
/>

这里是通过componentName来调用不同的组件,刚才的TextWidget已经配置了componentNametext-widget,现在我们就来实现这个组件:

<!-- textWidget.vue -->
<template>
  <div class="text-widget">demo</div>
</template>

<script>
  import { TextWidget } from 'poster/widgetConstructor'

  export default {
    mixins: [TextWidget.widgetMixin()],
    data() {
      return {}
    }
  }
</script>
<style lang="scss" scoped></style>

现在添加组件后,你就能在画布上看到demo了,这只是个示例,更加详细的内容可以看 GitHub 的源码。

注意这里引入了一个 mixin,这个TextWidget.widgetMixin其实就是Widget上的:

export default class Widget {
  constructor(config) {
    // ...
  }

  // 组件mixin
  static widgetMixin(options) {
    // ...
  }
}

就是一些组件公共的逻辑,其实这里用高阶组件的方式比较好,用 mixin 的话需要每个组件都要写一遍,比较繁琐,只是当时没想清楚。这个 mixin 一会就会用到,用到再说,这里有个印象即可。

拖拽缩放功能

这个是直接用了一个 Vue 组件:vue-draggable-resizable,直接调用这个组件就行:

<!-- textWidget.vue -->
<template>
  <vue-draggable-resizable>
    <div class="text-widget">demo</div>
  </vue-draggable-resizable>
</template>

这样虽然是可行的,但是有个问题就是我们不仅有文本组件,还有图片,矩形、背景,以后可能还要添加其他组件,而且这个拖动不是套一下就可以的,我们还要写其他的很多逻辑,比如拖动时将 x、y 轴的数据实时更新到组件的属性中,还要有吸附对齐等功能,肯定不能每个组件都去写一遍这些东西,这个时候其实可以把“拖拽”和“组件”拆开,“拖拽”作为一个容器,然后里面嵌套“组件”

<vue-draggable-resizable v-for="item in posterItems" :key="item.id">
  <component
    :is="item.componentName"
    ref="widget"
    :item="item"
    :is-active="isActive"
    v-on="$listeners"
    @draggableChange="draggable = $event"
  />
</vue-draggable-resizable>

这样其实就是多了一层,把拖拽相关的逻辑都写在拖拽容器里,我们只需要实现内层“组件”的逻辑就可以了。

设置组件属性

画布上有了组件后,还要可以在右侧的编辑区域编辑组件,比如字号,背景,边框等等,刚才的TextWidget里面有一个style属性,字号什么的就是更改这个属性。

那么我们要做的就是点击这个组件时,将这个组件置为active状态,然后右侧的区域就显示对应的属性编辑器,不同组件的设置项肯定也不一样,比如TextWidget需要字号,字体颜色,但是如果我们要做一个矩形的组件,设置项肯定和TextWidget不同,那么我们就需要单独实现各个组件的属性编辑器,然后判断当前active状态的组件的类型,根据不同的类型调用不同的属性编辑器。

复制组件

编辑器最必不可少的一个功能就是复制组件,复制组件分为“复制”、“粘贴”两个步骤,复制时就把当前这个组件的配置全部保存下来,,粘贴时就把这个配置拿出来,通过这个配置创建一个组件,添加到posterItems中去,下面是简化后的代码:

// 复制组件
const mutations = {
  [MTS.COPY_WIDGET](state, item) {
    const config = _.cloneDeep(item)
    state.copiedWidgets = config
  }
}
export default class CopiedWidget extends Widget {
  constructor(config) {
    config._copyCount += 1
    const configCopy = Object.assign({}, _.cloneDeep(config), {
      typeLabel: config.typeLabel + '-copy',
      isCopied: true
    })
    super(configCopy)
  }
}

然后粘贴的时候只需要state.posterItems.push(new CopiedWidget(state.copiedWidgets)),就可以了。

自动吸附

我这里说一下我的实现思路,代码就不贴了,因为比较多,感兴趣的小伙伴可以查看源码。

其实思路很简单,组件在X轴方向上对应“上”“中”“下”三条线,在Y轴上对应“左”“中”“右”三条线:

5.png

4.png

假设此时有A和B两个组件,现在在拖动B组件,拖动过程中,我们需要实时监测B的左中右三条边和A的是否靠的足够近,就要拿B的左边和A的左中右分别对比,再拿B的中边和A的左中右对比,再拿B的右边和A的左中右对比,总共对比三轮,中间哪一次对比发现两条边的距离达到预设值了,比如说B的左边和A的右边相差了5像素,这时候就可以手动更改B的X轴坐标将B和A对齐:

3.png

对应的,上中下三条边也是分别对比,然后匹配到的话就修改B的Y轴坐标。

就是这个思路,实际情况可能比较复杂点,因为不可能只有两个组件,而且除了要组件之间对齐,还要支持自定义参考线,还要和画布的边对齐,感兴趣的同学可以直接看Github的源码。

总结

这篇文章有些地方写的可能比较简略,因为不是一个教程类型的,是对以前的项目做个分享,所以写的比较简略,如果你有什么想法,或者想知道哪个功能点的具体实现细节的,欢迎和我交流。


如果你觉得这个项目还不错的话,欢迎点个赞,感谢~