这是可视化编辑器 Gaea-Editor 的第一篇连载分析文章,希望我能在有限的篇幅讲清楚制作这个网页编辑器的动机,以及可能带来的美好使用前景(画大饼)。它会具有如下几个特征:

  1. 运行在网页
  2. 文档流布局,绝对定位同时支持
  3. 对插入的任何 React 组件都可以直接作为编辑元素拖拽到页面中
  4. 兼容 React-Native 的 web 组件可以让它生成 android 和 ios 原生页面
  5. 拥有 Gaea-Preview 套件,传入 Gaea-Editor 生成的 json,可以立刻生成页面
  6. 拥有 Gaea-web-components Gaea-native-components 分别提供网页、原生基础最小粒度的组件
  7. 可以定制任何 React 组件插入到编辑器中
  8. 像 chrome-devtools 一样灵活,可以跨层级排序拖拽任何编辑区的元素
  9. 可以自定义组合模板,三下五除二搞定相似的需求

当然看完这篇文章,不仅限于了解这个编辑器的功能,我会非常详细介绍其设计细节,只要你仔细读它,完全可以做出自己的网页编辑器 _

在说这个可视化编辑器之前,不得不提到 React,这是我创作它的动机。虽然不确定 React 能火多久,但它带来的组件化掀起了一场前端界的工业革命,当然,组件化这个理念也不是 React 首创,但 React 大大降低了组件化的成本,就像发明了活字印刷术,让只有贵族才买得起的书本普及到了千家万户。

在全民组件化的时代里,我写过几篇文章介绍如何应用和管理组件 :http://www.jianshu.com/p/aaca5047a149 以及组件库的维护经验:https://github.com/fex-team/fit/issues/3 。现在组件化正在越来越普及,我们掌握了组件开发和管理的规律后,项目结构组织,团队间协作已经取得了飞速进步,组件化带来效率的提升也会日渐枯竭,但可视化编辑可能是一条突破瓶颈之路,第一,在有了现成组件的基础上,将其迁移到可视化编辑平台的成本非常小,第二,代码之外的页面开发更加直观,加上部分代码的辅佐会让结构组织更高效(类似 Unity 引擎)。

React 与原生拖拽结合

网页编辑器第一步,也是最重要的一步,就是拖拽功能了,我们希望最终效果如图所示:

如图所示,支持随意拖拽、拖拽动画,跨父级拖拽。我们使用 sortablejs 可以达到此效果,这篇文章重点就是介绍如何结合到 React。

使用 sortable.js

为了支持嵌套拖拽,我们使用开发版地址安装 "sortablejs":"git://github.com/RubaXa/Sortable.git#dev"

将 sortable 与 react 结合我们首先会想到在拖拽结束后重新 render,但这样做有如下几个缺点:

  • sortable 因为拖拽过程中改变了 dom 结构,所以操作流畅,但因此生成的 dom 节点脱离了 react 的控制
  • 排序拖拽后会,sortable 会删除之前拖拽的节点,导致 react diff 算法删除元素时发现 dom 已经消失

总结来说就是既要让 sortable 操作 dom,又不能让 dom 操作导致脱离 react 的控制,我们采用操作回放的方式,将 sortable 操作结束后的 dom 修改回退,再将操作结果状态用 react 刷新

右侧菜单配置

对右侧菜单配置如下:

Sortable.create(ReactDOM.findDOMNode(this.dragContainerInstance), {
// 放在一个组里,可以跨组拖拽
group: {
name: 'gaea-layout',
pull: 'clone',
put: false
},
sort: false,
delay: 0,
onStart: (event: any) => {
// 存储开始拖拽的位置和拖拽结束的位置
// ...
},
onEnd: (event: any) => {
// 拖拽菜单时,真实元素会被拖拽走,拖拽成功的话元素会重复, 没成功拖拽会被添加到末尾
// 所以先移除 clone 的元素(吐槽下, 拖走的才是真的, 留下的才是 clone 的)
// 有 parentNode, 说明拖拽完毕还是没有被清除, 说明被拖走了, 因为如果没真正拖动成功, clone 元素会被删除
if (event.clone.parentNode) {
// 有 clone, 说明已经真正拖走了
this.dragContainerDomInstance.removeChild(event.clone)
// 再把真正移过去的弄回来
if (this.lastDragStartIndex === this.dragContainerDomInstance.childNodes.length) {
// 如果拖拽的是最后一个
this.dragContainerDomInstance.appendChild(event.item)
} else {
// 拖拽的不是最后一个
this.dragContainerDomInstance.insertBefore(event.item, this.dragContainerDomInstance.childNodes[this.lastDragStartIndex])
}
} else {
// 没拖走, 只是晃了一下, 不用管了
}
}
})

如上代码注释写的很详尽,解释一下就是,从菜单拖拽的配置要用 pull:clone 的方式配置,这样同一个元素才可以拖拽多次。put:false 让菜单不能被其它元素拖入。

