Skip to main content

模板编辑器:组件

About 6 minVueVue编辑器设计模式

组件在低代码项目中是极其重要的一环,基石中的基石!!!

需求分析

组件设计最主要分为两部分

  1. 解耦

  2. 可扩展性

所以 Props 的设计 需要,设计区分开公共 Props 和组件独有的 Props,方便解耦,因此设计了propertyProps 为了传递 组件的公共属性 和 独有属性,其余 Props 则是为了组件公共 Props

所以可以把公共 Props 抽象为 基类,所有控件开发继承这个基类

组件

实现

Vue2.7 可以使用 mixin 或者 defineComponentextends 属性,参数是 一个 ComponentOptionsMixin来实现组件继承复用

Vue3 仅可以使用 defineComponent 如果是 setup 语法糖 则可以使用 defineOptionsextends 属性,参数同样是 ComponentOptionsMixin

Props

PropsType说明
lockBoolean是否锁定
mode"shape" | "element"控件显示模式 shape 编辑模式 element 展示模式
labelString控件名字
size{w: Number, h: Number}控制控件大小
propertyTemplate.Property控件属性(特殊属性&公共属性)
position{x: Number, y: Number}控件位置
groupBoolean标明组件是是组合组件

Computed

NameType说明
formValueanyvalue 的 setter & getter 方便触发事件更新value值
idString方便展示 组件名称和id
isShapeBoolean是否是 shape 编辑模式
isElementBoolean是否是 element 展示模式
styleStyle格式化 property 为内联样式
containerStyleStyle最终展示在组件容器上的样式
valueStyleStyle控件特殊属性 格式后的样式

Method

NameType说明
toHtml()HTMLElement在导出模板时需要格式化组件转为普通HTML

组件基类

import * as transformCase from '@/utils/case'
import { formatComponentStyle } from '@/views/template/utils'

const borderComponent = ['BuiltinInput', 'BuiltinSelect']

/**
 * @param {string} componentName
 */
function isInnerBorderComponent(componentName) {
  return borderComponent.indexOf(componentName) > -1
}

/**
 * @type {Vue.ComponentOptions<Vue>}
 */
const mixin = {
  inheritAttrs: false,
  props: {
    lock: {
      type: Boolean,
      required: true
    },
    mode: {
      type: String,
      default: 'shape'
    },
    label: {
      type: String,
      required: true
    },
    size: {
      type: Object,
      required: true
    },
    property: {
      type: Object,
      required: true
    },
    position: {
      type: Object,
      required: true
    },
    group: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      name: ''
    }
  },
  created() {
    this.name = this.$vnode.componentOptions.Ctor.options.name
  },
  computed: {
    formValue: {
      get() {
        return this.value
      },
      set(val) {
        this.$emit('input', val)
      }
    },
    id() {
      return transformCase.kebabCase(this.name) + '-' + this._uid
    },
    isShape() {
      return this.mode === 'shape'
    },
    isElement() {
      return this.mode === 'element'
    },
    style() {
      return formatComponentStyle(this.property)
    },
    containerStyle() {
      /**
       * @type {import('vue/types/jsx').CSSProperties}
       */
      const style = Object.assign(Object.create(null), this.style)
      style.columnGap = '5px'

      if (isInnerBorderComponent(this.name)) {
        delete style.borderWidth
        delete style.borderColor
        delete style.borderStyle
      }

      if (this.group || this.isElement) {
        style.position = 'absolute'
        style.left = this.position.x + 'px'
        style.top = this.position.y + 'px'
        style.width = this.size.w + 'px'
        style.height = style.lineHeight + 'px'
      }

      return style
    },
    valueStyle() {
      if (isInnerBorderComponent(this.name)) {
        return {
          borderWidth: this.style.borderWidth,
          borderBottomStyle: this.style.borderStyle,
          borderColor: this.style.borderColor
        }
      }

      return null
    }
  },
  methods: {
    toHtml() {
      /**
       * @type {HTMLDivElement}
       */
      const el = this.$el.cloneNode(true)

      el.style.position = 'absolute'
      el.style.left = this.position.x + 'px'
      el.style.top = this.position.y + 'px'
      el.style.width = this.size.w + 'px'
      el.style.height = this.size.h + 'px'

      return el
    }
  }
}

export default mixin

组件

// builtin-input.vue
<template lang="pug">
div(v-if="isShape" :style="containerStyle")
  label(:for="id") {{ label }}
  div(:id="id" :style="valueStyle" style="flex: 1")

div(v-else :style="containerStyle")
  label(:for="id") {{ label }}
  div.flex1(:style="valueStyle" style="flex: 1") {{ formValue }}
</template>

<script>
import mixin from './mixins'

export default {
  name: 'BuiltinInput',
  mixins: [mixin],
  props: {
    value: {
      type: String,
      required: true
    }
  }
}
</script>

