效果展示
本文要实现的插件是: 给思维导图添加拓扑图编辑的功能,最终实现的效果:点击ContextMenu下的Edit Topology Diagram 菜单项,
打开拓扑图编辑界面
该拓扑图会作为该topic的一个附件。
Online App: https://awehook.github.io/react-mindmap/
vscode extention: https://marketplace.visualstudio.com/items?itemName=awehook.vscode-blink-mind
引言
之前基于blink-mind这个库写了一个vscode的思维导图扩展vscode-blink-mind,反响还挺不错的,并且得到了掘金开源精选编辑的推荐,上了掘金的优秀开源推荐。blink-mind这个库是支持以插件的方式进行扩展功能的,这个我在项目的readme文档里已经写了,并且做了一个在线的网站https://awehook.github.io/blink-mind去演示。比方说怎么去定制快捷键,定制右键菜单。这个网站对demo的类型做了分类,看名字应该可以见名知义。
如图:
点击Canvas右侧的Notes,进入文档页面,
大部分例子都提供了中英文两种语言的文档。
为了更详细的介绍如何编写插件来扩展功能。结合我这两天做的一个给思维的主题项添加拓扑图附件的功能,在这篇文章里详细的介绍如何编写插件。
如何编写插件
关于blink-mind的插件的原理,之前已经写了一篇博客介绍过。
需求分析
在编写插件之前,首先要分析插件要实现的功能。本文中的插件要实现的功能有
- 在主题项的右键菜单上添加一项,点击该菜单项,为当前主题项添加一个拓扑图的附件,并且进入拓扑图编辑界面。
- 在拓扑图编辑界面可以进行快捷的编辑,退出编辑立即保存数据。
- 在编辑界面有删除按钮,可以删除正在编辑的拓扑图,删除操作要进行二次确认。
画了个流程图:
开始编写代码
Model分析
blink-mind的数据定义和状态管理只是使用了immutable.js, 没有使用redux或者mobx。它的model定义在@blink-mind/core这个包下面的src/model目录下,最顶层的数据结构是model.
model的数据定义:
type ModelRecordType = {
topics: Map; //所有topic使用immutable.Map来保存
data?: Map; //这个暂时没有使用,方便后面进行扩展需要
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; //子节点的key
blocks?: List; //每个topic可以有多个block,比方说内容是一个block,
//备注是一个block, 在本文中要实现的拓扑图附件也是一个block
relations?: List; //为后面支持描述节点之间的关系提前预留,暂时没有使用到
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()}
>
);
},
首先调用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 ;
},
renderTopicBlock方法所做的事情很简单,判断当前block的type如果是我们定义的BLOCK_TYPE_TOPOLOGY,就执行我们自定义的渲染逻辑,否则return next(),意思是交由其他同名扩展函数处理,在blink-mind内部有默认的renderTopicBlock逻辑可以渲染blockType为BlockType.CONTENT和BlockType.DESC的逻辑。
TopicBlockTopology是一个Functional Component, 他要做的事情是:在当前block上绘制一个icon
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 ;
}
扩展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 ;
}
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 (
Topology Diagram Editor}
icon={Icon('topology')}
isOpen
hasBackdrop
backdropClassName="backdrop"
backdropProps={{ onMouseDown: cancelEvent }}
canOutsideClickClose={false}
isCloseButtonShown={true}
onClose={onDiagramClose}
size="100%"
>
);
}
这个组件返回了一个Drawer, 这个Drawer的内容区域包括两个部分,TopologyDiagram和TopologyDiagramUtils,
TopologyDiagram是编辑器区域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这个项目~~~