利用Electron簡單擼一個Markdown編輯器

76

Markdown 是咱們每一位開發者的必備技能,在寫 Markdown 過程當中,老是尋找了各類各樣的編輯器,但每種編輯器都只能知足某一方面的須要,卻不能都知足於平常寫做的各類需求。javascript

因此萌生出本身動手試試,利用 Electron 折騰一個 Markdown 編輯器出來。css

下面羅列出我所理想的 Markdown 編輯器的痛點需求:html

  1. 必需要有圖牀功能,並且還能夠直接上傳到本身的圖片後臺,如七牛;
  2. 樣式必須是能夠自定義的;
  3. 導出的 HTML 內容能夠直接粘貼到公衆號編輯器裏,直接發佈,而不會出現格式的問題;
  4. 能夠自定義固定模塊,如文章的頭部,或者尾部。
  5. 能夠自定義功能,如:自動載入隨機圖片,豐富咱們的文章內容。
  6. 必須是跨平臺的。
  7. 其它。

環境搭建

使用 Electron 做爲跨平臺開發框架,是目前最理想的選擇,再者說,如:VS Code、Atom 等大佬級別的應用也是基於 Electron 開發的。前端

Electron

使用 JavaScript, HTML 和 CSS 構建跨平臺的桌面應用

https://electronjs.org/vue

初次使用 Electron,咱們下載回來運行看看:java

# 克隆示例項目的倉庫
$ git clone https://github.com/electron/electron-quick-start

# 進入這個倉庫
$ cd electron-quick-start

# 安裝依賴並運行
$ npm install && npm start

VUE

VUE 是當前的前端框架的佼佼者,並且仍是咱們國人開發的,不得不服。本人也是 VUE 的忠實粉絲,在還沒火的 1.0 版本開始,我就使用 VUE 了。webpack

electron-vue

將這二者結合在一塊兒,也就是本文推薦使用的 simulatedgreg/electron-vuegit

vue init simulatedgreg/electron-vue FanlyMD

安裝插件,並運行:github

npm install

npm run dev

選擇插件

1. Ace Editorweb

選擇一個好的編輯器相當重要:

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>
`;
  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
相關文章
相關標籤/搜索