vscode思维导图扩展更新:支持画流程图

3,796 阅读8分钟

效果展示

本文要实现的插件是: 给思维导图添加拓扑图编辑的功能,最终实现的效果:
点击ContextMenu下的Edit Topology Diagram 菜单项,

image.png

打开拓扑图编辑界面
image.png

该拓扑图会作为该topic的一个附件。
Online App: awehook.github.io/react-mindm…
vscode extention: marketplace.visualstudio.com/items?itemN…

引言

之前基于blink-mind这个库写了一个vscode的思维导图扩展vscode-blink-mind,反响还挺不错的,并且得到了掘金开源精选编辑的推荐,上了掘金的优秀开源推荐。blink-mind这个库是支持以插件的方式进行扩展功能的,这个我在项目的readme文档里已经写了,并且做了一个在线的网站awehook.github.io/blink-mind去演示。比方说怎么去定制快捷键,定制右键菜单。这个网站对demo的类型做了分类,看名字应该可以见名知义。
如图:

image.png

点击Canvas右侧的Notes,进入文档页面,
image.png

大部分例子都提供了中英文两种语言的文档。
image.png

为了更详细的介绍如何编写插件来扩展功能。结合我这两天做的一个给思维的主题项添加拓扑图附件的功能,在这篇文章里详细的介绍如何编写插件。

如何编写插件

关于blink-mind的插件的原理,之前已经写了一篇博客介绍过。

需求分析

在编写插件之前,首先要分析插件要实现的功能。本文中的插件要实现的功能有

  1. 在主题项的右键菜单上添加一项,点击该菜单项,为当前主题项添加一个拓扑图的附件,并且进入拓扑图编辑界面。
  2. 在拓扑图编辑界面可以进行快捷的编辑,退出编辑立即保存数据。
  3. 在编辑界面有删除按钮,可以删除正在编辑的拓扑图,删除操作要进行二次确认。

画了个流程图:

image.png

开始编写代码

Model分析

blink-mind的数据定义和状态管理只是使用了immutable.js, 没有使用redux或者mobx。它的model定义在@blink-mind/core这个包下面的src/model目录下,最顶层的数据结构是model.

model的数据定义:

type ModelRecordType = {
  topics: Map<KeyType, Topic>; 	//所有topic使用immutable.Map来保存
  data?: Map<any, any>; 				//这个暂时没有使用,方便后面进行扩展需要
  config: Config;								//配置项,比方说样式的配置,布局的配置
  rootTopicKey: KeyType;				//根节点的key
  editorRootTopicKey?: KeyType;	//正在编辑的视图的根节点key
  focusKey?: KeyType;						//当前focus的topic的key
  focusMode?: string;						//当前focus的topic的状态
  zoomFactor: number;						//视图的缩放系数
  formatVersion?: string;			//数据版本号,如果数据定义格式发生改变,可以做兼容早期数据的工作
};

topic的数据定义:

type TopicRecordType = {
  key: KeyType;								//topic的key
  parentKey?: KeyType;				//父节点的key,没有父节点则为null
  collapse?: boolean;					//是否折叠
  subKeys?: List<KeyType>;		//子节点的key
  blocks?: List<Block>;				//每个topic可以有多个block,比方说内容是一个block,
  														//备注是一个block, 在本文中要实现的拓扑图附件也是一个block
  relations?: List<Relation>;	//为后面支持描述节点之间的关系提前预留,暂时没有使用到
  style?: string;							//当前topic的自定义样式
};

block的数据定义:

type BlockRecordType = {
  type: string;  	//block的类型
  key?: KeyType; 	//block的key,为后续工作预留,暂时没有用到
  data: any;			//block的数据
};

Operation机制