当开始拖拽时,保存拖拽后的位置,便于找到用户拖拽的元素,在页面生成实例,同时保存拖拽前的位置,便于拖拽结束后恢复元素。

所以拖拽结束后,先判断 event.clone.parentNode,如果是空,说明元素并没有被拖走,所以不需要处理,否则需要先删除原先位置留下的 clone dom,因为这个元素不受 react 控制,再将真实拖走的元素还原到之前的位置

视图区域配置

编辑器视图区域的 sortable 配置比较长,因此拆解分析。

group 配置:

group: {
name: 'gaea-layout',
pull: true,
put: true
}

这个很容易理解,因为视图区域的元素可以被移走,也可以被其它元素移入,因此 pullput 都是 true

开始拖拽时

onStart: (event: any) => {
// 保存拖拽前、后的位置
}

拖拽结束时

onEnd: (event: any) => {
// 略
}

拖拽结束不需要做特殊处理,但可以做一些视觉设置,比如告诉用户拖拽结束了。

有元素新增时

onAdd: (event: any)=> {
// 取消 srotable 对 dom 的修改
// 删掉 dom 元素, 让 react 去生成 dom
if (this.props.viewport.currentMovingComponent.isNew) {
// 是新拖进来的, 不用管, 因为工具栏会把它收回去
// 为什么不删掉? 因为这个元素不论是不是 clone, 都被移过来了, 不还回去 react 在更新 dom 时会无法找到
} else {
// 如果是从某个元素移过来的(新增的,而不是同一个父级改变排序)
// 把这个元素还给之前拖拽的父级
if (this.props.viewport.dragStartParentElement.childNodes.length === 0) {
// 之前只有一个元素
this.props.viewport.dragStartParentElement.appendChild(event.item)
} else if (this.props.viewport.dragStartParentElement.childNodes.length === this.props.viewport.dragStartIndex) {
// 是上一次位置是最后一个, 而且父元素有多个元素
this.props.viewport.dragStartParentElement.appendChild(event.item)
} else {
// 不是最后一个, 而且有多个元素
// 插入到它下一个元素的前一个
this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[this.props.viewport.dragStartIndex])
}
}
}

有元素新增后,有两种情况:新增元素,或者从已有元素中拖拽进来新增。

如果是从工具栏拖拽进来新增的元素,只需要用 react 重新渲染一遍即可。

如果是从其它视图元素中移入进来的,需要把这个元素还原到之前拖拽的位置,这样就回退到 sortable 操作之前的状态,再用 react 渲染这两个父级组件。

同一父级内元素位置更新时

onUpdate: (event: any)=> {
// 同一个父级下子元素交换父级
// 取消 srotable 对 dom 的修改, 让元素回到最初的位置即可复原
const oldIndex = event.oldIndex as number
const newIndex = event.newIndex as number
if (this.props.viewport.dragStartParentElement.childNodes.length === oldIndex + 1) {
// 是从最后一个元素开始拖拽的
this.props.viewport.dragStartParentElement.appendChild(event.item)
} else {
if (newIndex > oldIndex) {
// 如果移到了后面
this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[oldIndex])
} else {
// 如果移到了前面
this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[oldIndex + 1])
}
}
this.props.viewport.sortComponents(this.props.mapUniqueKey, event.oldIndex as number, event.newIndex as number)
}

我们只需要对元素位置进行还原,之后根据起点位置和终点位置模拟元素移动,再使用 react 渲染即可。这里需要注意, sortable 的拖拽不是简单的a b互换,而是 a -> b,下面用图简单描述一下:

如上图所示,同一个父级下有6个元素,当我们拖拽第一个元素到第5个元素时,排序不是变成了 5 2 3 4 1 6,而是如下图所示:

不可避免的产生了互换,我们逐一互换元素位置,然后更新父级元素子元素的位置。注意此时最佳状态是不触发 react 元素渲染,我们只要保证子元素的 key 不变, react diff 算法会自动移动 dom 节点,而不是重新渲染 1 2 3 4 5 这 5 个子节点。

当元素被移走时

onRemove: (event: any)=> {
// 渲染父级元素,减少一个子元素在当前位置
}

当元素被移走时,不会触发 onUpdate 方法,而会触发 onAdd 方法,但是我们已经在 onAdd 方法中将移走的元素还原回去,因此这里不需要做任何处理,相当于没有改动,我们只需要更新 react 父级元素重新渲染,让 react 将元素移走即可。

总结

基于以上菜单区域和视图区域的博弈,终于将 sortable 与 react 渲染完美结合起来,然而不用担心有什么副作用,因为我们已经将所有 sortable 的操作还原,所以实际上只用了它的拖拽过程已经拖拽结果,忙到后来其实没有改变任何 dom 结构,最终 dom 元素的变化还是由 react 来控制。

后续系列我们会继续剖析实现部分,以及放上仓库地址。解析到底是如何将元素放在视图区域,并且并支持无限层级嵌套的,敬请期待!

