Twaver HTML5中的 CloudEditor 进行Angular2 重写

背景

业务进度紧迫,于是花费俩天时间对 twaver 的 CloudEditor 进行Angular2 重写改造以实现twaver初始视图结构的引入;

初识twaver

twaver是一个商业闭源的绘图引擎工具, 类似的开源产品有 mxgraph, jointjs, raphael等;

重写原因

  • 优点

    • 不增加引入三方件,manageone当前火车版本上已经存在twaver,可直接使用;
    • 符合业务场景, twaver官方提供了当前开发的应用场景样例且官方样例丰富;
    • 功能稳定性已验证,公司有产品已经使用其作出更复杂场景的功能,沟通后初次判断二次开发问题不大;
    • Angular2框架兼容, twaver的技术栈使用原生js实现与当前使用Angular2框架无缝集成;
  • 缺点

    • 官方demo中大量使用jquery库操作dom,jqueryUI库实现UI组件和样式,初次引入需要对这些额外的三方件功能进行剥离和剔除;
    • 没有源码,不利于调试和排查问题;
    • 熟悉度低,当前组内没人了解twaver;

CloudEditor主体内容:

|-- CloudEditor
    |-- CloudEditor.html
    |-- css
    |   |-- bootstrap.min.css
    |   |-- jquery-ui-1.10.4.custom.min.css
    |   |-- jquery.ui.all.css
    |   |-- images
    |       |-- animated-overlay.gif
    |-- images
    |   |-- cent32os_s.png
    |   |-- zoomReset.png
    |-- js
        |-- AccordionPane.js
        |-- category.js
        |-- editor.js
        |-- GridNetwork.js
        |-- images.js
        |-- jquery-ui-1.10.4.custom.js
        |-- jquery.js

重写的主要准则:

  • 输出文件均以Typescript语言实现,并增加类型声明文件;
  • 剥离直接操作dom的操作,即移除jquery库;
  • 改写twaver中过久的语法,ES6语法改造;

左树菜单

CloudEditor中左树菜单主要是一个手风琴效果的列表,其实现是使用AccordionPanel.js这个文件,其内容是使用动态拼接dom的方式动态生成右面板的内容;我们使用Angular的模板特性,将其改写为Angular组件menu ,将原来JS操作dom的低效操作全部移除。

AccorditonPanel分析

// 这里声明了一个editor命名空间下的函数变量AccordionPane
editor.AccordionPane = function() {
 this.init();
};
// 内部方法基本都是为了生成左树菜单结构,如下方法
createView: function() {
    var rootView = $('<div id="accordion-resizer" class="ui-widget-content"></div>');
    this.mainPane = $('<div id="accordion"></div>');
    this.setCategories(categoryJson.categories);
    rootView.append(this.mainPane);
    return rootView[0];
},
  // 生成菜单标题
  initCategoryTitle: function(title) {
    var titleDiv = $('<h3>' + title + '</h3>');
    this.mainPane.append(titleDiv);
  },
  // 生成菜单内容
  initCategoryContent: function(datas) {
    var contentDiv = $('<ul class="mn-accordion"></ul>');
    for (var i = 0; i < datas.length; i++) {
      var data = datas[i];
      contentDiv.append(this.initItemDiv(data));
    }
    this.mainPane.append(contentDiv);
  },
  // 生成菜单项
  initItemDiv: function(data) {
    var icon = data.icon;
    var itemDiv = $('<li class="item-li"></li>');
    var img = $('<img src=' + icon + '></img>');
    img.attr('title', data.tooltip);
    var label = $('<div class="item-label">' + data.label + '</div>');
    itemDiv.append(img);
    itemDiv.append(label);

    this.setDragTarget(img[0], data);
    return itemDiv;
  },

使用tiny组件重写结构

<div id='left-tree-menu'>
  <tp-accordionlist [options]="menuData">
      <!--自定义面板内容-->
      <ng-template #content let-menuGroup let-i=index>
        <div *ngFor="let item of menuGroup.contents" [id]="item.label" class="item"
            [attr.data-type]="item.type" [attr.data-width]="item.width" [attr.data-height]="item.height"
            [attr.data-os]="item.os" [attr.data-bit]="item.bit" [attr.data-version]="item.version" 
            [title]="item.tooltip"> 
          <img [src]="item.icon" (dragstart)="dragStartMenuItem($event, item)"/>
          <div class="item-label">{{item.label}}</div>
        </div>
      </ng-template>
  </tp-accordionlist>
