属性更新

  • 属性编辑通过 store 获取属性值
  • 通过发射事件触发 commit 修改属性值
  • 支持属性值的转换

image.png

  1. import { TextComponentProps } from '@/ts/defaultProps'
  2. // 属性转化成表单 哪个属性使用哪个类型的组件去编辑
  3. export interface PropsToForm {
  4. component: string
  5. // 支持给组件库传入属性
  6. extraProps?: { [key: string]: any }
  7. text: string
  8. // 支持组件包裹
  9. subComponent?: string
  10. // 包裹的组件选项
  11. options?: {
  12. text: string
  13. value: any
  14. }[]
  15. // 支持类型转换
  16. initalTransform?: (v: any) => any
  17. //因为开始定义的是any,这样会各个组件的类型不同发生错误,处理更新后的函数
  18. afterTransform?: (v: any) => any
  19. // 支持自定义属性名称
  20. valueProp?: string
  21. //触发事件的名称
  22. eventName?: string
  23. }
  24. // 属性列表转化成表单列表
  25. export type PropsToForms = {
  26. [p in keyof TextComponentProps]?: PropsToForm
  27. }
  28. // 属性转化成表单的映射表 key:属性 value:使用的组件
  29. export const mapPropsToForms: PropsToForms = {
  30. // 比如: text 属性,使用 a-input 这个组件去编辑
  31. text: {
  32. component: 'a-textarea',
  33. extraProps: {
  34. rows: 3,
  35. },
  36. text: '文本',
  37. afterTransform: (e: any) => e.target.value,
  38. },
  39. fontSize: {
  40. text: '字号',
  41. component: 'a-input-number',
  42. initalTransform: (v: string) => parseInt(v),
  43. afterTransform: (e: any) => (e ? `${e}px` : ''),
  44. },
  45. lineHeight: {
  46. text: '行高',
  47. component: 'a-slider',
  48. extraProps: {
  49. min: 0,
  50. max: 3,
  51. step: 0.1,
  52. },
  53. initalTransform: (v: string) => parseFloat(v),
  54. afterTransform: (v: string) => String(v),
  55. },
  56. textAlign: {
  57. component: 'a-radio-group',
  58. subComponent: 'a-radio-button',
  59. text: '对齐',
  60. options: [
  61. {
  62. value: 'left',
  63. text: '左',
  64. },
  65. {
  66. value: 'center',
  67. text: '中',
  68. },
  69. {
  70. value: 'right',
  71. text: '右',
  72. },
  73. ],
  74. afterTransform: (e: any) => e.target.value,
  75. },
  76. fontFamily: {
  77. component: 'a-select',
  78. subComponent: 'a-select-option',
  79. text: '字体',
  80. options: [
  81. {
  82. value: '',
  83. text: '无',
  84. },
  85. {
  86. value: '"SimSun","STSong',
  87. text: '宋体',
  88. },
  89. {
  90. value: '"SimHei","STHeiti',
  91. text: '黑体',
  92. },
  93. ],
  94. afterTransform: (e: any) => e,
  95. },
  96. }
  1. <template>
  2. <div class="props-table">
  3. <div v-for="(item, index) in finalPros" class="prop-item" :key="index">
  4. <span class="label" v-if="item.text">{{ item.text }}</span>
  5. <div class="prop-component">
  6. <!--v-bind 绑定属性 v-on 绑定事件 重点-->
  7. <component
  8. v-if="item.valueProp"
  9. :is="item?.component"
  10. :value="item?.valueProp"
  11. v-bind="item.extraProps"
  12. v-on="item.events"
  13. >
  14. <template v-if="item.options">
  15. <component
  16. v-for="(option, k) in item.options"
  17. :is="item.subComponent"
  18. :value="option.value"
  19. :key="k"
  20. >
  21. {{ option.text }}
  22. </component>
  23. </template>
  24. </component>
  25. </div>
  26. </div>
  27. </div>
  28. </template>
  29. <script lang="ts">
  30. import { TextComponentProps } from '@/ts/defaultProps'
  31. import { mapPropsToForms, PropsToForms } from '@/ts/propsMap'
  32. import { reduce } from 'lodash'
  33. import { computed, defineComponent, PropType } from 'vue'
  34. export interface FormProps {
  35. component: string
  36. subComponent?: string
  37. value: string
  38. extraProps?: { [key: string]: any }
  39. text?: string
  40. options?: {
  41. text: string
  42. value: any
  43. }[]
  44. initalTransform?: (v: any) => any
  45. valueProp: string
  46. eventName: string
  47. events: { [key: string]: (e: any) => void }
  48. }
  49. export default defineComponent({
  50. name: 'props-table',
  51. props: {
  52. props: {
  53. type: Object as PropType<TextComponentProps>,
  54. required: true,
  55. },
  56. },
  57. emits: ['change'],
  58. setup(props, context) {
  59. const finalPros = computed(() => {
  60. //重点1
  61. return reduce(
  62. props.props,
  63. (res, value, key) => {
  64. // console.log(value, key) //value是每一个组件属性值 value表示属性
  65. const newKey = key as keyof TextComponentProps
  66. const item = mapPropsToForms[newKey]
  67. if (item) {
  68. const {
  69. valueProp = value,
  70. eventName = 'change',
  71. initalTransform,
  72. afterTransform,
  73. } = item
  74. //转成特定的form表单
  75. const newItem: FormProps = {
  76. ...item,
  77. value: initalTransform ? initalTransform(value) : value,
  78. valueProp: initalTransform
  79. ? initalTransform(valueProp)
  80. : valueProp,
  81. eventName,
  82. events: {
  83. [eventName]: (e: any) => {
  84. context.emit('change', {
  85. key,
  86. value: afterTransform ? afterTransform(e) : e,
  87. })
  88. },
  89. },
  90. }
  91. res[newKey] = newItem
  92. }
  93. // console.log(res)
  94. return res
  95. },
  96. {} as { [key: string]: FormProps },
  97. )
  98. })
  99. return { finalPros }
  100. },
  101. })
  102. </script>
  103. ...
  1. <template>
  2. <div class="editor-container">
  3. ...
  4. <a-layout-sider
  5. width="300"
  6. style="background: #fff"
  7. class="settings-panel"
  8. >
  9. 组件属性
  10. <props-table
  11. v-if="currentElement && currentElement?.props"
  12. :props="currentElement?.props"
  13. @change="handleChange"
  14. ></props-table>
  15. </a-layout-sider>
  16. </a-layout>
  17. </div>
  18. </template>
  19. <script lang="ts">
  20. ...
  21. import {
  22. ADDCOMPONENT,
  23. DELCOMPONENT,
  24. AETACTIVE,
  25. GETCURRENTCOMPONENT,
  26. UPDATECOMPONENT,
  27. } from '@/store/mutation-types'
  28. export default defineComponent({
  29. components: { LText, ComponentsList, CloseOutlined, EditWrapper, PropsTable },
  30. setup() {
  31. ...
  32. const handleChange = (e: any) => {
  33. console.log('event', e)
  34. store.commit(UPDATECOMPONENT, e)
  35. }
  36. return {
  37. handleChange,
  38. }
  39. },
  40. })
  41. </script>
  42. ...
  1. import {
  2. ADDCOMPONENT,
  3. DELCOMPONENT,
  4. AETACTIVE,
  5. GETCURRENTCOMPONENT,
  6. UPDATECOMPONENT,
  7. } from './mutation-types'
  8. ...
  9. //两个参数 第一个本地的interface,第二个是全局的interface
  10. const editor: Module<EditorProps, GloabalDataProps> = {
  11. state: {
  12. components: testComponents,
  13. currentElement: '',
  14. },
  15. mutations: {
  16. ...
  17. [UPDATECOMPONENT](state, { key, value }) {
  18. const updatedComponent = state.components.find(
  19. (component) => component.id === state.currentElement,
  20. )
  21. if (updatedComponent) {
  22. updatedComponent.props[key as keyof TextComponentProps] = value
  23. }
  24. },
  25. },
  26. ...
  27. }
  28. export default editor

