最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 可视化拖拽页面编辑器 | 项目复盘

    正文概述 掘金(zhangzp)   2021-03-21   756

    前言

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

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

    在线预览

    GitHub 地址

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

    编辑器主要功能

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

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

    整体架构

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

    这种编辑器一般都是分为 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轴上对应“左”“中”“右”三条线:

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

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

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

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

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

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

    总结

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


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


    起源地下载网 » 可视化拖拽页面编辑器 | 项目复盘

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元