</div>

重写后组件逻辑

主要是处理数据模型与UI组件模型的映射关系

import { Component, Input, OnInit } from '@angular/core';
import { TpAccordionlistOption } from '@cloud/tinyplus3';

@Component({
  selector: 'design-menu',
  templateUrl: './menu.component.html',
  styleUrls: ['./menu.component.less']
})
export class MenuComponent implements OnInit {

  constructor() { }

  ngOnInit(): void {
  }
  @Input() set inputMenuData(v) {
    setTimeout(() => {
      this.menuData = this.b2uMenuData(v.categories);
    });
  }
  menuData:TpAccordionlistOption[] = [];
  categories: any[];

  /**
   * 设置菜单项数据
   * @param categories 菜单数据列表
   */
  setCategories(categories) {
    this.categories = categories;
  }

  /**
   * 菜单项数据转换为UI组件数据
   * @param bData 菜单模型数据
   * @returns 手风琴UI组件数据
   */
  b2uMenuData(bData: Array<any>): Array<TpAccordionlistOption>{ 
    return bData.map((item, i) => {
      let tpAccordionlistOption: TpAccordionlistOption = {};
      tpAccordionlistOption.disabled = false;
      tpAccordionlistOption.headLabel = item.title;
      tpAccordionlistOption.open = !Boolean(i);
      tpAccordionlistOption.headClick = () => { };
      tpAccordionlistOption.contents = [...item.contents];
      tpAccordionlistOption.actionmenu = {
        items: []
      };
      return tpAccordionlistOption;
    });
  }
  /**
   * 拖拽菜单项功能
   * @param event 拖拽事件
   * @param data 拖拽数据
   */
  dragStartMenuItem(event, data) {
    data.draggable = true;
    event.dataTransfer.setData("Text", JSON.stringify(data));
  }
}

绘制舞台

CloudEditor中舞台的实现是使用GridNetwork.js这个文件;舞台是通过扩展 twaver.vector.Network 来实现的

GridNetwork分析

在这个文件中,主要实现了跟舞台上相关的核心功能,拖放事件,导航窗格,简单的属性面板等

这个文件的重构需要增加大量类型声明, 以确保ts类型推断正常使用,在这部分,我保持最大的克制,尽量避免使用any类型,对于已知的类型进行了声明添加。

缺失的类型声明

declare interface Window {
  twaver: any;
  GAP: number;
}
declare var GAP: number;
declare interface Document { 
  ALLOW_KEYBOARD_INPUT: any;
}
declare namespace _twaver { 
  export var html: any;
  export class math {
    static createMatrix(angle, x, y);
  }
}
declare namespace twaver { 
  export class Util { 
    static registerImage(name: string, obj: object);
    static isSharedLinks(host: any, element: any);
    static moveElements(selections, xoffset, yoffset, flag: boolean);
  }
  export class Element { 
    getLayerId();
    getImage();
    getHost();
    getLayerId();
    setClient(str, flag: boolean);
  }
  export class Node { 
    getImage();
  }
  export class ElementBox {
    getLayerBox(): twaver.LayerBox;
    add(node: twaver.Follower| twaver.Link);
    getUndoManager();
    addDataBoxChangeListener(fn: Function);
    addDataPropertyChangeListener(fn: Function);
    getSelectionModel();
  }
  export class SerializationSettings { 
    static getStyleType(propertyName);
    static getClientType(propertyName);
    static getPropertyType(propertyName);
  }
  export class Follower { 
    constructor(obj: any);
    setLayerId(id: string);
    setHost(host: any);
    setSize(w: boolean, h: boolean);
    setCenterLocation(location: any);
    setVisible(visible:boolean);
  }
  export class Property { }
  export class Link { 
    constructor(one, two);
    getClient(name: string);
    getFromNode();
    getToNode();
    setClient(attr, val);
    setStyle(attr, val);
  }
  export class Styles { 
    static setStyle(attr: string, val: any);
  }
  export class List extends Set { }
  export class Layer{ 
    constructor(name: string);
  }
  export class LayerBox { 
    add(box: twaver.Layer, num?: number);
  }
  export namespace controls { 
    export class PropertySheet { 
      constructor(box: twaver.ElementBox);
      getView(): HTMLElement;
      setEditable(editable: boolean);
      getPropertyBox();
    }
  }
  export namespace vector { 
    export class Overview { 
      constructor(obj: any);
      getView(): HTMLElement;
    }
    export class Network { 
      invalidateElementUIs();
      setMovableFunction(fn:Function);
      getSelectionModel();
      removeSelection();
      getElementBox(): twaver.ElementBox;
      setKeyboardRemoveEnabled(keyboardRemoveEnabled: boolean);
      setToolTipEnabled(toolTipEnable: boolean);
      setTransparentSelectionEnable(transparent: boolean);
      setMinZoom(zoom:number);
      setMaxZoom(zoom:number);
      getView();
      setVisibleFunction(fn: Function);
      getLabel(data: twaver.Link | { getName();});
      setLinkPathFunction(fn:Function);
      getInnerColor(data: twaver.Link);
      adjustBounds(obj: any);
      addPropertyChangeListener(fn: Function);
      getElementAt(e: Event | any): twaver.Element;
      setInteractions(option: any);
      getLogicalPoint(e: Event | any);
      getViewRect();
      setViewRect(x,y,w,h);
      setDefaultInteractions();
      getZoom();
      // 如下页面用到的私有属性,但在api中为声明
      __button;
      __startPoint;
      __resizeNode;
      __originSize;
      __resize;
      __createLink;
      __fromButton;
      __dragging;
      __currentPoint;
      __focusElement;
    }
  }
}