在blink-mind里所有改变model的操作都通过operation机制来完成,operation是一个内置的插件。它的代码实现
在这个实现中,有一个OpMap定义了操作的类型,以及操作的函数,

  const OpMap = new Map([
    [OpType.TOGGLE_COLLAPSE, ModelModifier.toggleCollapse],
    [OpType.COLLAPSE_ALL, ModelModifier.collapseAll],
    [OpType.EXPAND_ALL, ModelModifier.expandAll],
    [OpType.ADD_CHILD, ModelModifier.addChild],
    [OpType.ADD_SIBLING, ModelModifier.addSibling],
    [OpType.DELETE_TOPIC, ModelModifier.deleteTopic],
    [OpType.FOCUS_TOPIC, ModelModifier.focusTopic],
    [OpType.SET_STYLE, ModelModifier.setStyle],
    [OpType.SET_TOPIC_BLOCK, ModelModifier.setBlockData],
    [OpType.DELETE_TOPIC_BLOCK,ModelModifier.deleteBlock],
    [OpType.START_EDITING_CONTENT, startEditingContent],
    [OpType.START_EDITING_DESC, startEditingDesc],
    [OpType.DRAG_AND_DROP, dragAndDrop],
    [OpType.SET_EDITOR_ROOT, ModelModifier.setEditorRootTopicKey]
  ]);

所有操作的函数必须是如下规格:函数的参数必须有model字段,表示被修改之前的model,以及要修改的其他字段
函数的返回值必须是一个model,这个model是被修改过后的model.

type OpFunc = (arg:IModifierArg)=>IModifierResult
export interface IModifierArg {
  model: Model;
  topicKey?: KeyType;
  topic?: Topic;
  blockType?: string;
  focusMode?: string;
  data?: any;
  desc?: any;
  style?: string;
  theme?: any;
  layoutDir?: any;
  zoomFactor?: number;
}
export type IModifierResult = Model;

operation的代码如下:

operation(props) {
      const { opType, controller, model, opArray } = props;
      const opMap = controller.run('getOpMap', props);
      controller.run('beforeOperation', props);
      if (opType != null && opArray != null) {
        throw new Error('operation: opType and opArray conflict!');
      }
      if (controller.run('getAllowUndo', props)) {
        const { undoStack } = controller.run('getUndoRedoStack', props);
        controller.run('setUndoStack', {
          ...props,
          undoStack: undoStack.push(model)
        });
      }
      let newModel;
      if (opArray != null) {
        if (!Array.isArray(opArray)) {
          throw new Error('operation: the type of opArray must be array!');
        }
        newModel = opArray.reduce((acc, cur) => {
          const { opType } = cur;
          if (!opMap.has(opType))
            throw new Error(`opType:${opType} not exist!`);
          const opFunc = opMap.get(opType);
          const res = opFunc({ controller, ...cur, model: acc });
          return res;
          return acc;
        }, model);
      } else {
        if (!opMap.has(opType)) throw new Error(`opType:${opType} not exist!`);
        const opFunc = opMap.get(opType);
        newModel = opFunc(props);
      }
      controller.change(newModel);
      controller.run('afterOperation', props);
    },

他可以接受一个opType 或者一个 opArray, opArray里面包含了多个操作。
举个例子,执行一个opType:

    controller.run('operation', {
      ...this.props,
      opType: OpType.SET_TOPIC_BLOCK,
      blockType: BlockType.CONTENT,
      data: this.state.content,
      focusMode: FocusMode.NORMAL
    });

一次执行多个操作:

    controller.run('operation', {
      ...this.props,
      opArray: [
        {
          opType: OpType.SET_TOPIC_BLOCK,
          topicKey: 'sub1',
          blockType: BlockType.CONTENT,
          data: this.state.content,
          focusMode: FocusMode.NORMAL
        },
        {
          opType: OpType.SET_TOPIC_BLOCK,
          topicKey: 'sub1',
          blockType: BlockType.DESC,
          data: this.state.desc,
          focusMode: FocusMode.NORMAL
        }
      ]
      
    });

同时OpMap也是支持扩展的,扩展OpMap通过重写getOpMap这个扩展点函数来实现,具体的示例在后面会演示。

插件扩展点

扩展OpMap

首先定义一个新的OpType:

export const OP_TYPE_START_EDITING_TOPOLOGY = 'OP_TYPE_START_EDITING_TOPOLOGY';

重写getOpMap扩展点函数:

    getOpMap(props, next) {
      const opMap = next();
      opMap.set(OP_TYPE_START_EDITING_TOPOLOGY, startEditingTopology);
      return opMap;
    }

