公司研發的管理系統有工作流圖形化設計和查看功能,這個功能的開發歷史比較久遠。在那個暗無天日的年月里,IE幾乎一統江湖,所以順理成章地采用了當時紅極一時的VML技術。
后來的事情大家都知道了,IE開始走下坡路,VML這個技術現在早已滅絕,導致原來的工作流圖形化功能完全不能使用,所以需要采用新技術來重寫工作流圖形化功能。
多方對比之后,決定采用zrender庫來實現(關於zrender庫的介紹,請看http://ecomfe.github.io/zrender/),花了一天的時間,終於做出了一個大致的效果模型,如下圖所示:


流程圖由兩類部件組成:活動部件和連接弧部件,每一類部件包含多個性狀不同的部件。
以活動部件為例,圓形的是開始活動,平行四邊形是自動活動,長方形是人工活動,等等。
在代碼實現上,定義了Unit(部件基類),所有的部件都繼承自這個基類。通過Graph類來管理整個流程圖,包括所有部件、上下文菜單等等都由Graph來統一管理和調度,代碼如下:
var Libra = {};
Libra.Workflow = {};
Libra.Workflow.Graph = function(type, options){
var graph = this,
activities = {},
transitions = {};
var zrenderInstance,
contextMenuContainer;
this.type = type;
this.addActivity = function(activity){
activity.graph = graph;
activities[activity.id] = {object:activity};
};
this.getActivity = function(id){ return activities[id].object; };
this.addTransition = function(transition){
transition.graph = graph;
transitions[transition.id] = {object:transition};
};
function modElements(shapes){
shapes.each(function(shape){ zrenderInstance.modElement(shape); });
return shapes;
}
// 當前正在拖放的節點
var dragingActivity = null;
// 活動節點拖放開始
this.onActivityDragStart = function(activity){ dragingActivity = activity; };
// 活動節點拖放結束
this.onActivityDragEnd = function(){
if(dragingActivity) refreshActivityTransitions(dragingActivity);
dragingActivity = null;
};
// 拖動過程處理
function zrenderInstanceOnMouseMove(){
if(dragingActivity != null) refreshActivityTransitions(dragingActivity);
}
// 刷新活動相關的所有連接弧
function refreshActivityTransitions(activity){
var activityId = activity.id;
for(var key in transitions){
var transition = transitions[key].object;
if(transition.from === activityId || transition.to == activityId){
zrenderInstance.refreshShapes(modElements(transition.refresh(graph)));
}
}
}
// 當前選中的部件
var selectedUnit = null;
this.onUnitSelect = function(unit){
if(selectedUnit) zrenderInstance.refreshShapes(modElements(selectedUnit.unselect(graph)));
zrenderInstance.refreshShapes(modElements(unit.select(graph)));
selectedUnit = unit;
};
// 記錄當前鼠標在哪個部件上,可以用來生成上下文相關菜單
var currentUnit = null;
this.onUnitMouseOver = function(unit){
currentUnit = unit;
};
this.onUnitMouseOut = function(unit){
if(currentUnit === unit) currentUnit = null;
};
// 上下文菜單事件響應
function onContextMenu(event){
Event.stop(event);
if(currentUnit) currentUnit.showContextMenu(event, contextMenuContainer, graph);
}
this.addShape = function(shape){
zrenderInstance.addShape(shape);
};
// 初始化
this.init = function(){
var canvasElement = options.canvas.element;
canvasElement.empty();
canvasElement.setStyle({height: document.viewport.getHeight() + 'px'});
zrenderInstance = graph.type.zrender.init(document.getElementById(canvasElement.identify()));
for(var key in activities){ activities[key].object.addTo(graph); }
for(var key in transitions){ transitions[key].object.addTo(graph); }
// 創建上下文菜單容器
contextMenuContainer = new Element('div', {'class': 'context-menu'});
contextMenuContainer.hide();
document.body.appendChild(contextMenuContainer);
Event.observe(contextMenuContainer, 'mouseout', function(event){
// 關閉時,應判斷鼠標是否已經移出菜單容器
if(!Position.within(contextMenuContainer, event.clientX, event.clientY)){
contextMenuContainer.hide();
}
});
// 偵聽拖動過程
zrenderInstance.on('mousemove', zrenderInstanceOnMouseMove);
// 上下文菜單
Event.observe(document, 'contextmenu', onContextMenu);
};
// 呈現或刷新呈現
this.render = function(){
var canvasElement = options.canvas.element;
canvasElement.setStyle({height: document.viewport.getHeight() + 'px'});
zrenderInstance.render();
};
};
/*
* 部件(包括活動和連接弧)
*/
Libra.Workflow.Unit = Class.create({
id: null,
title: null,
graph: null,
// 當前是否被選中
selected: false,
// 上下文菜單項集合
contextMenuItems: [],
initialize: function(options){
var _this = this;
_this.id = options.id;
_this.title = options.title;
},
createShapeOptions: function(){
var _this = this;
return {
hoverable : true,
clickable : true,
onclick: function(params){
// 選中並高亮
_this.graph.onUnitSelect(_this);
},
onmouseover: function(params){ _this.graph.onUnitMouseOver(_this); },
onmouseout: function(params){ _this.graph.onUnitMouseOut(_this); }
};
},
addTo: function(graph){},
// 刷新顯示
refresh: function(graph){ return []; },
// 選中
select: function(graph){
this.selected = true;
return this.refresh(graph);
},
// 取消選中
unselect: function(graph){
this.selected = false;
return this.refresh(graph);
},
// 顯示上下文菜單
showContextMenu: function(event, container, graph){
container.hide();
container.innerHTML = '';
var ul = new Element('ul');
container.appendChild(ul);
this.buildContextMenuItems(ul, graph);
// 加偏移,讓鼠標位於菜單內
var offset = -5;
var rightEdge = document.body.clientWidth - event.clientX;
var bottomEdge = document.body.clientHeight - event.clientY;
if (rightEdge < container.offsetWidth)
container.style.left = document.body.scrollLeft + event.clientX - container.offsetWidth + offset;
else
container.style.left = document.body.scrollLeft + event.clientX + offset;
if (bottomEdge < container.offsetHeight)
container.style.top = document.body.scrollTop + event.clientY - container.offsetHeight + offset;
else
container.style.top = document.body.scrollTop + event.clientY + offset;
container.show();
},
// 創建上下文菜單項
buildContextMenuItems: function(container, graph){
var unit = this;
unit.contextMenuItems.each(function(item){
item.addTo(container);
});
}
});
zrender默認已經支持了對圖形的拖動,所以活動部件的拖動只需要設置dragable屬性為真即可。不過雖然活動部件可以拖動,但活動部件上的連接線不會跟着一起動,這需要偵聽拖動開始事件、拖動結束事件以及拖動過程中的鼠標移動事件,來實現連接線的實時重繪。在Graph中偵聽鼠標移動事件,就是為了實現連接線等相關圖形的實時重繪。
每個部件都規划了八個連接點,默認情況下,連接弧不固定與某個連接點,而是根據活動部件的位置關系,自動找出最近的連接點,所以在拖動活動部件的時候,可以看到連接弧在活動部件上的連接點在不斷變化。
上面只是以最簡化的方式實現了工作流圖形化設計的基本功能,完善的圖形化設計應包含曲線、連接點的拖放等等,如下圖所示:

上面是公司產品中的工作流圖形化設計功能,功能相對於上面的范例要完善許多,但基本原理不變,無非就是細節處理更多一些。
特別是在畫曲線的地方花了很多時間,中學的平面幾何知識幾乎都忘記了,所以做起來花了不少功夫,這部分准備以后專門寫篇文章來詳談。
本文的結尾會給出前期建模測試階段的完整代碼下載,是前期代碼,不是最終代碼,原因你懂的,見諒。
