hello,好久不见,最近笔者花了几天工夫入门Electron,而后做了一个非常简单的利用,本文就来给各位分享一下过程,Electron
大佬请随便~
笔者开源了一个Web
思维导图,尽管借助showSaveFilePicker
等api
能够间接操作电脑本地文件,但终归不能离线应用,所以就萌生了做一个客户端的想法,作为一个只会前端的废物,做客户端,Electron
显然是最好的抉择,不过毛病也很显著,安装包体积比拟大,如果你对此比拟介意的话能够尝试tauri。
笔者的需要很简略,能新建、关上本地文件进行编辑,另外能查看最近编辑过的文件列表。
思维导图的编辑页面间接用原来的Web
版的页面即可,所以只须要新做一个主页。
最终成果如下:
主页:
编辑页:
我的项目引入Electron
笔者的我的项目是基于Vue2.x + Vue Cli
开发的一个单页利用,路由用的是hash
模式,引入Electron
很简略,也不须要做啥大改变,间接应用vue-cli-plugin-electron-builder插件:
vue add electron-builder
而后启动服务:
npm run electron:serve
就会在Vue
我的项目启动实现后主动帮你启动Electron
,接下来就能够欢快的开发了。
主过程
Electron
利用须要一个入口文件,用来管制主过程,须要在我的项目的package.json
文件中的main
字段指定:
{ "main": "background.js"}
主过程中存在一些根本代码,用于管制利用退出:
// background.jsimport { app } from 'electron'const isDevelopment = process.env.NODE_ENV !== 'production'// 敞开所有窗口后退出app.on('window-all-closed', () => { // 在macOS上,应用程序及其菜单栏通常放弃活动状态,直到用户应用Cmd+Q明确退出 if (process.platform !== 'darwin') { app.quit() }})// 在开发模式下,应父过程的申请退出。if (isDevelopment) { if (process.platform === 'win32') { process.on('message', data => { if (data === 'graceful-exit') { app.quit() } }) } else { process.on('SIGTERM', () => { app.quit() }) }}
而后就是创立和关上利用的窗口:
// background.jsimport { app, protocol, BrowserWindow } from 'electron'import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'// 注册协定protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } }])// 在ready事件里创立窗口app.on('ready', async () => { createMainWindow()})app.on('activate', () => { // 在macOS上,当点击dock图标且没有其余窗口关上时,通常会在应用程序中从新创立一个窗口。 if (BrowserWindow.getAllWindows().length === 0) { createMainWindow() }})// 创立主页面let mainWindow = nullasync function createMainWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, frame: false, titleBarStyle: 'hiddenInset', webPreferences: { webSecurity: false, preload: path.join(__dirname, 'preload.js') } }) if (process.env.WEBPACK_DEV_SERVER_URL) { await mainWindow.loadURL( process.env.WEBPACK_DEV_SERVER_URL + '/#/workbenche' ) } else { createProtocol('app') mainWindow.loadURL('app://./index.html/#/workbenche') }}
在ready
事件中创立新窗口,默认是关上主页面,开发环境关上本地启动的服务,生产环境间接关上本地文件。
frame
设为false
,创立的是一个无边框窗口,也就是没有默认的工具栏和控件,只有你的页面区域。
另外能够看到在创立窗口时指定了一个文件preload.js
,这个文件是渲染过程和主过程的通信桥梁。
如果你要关上页面调试的控制台,能够调用openDevTools
办法:
mainWindow.webContents.openDevTools()
渲染过程
通过BrowserWindow
创立的每个窗口都是一个独自的渲染过程,为了平安,个别不容许渲染过程间接拜访Node.js
环境,也就是咱们的页面无奈间接调用Node.js
的API
,然而作为一个客户端,页面显然是须要这种能力的,比方最根本的性能,操作本地文件,这就是preload.js
(预加载脚本)文件的作用。
预加载脚本会在渲染器过程加载之前加载,并有权拜访:两个渲染器全局对象 ( window
和 document
) 、Node.js
环境。
能够在预加载脚本中通过contextBridge.exposeInMainWorld
办法在页面的window
对象上挂载属性和办法,这样页面就能应用了,具体的应用前面会介绍。
页面控制器和拖拽区域
咱们创立的是无边框页面,然而作为一个客户端页面,页面控制器(最小化、全屏、敞开)和拖拽区域是必不可少的。
拖拽区域
拖拽区域个别放在页面顶部,宽度和页面宽度统一,高度随便,一个div
即可:
<div class="workbencheHomeHeader"></div>
.workbencheHomeHeader { position: relative; width: 100%; height: 40px; background-color: #ebeef1; display: flex; align-items: center; flex-shrink: 0;}
要让这个一般的div
能被拖动也很简略,加上如下的款式即可:
.workbencheHomeHeader { // ... -webkit-app-region: drag;}
如果这个区域外部的有些元素你不想作为拖拽区域的话,只有在这个元素上加上如下款式:
.innerElement { -webkit-app-region: no-drag;}
控制器
Windows
零碎在无边框模式下默认不会显示控制器,然而Mac
零碎的控制器(红绿灯)是无奈暗藏的,默认会显示在页面的左上方,所以笔者的做法是判断以后零碎,如果是Windows
则显示一个咱们本人做的控制器,而Mac
零碎只有在红绿灯区域显示一个占位元素即可。
为了在页面内不便的判断以后的零碎,咱们能够在预加载脚本中注入一个全局变量:
// preload.jsconst { contextBridge } = require('electron')contextBridge.exposeInMainWorld('platform', process.platform)contextBridge.exposeInMainWorld('IS_ELECTRON', true)
这样咱们就能够在页面中通过window.platform
获取以后所在的零碎了,另外还注入了一个全局变量window.IS_ELECTRON
用来给页面判断是否处于Electron
环境。
Mac
零碎的控制器默认在左上角,也就是咱们的拖拽区域内,Windows
上的控制器个别是在右上角的,然而笔者间接让Windows
和Mac
保持一致,一起放在左上角:
<div class="workbencheHomeHeader"> <MacControl></MacControl> <WinControl></WinControl></div>
// MacControl.vue<template> <div class="macControl" v-if="IS_MAC"></div></template><style lang="less" scoped>.macControl { width: 100px; height: 100%; flex-shrink: 0;}</style>
// WinControl.vue<template> <div class="winControl noDrag" v-if="IS_WIN"> <div class="winControlBtn iconfont iconzuixiaohua" @click="minimize"></div> <div class="winControlBtn iconfont" :class="[isMaximize ? 'icon3zuidahua-3' : 'iconzuidahua']" @click="toggleMaximize" ></div> <div class="winControlBtn iconfont iconguanbi" @click="close"></div> </div></template><script>export default { data() { return { isMaximize: false } }, methods: { // ... }}</script>
Windows
控制器显然须要调用窗口的相干办法来管制窗口的最小化、敞开等。这就波及到过程间的通信了,具体来说是渲染过程到主过程的通信。
渲染过程到主过程通信
过程间通信须要用到预加载脚本。
咱们能够在预加载脚本中给页面注入一些全局办法,而后在办法中应用过程间通信 (IPC)告诉主过程,拿后面的控制器为例:
// preload.jsconst { contextBridge, ipcRenderer } = require('electron')contextBridge.exposeInMainWorld('electronAPI', { minimize: () => ipcRenderer.send('minimize'), maximize: () => ipcRenderer.send('maximize'), unmaximize: () => ipcRenderer.send('unmaximize'), close: () => ipcRenderer.send('close'),}
给页面的window
对象注入了一个值为对象的属性electronAPI
,咱们的所有通信办法都会挂在这个对象上,这样咱们的控制器就能够调用相干办法了:
// WinControl.vue<script>export default { methods: { minimize() { window.electronAPI.minimize() }, toggleMaximize() { if (this.isMaximize) { this.isMaximize = false window.electronAPI.unmaximize() } else { this.isMaximize = true window.electronAPI.maximize() } }, close() { window.electronAPI.close() } }}</script>
接下来就是在主过程中接管音讯:
// background.jsimport { BrowserWindow, ipcMain } from 'electron'const bindEvent = () => { ;['minimize', 'maximize', 'unmaximize', 'close'].forEach(eventName => { ipcMain.on(eventName, event => { // 获取发送音讯的 webContents const webContents = event.sender // 获取给定webContents的窗口 const win = BrowserWindow.fromWebContents(webContents) // 调用窗口的办法 win[item]() }) })}app.on('ready', async () => { createMainWindow() bindEvent()// ++ 监听事件})
新建、关上、保留
新建
当点击新建按钮时,会创立一个新的思维导图编辑窗口:
// preload.jscontextBridge.exposeInMainWorld('electronAPI', { create: () => ipcRenderer.send('create')}
// background.jsimport { v4 as uuid } from 'uuid'ipcMain.on('create', createEditWindow)const createEditWindow = async (event, id) => { id = id || uuid() const win = new BrowserWindow({ width: 1200, height: 800, frame: false, titleBarStyle: 'hiddenInset', webPreferences: { webSecurity: false, preload: path.join(__dirname, 'preload.js') } }) if (process.env.WEBPACK_DEV_SERVER_URL) { win.loadURL( process.env.WEBPACK_DEV_SERVER_URL + '/#/workbenche/edit/' + id ) } else { win.loadURL('app://./index.html/#/workbenche/edit/' + id) }}
编辑页面须要一个惟一的id
,而后关上编辑页面窗口即可。
关上
关上是指关上本地的文件,首先笔者自定义了一个文件扩展名.smm
,作为利用反对的文件,实质就是json
格局。
关上本地文件须要应用到dialog
模块:
// preload.jscontextBridge.exposeInMainWorld('electronAPI', { selectOpenFile: () => ipcRenderer.send('selectOpenFile')}
// background.jsimport { dialog } from 'electron'// 关上本地文件ipcMain.on('selectOpenFile', event => { const res = dialog.showOpenDialogSync({ title: '抉择',// 对话框窗口的题目 filters: [{ name: '思维导图', extensions: ['smm'] }]// 指定一个文件类型数组,用于规定用户可见或可选的特定类型范畴 }) if (res && res[0]) { openFile(null, res[0]) }})// 关上文件进行编辑const idToFilePath = {}// 关联id和文件门路const openFile = (event, file) => { let id = uuid() idToFilePath[id] = file createEditWindow(null, id)}
指定只能抉择.smm
文件,抉择实现后会返回抉择文件的门路。
而后调用openFile
办法关上编辑窗口,同样会生成一个惟一的id
,另外咱们创立了一个对象用来关联id
和id
对应的文件门路,用于后续的保留操作。
页面关上后,页面须要获取文件的数据,作为初始数据渲染到画布,这个须要渲染过程给主过程发信息,并且能接收数据,还是渲染过程到主过程的通信,只不过是双向的。
渲染过程到主过程通信(双向)
同样是应用ipcRenderer
对象,只不过不是应用send
和on
办法,而是应用invoke
和handle
办法。
// 页面const getData = async () => { try { let data = await window.electronAPI.getFileContent(this.$route.params.id) } catch(err) { console.errror(err) }}
// preload.jscontextBridge.exposeInMainWorld('electronAPI', { getFileContent: id => ipcRenderer.invoke('getFileContent', id)}
// background.js// 获取文件内容ipcMain.handle('getFileContent', (event, id) => { return new Promise((resolve, reject) => { let file = idToFilePath[id] if (!file) { resolve(null) return } fs.readFile(file, { encoding: 'utf-8' }, (err, data) => { if (err) { reject(err) } else { resolve({ name: path.parse(file).name, content: JSON.parse(data) }) } }) })})
拖拽文件到页面
除了关上文件抉择对话框抉择文件外,当然也能够间接拖拽文件到页面,这和一般的web
页面实现逻辑是一样的,也就是应用拖放API
。
<div class="workbencheHomeContainer" @drop="onDrop" @dragenter="onPreventDefault" @dragover="onPreventDefault" @dragleave="onPreventDefault" ></div><script>export default { // 搁置文件 onDrop(e) { e.preventDefault() e.stopPropagation() let df = e.dataTransfer let dropFiles = [] // 从拖拽的文件中过滤出.smm文件 if (df.items !== undefined) { for (let i = 0; i < df.items.length; i++) { let item = df.items[i] if (item.kind === 'file' && item.webkitGetAsEntry().isFile) { let file = item.getAsFile() if (/\.smm$/.test(file.name)) { dropFiles.push(file) } } } } if (dropFiles.length === 1) { // 如果只有一个文件,间接关上编辑 window.electronAPI.openFile(dropFiles[0].path) } else if (dropFiles.length > 1) { // 否则增加到最近文件列表 // ... } }, onPreventDefault(e) { e.preventDefault() e.stopPropagation() }}</script>
如果只拖拽了一个文件,那么间接关上编辑窗口,否则增加到最近的文件列表上。
保留
保留存在两种状况,一是新建还未保留过的状况,这种须要先创立本地文件,再进行保留,第二种就是文件曾经存在了,间接保留到文件即可。
// preload.jscontextBridge.exposeInMainWorld('electronAPI', { save: (id, data, fileName) => ipcRenderer.invoke('save', id, data, fileName)}
// background.jsipcMain.handle('save', async (event, id, data, fileName = '未命名') => { // 从idToFilePath对象中获取id对应的文件门路 // id没有关联的文件门路,代表文件没有创立,那么先创立文件 if (!idToFilePath[id]) { const res = dialog.showSaveDialogSync({ title: '保留', defaultPath: fileName + '.smm', filters: [{ name: '思维导图', extensions: ['smm'] }] }) // 创立胜利后返回文件门路 if (res) { idToFilePath[id] = res fs.writeFile(res, data) } } else { // 文件曾经存在,那么间接保留 fs.writeFile(idToFilePath[id], data) }})
依据id
从idToFilePath
对象中获取是否存在关联的文件门路,存在的话则代表文件曾经创立了,否则先创立一个文件,并且和id
关联起来。
拦挡页面敞开事件
当在编辑页面进行了编辑,还未保留的状况下,如果间接点击敞开页面,通常须要进行二次确认,避免误敞开导致数据失落。
因为Mac
零碎的敞开是应用默认的控制器,所以无奈拦挡敞开办法,只能拦挡敞开事件:
// 页面window.onbeforeunload = async e => { e.preventDefault() e.returnValue = '' // 没有未保留内容间接敞开 if (!this.isUnSave) { window.electronAPI.destroy() } else { try { // 否则询问用户是否敞开 await this.checkIsClose() // 用户抉择敞开会走这里 window.electronAPI.destroy() } catch (error) { // 用户抉择不敞开会走这里 } }}// 询问是否敞开页面checkIsClose() { return new Promise((resolve, reject) => { this.$confirm('有操作尚未保留,是否确认敞开?', '提醒', { confirmButtonText: '确定', cancelButtonText: '勾销', type: 'warning' }) .then(async () => { resolve() }) .catch(() => { reject() }) })}
判断以后是否存在未保留的操作,是的话询问用户是否敞开,敞开窗口调用的是destroy
,因为应用close
办法又会被这个事件拦挡,就进入死循环了。
// preload.jscontextBridge.exposeInMainWorld('electronAPI', { destroy: () => ipcRenderer.send('destroy')}
// background.js;[..., 'destroy'].forEach(item => { ipcMain.on(item, event => { const webContents = event.sender const win = BrowserWindow.fromWebContents(webContents) win[item]() })})
最近文件
客户端须要存储、更新、删除最近操作的文件记录,存储应用的是electron-json-storage,API
和localstorage
的差不多。
创立文件、关上文件、拖入文件、复制文件、删除文件等操作都须要更新最近文件列表,比方后面提到的关上文件:
// background.js// 关上文件const openFile = (event, file) => { let id = uuid() idToFilePath[id] = file saveToRecent(file)// ++ 保留到最近文件 createEditWindow(null, id)}// 保留到最近文件import storage from 'electron-json-storage'const RECENT_FILE_LIST = 'recentFileList'const saveToRecent = file => { return new Promise((resolve, reject) => { let list = getRecent() // 如果文件曾经存在,那么先删除 let index = list.findIndex(item => { return item === file }) if (index !== -1) { list.splice(index, 1) } // 再增加,也就是使之变成最近的一个文件 list.push(file) storage.set(RECENT_FILE_LIST, list, err => { if (err) { reject(err) } else { resolve() } }) })}// 获取最近文件列表const getRecent = () => { let res = storage.getSync(RECENT_FILE_LIST) return (Array.isArray(res) ? res : []).filter(item => { return !!item })}
当然,这个操作只是更新了客户端的存储,还须要告诉页面更新才行,这就波及到主过程到渲染过程的通信了。
主过程到渲染过程通信
还是以后面的关上文件编辑办法为例:
// background.js// 关上文件const openFile = (event, file) => { let id = uuid() idToFilePath[id] = file saveToRecent(file).then(() => {// 保留到最近文件实现后告诉页面刷新 notifyMainWindowRefreshRecentFileList()// ++ }) createEditWindow(null, id)}// 告诉主页面刷新最近文件列表const notifyMainWindowRefreshRecentFileList = () => { mainWindow.webContents.send('refreshRecentFileList')}
调用指定窗口的webContents
对象的send
办法发送信息,同样须要在预加载脚本中直达:
// preload.jscontextBridge.exposeInMainWorld('electronAPI', { onRefreshRecentFileList: callback => ipcRenderer.on('refreshRecentFileList', callback)}
而后在页面中调用onRefreshRecentFileList
办法注册回调:
// 页面window.electronAPI.onRefreshRecentFileList(() => { this.getRecentFileList()})
这样预加载脚本中监听到主过程发送的信息后,就会执行传入的回调办法。
页面获取最近文件列表应用后面介绍的渲染过程和主过程的双向通信办法即可。
在文件夹里显示某个文件
这也是一个常见的性能,关上文件所在文件夹,并且定位到文件。
这个性能须要应用到shell
模块。
// background.jsimport { shell } from 'electron'// 关上文件所在目录,并定位文件ipcMain.on('openFileInDir', (event, file) => { shell.showItemInFolder(file)})
应用零碎默认浏览器关上页面
如果间接应用a
标签关上页面,Electron
默认会新开一个窗口显示,当然这个窗口就不被你管制了,所以会显示丑丑的默认控件,通常关上这种非客户端页面的url
都是应用零碎默认的浏览器关上,实现上,间接应用open库即可。
// background.jsimport open from 'open'// 应用默认浏览器关上指定urlipcMain.on('openUrl', (event, url) => { open(url)})
设置利用为文件的默认关上利用
这是一个很重要的性能,比方咱们双击.txt
文件,默认会关上txt
编辑器,如果咱们的利用反对关上某种文件,或者自定义了一种类型的文件,比方笔者的.smm
文件,那么显然在双击这些文件时应该关上咱们的利用,否则还要用户本人去设置默认利用,那体验是十分不好的。
要实现这个性能,首先须要在打包配置里设置,vue-cli-plugin-electron-builder
插件应用的显然是electron-builder,具体的配置字段为fileAssociations
:
笔者的配置为:
// vue.config.jsmodule.exports = { pluginOptions: { electronBuilder: { fileAssociations: [ { ext: 'smm', name: 'mind map file', role: 'Editor', icon: './build/icons/icon.ico' } ] } }}
ext
指定反对的文件扩展名,icon
用于该种类型文件在文件夹里显示的图标,这样当装置了咱们的利用,反对的文件默认就会显示咱们配置的图标:
以上只解决了文件关联的性能,双击也能关上咱们的利用,然而通常状况下,还须要间接在利用中关上该文件,比方双击html
文件,要的不是关上浏览器主页,而是间接在浏览器中关上该文件。
这就是须要在利用中反对了,要获取双击关上文件的门路,能够在主过程中监听will-finish-launching
事件,当应用程序实现根底的启动的时候会触发该事件,而后分平台解决,在Windows
平台能够间接通过process.argv
来获取文件门路,在Mac
零碎上通过监听open-file
事件来获取:
// background.js// 存储被双击关上的文件门路const initOpenFileQueue = []app.on('will-finish-launching', () => { if (process.platform == 'win32') { const argv = process.argv if (argv) { argv.forEach(filePath => { if (filePath.indexOf('.smm') >= 0) { initOpenFileQueue.push(filePath) } }) } } else { app.on('open-file', (event, file) => { if (app.isReady() === false) { initOpenFileQueue.push(file) } else { // 利用曾经启动了,间接关上文件 } event.preventDefault() }) }})// 能够在ready事件触发后处理initOpenFileQueue数据
当然,目前的实现存在一个问题,就是屡次双击文件,会反复关上利用,实践上来说关上一次就够了,这个晓得怎么解决的敌人欢送评论区见~
打包
性能开发完了,最初一步当然是打包了,想要打包出Windows
利用和Mac
利用,你至多须要两台电脑,在Windows
电脑上能够打包出Windows
利用,在Mac
零碎上能够打包出Mac
和Linux
利用。
打包应用的是electron-builder,它有十分多的配置,反对签名和自动更新等性能,笔者并没有深入研究,更多的性能只能各位本人摸索了,上面是笔者参考其余我的项目的打包配置。
打包配置
// vue.config.jsmodule.exports = { pluginOptions: { electronBuilder: { preload: 'src/electron/preload.js', builderOptions: { productName: '思路思维导图', copyright: 'Copyright © 思路思维导图', asar: true, // 设置为文件的默认利用 fileAssociations: [ { ext: 'smm', name: 'mind map file', role: 'Editor', icon: './build/icons/icon.ico' } ], directories: { output: 'dist_electron' }, mac: { target: [ { target: 'dmg', arch: ['x64', 'arm64', 'universal'] } ], artifactName: '${productName}-${os}-${version}-${arch}.${ext}', category: 'public.app-category.utilities', darkModeSupport: false }, win: { target: [ { target: 'portable', arch: ['x64'] }, { target: 'nsis', arch: ['x64'] } ], publisherName: '思路思维导图', icon: 'build/icons/icon.ico' }, linux: { target: [ { target: 'AppImage', arch: ['x64'] } ], category: 'Utilities', icon: './build/icon.icns' }, dmg: { icon: 'build/icons/icon.icns' }, nsis: { oneClick: false,// 勾销一键装置 allowToChangeInstallationDirectory: true,// 容许用户抉择装置门路 perMachine: true } } } }}
而后在package.json
文件中增加打包命令:
// package.json{ "scripts": { "electron:build": "vue-cli-service electron:build -p never", "electron:build-all": "vue-cli-service electron:build -p never -mwl", "electron:build-mac": "vue-cli-service electron:build -p never -m", "electron:build-win": "vue-cli-service electron:build -p never -w", "electron:build-linux": "vue-cli-service electron:build -p never -l" }}
第一个命令会主动依据以后零碎打包对应的利用。
打包过程中可能会在下载electron
的原型包的一步卡住,这个只能多试几次,或者手动下载,具体操作能够百度一下。
利用图标
后面的打包配置中能够看到配置了几种不同格局的图标,也就是咱们的利用图标,Windows
零碎用的是.ico
格局的图片,而Mac
和Linux
零碎用的是.icns
的图标。
首先你须要筹备一张1024*1024
的png
图片icon.png
。
生成.ico
的图片很简略,网上搜寻一下找一个在线网站转一下就行了,比方这个:png-to-ico。
要生成.icns
图片,你须要在Mac
零碎的命令行中执行一些命令:
// 命令行进入图片所在门路// 创立一个长期文件夹mkdir temp.iconset// 在长期文件夹中生成10种大小的图片sips -z 16 16 icon.png --out temp.iconset/icon_16x16.pngsips -z 32 32 icon.png --out temp.iconset/icon_16x16@2x.pngsips -z 32 32 icon.png --out temp.iconset/icon_32x32.pngsips -z 64 64 icon.png --out temp.iconset/icon_32x32@2x.pngsips -z 128 128 icon.png --out temp.iconset/icon_128x128.pngsips -z 256 256 icon.png --out temp.iconset/icon_128x128@2x.pngsips -z 256 256 icon.png --out temp.iconset/icon_256x256.pngsips -z 512 512 icon.png --out temp.iconset/icon_256x256@2x.pngsips -z 512 512 icon.png --out temp.iconset/icon_512x512.pngsips -z 1024 1024 icon.png --out temp.iconset/icon_512x512@2x.png// 生成.icns图片iconutil -c icns temp.iconset -o icon.icns
而后长期文件夹能够删除,不过最好把长期生成的10张图片也复制到icon.png
所在文件,否则在打包Mac
利用时可能会用到,然而不存在就会报错。
总结
本文分享了一下笔者做的一个简略的利用的细节,因为也是刚入门,所以某些方面可能会存在谬误,或者有更好的实现形式,欢送评论区见。有趣味的敌人也能够下载体验一下~
源码地址:https://github.com/wanglin2/mind-map/tree/electron。
下载地址:https://github.com/wanglin2/mind-map/releases/tag/v0.1.0。