设置OP_TYPE_START_EDITING_TOPOLOGY对应的OpFunc是startEditingTopology

function startEditingTopology({ model, topicKey }) {
  const topic = model.getTopic(topicKey);
  const { block } = topic.getBlock(BLOCK_TYPE_TOPOLOGY);
  if (block == null || block.data == null) {
    model = ModelModifier.setBlockData({
      model,
      topicKey,
      blockType: BLOCK_TYPE_TOPOLOGY,
      data: ''
    });
  }
  model = ModelModifier.focusTopic({
    model,
    topicKey,
    focusMode: FOCUS_MODE_EDITING_TOPOLOGY
  });
  return model;
}

这里我们自定义了一个新的BlockType:

export const BLOCK_TYPE_TOPOLOGY = 'TOPOLOGY';

扩展topic的ContextMenu

右键菜单的扩展点是customizeTopicContextMenu,
具体的实现代码:

    customizeTopicContextMenu(props, next) {
      const { controller } = props;
      function editTopology(e) {
        controller.run('operation', {
          ...props,
          opType: OP_TYPE_START_EDITING_TOPOLOGY
        });
      }
      return (
        <>
          {next()}
          <MenuDivider />
          <MenuItem
            icon={Icon('topology')}
            text="Edit Topology Diagram"
            onClick={editTopology}
          />
        </>
      );
    },

首先调用next()函数获取系统默认的菜单项,然后在最下方加入自定义的菜单项。这个菜单项的事件处理函数中,通过controller去执行一个操作类型是OP_TYPE_START_EDITING_TOPOLOGY的操作。

扩展渲染topic的block的方法

    renderTopicBlock(props, next) {
      const { controller, block } = props;
      if (block.type === BLOCK_TYPE_TOPOLOGY) {
        return controller.run('renderTopicBlockTopology', props);
      }
      return next();
    },
      
    renderTopicBlockTopology(props) {
     	 return <TopicBlockTopology {...props} />;
    },

renderTopicBlock方法所做的事情很简单,判断当前block的type如果是我们定义的BLOCK_TYPE_TOPOLOGY,就执行我们自定义的渲染逻辑,否则return next(),意思是交由其他同名扩展函数处理,在blink-mind内部有默认的renderTopicBlock逻辑可以渲染blockType为BlockType.CONTENT和BlockType.DESC的逻辑。

TopicBlockTopology是一个Functional Component, 他要做的事情是:
在当前block上绘制一个icon

image.png
,点击这个icon 即可进入拓扑图编辑界面
image.png

export function TopicBlockTopology(props) {
  const { controller, model, topicKey, getRef } = props;
  const onClick = e => {
    e.stopPropagation();
    controller.run('operation', {
      ...props,
      opType: OP_TYPE_START_EDITING_TOPOLOGY
    });
  };
  const isEditing =
    model.focusKey === topicKey &&
    model.focusMode === FOCUS_MODE_EDITING_TOPOLOGY;

  const { block } = model.getTopic(topicKey).getBlock(BLOCK_TYPE_TOPOLOGY);

  if (!isEditing && !block) return null;
  const iconProps = {
    className: iconClassName('topology'),
    onClick,
    tabIndex: -1
  };

  return <TopicBlockIcon {...iconProps} />;
}

#### 扩展Drawer blink-mind 默认是以Drawer组件的形式来作为不同类型的block的编辑器的载体(Container), 当前你也可以不使用Drawer组件,那样的话就需要重写renderDiagramCustomize这个扩展点函数了。这里篇幅原因就不对这块做过多介绍,感兴趣的朋友可以去看源码。

本插件扩展renderDrawer方法:

export const FOCUS_MODE_EDITING_TOPOLOGY = 'FOCUS_MODE_EDITING_TOPOLOGY';

renderDrawer(props, next) {
      const { model } = props;
      if (model.focusMode === FOCUS_MODE_EDITING_TOPOLOGY) {
        const topoProps = {
          ...props,
          topicKey: model.focusKey,
          key: 'topology-drawer'
        };
        return <TopologyDrawer {...topoProps} />;
      }
      return next();
    },