React Editor 应用编辑器(1) - 拖拽功能剖析的更多相关文章

  1. RCP:拖拽功能的实现 Drag and Drop

    SWT中的拖拽是使用的org.eclipse.swt.dnd. 有三个需要密切注意的类: 1.DragSource 2.DropTarget 3.Transfer DragSource封装了需要被拖拽 ...

  2. 再谈React.js实现原生js拖拽效果

    前几天写的那个拖拽,自己留下的疑问...这次在热心博友的提示下又修正了一些小小的bug,也加了拖拽的边缘检测部分...就再聊聊拖拽吧 一.不要直接操作dom元素 react中使用了虚拟dom的概念,目 ...

  3. js实现登陆页面的拖拽功能

    <!DOCTYPE html><html> <head> <meta charset="UTF-8"> <title>登 ...

  4. duilib中控件拖拽功能的实现方法(附源码)

    转载请说明原出处,谢谢~~:http://blog.csdn.net/zhuhongshu/article/details/41144283 duilib库中原本没有显示的对控件增加拖拽的功能,而实际 ...

  5. DIV 实现可拖拽 功能(留档)

    //可拖拽 功能 $.fn.extend({    //用法:$(element).jqDrag();    //element需要具备定位属性,需要手动调整层叠样式,这里只是修改鼠标拖动效果    ...

  6. 使用NGUI实现拖拽功能(拼图小游戏)

    上一次用UGUI实现了拼图小游戏,这次,我们来用NGUI来实现 实现原理 NGUI中提供了拖拽的基类UIDragDropItem,所以我们要做的就是在要拖拽的图片上加一个继承于该类的脚本,并实现其中的 ...

  7. 使用UGUI实现拖拽功能(拼图小游戏)

    实现方式 1.引入UGUI自带的事件系统 UnityEngine.EventSystems 2.为我们的类添加接口 IBeginDragHandler, IDragHandler, IEndDragH ...

  8. JQuery UI的拖拽功能

    JQuery UI是JQuery官方支持的WebUI 代码库,包含底层交互.动画.特效等API,并且封装了一些Web小部件(Widget).同时,JQuery UI继承了jquery的插件支持,有大量 ...

  9. 通过 JS 实现简单的拖拽功能并且可以在特定元素上禁止拖拽

    前言 关于讲解 JS 的拖拽功能的文章数不胜数,我确实没有必要大费周章再写一篇重复的文章来吸引眼球.本文的重点是讲解如何在某些特定的元素上禁止拖拽.这是我在编写插件时遇到的问题,其实很多插件的拖拽功能 ...

随机推荐

  1. HDU 3533 Escape BFS搜索

    题意:懒得说了 分析:开个no[100][100][1000]的bool类型的数组就行了,没啥可说的 #include <iostream> #include <cstdio> ...

  2. [zouxianghui] 清空GridPanel的checkbox选中行

    清空GridPanel的checkbox选中行,GridPanel.getSelectionModel().clearSelections();可以清空选中状态

  3. 统计计算与R语言的资料汇总(截止2016年12月)

    本文在Creative Commons许可证下发布. 在fedora Linux上断断续续使用R语言过了9年后,发现R语言在国内用的人逐渐多了起来.由于工作原因,直到今年暑假一个赴京工作的机会与一位统 ...

  4. inline和宏之间的区别

    1.内联函数在编译时展开,而宏在预编译时展开 2.在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换. 3.内联函数可以进行诸如类型安全检查.语句是否正确等编译功能,宏不具有这 ...

  5. phpMyAdmin 中数据库替换问题

    将原来的数据库删除,然后进入data文件夹中修改名字, 但是后来前台出现错误: Zend_Db_Statement_Exception Object ( [_previous:Zend_Excepti ...

  6. C++中的虚函数解析[The explanation for virtual function of CPlusPlus]

    1.什么是虚函数?                                                                                            ...

  7. 【noip模拟】考试总结

    今天睡了14个小时啊 把一星期的觉都补回来了 要不是被叫醒了 我肯定还在睡觉- - 其实现在还想睡... 集训真是伤身啊 感觉再睡就要睡成sb了 鉴于昨天被完虐(真·完虐 怒垫底) 来写篇总结 得分: ...

  8. 关于JavaScripting API您不知道的5件事

    现在,许多 Java 开发人员都喜欢在 Java 平台中使用脚本语言,但是使用编译到 Java 字节码中的动态语言有时是不可行的.在某些情况中,直接编写一个 Java 应用程序的脚本 部分 或者在一个 ...

  9. iOS - Xcode升级到5.1& iOS升级到iOS7.1问题:Undefined symbols for architecture x86_64

    Xcode升级到5.1 新特性之一就是默认让所有App都通过64位编译器编译.原来在Xcode5.0.x的时候默认的Standard architectures只有(arm7,armv7s),到5.1 ...

  10. 【数据结构】非常有用的hash表

        这篇博客的目的是让尚未学会hash表的朋友们对hash表有一个直观的理解,并且能根据本文定义出属于自己的第一个hash表,但算不上研究文,没有深究概念和成功案例.         什么是has ...