上一篇说了如何实现灵活的拖拽,那么加上编辑功能,拖拽编辑器的两大核心功能就集齐了,剩下就是组件树、版本管理、模板、预览、快捷键、事件、动画、在线编辑代码以及部署方式这些边角功能,当然这些边角功能都不影响大局,这次我们来谈谈如何设计编辑区,类似下图的结构:
editor.gif- 从图中可以看出,编辑区涉及很多数据同步操作,我们使用了
mobx
很好的解决了这个问题,本篇文章因为重点描述编辑器设计,因此数据设计部分不会过多涉及。 - 除了基本属性设置,还应该有脚本设置、事件设置、动画设置,这些后续文章再讨论。
通用属性编辑
我们发现,样式才是最通用的属性,无论何种组件都逃离不了样式的设置,除此以外的属性都是自定义的,我们无法抽象出共性加以定制,但是样式是固定的,所以编辑区先要支持通用样式的编辑。
通用样式:背景
边框
字体
边距
布局
溢出处理
宽高
透明度
我们提供了对应的 13 余中定制编辑类型,比如像上图的边距调节器,专门针对边距进行修改,只要将编辑类型设置为 marginPadding
,编辑框中就会出现非常方便的边距调节器。
还有一种通用属性处理,比如有一个图标组件,实现以下效果:
icon.gif如果单独为图标类型设置一种编辑状态很不划算,这种分类可以划为 实例类型
,每一个图标其实是这个组件接收了某种参数后的状态,我们预先提供这些状态,编辑器将这些状态的组件分别实例化显示出来,每当鼠标点击时,就将当前状态覆盖到页面中。编辑配置入下:
const instances = [{
name: 'icnMineSettingB'
}, {
name: 'iconFindSearch'
}, {
name: 'minus'
}]
const editOption = {
field: null as string,
label: '',
editor: 'instance',
editable: true,
instance: instances
}
每一种图标样式其实就是 name
属性的不同,将这些 name
分别填充给实例化出来的组件,就能看到上图的效果,每次点击都会将 instances
中当前项作为 props
覆盖到页面组件中,便实现了预期效果,并且类似需求都具有很强的通用性。
通用属性如何设置在组件上
每个组件都是一个 React Class
,其 defaultProps
属性只要包含了 gaeaName
gaeaIcon
gaeaUniqueKey
和 gaeaEdit
属性,就拥有编辑功能。
gaeaName
和 gaeaIcon
分别是显示在编辑器上的组件名和图标。
gaeaUniqueKey
是给每个组件起的唯一 key
,所有类的寻找都以此为依据。
gaeaEdit
是数组,存放了编辑类型。
一个基本的 gaeaEdit
对象如下:
gaeaEdit = [{
field: 'name',
label: '名称',
editor: 'text',
editable: true
}]
editor
表示了当前属性用什么类型编辑器编辑,通用编辑类型有文本框,选择框,开关等等,除此之外还有定制编辑类型,比如 background
。
field
表示了编辑后对应改变哪个字段的值。
label
表示在编辑器上显示的提示文案。
editor
还有许多类型,比如 editor: number
类型的配置如下(透明度就是封装了 number
的编辑类型):
export const opacityEditor = {
field: 'style.opacity',
label: '透明度',
editor: 'number',
number: {
units: [{
key: '',
value: '%'
}],
currentUnit: '',
max: 100,
min: 0,
step: 1,
inputRange: [0, 100],
outputRange: [0, 1],
slider: true
},
editable: true
}
使用时我们直接放入 gaeaEdit
数组中:
gaeaEdit = [
opacityEditor
]
其中 utils
表示数字类型框可选的单位,inputRange
outputRange
如上设置,那么编辑器中输入框填入80,实际会转换成 0.8 赋值到 opacity
属性上。
因为通用属性是固定的,所以我们提供了 gaeaHelper ,提供许多常用编辑类型:
import gaeaHelper from 'gaea-helper'
export class PropsGaea {
gaeaName = '图标'
gaeaIcon = 'square-o'
gaeaUniqueKey = 'wefan-icon'
gaeaEdit = [
'图标',
{
field: null as string,
label: '',
editor: 'instance',
editable: true,
instance: instances
},
'布局',
gaeaHelper.marginPaddingEditor,
gaeaHelper.widthHeightEditor,
'特效',
gaeaHelper.opacityEditor
]
}
最后我们写自定义的 props 类集成描述编辑状态的 PropsGaea
:
export class Props extends PropsGaea {
name = '名称'
}
将其实例化后赋值在 defaultProps
即可:
static defaultProps = new Props()
自定义属性编辑
值得寻味的是,通用属性看起来其实更像定制属性,而自定义属性其实更需要通用设计。
许多时候编辑器需要修改的属性都是某些字段,而这些字段都其对应的类型和通用编辑规则,所以我们提供了基础的 text
number
selector
switch
array
object
等通用编辑类型,并且通过额外配置来适配简单需求。
比如 number
类型的编辑配置:
{
field: 'style.opacity',
label: '透明度',
editor: 'number',
number: {
units: [{
key: '',
value: '%'
}],
currentUnit: '',
max: 100,
min: 0,
step: 1,
inputRange: [0, 100],
outputRange: [0, 1],
slider: true
}
}
field
属性支持 .
的方式访问深层对象,比如 style 属性的 opacity 字段就是这次要修改的字段。number
类型的编辑类型,通过 number
字段描述其详细设置。比如最大最小值、单位、输出转换、按钮调解速度、步长、是否拥有 Slider
做滑动调节。
自定义与通用属性混合编辑
编辑器混合了通用属性与自定义属性,完全通过 gaeaEditor
这个字段来描述:
gaeaEdit = [
'图标',
{
field: null as string,
label: '',
editor: 'instance',
editable: true,
instance: instances
},
'布局',
gaeaHelper.marginPaddingEditor,
gaeaHelper.widthHeightEditor,
'特效',
gaeaHelper.opacityEditor
]
只要将两者混合写入数组即可,同时如果传入的是字符串,会作为标题分割,方便区分功能区域。
记录编辑历史
本来支持 undo
redo
快捷键是个边角功能,但是由于需要编辑区的支持,所以也放在这一节说。
Undo Redo
就像编辑 word 一样,我们需要记录每一次用户操作,以便回退或者重做,记录历史有以下三种方案:
每次操作记录全量编辑
json
,撤销的时候刷新整体视图区域
这种方式太原始了,虽然操作方便不容易出错,但弊端也非常明显,就是占用内存过大,每次记录了全量数据肯定不是一件好事。
每次操作记录增量编辑
json
, 撤销的时候根据每一步骤做merge
,再刷新整体视图区域
这种方式改进了一下内存占用,但缺点是刷新整体视图区域的操作太笨重,如果视图区域有 1000 个组件实例,全量刷新就是一件很痛苦的事,我们操作时明明是局部刷新,为什么回退历史要全量呢?
记录每一步的操作类型、操作数据,回退时根据操作类型模拟人工操作
一个好的系统架构,是会将 action
store
分离出来的,我们手动拖拽、编辑组件的时候,都会触发对应 action
,进而修改 store
,自动触发视图区域刷新(利用了mobx),在回退历史记录的时候,我们只需要逆向调用对应的 action
就能够模拟出高性能人工操作,付出的代价是需要记录不同操作类型,并记录不同的数据格式。
分类记录操作历史
值得记录的操作种类有 添加 移动 删除 排序 更新组件属性 粘贴 等,我们的 editor 还有 属性重置 新增模板 这两种操作属性,下面是对这几种操作类型的描述:
export interface Diff {
// 操作类型
type: 'add' | 'move' | 'remove' | 'exchange' | 'update' | 'paste' | 'reset' | 'addCombo' | 'addSource'
// 操作组件的 mapUniqueKey
mapUniqueKey: string
// 新增操作
add?: {
// 新增组件的唯一标识 id
uniqueId: string
// 父级 mapKey
parentMapUniqueKey: string
// 插入的位置
index: number
}
// 移动到另一个父元素
move?: {
// 移动到的父级 mapKey
targetParentMapUniqueKey: string
// 移动前父级 mapKey
sourceParentMapUniqueKey: string
// 插入的位置
targetIndex: number
// 移除的位置
sourceIndex: number
}
// 删除组件
remove?: DiffRemove
// 内部交换顺序
exchange?: {
oldIndex: number
newIndex: number
}
// 更新操作
update?: {
oldValue: ComponentProps
newValue: ComponentProps
}
// 粘贴操作
paste?: DiffRemove
// 重置组件
reset?: {
// 重置前的信息
beforeProps: ComponentProps
beforeName: string
}
// 新增组合
addCombo?: {
// 父级 mapKey
parentMapUniqueKey: string
// 父级的 index
index: number
// 组合的完整信息(不是 copy 的, 是真正对应的 mapUniqueKey)
componentInfo: ViewportComponentFullInfo
}
// 新增模板
addSource?: {
// 父级 mapKey
parentMapUniqueKey: string
// 父级的 index
index: number
// 组合的完整信息(不是 copy 的, 是真正对应的 mapUniqueKey)
componentInfo: ViewportComponentFullInfo
}
}
在 undo,redo时,根据不同编辑类型还原操作,就可以高效模拟操作了。
https://github.com/ascoders/gaea-editor/blob/master/gaea-editor/store/viewport.tsx#L768
上述仓库地址中可以看到每一步历史只存了还原它需要的最小字段,因此大大降低了内存占用。顺带一提,因为使用了 Mobx
打平 map
存储视图中的所有组件,因此每个组件都会保存对应 mapUniqueKey
来找到对应实例。
undo
redo
操作效果如图所示:
网友评论