优化需求

选择字体的下拉框可以直接显示当前的字体样式
image.png

使用 h 函数改写

  1. ...
  2. import { h, VNode } from 'vue';
  3. const fontFamilyArr = [
  4. { text: '宋体', value: '"SimSun","STSong"' },
  5. { text: '黑体', value: '"SimHei","STHeiti"' },
  6. { text: '楷体', value: '"KaiTi","STKaiti"' },
  7. { text: '仿宋', value: '"FangSong","STFangsong"' },
  8. ]
  9. const fontFamilyOptions = fontFamilyArr.map((font) => {
  10. return {
  11. value: font.value,
  12. text: h('span', { style: { fontFamily: font.value } }, font.text),
  13. };
  14. });
  15. // 属性转化成表单的映射表 key:属性 value:使用的组件
  16. export const mapPropsToForms: PropsToForms = {
  17. fontFamily: {
  18. component: 'a-select',
  19. subComponent: 'a-select-option',
  20. text: '字体',
  21. options: [
  22. {
  23. value: '',
  24. text: '无',
  25. },
  26. ...fontFamilyOptions,
  27. ],
  28. afterTransform: (e: any) => e,
  29. },
  30. };
  31. ...

tsx

  1. import { TextComponentProps } from '@/ts/defaultProps'
  2. import { VNode } from 'vue'
  3. // 属性转化成表单 哪个属性使用哪个类型的组件去编辑
  4. export interface PropsToForm {
  5. component: string
  6. // 支持给组件库传入属性
  7. extraProps?: { [key: string]: any }
  8. text?: string
  9. // 支持组件包裹
  10. subComponent?: string
  11. // 包裹的组件选项
  12. options?: {
  13. text: string | VNode
  14. value: any
  15. }[]
  16. // 支持类型转换
  17. initalTransform?: (v: any) => any
  18. afterTransform?: (v: any) => any
  19. // 支持自定义属性名称
  20. valueProp?: string
  21. //触发事件的名称
  22. eventName?: string
  23. }
  24. // 属性列表转化成表单列表
  25. export type PropsToForms = {
  26. [p in keyof TextComponentProps]?: PropsToForm
  27. }
  28. const fontFamilyArr = [
  29. { text: '宋体', value: '"SimSun","STSong"' },
  30. { text: '黑体', value: '"SimHei","STHeiti"' },
  31. { text: '楷体', value: '"KaiTi","STKaiti"' },
  32. { text: '仿宋', value: '"FangSong","STFangsong"' },
  33. ]
  34. const fontFamilyOptions = fontFamilyArr.map((font) => {
  35. return {
  36. value: font.value,
  37. text: (
  38. <span style={{ fontFamily: font.value }}>{font.text}</span>
  39. ) as VNode,
  40. }
  41. })
  42. // 属性转化成表单的映射表 key:属性 value:使用的组件
  43. const pxToNumberHandler: PropsToForm = {
  44. component: 'a-input-number',
  45. initalTransform: (v: string) => parseInt(v),
  46. afterTransform: (e: number) => (e ? `${e}px` : ''),
  47. }
  48. export const mapPropsToForms: PropsToForms = {
  49. text: {
  50. text: '文本',
  51. component: 'a-textarea',
  52. extraProps: { rows: 3 },
  53. afterTransform: (e: any) => e.target.value,
  54. },
  55. fontSize: {
  56. text: '字号',
  57. ...pxToNumberHandler,
  58. },
  59. lineHeight: {
  60. text: '行高',
  61. component: 'a-slider',
  62. extraProps: { min: 0, max: 3, step: 0.1 },
  63. initalTransform: (v: string) => parseFloat(v),
  64. afterTransform: (e: number) => e.toString(),
  65. },
  66. textAlign: {
  67. component: 'a-radio-group',
  68. subComponent: 'a-radio-button',
  69. text: '对齐',
  70. options: [
  71. { value: 'left', text: '左' },
  72. { value: 'center', text: '中' },
  73. { value: 'right', text: '右' },
  74. ],
  75. afterTransform: (e: any) => e.target.value,
  76. },
  77. fontFamily: {
  78. component: 'a-select',
  79. subComponent: 'a-select-option',
  80. text: '字体',
  81. options: fontFamilyOptions,
  82. },
  83. width: {
  84. text: '宽度',
  85. ...pxToNumberHandler,
  86. },
  87. }