每个组件需要自己声明 value 属性,目的是为了 可以有正确的数据类型校验,组件的视图需要考虑到 在不同渲染模式下的样式

控件

一个组件往往会被复用多次,区别也仅仅是 名称 和 绑定的key的区别

控件数据模型

type BuiltinComponentName = 'builtin-group' | 'builtin-input' | 'builtin-select'

interface BuiltinComponent {
  /** 组件id */
  id: string
  uid: number
  shapeId: string
  visible: boolean
  builtin: boolean
  value: string
  /** 组件名称 */
  name: BuiltinComponentName
  props: BuiltinComponentProps
  children?: BuiltinComponent[]
}

组件的顶层数据模型,主要是跟组件标识符相关,或者是一些顶层变量

初始化控件

export function createBuiltinComponent(data, type = 'normal') {
  const name = data.name ?? 'builtin-input'

  /**
   * @type {Template.BuiltinComponent}
   */
  const component = {
    id: data.id ?? '',
    uid: data.uid ?? -1,
    shapeId: data.shapeId,
    name,
    value: data.value ?? '',
    visible: data.visible ?? false,
    builtin: data.builtin ?? false,
    props: initBuiltinComponentProps(data.props ?? {}, name, type)
  }

  return component
}

因为没有涉及到后端数据填充,所以只设计了 name 字段,key 字段未设计实现

分层架构

创建控件拆分为三部分

  1. 初始化组件基础数据
  2. 初始化组件Props
  3. 初始化组件属性

Props 数据模型

interface BuiltinComponentProps {
  lock: boolean
  label: string
  zIndex: number
  required: boolean
  property: Property
  position: {
    x: number
    y: number
  }
  size: {
    w: number
    h: number
  }
  value: string
  option: {
    value: BuiltinComponentPropsOptions[]
    del(key: number): void
    add(): void
	}
  get options(): BuiltinComponentPropsOptions[]
}

初始化 控件 props

/**
 * @param {TemplateDocument.BuiltinComponentProps} props
 * @param {Template.BuiltinComponentName} name
 * @param {'init'|'document'|'normal'} type `init` 初始化 `document` 模板数据 `normal` 普通初始化
 */
export function initBuiltinComponentProps(props, name, type) {
  /**
   * @type {Template.BuiltinComponentProps}
   */
  const result = {
    zIndex: props.zIndex ?? 1,
    required: props.required ?? false,
    lock: props.lock ?? false,
    label: props.label ?? '',
    value: props.value ?? '',
    property: initBuiltinComponentProperty(props.property ?? defaultProperty),
    position: props.position ?? usePosition(),
    size: props.size ?? useSize({ name }),
    option: {
      value: Vue.observable(props.options ?? []),
      add() {
        if (this.value.length) {
          // 当最后一项不为空时 才可以 新增项
          if (this.value.at(-1).value) {
            this.value.push({ value: '', key: Date.now() })
          }
          return
        }

        this.value.push({ value: '', key: Date.now() })
      },
      del(index) {
        this.value.splice(index, 1)
      }
    },
    get options() {
      return this.option.value
    }
  }

  if (type !== 'document' && !result.options.length) {
    result.option.add()
  }

  return result
}

因为下拉列表想要实现自定义

所以option.value 值必须是一个响应式

在这里使用 vue2 的 observableopen in new window 主要是考虑去掉.value尾巴 又可以获得响应式数据

Property 数据模型

type TemplatePropertyValue = Pick<TemplateProperty, 'value'>

class TemplateProperty {
  constructor(opt: TemplateProperty)
  static format(this: TemplateProperty): string
  static options: TemplateProperty
  value: string | number | boolean
  unit?: string
  trueValue?: string | number
  falseValue?: string | number
  indeterminate?: boolean
  indeterminateTrueValue?: string | number
  indeterminateFalseValue?: string | number
}

interface Property {
  display: TemplatePropertyValue
  color: TemplatePropertyValue
  fontFamily: TemplatePropertyValue
  fontSize: TemplatePropertyValue
  lineHeight: TemplatePropertyValue
  borderWidth: TemplatePropertyValue
  borderStyle: TemplatePropertyValue
  borderColor: TemplatePropertyValue
  [key: string]: TemplatePropertyValue
}

Property 初始化

/**
 * @param {TemplateDocument.Property} property
 */
export function initBuiltinComponentProperty(property) {
  /**
   * @type {TemplateDocument.Property}
   */
  const result = {
    get dashed() {
      return this.borderStyle.value
    },
    set dashed(val) {
      this.borderStyle.value = val
    },
    get underline() {
      return this.borderStyle.indeterminate
    },
    set underline(val) {
      this.borderStyle.indeterminate = val
    }
  }

  for (const [key, item] of Object.entries(property)) {
    result[key] = new TemplateProperty(item)
  }

  return result
}

Property 就主要是对组件的样式调整,并设计了一个 TemplatePropertyValue 模型 主要是想统一 value 的类型 从而可以更好的转为 style 样式