重写后的stage.ts文件(本文省略了未改动代码)

export default class Stage extends twaver.vector.Network {
  constructor(editor) { 
    super();
    this.editor = editor;
    this.element = this.editor.element;
    twaver.Styles.setStyle('select.style', 'none');
    twaver.Styles.setStyle('link.type', 'orthogonal');
    twaver.Styles.setStyle('link.corner', 'none');
    twaver.Styles.setStyle('link.pattern', [8, 8]);
    this.init();
  }
  editor;
  element: HTMLElement;
  box: twaver.ElementBox;
  init() { 
    this.initListener();
  }
  initOverview () {
  }
  sheet;
  sheetBox;
  initPropertySheet () {
  }
  getSheetBox() { 
    return this.sheetBox;
  }
  infoNode;
  optionNode;
  linkNode;
  fourthNode;
  initListener() {
    _twaver.html.addEventListener('keydown', 'handle_keydown', this.getView(), this);
    _twaver.html.addEventListener('dragover', 'handle_dragover', this.getView(), this);
    _twaver.html.addEventListener('drop', 'handle_drop', this.getView(), this);
    _twaver.html.addEventListener('mousedown', 'handle_mousedown', this.getView(), this);
    _twaver.html.addEventListener('mousemove', 'handle_mousemove', this.getView(), this);
    _twaver.html.addEventListener('mouseup', 'handle_mouseup', this.getView(), this);
    //...
  }
  refreshButtonNodeLocation (node) {
    var rect = node.getRect();
    this.infoNode.setCenterLocation({ x: rect.x, y: rect.y });
    this.optionNode.setCenterLocation({ x: rect.x, y: rect.y + rect.height });
    this.linkNode.setCenterLocation({ x: rect.x + rect.width, y: rect.y });
    this.fourthNode.setCenterLocation({ x: rect.x + rect.width, y: rect.y + rect.height });
  }
  handle_mousedown(e) {
  }
  handle_mousemove(e) {
  }
  handle_mouseup(e) {
  }
  handle_keydown(e) {
  }
  //get element by mouse event, set lastElement as ImageShapeNode
  handle_dragover(e) {
  }
  handle_drop(e) {
  }
  _moveSelectionElements(type) {
  }
  isCurveLine () {
    return this._curveLine;
  }
  setCurveLine (value) {
    this._curveLine = value;
    this.invalidateElementUIs();
  }
  isShowLine () {
    return this._showLine;
  }
  setShowLine (value) {
    this._showLine = value;
    this.invalidateElementUIs();
  }
  isLineTip () {
    return this._lineTip;
  }
  setLineTip (value) {
    this._lineTip = value;
    this.invalidateElementUIs();
  }
  paintTop (g) {
  }
  paintBottom(g) {
  }
}

主入口控制器