方案一:使用 JSX 重写 PropsTable 组件(较推荐)

  1. import { computed, defineComponent, PropType, VNode } from 'vue'
  2. import { reduce } from 'lodash'
  3. import { PropsToForms, mapPropsToForms } from '../propsMap'
  4. import { TextComponentProps } from '../defaultProps'
  5. import { Input, InputNumber, Slider, Radio, Select } from 'ant-design-vue'
  6. const mapToComponent = {
  7. 'a-textarea': Input.TextArea,
  8. 'a-input-number': InputNumber,
  9. 'a-slider': Slider,
  10. 'a-radio-group': Radio.Group,
  11. 'a-radio-button': Radio.Button,
  12. 'a-select': Select,
  13. 'a-select-option': Select.Option
  14. } as any
  15. interface FormProps {
  16. component: string;
  17. subComponent?: string;
  18. value: string;
  19. extraProps?: { [key: string]: any };
  20. text?: string;
  21. options?: { text: string | VNode; value: any }[];
  22. valueProp: string;
  23. eventName: string;
  24. events: { [key: string]: (e: any) => void };
  25. }
  26. function capitalizeFirstLetter(string: string) {
  27. return string.charAt(0).toUpperCase() + string.slice(1)
  28. }
  29. export default defineComponent({
  30. name: 'props-table',
  31. props: {
  32. props: {
  33. type: Object as PropType<TextComponentProps>,
  34. required: true
  35. }
  36. },
  37. emits: ['change'],
  38. setup(props, context) {
  39. const finalProps = computed(() => {
  40. return reduce(props.props, (result, value, key) => {
  41. const newKey = key as keyof TextComponentProps
  42. const item = mapPropsToForms[newKey]
  43. if (item) {
  44. const { valueProp = 'value', eventName = 'change', initalTransform, afterTransform } = item
  45. const newItem: FormProps = {
  46. ...item,
  47. value: initalTransform ? initalTransform(value) : value,
  48. valueProp,
  49. eventName,
  50. events: {
  51. ['on' + capitalizeFirstLetter(eventName)]: (e: any) => { context.emit('change', { key, value: afterTransform? afterTransform(e) : e })}
  52. }
  53. }
  54. result[newKey] = newItem
  55. }
  56. return result
  57. }, {} as { [key: string]: FormProps })
  58. })
  59. return () =>
  60. <div class="props-table">
  61. {
  62. Object.keys(finalProps.value).map(key => {
  63. const value = finalProps.value[key]
  64. const ComponentName = mapToComponent[value.component]
  65. const SubComponent = value.subComponent ? mapToComponent[value.subComponent] : null
  66. const props = {
  67. [value.valueProp]: value.value,
  68. ...value.extraProps,
  69. ...value.events
  70. }
  71. return (
  72. <div key={key} class="prop-item">
  73. { value.text && <span class="label">{value.text}</span> }
  74. <div class="prop-component">
  75. <ComponentName {...props}>
  76. { value.options &&
  77. value.options.map(option => {
  78. return (
  79. <SubComponent value={option.value}>{option.text}</SubComponent>
  80. )
  81. })
  82. }
  83. </ComponentName>
  84. </div>
  85. </div>
  86. )
  87. })
  88. }
  89. </div>
  90. }
  91. })

