Markdown 是咱們每一位開發者的必備技能,在寫 Markdown 過程當中,老是尋找了各類各樣的編輯器,但每種編輯器都只能知足某一方面的須要,卻不能都知足於平常寫做的各類需求。javascript
因此萌生出本身動手試試,利用 Electron 折騰一個 Markdown 編輯器出來。css
下面羅列出我所理想的 Markdown 編輯器的痛點需求:html
- 必需要有圖牀功能,並且還能夠直接上傳到本身的圖片後臺,如七牛;
- 樣式必須是能夠自定義的;
- 導出的 HTML 內容能夠直接粘貼到公衆號編輯器裏,直接發佈,而不會出現格式的問題;
- 能夠自定義固定模塊,如文章的頭部,或者尾部。
- 能夠自定義功能,如:自動載入隨機圖片,豐富咱們的文章內容。
- 必須是跨平臺的。
- 其它。
使用 Electron 做爲跨平臺開發框架,是目前最理想的選擇,再者說,如:VS Code、Atom 等大佬級別的應用也是基於 Electron 開發的。前端
使用 JavaScript, HTML 和 CSS 構建跨平臺的桌面應用
初次使用 Electron,咱們下載回來運行看看:java
# 克隆示例項目的倉庫 $ git clone https://github.com/electron/electron-quick-start # 進入這個倉庫 $ cd electron-quick-start # 安裝依賴並運行 $ npm install && npm start
VUE 是當前的前端框架的佼佼者,並且仍是咱們國人開發的,不得不服。本人也是 VUE 的忠實粉絲,在還沒火的 1.0 版本開始,我就使用 VUE 了。webpack
將這二者結合在一塊兒,也就是本文推薦使用的 simulatedgreg/electron-vue
:git
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> `;
- 這裏,使用插件
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