CloudEditor中入口控制器使用editor.js实现,我这里为了集成到angular项目中增加了twaver.component.ts组件,用来引导editor的引入和实例化。

第一部分 twaver组件文件

模板部分

<div id="toolbar">
  <button *ngFor="let toolItem of toolbarData" [id]="toolItem.id" [title]="toolItem.title">
    <img [src]="toolItem.src"/>
  </button>
</div>
<div class="main">
  <div class="editor-container">
    <design-menu [inputMenuData]="menuData"></design-menu>
    <div class="stage" id="stage">
    </div>
  </div>
</div>

逻辑部分

import { Component, OnInit, ElementRef, NgZone, AfterViewInit } from '@angular/core';
import * as twaver from "../../../lib/twaver.js";
import "./shapeDefined";
import TwaverEditor from "./twaver-editor";
import { menuData, toolbarData } from './editorData';
window.GAP = 10;
@Component({
  selector: 'design-twaver',
  templateUrl: './twaver.component.html',
  styleUrls: ['./twaver.component.less']
})
export class TwaverComponent implements OnInit, AfterViewInit {

  constructor(private element: ElementRef, private zone: NgZone) {
  }
  twaverEditor: TwaverEditor;
  menuData = {
    categories: []
  };
  toolbarData = toolbarData;
  ngOnInit(): void {
  }
  ngAfterViewInit() {
    this.twaverEditor = new TwaverEditor(this.element.nativeElement);
    this.menuData = menuData;
  }
}

第二部分 TwaverEditor文件

这个文件是editor.js的主体部分重写后的文件(省略未改动内容,只保留结构)。

import Stage from './stage';
export default class TwaverEditor { 
  constructor(element) { 
    this.element = element;
    this.init()
  }
  element;
  stage: Stage;
  init() { 
    this.stage = new Stage(this);
    let stageDom = this.element.querySelector('#stage');
    stageDom.append(this.stage.getView());


    this.stage.initOverview();
    this.stage.initPropertySheet();
        
    this.adjustBounds();
    this.initProperties();
    // this.toolbar = new Toolbar();
    window.onresize = (e)  => {
      this.adjustBounds();
    };
  }
  adjustBounds() {
    let stageDom = this.element.querySelector('#stage');
    this.stage.adjustBounds({
      x: 0,
      y: 0,
      width: stageDom.clientWidth,
      height: stageDom.clientHeight
    });
  }
  initProperties() { 
  }
  isFullScreenSupported () {
  }
  toggleFullscreen() {
  }
  getAngle (p1, p2) {
  }
  fixNodeLocation (node) {
  }
  layerIndex = 0;
  addNode (box, obj, centerLocation, host) {
  }
  GAP = 10;
  fixLocation (location, viewRect?) {
  }
  fixSize (size) {
  }
  addStyleProperty (box, propertyName, category, name) {
    return this._addProperty(box, propertyName, category, name, 'style');
  }
  addClientProperty (box, propertyName, category, name) {
    return this._addProperty(box, propertyName, category, name, 'client');
  }
  addAccessorProperty (box, propertyName, category, name) {
    return this._addProperty(box, propertyName, category, name, 'accessor');
  }
  _addProperty (box, propertyName, category, name, proprtyType) {
  }
}

输出清单

实现主要输出内容:

  • 实现Typescript需要的类型声明文件,即 twaver.d.ts文件
  • 实现左树菜单的功能,即 menu组件文件;
  • 实现绘制操作舞台功能, 即stage.ts文件;
  • 实现编辑器主控制器,即TwaverEditor.ts文件
|-- twaver
    |-- editorData.ts                  # 数据文件,包含左树列表数据
    |-- shapeDefined.ts                   # 图形绘制定义
    |-- stage.ts                       # 舞台类
    |-- twaver-editor.ts               # twaver主入口控制器
    |-- twaver.component.html        
    |-- twaver.component.less
    |-- twaver.component.ts               # twaver Angular 组件
    |-- twaver.module.ts               # twaver Module
    |-- menu                           # meun组件
        |-- menu.component.html
        |-- menu.component.less
        |-- menu.component.ts

总结

重写CloudEditor只是一段旅途的开始,希望此文能帮助小伙伴们开个好头,大家可以顺利理解twaver中的一些api和语法。


言月
1.8k 声望490 粉丝

从有技术广度到技术深度的转变,这样才能被自己迷恋


引用和评论

0 条评论