方案二:使用 render 函数实现桥梁

增加返回render的Vnode

  1. import { defineComponent } from 'vue'
  2. const renderNode = defineComponent({
  3. name: 'render-node',
  4. props: {
  5. vNode: {
  6. type: [Object, String],
  7. required: true,
  8. },
  9. },
  10. render() {
  11. return this.vNode
  12. },
  13. })
  14. export default renderNode

修改{{option.text}}成render-node组件

  1. <template>
  2. <div class="props-table">
  3. <div v-for="(item, index) in finalPros" class="prop-item" :key="index">
  4. <span class="label" v-if="item.text">{{ item.text }}</span>
  5. <div class="prop-component">
  6. <!--v-bind 绑定属性 v-on 绑定事件-->
  7. <component
  8. v-if="item.valueProp"
  9. :is="item?.component"
  10. :value="item?.valueProp"
  11. v-bind="item.extraProps"
  12. v-on="item.events"
  13. >
  14. <template v-if="item.options">
  15. <component
  16. v-for="(option, k) in item.options"
  17. :is="item.subComponent"
  18. :value="option.value"
  19. :key="k"
  20. >
  21. <render-node :vNode="option.text"></render-node>
  22. </component>
  23. </template>
  24. </component>
  25. </div>
  26. </div>
  27. </div>
  28. </template>
  29. <script lang="ts">
  30. ...
  31. export default defineComponent({
  32. name: 'props-table',
  33. ...
  34. components: { RenderNode },
  35. ...
  36. })
  37. </script>

