深圳幻海软件技术有限公司 欢迎您!

低代码海报平台的编辑器难点剖析

2023-02-28

通过上一篇文章,我们对乔巴乐高海报平台的整体架构有了初步的了解。今天我们深入到编辑器部分,对其中的难点和实现细节进行分析。这是目前生产的编辑器页面:对应的原型图:不难看出和市面上大部分低代码平台一样,由三部分组成:左侧组件列表、中间画布区域、右侧属性区域。大致操作流程就是拖动左侧的组件到中间的画布,

通过上一篇文章,我们对乔巴乐高海报平台的整体架构有了初步的了解。今天我们深入到编辑器部分,对其中的难点和实现细节进行分析。

这是目前生产的编辑器页面:

对应的原型图:

不难看出和市面上大部分低代码平台一样,由三部分组成:左侧组件列表、中间画布区域、右侧属性区域。

大致操作流程就是拖动左侧的组件到中间的画布,选中组件,右侧属性面板就会展示与该组件关联的属性。编辑右侧属性,画布中对应的组件样式就会同步更新。页面拼接完成。

从中看出组件串联其中,在前面一篇文章中,我们大致分析了整体页面和组件的数据结构,但没有细化。抽取一下文字、图片、素材组件的通用特性:

尺寸属性(Size)

  • 宽度(width)
  • 高度(height)

填充属性(Padding)

  • 上填充(padding-top)
  • 右填充(padding-right)
  • 下填充(padding-bottom)
  • 左填充(padding-left)

视觉格式属性

  • 指定如何定位元素(position)
  • 指定所定位元素的上边缘的位置(top)
  • 指定所定位元素的右边缘的位置(right)
  • 指定所定位元素底边的位置(bottom)
  • 指定定位元素左边缘的位置(left)
  • 将一个或多个阴影应用于元素的框(box-shadow)

颜色属性(Color)

  • 透明度(opacity)

边框属性(Border)

  • 设置元素所有四个侧面的边框颜色(border-color)
  • 设置元素所有四个侧面的边框宽度(border-width)
  • 在元素的所有四个面上设置边框的样式(border-style)
  • 定义元素边界角的形状(border-radius)

除此之外,文字组件还具有以下属性:

字体属性(Fonts)

  • 定义元素的字体列表(font-family)
  • 定义文本的字体大小(font-size)
  • 定义文本的字体样式(font-style)
  • 指定文本的字体粗细(font-weight)

文字属性(Text)

  • 设置内联内容的水平对齐方式(text-align)
  • 指定添加到文本的装饰(text-decoration)
  • 设置文本行之间的高度(line-height)

图片组件还具有:

图片属性(Image)

  • 图片链接(src)

素材组件还具有:

  • 背景属性(Background)

定义元素的背景色(background-color)

我们将上面的操作流程拆解为三步:

1⃣️ 拖动左侧的组件到中间的画布

2⃣️ 选中组件,右侧属性面板就会展示与该组件关联的属性

3⃣️ 编辑右侧属性,画布中对应的组件样式就会同步更新

1.添加组件到画布

通过上一篇文章,我们知道编辑器整体的数据结构是这么设计的:

state:{
// 所有添加到画布中的组件数据
componentData:[],
}

