Markdown 是我们每一位开发者的必备技能,在写 Markdown 过程中,总是寻找了各种各样的编辑器,但每种编辑器都只能满足某一方面的需要,却不能都满足于日常写作的各种需求。
所以萌生出自己动手试试,利用 Electron 折腾一个 Markdown 编辑器出来。
下面罗列出我所理想的 Markdown 编辑器的痛点需求:
- 必须要有图床功能,而且还可以直接上传到自己的图片后台,如七牛;
- 样式必须是可以自定义的;
- 导出的 HTML 内容可以直接粘贴到公众号编辑器里,直接发布,而不会出现格式的问题;
- 可以自定义固定模块,如文章的头部,或者尾部。
- 可以自定义功能,如:自动载入随机图片,丰富我们的文章内容。
- 必须是跨平台的。
- 其它。
环境搭建
使用 Electron 作为跨平台开发框架,是目前最理想的选择,再者说,如:VS Code、Atom 等大佬级别的应用也是基于 Electron 开发的。
Electron
使用 JavaScript, HTML 和 CSS 构建跨平台的桌面应用
https://electronjs.org/
初次使用 Electron,我们下载回来运行看看:
# 克隆示例项目的仓库
$ git clone https://github.com/electron/electron-quick-start
# 进入这个仓库
$ cd electron-quick-start
# 安装依赖并运行
$ npm install && npm start
VUE
VUE 是当前的前端框架的佼佼者,而且还是我们国人开发的,不得不服。本人也是 VUE 的忠实粉丝,在还没火的 1.0 版本开始,我就使用 VUE 了。
electron-vue
将这两者结合在一起,也就是本文推荐使用的 simulatedgreg/electron-vue
:
vue init simulatedgreg/electron-vue FanlyMD
安装插件,并运行:
npm install
npm run dev
选择插件
1. Ace Editor
选择一个好的编辑器至关重要:
chairuosen/vue2-ace-editor: https://github.com/chairuosen/vue2-ace-editor
npm install buefy vue2-ace-editor vue-material-design-icons --save
2. markdown-it
能够快速的解析 Markdown 内容,我选择是用插件:markdown-it
npm install markdown-it --save
3. electron-store
既然是编辑器应用,所有很多个性化设置和内容,就有必要存于本地,如编辑器所需要的样式文件、自定义的头部尾部内容等。这里我选择:electron-store
npm install electron-store --save
整合
万事俱备,接下来我们就开始着手实现简单的 Markdown 的编辑和预览功能。
先看 src
文件夹结构:
.
├── README.md
├── app-screenshot.jpg
├── appveyor.yml
├── build
│ └── icons
│ ├── 256x256.png
│ ├── icon.icns
│ └── icon.ico
├── dist
│ ├── electron
│ │ └── main.js
│ └── web
├── package.json
├── src
│ ├── index.ejs
│ ├── main
│ │ ├── index.dev.js
│ │ ├── index.js
│ │ ├── mainMenu.js
│ │ ├── preview-server.js
│ │ └── renderer.js
│ ├── renderer
│ │ ├── App.vue
│ │ ├── assets
│ │ │ ├── css
│ │ │ │ └── coding01.css
│ │ │ └── logo.png
│ │ ├── components
│ │ │ ├── EditorPage.vue
│ │ │ └── Preview.vue
│ │ └── main.js
│ └── store
│ ├── content.js
│ └── store.js
├── static
└── yarn.lock
整个 APP 主要分成左右两列结构,左侧编辑 Markdown 内容,右侧实时看到效果,而页面视图主要由 Renderer 来渲染完成,所以我们首先在 renderer/components/
下创建 vue 页面:EditorPage.vue
:
<div id="wrapper">
<div id="editor" class="columns is-gapless is-mobile">
<editor
id="aceeditor"
ref="aceeditor"
class="column"
v-model="input"
@init="editorInit"
lang="markdown"
theme="twilight"
width="500px"
height="100%"></editor>
<preview
id="previewor"
class="column"
ref="previewor"></preview>
</div>
</div>
编辑区
左侧使用插件:require('vue2-ace-editor')
,处理实时监听 Editor
输入 Markdown 内容,将内容传出去。
watch: {input: function(newContent, oldContent) {messageBus.newContentToRender(newContent);
}
},
其中这里的 messageBus
就是把 vue 和 ipcRenderer 相关逻辑事件放在一起的 main.js
:
import Vue from 'vue';
import App from './App';
import 'buefy/dist/buefy.css';
import util from 'util';
import {ipcRenderer} from 'electron';
if (!process.env.IS_WEB) Vue.use(require('vue-electron'))
Vue.config.productionTip = false
export const messageBus = new Vue({
methods: {newContentToRender(newContent) {ipcRenderer.send('newContentToRender', newContent);
},
saveCurrentFile() {}
}
});
// 监听 newContentToPreview,将 url2preview 传递给 vue 的 newContentToPreview 事件
// 即,传给 Preview 组件获取
ipcRenderer.on('newContentToPreview', (event, url2preview) => {console.log(`ipcRenderer.on newContentToPreview ${util.inspect(event)} ${url2preview}`);
messageBus.$emit('newContentToPreview', url2preview);
});
/* eslint-disable no-new */
new Vue({components: { App},
template: '<App/>'
}).$mount('#app')
编辑器的内容,将实时由 ipcRenderer.send('newContentToRender', newContent);
下发出去,即由 Main 进程的 ipcMain.on('newContentToRender', function(event, content)
事件获取。
一个 Electron 应用只有一个 Main 主进程,很多和本地化东西 (如:本地存储,文件读写等) 更多的交由 Main 进程来处理。
如本案例中,想要实现的第一个功能就是,「可以自定义固定模块,如文章的头部,或者尾部」
我们使用一个插件:electron-store
,用于存储头部和尾部内容,创建 Class:
import {app} from 'electron'
import path from 'path'
import fs from 'fs'
import EStore from 'electron-store'
class Content {constructor() {this.estore = new EStore()
this.estore.set('headercontent', `<img src="http://bimage.coding01.cn/logo.jpeg" class="logo">
<section class="textword"><span class="text"> 本文 <span id="word">111</span> 字,需要 <span id="time"></span> 1 分钟 </span></section>`)
this.estore.set('footercontent', `<hr>
<strong>coding01 期待您继续关注 </strong>
<img src="http://bimage.coding01.cn/coding01_me.GIF" alt="qrcode">`)
}
// This will just return the property on the `data` object
get(key, val) {return this.estore.get('windowBounds', val)
}
// ...and this will set it
set(key, val) {this.estore.set(key, val)
}
getContent(content) {return this.headerContent + content + this.footerContent}
getHeaderContent() {return this.estore.get('headercontent', '')
}
getFooterContent() {return this.estore.get('footercontent', '')
}
}
// expose the class
export default Content
注:这里只是写死的头部和尾部内容。
有了头尾部内容,和编辑器的 Markdown 内容,我们就可以将这些内容整合,然后输出给我们的右侧 Preview
组件了。
ipcMain.on('newContentToRender', function(event, content) {const rendered = renderContent(headerContent, footerContent, content, cssContent, 'layout1.html');
const previewURL = newContent(rendered);
mainWindow.webContents.send('newContentToPreview', previewURL);
});
其中,renderContent(headerContent, footerContent, content, cssContent, 'layout1.html')
方法就是将我们的头部、尾部、Markdown 内容、css 样式和我们的模板 layout1.html
载入。这个就比较简单了,直接看代码:
import mdit from 'markdown-it';
import ejs from 'ejs';
const mditConfig = {
html: true, // Enable html tags in source
xhtmlOut: true, // Use '/' to close single tags (<br />)
breaks: false, // Convert '\n' in paragraphs into <br>
// langPrefix: 'language-', // CSS language prefix for fenced blocks
linkify: true, // Autoconvert url-like texts to links
typographer: false, // Enable smartypants and other sweet transforms
// Highlighter function. Should return escaped html,
// or '' if input not changed
highlight: function (/*str, , lang*/) {return '';}
};
const md = mdit(mditConfig);
const layouts = [];
export function renderContent(headerContent, footerContent, content, cssContent, layoutFile) {const text = md.render(content);
const layout = layouts[layoutFile];
const rendered = ejs.render(layout, {
title: 'Page Title',
content: text,
cssContent: cssContent,
headerContent: headerContent,
footerContent: footerContent,
});
return rendered;
}
layouts['layout1.html'] = `
<html>
<head>
<meta charset='utf-8'>
<title><%= title %></title>
<style>
<%- cssContent %>
</style>
</head>
<body>
<div class="markdown-body">
<section class="body_header">
<%- headerContent %>
</section>
<div id="content">
<%- content %>
</div>
<section class="body_footer">
<%- footerContent %>
</section>
</div>
</body>
</html>
`;
- 这里,使用插件
markdown-it
来解析 Markdown 内容,然后使用 ejs.render() 来填充模板的各个位置内容。- 这里,同时也为我们的目标:样式必须是可以自定义的 和封装各种不同情况下,使用不同的头部、尾部、模板、和样式提供了伏笔
当有了内容后,我们还需要把它放到「服务器」上,const previewURL = newContent(rendered);
import http from 'http';
import url from 'url';
var server;
var content;
export function createServer() {if (server) throw new Error("Server already started");
server = http.createServer(requestHandler);
server.listen(0, "127.0.0.1");
}
export function newContent(text) {
content = text;
return genurl('content');
}
export function currentContent() {return content;}
function genurl(pathname) {
const url2preview = url.format({
protocol: 'http',
hostname: server.address().address,
port: server.address().port,
pathname: pathname
});
return url2preview;
}
function requestHandler(req, res) {
try {
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': content.length
});
res.end(content);
} catch(err) {
res.writeHead(500, {'Content-Type': 'text/plain'});
res.end(err.stack);
}
}
最终得到 URL 对象,转给我们右侧的 Preview
组件,即通过 mainWindow.webContents.send('newContentToPreview', previewURL);
注:在 Main 和 Renderer 进程间通信,使用的是
ipcMain
和ipcRenderer
。ipcMain
无法主动发消息给ipcRenderer
。因为ipcMain
只有.on()
方法没有.send()
的方法。所以只能用webContents
。
预览区
右侧使用的时间上就是一个 iframe
控件,具体做成一个组件 Preview
:
<template>
<iframe src=""/>
</template>
<script>
import {messageBus} from '../main.js';
export default {
methods: {reload(previewSrcURL) {this.$el.src = previewSrcURL;}
},
created: function() {messageBus.$on('newContentToPreview', (url2preview) => {console.log(`newContentToPreview ${url2preview}`);
this.reload(url2preview);
});
}
}
</script>
<style scoped>
iframe {height: 100%;}
</style>
在 Preview
组件我们使用 vue 的 $on
监听 newContentToPreview
事件,实时载入 URL 对象。
messageBus.$on('newContentToPreview', (url2preview) => {this.reload(url2preview);
});
到此为止,我们基本实现了最基础版的 Markdown 编辑器功能,yarn run dev
运行看看效果:
总结
第一次使用 Electron,很肤浅,但至少学到了一些知识:
- 每个 Electron 应用只有一个 Main 进程,主要用于和系统打交道和创建应用窗口,在 Main 进程中,利用 ipcMain 监听来自 ipcRenderer 的事件,但没有 send 方法,只能利用
BrowserWindow。webContents.send()
。- 每个页面都有对应的 Renderer 进程,用于渲染页面。当然也有对应的 ipcRenderer 用于接收和发送事件。
- 在 vue 页面组件中,我们还是借助 vue 的
$on
和`$emit
传递和接收消息。
接下来一步步完善该应用,目标是满足于自己的需要,然后就是:也许哪天就开源了呢。
解决中文编码问题
由于我们使用 iframe
,所以需要在 iframe
内嵌的 <html></html>
增加 <meta charset='utf-8'>
<iframe id="ueditor_0" allowtransparency="true" width="100%" height="100%" frameborder="0" src="javascript:void(function(){document.open();document.write("<!DOCTYPE html><html xmlns='http://www.w3.org/1999/xhtml'><head><style type='text/css'>body{font-family:sans-serif;}</style><link rel='stylesheet'type='text/css'href='https://res.wx.qq.com/mpres/zh_CN/htmledition/comm_htmledition/style/widget/ueditor_new/themes/iframe3f3927.css'/></head><body class='view'lang='en'></body><script type='text/javascript'id='_initialScript'>setTimeout(function(){window.parent.UE.instants['ueditorInstant0']._setup(document);},0);var _tmpScript = document.getElementById('_initialScript');_tmpScript.parentNode.removeChild(_tmpScript);</script></html>");document.close();}())" style="height: 400px;"></iframe>
参考
- How to store user data in Electron https://medium.com/cameron-nokes/how-to-store-user-data-in-electron-3ba6bf66bc1e
- Electron-vue 开发实战 1——Main 进程和 Renderer 进程的简单开发。https://molunerfinn.com/electron-vue-2
- An Electron & Vue.js quick start boilerplate with vue-cli scaffolding, common Vue plugins, electron-packager/electron-builder, unit/e2e testing, vue-devtools, and webpack. https://github.com/SimulatedGREG/electron-vue