FOCUS_MODE_EDITING_TOPOLOGY是我们自定义的一种FocusMode, 表示当前Focus的topic的状态是正在编辑拓扑图。

TopologyDrawer 的实现如下:

import { cancelEvent, Icon } from '@blink-mind/renderer-react';
import { Drawer } from '@blueprintjs/core';
import * as React from 'react';
import { TopologyDiagram } from './topology-diagram';

import { FocusMode, OpType } from '@blink-mind/core';
import styled from 'styled-components';
import {TopologyDiagramUtils} from "./topology-diagram-utils";
import {BLOCK_TYPE_TOPOLOGY, REF_KEY_TOPOLOGY_DIAGRAM, REF_KEY_TOPOLOGY_DIAGRAM_UTIL} from './utils';

const DiagramWrapper = styled.div`
  position: relative;
  overflow: auto;
  padding: 0px 0px 0px 5px;
  background: #88888850;
  height: 100%;
`;

const Title = styled.span`
  padding: 0px 20px;
`;

export function TopologyDrawer(props) {
  const { controller, topicKey, getRef, saveRef } = props;
  const onDiagramClose = e => {
    e.stopPropagation();
    const diagram: TopologyDiagram = getRef(REF_KEY_TOPOLOGY_DIAGRAM);
    const topologyData = diagram.topology.data;
    controller.run('operation', {
      ...props,
      opType: OpType.SET_TOPIC_BLOCK,
      topicKey,
      blockType: BLOCK_TYPE_TOPOLOGY,
      data: topologyData,
      focusMode: FocusMode.NORMAL
    });
  };
  const diagramProps = {
    ...props,
    ref: saveRef(REF_KEY_TOPOLOGY_DIAGRAM)
  };
  const utilProps = {
    ...props,
    ref: saveRef(REF_KEY_TOPOLOGY_DIAGRAM_UTIL)
  }
  return (
    <Drawer
      title={<Title>Topology Diagram Editor</Title>}
      icon={Icon('topology')}
      isOpen
      hasBackdrop
      backdropClassName="backdrop"
      backdropProps={{ onMouseDown: cancelEvent }}
      canOutsideClickClose={false}
      isCloseButtonShown={true}
      onClose={onDiagramClose}
      size="100%"
    >
      <DiagramWrapper onClick={cancelEvent} onDoubleClick={cancelEvent}>
        <TopologyDiagram {...diagramProps} />
        <TopologyDiagramUtils {...utilProps} />
      </DiagramWrapper>
    </Drawer>
  );
}

这个组件返回了一个Drawer, 这个Drawer的内容区域包括两个部分,TopologyDiagram和TopologyDiagramUtils,

TopologyDiagram是编辑器区域

image.png

TopologyDiagramUtils是杂项区域,在编辑器的右下角

image.png
,目前提供的功能有缩放和删除
image.png

TopologyDiagram和TopologyDiagramUtils怎么实现这里就不做过多介绍了,因为是集成了一个开源的拓扑图编辑器topology,这个库的使用说明可以参考它的文档。篇幅原因,本文不详细介绍了。

扩展序列化和反序列化

因为这个插件新增了一种BlockType, const **BLOCK_TYPE_TOPOLOGY **= 'TOPOLOGY';
所以需要考虑扩展序列化和反序列化的问题,默认的json-serializer的实现是

    serializeBlock(props) {
      const { block } = props;
      return block.toJS();
    },

    deserializeBlock(props) {
      const { obj } = props;
      return new Block(obj);
    },

由于这个插件使用的topology库默认也是以JS object的方式保存的canvas的data, 所以这里我们不用做任何特殊处理。

结语

本来以为可以很快速的写完一个文档,结果在写的时候发现需要处处斟酌,尽量考虑到读者的感受,尽量多的交代清楚上下文。通过此文可以了解到编写插件的大致流程,至于具体的实现细节,在源码中都可以看到。

好了,如果大家对blink-mind有什么好的想法和需求,欢迎和我联系,当然啦,也可以fork这个项目~~~