reducers:{
// 添加组件到componentData
addComponentData(){},
// 编辑组件,更新componentData及curComponent
editComponentData(){},
// 删除组件
delComponentData(){}
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

那么从左侧组件列表添加组件到画布的操作其实就是向componentData中push一条组件数据。

这里主要是关注下组件列表要怎么设计:为了便于用户快速创建活动,组件列表最好是预设一些模板,其实就是针对文字、图片和素材分别提供一些已有的元素。这样当对应组件点击添加到画布时,对应就会commit一个mutation来修改store中的componentData。

这里组件列表底层渲染也是用的组件库,只是不同模板的props不同。

2.选中组件展示其关联属性

当在画布中选中具体组件时,我们需要知道此刻是哪个组件被选中了,意味着需要一个变量来存储当前高亮的组件。那么在store中添加setActive和getCurrentElement:

const editorModule = {
  state: {
    componentData: [],
    currentElement: "",
  },
  mutations: {
     addComponentData(state, component) {

     },
     setActive(state, id) {
      state.currentElement = id;
     },
  },
  getters: {
    getCurrentElement: (state) => {
      return state.componentData.find(
        (component) => component.id === state.currentElement
      );
    },
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

当在画布中选中组件时,就会触发setActive,更新currentElement。(通过getCurrentElement可以获取到当前正在被操作的组件)。

这个时候,怎么在右侧属性区域动态展示不同组件的不同属性呢?

对于单独的组件来说,属性面板应该是语义化的,无论是开发还是非开发同学,通过属性面板的操作区,就可以直观的知道一个组件的属性是什么,应该如何使用和编辑。

那么属性面板应该包含哪些内容呢?

1、label:属性名称。这个可以显式的告诉具体的属性的作用,比如元素的宽高、边框、背景颜色等。

2、description:属性的描述信息。对于一些特殊属性,可能第一下通过label并不能直观的识别属性的含义,添加描述信息可以进行详细的阐述。

3、content:属性渲染器。用户可以基于此实现对属性的修改。最常见的有 textarea、input、select 等。

4、error:属性校验信息。当用户输入了不合法的或者类型不匹配时,可给予适当的错误提示信息。

通过以上描述,我们会发现,这其实就是我们常用的表单。

对应上面组件的props信息,我们可以对这些属性做一些归类,那归类的标准又是什么呢?我认为应该把属性与js中的数据类型做一下映射,然后在具体的分类下选用合适的渲染器。

我们知道在JavaScript中,一共有七种数据类型,字符串(String)、数字(Number)、布尔(Boolean)、空(Null)、未定义(Undefined)、Symbol和对象(Object)。其中对象类型包括:数组(Array)、函数(Function)、还有两个特殊的对象:正则(RegExp)和日期(Date)。

这里面的空(Null)、未定义(Undefined)、Symbol和正则(RegExp)在渲染器中基本用不到。

我们先来看一下字符串(String)、数字(Number)、布尔(Boolean)和日期(Date)可能渲染的方式:

字符串(String)

渲染器类型

组件

input

textarea

数字(Number)

渲染器类型

组件

input-number

QQ47GAT">

slider

布尔(Boolean)

渲染器类型

组件

switch

日期(Date)

渲染器类型

组件

date

除了这几种,还有对象(Object)、数组(Array)、函数(Function)。

对象和数组属于较复杂的类型,不过我们可以把它抽象为多层级(可以理解为嵌套)的基础数据类型:

渲染器类型

组件

array

像数组一般是用下拉框的形式来展现。

至于函数(Function),可以采用预定义的形式:

渲染器类型

组件

function

到这里,不难想到,我们要维护一个属性和表单组件的对应关系。属性对应上面的key,像borderColor、text、width、fontFamily这些,那组件呢?组件其实就是对属性的具体呈现,像width可以用数字输入框、text可以用普通输入框,但是对于一些比较复杂的特性,我们自己去实现这些组件,就显得捉襟见肘了,这个时候我们就可以考虑和现有的组件库做一下结合了(这里我采用的是Ant Design Vue)。

那么这样,属性prop和component基础的对应关系就有了:

const mapPropsToComponents = {
  text: {
   component: "a-input",
  },
  width: {
   component: "a-input-number",
  },
  borderWidth: {
   component: "a-slider",
  },
  // ...
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

但这只是满足了常规的基础组件设计,像一些独有的属性或者基础组件不能满足的情况,我们需要对其做一定扩展:

渲染器类型

组件

upload

color-picker

上面提到的上传组件和颜色选择组件是需要我们单独去实现的。

3.编辑属性,画布同步更新

上面只是初步建立了属性和组件的对应关系,组件初始值的展示、复杂组件的展示以及表单值更新后,画布如何同步更新,这些问题我们还都没有解决。

其实把问题简化,这就是表单的回显和更新问题。

以我以往的经验来看:表单组件在设计时,有两点是必须的:

  • 表单初始值(默认value),供初始展示使用
  • 表单属性更改的事件(默认为change)

对于不同的表单,初始值和属性更改后,参数的处理是不一样的:

  • 像高度、宽度这种数字类型的,传入表单时应保证是number(24)类型,属性更改后,事件参数应该是string(24px)类型的
  • 字体加粗与否、倾斜与否、加下划线与否,传入表单时应保证是boolean(true/false)类型,属性更改后,事件参数应该是string(bold/normal)类型的

所以给每一个属性在传入表单和事件更改后都要加一个额外的转化函数去处理值:

  • initialValueConvert
  • eventChangeValueConvert

还有对属性进行赋值时,不是所有的表单控件接收的都是value,像checkbox就是checked,这种单独抽一个属性valueProp去控制即可。

其次,像上面提到的复杂组件(我们这里是指父子层级)的渲染,除了component还要多加一个subComponent。

完善后,属性prop和component的对应关系为:

const mapPropsToComponents = {
    text: {
        component: "textarea-fix",
        eventName: "change",
        valueProp: "value",
        initialValueConvert: (v: any) => v,
        eventChangeValueConvert: (e: any) => e.target.value,
        text: "文本",
    },
    width: {
        component: "a-input-number",
        eventName: "change",
        valueProp: "value",
        initialValueConvert: (v: string) => (v ? parseInt(v) : ""),
        eventChangeValueConvert: (e: number) => (e ? `${e}px` : ""),
        text: "宽度",
    },
    textDecoration: {
        component: "icon-switch",
        eventName: "change",
        initialValueConvert: (v: string) => v === "underline",
        eventChangeValueConvert: (e: boolean) => (e ? "underline" : "none"),
        valueProp: "checked",
    },
    backgroundSize: {
        component: "a-select",
        eventName: "change",
        initialValueConvert: (v: any) => v,
        eventChangeValueConvert: (e: any) => e,
        subComponent: "a-select-option",
        text: "背景大小",
        options: [
          { value: "contain", text: "自动缩放" },
          { value: "cover", text: "自动填充" },
          { value: "", text: "默认" },
        ],
   },
   // ...
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.

我们的数据始终保持自上而下的顺序,也就是说表单更新最终要反射回到总体的 store 当中去。这个时候我们在对应的组件当中发射出一个事件(change),当 change 发生的时候,我们能够知道是哪个元素的哪个属性,以及新的值是什么,我们就用这些信息更新这个值,这样 store完成更新,元素的 props 发生更新,那么整个数据流动就完成了。

4.画布区域交互设计实现

上面说了这么多,基本都是围绕左侧组件区域、中间画布区域、右侧属性区域相互之间的数据流动来讲的。最后来说一下画布区域本身一些比较复杂的交互实现。

我大概整理了这几种:

  • 拖拽(组件在画布中移动)
  • 组件图层
  • 放大/缩小
  • 撤销/重做

拖拽(组件在画布中移动)

这个相对比较简单,就是mousedown、mousemove和mouseup事件的结合使用:在组件上按下鼠标后,记录组件当前位置,也就是 x、y 坐标(对应的是 css 中的 left 和 top);每次鼠标移动时用当前最新的 xy 坐标减去最开始的 xy 坐标,计算出移动的距离,然后更新组件位置;鼠标抬起时结束移动。

组件图层

图层面板主要是控制组件的显示/隐藏、不同组件的层级关系以及点击选中。

这里主要说一下层级关系吧,正常情况下,我们会选择使用z-index来控制层级。但是这里我没有使用z-index,而是利用了层叠领域黄金准则的第二条。

层叠领域黄金准则:1、谁大谁上: 当具有明显的层叠水平标示的时候,如识别的 z-indx 值,在同一个层叠上下文领域,层叠水平值大的那一个覆盖小的那一个。2、后来居上: 当元素的层叠水平一致、层叠顺序相同的时候,在 DOM 流中处于后面的元素会覆盖前面的元素。

为什么选择第二个而没有选择最常见的第一条呢?首先,我们需要一个图层列表可以对每个组件对应的图层进行排序,其实就是对store中的components进行排序,也就是数组排序了,那么在图层列表中,如果你想增加某一图层的层级,把它放置到后面就可以了(这样渲染时,数组后面的元素就会处在 DOM 流的后面了。对应的层叠顺序也就居上了),这样不仅操作方便,也不用增加额外冗余代码,可谓一举两得。

放大/缩小

核心实现:在画布组件的四个角(↖️、↗️、↙️、↘️)分别加一个小圆点:

左上:组件 left、top 均减小;width、height 均变大

右上:组件 left 不变、top 减小;width、height 均变大

左下:组件 left 减小、top 不变;width、height 均变大

右下:组件 left、top 均不变;width、height 均变大

撤销/重做

撤销、重做其实是我们平时一直在用的操作。对应快捷键一般就是⌘ Z / Ctrl+Z、⌘⇧ Z / Ctrl+Shift+Z。这个功能是很常见的,他可以极大的提升用户体验,提高编辑效率,但是用代码应该如何实现呢?