Markdown 是我们每一位开发者的必备技能,在写 Markdown 过程中,总是寻找了各种各样的编辑器,但每种编辑器都只能满足某一方面的需要,却不能都满足于日常写作的各种需求。

所以萌生出自己动手试试,利用 Electron 折腾一个 Markdown 编辑器出来。

下面罗列出我所理想的 Markdown 编辑器的痛点需求:

  1. 必须要有图床功能,而且还可以直接上传到自己的图片后台,如七牛;
  2. 样式必须是可以自定义的;
  3. 导出的 HTML 内容可以直接粘贴到公众号编辑器里,直接发布,而不会出现格式的问题;
  4. 可以自定义固定模块,如文章的头部,或者尾部。
  5. 可以自定义功能,如:自动载入随机图片,丰富我们的文章内容。
  6. 必须是跨平台的。
  7. 其它。

环境搭建

使用 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 installnpm 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 = falseexport 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 classexport 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>`;
  1. 这里,使用插件 markdown-it 来解析 Markdown 内容,然后使用ejs.render() 来填充模板的各个位置内容。
  2. 这里,同时也为我们的目标:样式必须是可以自定义的 和封装各种不同情况下,使用不同的头部、尾部、模板、和样式提供了伏笔

当有了内容后,我们还需要把它放到「服务器」上,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 进程间通信,使用的是 ipcMainipcRendereripcMain 无法主动发消息给 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,很肤浅,但至少学到了一些知识:

  1. 每个 Electron 应用只有一个 Main 进程,主要用于和系统打交道和创建应用窗口,在 Main 进程中,利用 ipcMain 监听来自 ipcRenderer的事件,但没有 send 方法,只能利用 BrowserWindow。webContents.send()
  2. 每个页面都有对应的 Renderer 进程,用于渲染页面。当然也有对应的 ipcRenderer 用于接收和发送事件。
  3. 在 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(&quot;<!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>&quot;);document.close();}())" style="height: 400px;"></iframe>

参考

  1. How to store user data in Electron https://medium.com/cameron-nokes/how-to-store-user-data-in-electron-3ba6bf66bc1e
  2. Electron-vue开发实战1——Main进程和Renderer进程的简单开发。https://molunerfinn.com/electron-vue-2
  3. 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