PropsOption 数据模型

interface BuiltinComponentPropsOptions {
  label: string
  value: string
  key: number
}

控件组

这个组件是实现自由组合的核心功能之一

实现原理很简单,将组件的数据存入一个数组 children 中,从 children 里 找到 最小 left 和 最大 right 得到 x&y,最小 top 和 最大 bottom 相减,取绝对值得到 h&w

公式

Position

x = Math.min.apply(null,所有组件的left)

y = Math.min.apply(null,所有组件的top)

Size

w 和 h 仅仅只需要找到差值即可 不需要关心正负值

w = Math.abs(Math.min.apply(null, 所有组件的left) - Math.max.apply(null, 所有组件的right))

h = Math.abs(Math.min.apply(null, 所有组件的top) - Math.max.apply(null, 所有组件的bottom))

实现

<template lang="pug">
.builtin-group(:style="groupStyle")
  component(
    ref="builtinComponentRef"
    v-for="(component) in children"
    v-bind="component.props"
    v-model="component.value"
    :mode="mode"
    :children="component.children"
    :key="component.id"
    :is="component.name"
    group)
</template>

<script>
import { reactive } from 'vue'
import mixin from './mixins'
import BuiltinInput from './builtin-input.vue'
import BuiltinSelect from './builtin-select.vue'

export default {
  name: 'BuiltinGroup',
  mixins: [mixin],
  components: {
    BuiltinInput,
    BuiltinSelect
  },
  props: {
    top: {
      type: Boolean,
      default: false
    },
    children: {
      type: Array,
      default: () => []
    }
  },
  setup(props) {
    const rect = reactive({
      w: props.size.w,
      h: props.size.h
    })

    return { rect }
  },
  computed: {
    groupStyle() {
      const style = {
        width: this.rect.w + 'px',
        height: this.rect.h + 'px'
      }

      if (this.top) {
        style.position = 'reactive'
      } else {
        style.position = 'absolute'
        style.left = this.position.x + 'px'
        style.top = this.position.y + 'px'
      }

      return style
    }
  },
  methods: {
    toHtml() {
      /**
       * @type {Vue[]}
       */
      const builtinComponentRef = Array.isArray(this.$refs.builtinComponentRef)
        ? this.$refs.builtinComponentRef
        : [this.$refs.builtinComponentRef]

      const group = this.$el.cloneNode()
      group.style.position = 'absolute'
      group.style.left = this.position.x + 'px'
      group.style.top = this.position.y + 'px'

      builtinComponentRef.forEach(function (item) {
        group.append(item.toHtml())
      })

      return group
    }
  }
}
</script>

重写 toHtml

因为组内的是vue 组件,跟普通的组件不一样,所以需要拿到组件 Ref 再调用 vue实例的 toHTML 方法

初始化组

/**
 * @param {[TemplateDocument.BuiltinComponent, TemplateDocument.BuiltinComponent[], TemplateDocument.CreateBuiltinComponentType]|
 * [TemplateDocument.BuiltinComponent[], TemplateDocument.CreateBuiltinComponentType]} args
 */
export function createBuiltinComponentGroup(...args) {
  /**
   * @type {TemplateDocument.CreateBuiltinComponentType}
   */
  let type = 'normal'
  /**
   * @type {TemplateDocument.BuiltinComponent}
   */
  let group = null
  /**
   * @type {TemplateDocument.BuiltinComponent[]}
   */
  let children = []

  if (Array.isArray(args[0])) {
    type = args[1]
    children = args[0]
    group = createBuiltinComponent({ name: 'builtin-group' }, type)
  } else {
    type = args[2]
    children = args[1]
    group = createBuiltinComponent({ name: 'builtin-group', ...args[0] }, type)
  }

  /**
   * @type {[number[],number[],number[],number[]]}
   */
  const [left, top, right, bottom] = children.map(getRect).reduce(
    function (previousValue, currentValue) {
      /**
       * @type {Array<number[],number[],number[],number[]>}
       */
      const [left, top, right, bottom] = previousValue

      left.push(currentValue.left)
      top.push(currentValue.top)
      right.push(currentValue.right)
      bottom.push(currentValue.bottom)
      return [left, top, right, bottom]
    },
    [[], [], [], []]
  )

  group.props.position.x = Math.min.apply(null, left)
  group.props.position.y = Math.min.apply(null, top)
  group.props.size.w = Math.abs(Math.min.apply(null, left) - Math.max.apply(null, right))
  group.props.size.h = Math.abs(Math.min.apply(null, top) - Math.max.apply(null, bottom))

  if (type !== 'document') {
    children.forEach(item => {
      item.props.position.x -= group.props.position.x
      item.props.position.y -= group.props.position.y
    })
  }

  group.children = children

  return group
}

通过计算组内的组件来计算组控件的位置和尺寸

最后

希望通过我的见解能给你带来一点点的小启发

Last update:
Contributors: 机车靓仔