Twaver HTML5中的 CloudEditor 進行Angular2 重寫

言月發表於2021-10-11

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和語法。

相關文章