处理编辑时跳转问题

  1. import { computed } from 'vue'
  2. import _ from 'lodash-es'
  3. export default function useComponentCommon<T extends { [key: string]: any }>(
  4. props: T,
  5. picks: string[],
  6. ) {
  7. const styleProps = computed(() => _.pick(props, picks))
  8. const handleClick = () => {
  9. if (
  10. window.location.hash !== '#/editor' &&
  11. props.actionType === 'url' &&
  12. props.url
  13. ) {
  14. window.location.href = props.url
  15. }
  16. }
  17. //事件类型:无|跳转URL下拉菜单
  18. return { styleProps, handleClick }
  19. }

修改data数据,字体为空时不显示数据

  1. ...
  2. export const testComponents: ComponentData[] = [
  3. {
  4. id: uuidv4(),
  5. name: 'l-text',
  6. props: {
  7. text: 'hello',
  8. fontSize: '20px',
  9. color: '#000000',
  10. lineHeight: '1',
  11. textAlign: 'left',
  12. fontFamily: '宋体',
  13. },
  14. },
  15. {
  16. id: uuidv4(),
  17. name: 'l-text',
  18. props: {
  19. text: 'hello2',
  20. fontSize: '10px',
  21. fontWeight: 'bold',
  22. lineHeight: '2',
  23. textAlign: 'left',
  24. fontFamily: '宋体',
  25. },
  26. },
  27. {
  28. id: uuidv4(),
  29. name: 'l-text',
  30. props: {
  31. text: 'hello3',
  32. fontSize: '15px',
  33. actionType: 'url',
  34. url: 'https://www.baidu.com',
  35. lineHeight: '3',
  36. textAlign: 'left',
  37. fontFamily: '宋体',
  38. },
  39. },
  40. ]
  41. ...
  42. export default editor

image.png

总结:

业务组件

  • 创建编辑器 vuex store 结构,画布循环展示组件
  • 组件初步实现,使用 lodash 分离样式属性
  • 添加通用和特殊属性,转换为 props 类型
  • 抽取重用逻辑,style 抽取和点击跳转
  • 左侧组件库点击添加到画布的逻辑

组件属性对应表单组件的展示和更新

  • 获得正在被编辑的元素,通过 vuex getters
  • 创建属性和表单组件的对应关系
  • 使用 propsTable 将传入的属性渲染为对应的表单组件
  • 丰富对应关系字段支持更多自定义配置
  • 使用标准流程更新表单并实时同步单项数据流
  • 使用 h 函数以及 vnode 实现字体下拉框实时显示

存在问题:设置为0或者’’时,组件会消失,后续记得改