[Today’s random Sourcerer profile: https://sourcerer.io/achyudhk]

Creating a Markdown editor/previewer in Electron and Vue.js

Writing desktop applications with Electron is by far the easiest and most enjoyable GUI application toolkit I’ve used over my career. Electron is the toolkit used in such popular applications as Atom, Visual Studio Code, the Slack desktop client, Postman, GitKraken, and Etcher. Each are powerful, performant, cross-platform applications with a rich user experience, offering excellent capabilities to the user, with the same user interface interface on each platform while integrating correctly into the platform.

Cross platform GUI toolkits are often derided as being slow, not offering good integration with the platform, or having compromises in GUI capabilities. Electron pulls off this feat is by having an ingenious architecture based on Google’s Chrome web browser. Namely, Electron wraps the core of Chromium such that you write a Node.js application to manage one or more BrowserWindow objects containing application windows. As the name implies, a BrowserWindow has available a full HTML5, CSS3, JavaScript ES-2016 programming environment based on the latest version of Chrome.

In other words — with Electron you can use the most modern web technologies to develop desktop GUI applications.

An interesting factor is that because Electron is based on Chromium, the Chrome Developer Tools are built in. Which means an Electron application developer has at their fingertips excellent web application debugging and inspection tools that have already been battle-tested by front end engineers around the world.

Electron’s architecture has two sets of processes. The Main process is the aforementioned Node.js application managing the rest of the application. The Renderer processes are the aforementioned BrowserWindow instances. Each is a top-level window containing a UI specified through HTML+CSS+JavaScript code. The processes communicate with each other through an interprocess communication channel.

While Electron uses Chromium, the security model is extremely different from regular browser JavaScript. Code executing in an Electron BrowserWindow can access Node.js modules, and can access the file system. There are some security warnings at: https://electronjs.org/docs/tutorial/security It is extremely important to read the security warnings.

To explore Electron, we will build a Markdown editor with live preview of generated HTML, using an HTML template of your choice. The UI will be built with a combination of Vue.js, Bulma/Buefy, and the ACE editor component.

The result might be an application like this:

The goal

On the left is the ACE editor component with a Markdown test file, and on the right is a preview of that Markdown rendered to HTML. This is the core of the application. Of course a completed application will require a toolbar, menu choices, and a few other things. We’ll go over enough of an application to provide a useful example.

The source code accompanying this article is available on GitHub to aid reading along: https://github.com/sourcerer-io/electron-vue-buefy-editor

Because Electron is based on Node.js and JavaScript, you will need to be familiar with both. There’s a whole universe of things one can create using Node.js. It is a code development platform for running JavaScript outside the browser, primarily in server environments. Books like Node.js Web Development can get you started with Node.js. Electron takes Node.js into the realm of desktop application development.

Building the editor scaffolding

With all these pieces to assemble, there is a lot of potential complexity that could make application development hard. Thanks to respective teams providing useful application starter code, the task is nowhere near as difficult as it might be.

The Electron framework already bundles some complexity into an easy-to-use package. For example, the Electron documentation (https://electronjs.org/docs/tutorial/first-app) contains a recipe for launching a simple demo application. It’s useful to follow those instructions and study the resulting application. But, we need something else as the starting point.

Since the goal is building this application with Vue.js, we need a starting point which works well with that toolkit. Vue.js applications are normally built for websites, so we need something a little different.

In this case we want to run Vue.js code in Electron. Since Electron UI’s are created using web technologies, it can of course support Vue.js. The Electron-Vue framework provides everything we need, a build and packaging system supporting use of Vue.js in an Electron app. https://simulatedgreg.gitbooks.io/electron-vue/

First, install the Vue command line tool:

$ sudo npm install -g vue-cli

The primary capability provided by vue-cli is to download application templates and set up starter applications. While it has a handful of built-in templates, it can be used to download 3rd party templates including Electron-Vue.

$ vue init simulatedgreg/electron-vue electron-vue-buefy-editor

? Project description An electron-vue project

? Select which Vue plugins to install vue-electron

? Use linting with ESLint? No

? Set up unit testing with Karma + Mocha? No

? Set up end-to-end testing with Spectron + Mocha? No

? What build tool would you like to use? builder

? author David Herron < ? Application Name electron-vue-buefy-editor? Project description An electron-vue project? Select which Vue plugins to install vue-electron? Use linting with ESLint? No? Set up unit testing with Karma + Mocha? No? Set up end-to-end testing with Spectron + Mocha? No? What build tool would you like to use? builder? author David Herron < david@davidherron.com > vue-cli · Generated “electron-vue-buefy-editor”. — - All set. Welcome to your new electron-vue project! Make sure to check out the documentation for this boilerplate at

https://simulatedgreg.gitbooks.io/electron-vue/content/. Next Steps: $ cd electron-vue-buefy-editor

$ yarn (or `npm install`)

$ yarn run dev (or `npm run dev`) You have a number of alternatives for the initial project setup. In this example, we only installed vue-electron, did not install ESLint or unit testing support, and specified using electron-builder to package the application. You may prefer a different setup, so answer the questions as you wish.

You can follow those instructions and see the base Electron-Vue application.

The demo Electron-Vue application

Selecting UI toolkit components for use with Vue.js

Vue.js is a framework for implementing components that run in the web browser. Vue.js applications are built using these components and in theory one could build the UI entirely with Vue.js plus custom-developed CSS and JavaScript. But, again, there are several open source projects offering prebaked UI components with responsive web best practices built-in.

Since Bootstrap is so popular, the first thought of many is to use Bootstrap. While it’s easy to integrate Bootstrap into a Vue.js app, you quickly run into a problem. Vue.js and jQuery are extremely incompatible, and it is strongly recommended to not use Bootstrap or jQuery with Vue.js. The problem is that Vue.js expects to have strict control over DOM manipulations, making it impossible to do DOM manipulations with other technology such as jQuery. There are multiple projects attempting to integrate Bootstrap code as Vue.js components, but none are supporting Bootstrap 4.

A widely recommended alternative is the Bulma toolkit. Can’t say the name is all that attractive, but the website, https://bulma.io/ makes a good case for Bulma as a worthy UI framework. There are 100,000+ developers using Bulma, and it is a CSS-only toolkit making it lightweight and easy to integrate with Vue.js. The Buefy component library integrates Bulma with Vue.js: https://buefy.github.io/#/ We will use Buefy in the application.

Buefy can use the Material Design Icon set at https://materialdesignicons.com/ Those icons are available as a Node.js module at https://www.npmjs.com/package/vue-material-design-icons

The last UI choice to make is a component for editing Markdown. While we could just have the user write their Markdown in a regular TextArea component, we can do better. For example, the Ace editor component offers a very competent editing experience for all kinds of coding languages like JavaScript or C++ or HTML. We might want the editor down the road to support editing HTML templates, CSS files or many other assets, and the Ace editor component can handle all that. Ace is documented on its website: https://ace.c9.io/ The Vue2 Ace Editor component packages Ace for use in Vue.js: https://github.com/chairuosen/vue2-ace-editor

Adding Buefy and ACE to the Electron/Vue.js app

We have already initialized a blank Vue.js app, and have selected the UI framework to use. Let’s start by integrating these components with the blank app.

The directory structure includes this:

The directory src/main contains code for the Main process, while src/renderer contains code for the Renderer processes. You’ll see the latter is where the Vue components are stored.

First is to install the package dependencies:

$ npm install buefy vue2-ace-editor vue-material-design-icons --save

This installs the Vue.js components described earlier, plus their dependencies such as the Bulma framework and the Ace editor component.

Edit index.ejs , which is the layout template provided by Electron-Vue to match this code:

<html style=”height: 100%;”>

<head>

<meta charset=”utf-8">

<meta name=”viewport” content=”width=device-width, initial-scale=1">

<title>electron-vue-buefy-editor</title>

..

</head>

<body style=”height: 100%;”>..</body>

</html>

The modification to the <html> and <body> tags is required so the application fills the entire height of the window.

Next we bring Buefy into the app, by changing the front section of src/renderer/main.js to this:

import Vue from ‘vue’;

import App from ‘./App’;

import router from ‘./router’;

import Buefy from ‘buefy’;

import ‘buefy/lib/buefy.css’;

import util from ‘util’; import { ipcRenderer } from ‘electron’; Vue.use(Buefy);

We’ll be making more changes to this file as we go. This part adds the Buefy components to Vue.js.

Adding Buefy this way follows a best practice of Electron applications. According to the Security page in the Electron documentation (https://electronjs.org/docs/tutorial/security), it is extremely important, as in extremely important, to not load code from a 3rd party web service. Read the Security page for more details. Really. That’s not an idle suggestion, go read it.

The ipcRenderer object is used for communication from the Main process to Renderer processes.

At this point you can do npm run dev — Running the app in development mode — and see that nothing has changed. We’ve laid the groundwork for something interesting, however.

Implementing the editor application

Next, delete every file under src/renderer/components . Those files comprise the demo screen, which we do not need in the application.

Change App.vue to this:

<template>

<div id=”app”>

<editor-page></editor-page>

</div>

</template> <script>

import EditorPage from ‘@/components/EditorPage’

export default {

name: ‘electron-vue-buefy-editor’,

components: {

EditorPage

}

}

</script> <style>

/* CSS */

#app {

height: 100%;

}

</style>

The first change is to use a component named EditorPage for the main part of the application. Hang on for the next paragraph and we’ll create that component. The second change is to ensure the editor fills the vertical height of the window.

Vue.js calls this a single file template. There is another method of implementing Vue components which is JavaScript code that can reference other files including external templates or CSS files. Single file templates combine it all in one file, as the name implies.

In src/renderer/components create EditorPage.vue , which will define the EditorPage component, and start with the <template> and <style> sections:

<template>

<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-iframe

id=”previewor”

class=”column”

ref=”previewor”></preview-iframe>

</div>

</div>

</template> <style scoped>

#wrapper {

height: 100%;

} #editor {

/* margin: 4px; */

height: 100%;

} #previewor {

margin-left: 2px;

height: 100%;

}

</style>

This sets up a two-column user interface, one containing an editor component, the other containing a preview-iframe . These two components implement the user interface shown earlier, and each are what Vue.js calls a custom component. When the application runs, Vue.js will interpolate each to the actual HTML.

Everything within the <template> shown here will be within the <App/> tag in App.vue , and therefore will be what’s shown as the application. In the <style> section we are again specifying a height of 100% , to ensure the components fill the entire vertical space available.

The scoped tag arranges the CSS in this component to reference the HTML generated by this component.

The vue2-ace-editor module gives us the <editor> tag. We implement this by adding a <script> section to EditorPage.vue .

<script>

import PreviewIframe from ‘./PreviewIframe.vue’;

import { messageBus } from ‘../main.js’;

import fs from ‘fs-extra’; export default {

data: function() {

return {

input: ‘# hello’,

isNewFile: true,

isChangedFile: false,

fileName: “”,

layoutFileName: “”

};

},

components: {

editor: require(‘vue2-ace-editor’),

previewIframe: PreviewIframe

},

watch: {

input: function(newContent, oldContent) {

messageBus.newContentToRender(newContent);

this.isChangedFile = true;

}

},

computed: {

editor() { return this.$refs.aceeditor; },

previewor() { return this.$refs.previewor; }

},

methods: {

editorInit(editor) {

require(‘brace/ext/language_tools’);

require(‘brace/mode/html’);

require(‘brace/mode/markdown’);

require(‘brace/theme/twilight’);

editor.setWrapBehavioursEnabled(true);

editor.setShowInvisibles(true);

editor.setShowFoldWidgets(true);

editor.setShowPrintMargin(true);

editor.getSession().setUseWrapMode(true);

editor.getSession().setUseSoftTabs(true);

messageBus.newContentToRender(this.input);

},

}

}

</script>

In Vue.js, a Vue instance is instantiated using an anonymous object like this. In a single file template like this, the Vue instance object is described using the default export as shown here.

The components field lists the components used by this component. The tag for each entry in the object becomes the tag name in the template. Hence “ editor ” becomes the “ <editor> ” tag, while “ previewIframe ” becomes the “ <preview-iframe> ” tag in the template. We will define the PreviewIframe component later, so sit tight.

The data field lists the data used in implementing this component. Managing data and notifying listeners of changes to that managed data is one of the core features of Vue.js. The structure shown here is not the actual data which is managed, but is instead an input to Vue.js as it constructs the Vue instance. Behind the scenes Vue.js sets up watcher methods and notification methods, so that if a managed data item is changed notification events are sent out and the user interface can be updated. For details see: https://vuejs.org/v2/guide/components.html

The input field contains the Markdown text. The isNewFile flag indicates whether the content has been created using the New menubar option — we’ll get to the menu bar later — while isChangedFile indicates whether changes have been made. The fileName field records the file name associated with this content, if any. The layoutFileName option records the layout template to use.

The v-model attribute on the editor component ensures the content being edited is shadowed into the input item listed in the data .

The messageBus is something we’ll define shortly, and is a mechanism we’ll use to send messages between components. Vue.js has a mechanism for a component to send a message to its parent. What if the application needs to send a message to a component that is not the parent component? We’ll use the messageBus for that purpose.

The computed field is an object of functions to derive values. Other code in the component will then be able to reference this.computedValue to retrieve the derived values. See this for details: https://vuejs.org/v2/guide/computed.html

In this case the application needs to reference the two components. In each we added a ref=”identifier” attribute to aid in identifying the components. The this.$refs object is populated with references to components based on the value of each ref=”identifier” . Therefore these two computed functions give us a convenient shorthand to access the child components.

The @init attribute on the editor component causes a message to be emitted by vue2-ace-editor when it is initialized. We use it here to initialize look & feel settings of the Ace editor. The lang , theme , width and height attributes are used for the same purpose. A very important thing, since we’ll be writing Markdown text, is to set up word wrapping.

The watch field lets us define handler functions that are called when data variables change. In this case the application must know when the content changes, so that this fact can be recorded, and so that the application can re-render the previewed HTML.

There will be a lot more to implement in the EditorPage component. For now, though, let’s go for a quick win by connecting up the preview pane.

PreviewIFrame Component

To preview the HTML corresponding to the Markdown text we’ll use an <iframe> along with a built-in HTTP server. The reasoning is that we want to support rendering the Markdown into any layout template. Displaying the rendered HTML in an <iframe> will give the most accurate representation of the HTML.

In our first attempt to preview the HTML, we simply inserted the HTML into a <div> component. But the presentation was affected by the CSS of the application and there were many oddities such as lists with no bullets. By using an <iframe> we have a blank slate, CSS wise, that correctly shows the rendered HTML.

Create a file named src/renderer/components/PreviewIframe.vue containing:

<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) => {

this.reload(url2preview);

});

}

}

</script> <style scoped>

iframe { height: 100%; }

</style>

The template is simply an <iframe> tag. In the created function we set up a listener on the newContentToPreview event from messageBus , which then calls reload , which sets the src attribute of the iframe to the supplied URL.

This approach is not like typical Vue.js component practice. One would normally add previewSrcURL to the props array, to manage it as a property that can be set by other code.

In this application that normal practice will not work. Our user will be typing away in the editor, and on every keystroke the value of input will be changed. We want these changes to propagate to a rendering function, and then to propagate to this component where the iframe will reload.

One makes an iframe reload by assigning a URL to the src attribute. Therefore we want to reliably update the src attribute for every re-render of the Markdown. Since Vue.js does not trigger any update notification to property values if there is no change in the value, we instead use this reload function.

The messageBus

We’ve seen the messageBus mentioned several times already. It is the key for sending data between components in the application.

The messageBus is simply a Vue instance. Vue instances already provide an event subscribing and distributing mechanism, plus other attributes like methods and data management. We can use this as the means for cross-component data exchange.

Return to src/renderer/main.js and add this code

export const messageBus = new Vue({

methods: {

newContentToRender(newContent) {

ipcRenderer.send(‘newContentToRender’, newContent);

},

}

}); ipcRenderer.on(‘newContentToPreview’, (event, url2preview) => {

messageBus.$emit(‘newContentToPreview’, url2preview);

}); ipcRenderer.on(‘newFile2Edit’, (event) => {

messageBus.$emit(‘newFile2Edit’);

}); ipcRenderer.on(‘editorDoUndo’, (event) => {

messageBus.$emit(‘editorDoUndo’);

}); ipcRenderer.on(‘editorDoRedo’, (event) => {

messageBus.$emit(‘editorDoRedo’);

}); ipcRenderer.on(‘editorSelectAll’, (event) => {

messageBus.$emit(‘editorSelectAll’);

}); ipcRenderer.on(‘openNewFile’, (event, file2open) => {

messageBus.$emit(‘openNewFile’, file2open);

}); ipcRenderer.on(‘saveCurrentFile’, (event) => {

messageBus.$emit(‘saveCurrentFile’);

});

There are many more events here than we need at the moment, we’ll make use of these as we go along. Calling new Vue like this creates a new Vue instance. The messageBus Vue instance is exported from this module, and you will have noticed it is imported into EditorPage and PreviewIframe .

What we’re looking at currently is newContentToRender because it is sent by EditorPage . This calls ipcRenderer.send , which sends a message over to the Main process.

We see a number of instances of ipcRenderer.on . This is where we receive messages from the Main process. In each case a corresponding event is sent on the messageBus , using messageBus.$emit to send the message.

The newContentToRender Vue instance method is used to send a corresponding message to the Main process. It will be sent when the Renderer process has, as the name implies, new content which must be rendered.

Another message, newContentToPreview , is received from the Main process when the content is rendered. The PreviewIframe component listens to this event and updates the <iframe> to view the URL it is given.

The Main process must therefore contain code to render the Markdown arriving in the newContentToRender message, make the rendered HTML available on an HTTP URL, and send a newContentToPreview message to the Renderer process with the corresponding URL. As they say, Quite Easily Done.

The Preview Server

As already described we need a simple HTTP server from which to serve up the rendered HTML.

You may be scratching your head and wondering what’s going on. We have an <iframe> requesting rendered HTML over an HTTP connection to an HTTP server in the same process. Have we gone bonkers?

The requirement is to supply HTML to the <iframe> using HTTP. Therefore we need an HTTP server somewhere, and it might as well be running in the Main process. It’ll be a lot easier to manage the HTTP server inside the same process, than by spinning up and managing a child containing the HTTP server. Node.js makes it easy to build simple HTTP servers of this sort.

With that in mind, create a file named src/main/preview-server.js containing this:

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);

}

}

The module is initialized by calling createServer , which sets up an HTTPServer object. Instead of supplying a port number, one is assigned for us so we do not have to worry about the port number conflicting with any existing port on the computer. Hence, the genurl function consults the server object to find out the IP address and port number. Another detail is that we force it to listen only on IP address 127.0.0.1 , to minimize the chance miscreants could sneak into the application. One could take another step and generate randomized tokens that are added to the URL, and refuse connection requests that lack a valid token.

Whenever rendered content is available, we are to be notified using the newContent function. The content is stored in a global variable. This function returns a URL that can be used to fetch the content. Notice that the URL is hard-coded.

The currentContent function does what the name suggests, and returns the currently rendered content. We’ll use this to implement the Export to HTML feature.

It doesn’t matter what URL is used, since the requestHandler function makes the same response, the rendered content, for any requested URL.

Rendering Markdown to HTML

Now that we have the preview server, let’s look at how to render Markdown to HTML.

There are many Markdown renderer libraries for Node.js. In this project we will use Markdown-it because it is fairly popular and it supports CommonMark. It also supports a long list of plugins adding extra abilities such as footnotes. Out of the box it supports Github-like tables. See https://www.npmjs.com/package/markdown-it

Create a file named src/main/renderer.js containing:

import mdit from ‘markdown-it’;

import ejs from ‘ejs’; const mditConfig = {

html: true, xhtmlOut: true,

breaks: false, linkify: true,

typographer: false,



highlight: function (/*str, , lang*/) { return ‘’; }

};

const md = mdit(mditConfig); const layouts = []; export function renderContent(content, layoutFile) {

const text = md.render(content);

const layout = layouts[layoutFile];

const rendered = ejs.render(layout, {

title: ‘Page Title’,

content: text

});

return rendered;

} layouts[‘layout1.html’] = `

<html>

<head>

<title><%= title %></title>

<style>

H1, h2, h3, h4 { color: red; }

pre {

background: rgb(59, 58, 58);

color: white;

}

</style>

</head>

<body><%- content %></body>

</html>`;

Markdown-it takes a configuration object with a number of options. That’s what we do at the top of the module.

The renderContent function takes care not only of rendering the Markdown to HTML, but then rendering that HTML with a template. The concept is to potentially support many templates, which would be stored as files in the file system. For now we can use this one, and keep it in memory.

The Main process

Now we can bring this all together by modifying the Main process to setting up the message reception, and dispatch to renderer and HTTP server modules. In src/main/main.js start making changes

import {

app, BrowserWindow, ipcMain, dialog

} from ‘electron’;

import { renderContent } from ‘./renderer.js’;

import { createServer, newContent } from ‘./preview-server.js’;

This brings in necessary functions from Electron and the two modules we just implemented.

function createWindow () {

mainWindow = new BrowserWindow({

height: 563, useContentSize: true,

width: 1000, webPreferences: { backgroundThrottling: false }

});

mainWindow.loadURL(winURL);

if (process.env.NODE_ENV === ‘development’) {

mainWindow.webContents.openDevTools();

}

createServer(); mainWindow.on(‘closed’, () => {

mainWindow = null

});

}

The createWindow function is where we, as the name implies, create the main window. This function is automatically called by app.on(‘ready’) and app.on(‘activate’) handlers when the Electron process is started.

The BrowserWindow object is what wraps a Chromium window in Electron. It is for all intents and purposes a web browser, that we’ll be using to host our application code.

The next two lines demonstrate that this is a web browser. We first cause the BrowserWindow to visit a URL. That URL is calculated depending on whether or not the application is running in development mode. In development mode it uses a URL corresponding to a Webpack service, while otherwise it refers to a location in the deployed application.

Secondly we automatically open the Chrome developer tools. Remember this is a huge advantage that Electron gives us, because we have access to a world class developer toolbench with which to inspect the HTML+CSS+JavaScript code of the application. This is disabled when running in development mode.

Lastly we call createServer to initialize the preview server.

Finally, at the bottom add these two functions.

ipcMain.on(‘newContentToRender’, function(event, content) {

const rendered = renderContent(content, ‘layout1.html’);

const previewURL = newContent(rendered);

mainWindow.webContents.send(‘newContentToPreview’, previewURL);

}); process.on(‘unhandledRejection’, (reason, p) => {

console.error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);

});

The newContentToRender event handler is where we render Markdown into HTML, and show it in the PreviewIframe . We call renderContent to render the text, then inform the preview server by calling its newContent function, and lastly sending the preview URL over to the Renderer process. If you refer back, you’ll recall that the newContentToPreview message is handled by the PreviewIframe component, and causes the <iframe> to reload on the given URL.

The unhandledRejection handler is important, and in a later release of Node.js will become required. This event is issued for Promises in the reject state but no code catches that rejected Promise. In other words, an unhandledRejection is an uncaught error. It’s obviously bad to not catch your errors, and Node.js is slated to enforce this in a future release by causing the application to exit on this event. Therefore we must all get accustomed to adding this handler so we can be warned of our uncaught errors.

By the way, every place where the code issues console.error it should additionally be putting up a visible warning message in the application.

Launching the application

We now have enough code to be able to run the editor application. It won’t be able to open files, or save files, but we will be able to use the editor, view the preview output, and use the developer tools.

First run this:

$ npm install buefy ejs markdown-it — save

$ npm install

We’ve added several packages, so we need to add them to the package.json and then install everything afresh.

$ npm run dev

This runs the application in developer mode. If you inspect the generated package.json you’ll see many other scripts that can be run, and we’ll get into those later. In any case, after doing a little editing we can end up with a window looking like this:

That’s quite a bit of progress, but we have some glaringly missing functionality. For starters:

The system menu is a default provided by Electron

The application might be improved by adding toolbar buttons at the top.

It needs to save the rendered HTML.

Generate an installable application.

Let’s handle those tasks in the subsequent sections.

Application menus

Electron supplies a very simple mechanism to specify the system menu, that even correctly integrates correctly with the differing Windows and Mac menu paradigms. One creates a menuTemplate object to hand to Electron, which then installs the menu items.

In src/main create a new file named mainMenu.js containing:

import { app, Menu, dialog } from ‘electron’;

import { currentContent } from ‘./preview-server.js’;

import fs from ‘fs-extra’; export function mainMenu(mainWindow) {

const menuTemplate = [ {

label: ‘File’,

submenu: [

{

label: ‘New’,

accelerator:

process.platform === ‘darwin’ ? ‘Command+N’ : ‘Ctrl+N’,

click: () => { mainWindow.webContents.send(‘newFile2Edit’); }

},

{

label: ‘Open’,

accelerator:

process.platform === ‘darwin’ ? ‘Command+O’ : ‘Ctrl+O’,

click: () => { mainWindow.webContents.send(‘openNewFile’); }

},

{

label: ‘Save’,

accelerator:

process.platform === ‘darwin’ ? ‘Command+S’ : ‘Ctrl+S’,

click: () => { mainWindow.webContents.send(‘saveCurrentFile’); }

},

{

label: ‘Export to HTML’,

click: async () => {

let filename = await new Promise((resolve, reject) => {

dialog.showSaveDialog({

title: “Export to HTML”

}, filename => {

console.log(

`Export to HTML GOT SAVE TO ${filename}`);

if (filename) {

resolve(filename);

} else {

resolve(undefined);

}

});

});

if (filename) {

await fs.writeFile(filename,

currentContent(), ‘utf8’);

}

}

},

{

label: ‘Quit’,

accelerator:

process.platform === ‘darwin’ ? ‘Command+Q’ : ‘Ctrl+Q’,

click: () => { app.quit(); }

}

]

},

{

label: ‘Edit’,

submenu: [ {

label: ‘Undo’,

accelerator: process.platform === ‘darwin’

? ‘Command+Z’ : ‘Ctrl+Z’,

click: () => {

mainWindow.webContents.send(‘editorDoUndo’); }

},

{

label: ‘Redo’,

accelerator: process.platform === ‘darwin’

? ‘Command+Shift+Z’ : ‘Ctrl+Shift+Z’,

click: () => {

mainWindow.webContents.send(‘editorDoRedo’); }

},

{type: ‘separator’},

{role: ‘cut’},

{role: ‘copy’},

{role: ‘paste’},

{role: ‘pasteandmatchstyle’},

{role: ‘delete’},

{

label: ‘Select All’,

accelerator: process.platform === ‘darwin’

? ‘Command+A’ : ‘Ctrl+A’,

click: () => {

mainWindow.webContents.send(‘editorSelectAll’); }

}

]

},

{

label: ‘View’,

submenu: [

{role: ‘reload’},

{role: ‘forcereload’},

{role: ‘toggledevtools’},

{type: ‘separator’},

{role: ‘resetzoom’},

{role: ‘zoomin’},

{role: ‘zoomout’},

{type: ‘separator’},

{role: ‘togglefullscreen’}

]

},

{

role: ‘window’,

submenu: [

{role: ‘minimize’},

{role: ‘close’}

]

} ];



if (process.platform === ‘darwin’) {

menuTemplate.unshift({

label: app.getName(),

submenu: [

{role: ‘about’},

{type: ‘separator’},

{role: ‘services’, submenu: []},

{type: ‘separator’},

{role: ‘hide’},

{role: ‘hideothers’},

{role: ‘unhide’},

{type: ‘separator’},

{

role: ‘quit’,

accelerator: process.platform === ‘darwin’

? ‘Command+Q’ : ‘Ctrl+Q’

}

]

});

} const mainMenu = Menu.buildFromTemplate(menuTemplate);

Menu.setApplicationMenu(mainMenu);

};

These are fairly typical menu choices. In many cases the menu item handler sends an event from the Main process to the Renderer process. This requires implementing matching event handlers in that process. In other cases the menu item uses a built-in handler.

The exception is the Export to HTML feature. This feature was simple enough to implement inline like this. We simply use an Electron Save As dialog to ask the user for a file name (see https://electronjs.org/docs/api/dialog). If the user hits CANCEL, then we get undefined as the filename , and otherwise we are given a file name, in which case we simply write the currentContent to the named file.

In src/main/index.js add this line to the createWindow function:

mainMenu(mainWindow);

That way the menu gets setup when the application runs.

To handle all those events add this created function in src/renderer/components/EditorPage.vue :

created: function() {

messageBus.$on(‘newFile2Edit’, (targetWindow) => {

this.newFile2Edit(targetWindow);

});

messageBus.$on(‘editorDoUndo’, () => {

this.editor.editor.undo();

});

messageBus.$on(‘editorDoRedo’, () => {

this.editor.editor.redo();

});

messageBus.$on(‘editorSelectAll’, () => {

this.editor.editor.selectAll();

});

messageBus.$on(‘openNewFile’, async (file2open) => {

try { this.openNewFile(); } catch (err) {

console.error(`openNewFile ERROR ${file2open} ${err.stack}`);

}

});

messageBus.$on(‘saveCurrentFile’, () => {

try { this.saveCurrentFile(); } catch (err) {

console.error(

`saveCurrentFile ERROR ${file2open} ${err.stack}`);

}

});

},

For each we receive the message, routing it to an appropriate place, such as some methods that we now have to add to EditorPage.vue . In the methods object, add these functions:

editorChanged(input) { this.isChangedFile = true; },

askSaveFile(file2save) {

return new Promise((resolve, reject) => {

this.$dialog.confirm({

title: `Save File?`,

message: `${file2save}`,

cancelText: ‘No’,

confirmText: ‘Yes’,

onCancel: () => { resolve(“cancel”); },

onConfirm: () => { resolve(“confirm”); }

})

});

},

async saveContentToFile(file2save) {

return await fs.writeFile(file2save, this.input, ‘utf8’);

},

saveAsGetFileName() {

const remote = this.$electron.remote;

const dialog = remote.dialog;

return new Promise((resolve, reject) => {

try {

dialog.showSaveDialog({

title: “Save”

}, filename => { resolve(filename); });

} catch (err) { reject(err); }

});

},

async openNewFile() {

if (this.isNewFile && this.isChangedFile) {

let doit = await this.askSaveFile(‘UNTITLED’);

if (doit === “confirm”) {

let fileName = await this.saveAsGetFileName();

try { await this.saveContentToFile(fileName); } catch (e) {

console.error(`openNewFile saveContentToFile FAIL because for ${fileName} ${e.stack}`);

}

}

} else if (this.isChangedFile) {

let doit = await this.askSaveFile(this.fileName);

}

let file2open = await new Promise((resolve, reject) => {

const remote = this.$electron.remote;

const dialog = remote.dialog;

dialog.showOpenDialog({

properties: [ ‘openFile’ ],

title: “Open document”,

filters: [ {

name: “Markdown Files”,

extensions: [ “md” ]

} ]

},

filePaths => {

if (filePaths) {

resolve(filePaths[0]);

} else resolve(undefined);

});

});

if (!file2open) return;

await new Promise((resolve, reject) => {

fs.readFile(file2open, ‘utf8’, (err, text) => {

if (err) reject(err);

else {

this.isNewFile = false;

this.isChangedFile = false;

this.fileName = file2open;

this.input = text;

resolve();

}

});

});

},

async saveCurrentFile() {

let p;

let fileName;

if (this.isNewFile && this.isChangedFile) {

fileName = await this.saveAsGetFileName();

if (!fileName) return;

} else if (this.isChangedFile) {

fileName = this.fileName;

} else return;

try { await this.saveContentToFile(fileName); } catch (e) {

console.error(`openNewFile saveContentToFile FAIL because for ${fileName} ${e.stack}`);

}

this.isNewFile = false;

this.isChangedFile = false;

this.fileName = fileName;

},

async newFile2Edit() {

let p;

if (this.isNewFile && this.isChangedFile) {

let doit = await this.askSaveFile(‘UNTITLED’);

if (doit === “confirm”) {

let fileName = await this.saveAsGetFileName();

try { await this.saveContentToFile(fileName); } catch (e) {

console.error(`openNewFile saveContentToFile FAIL because for ${fileName} ${e.stack}`);

}

}

} else if (this.isChangedFile) {

let doit = await this.askSaveFile(this.fileName);

if (doit === “confirm”) {

try { await this.saveContentToFile(fileName); } catch (e) {

console.error(`openNewFile saveContentToFile FAIL because for ${fileName} ${e.stack}`);

}

}

}

this.isNewFile = true;

this.isChangedFile = false;

this.fileName = undefined;

this.layoutFileName = undefined;

this.input = “# hello”;

}

In editorChanged we’re simply tracking if the content has changed. The flag maintained here is used all through the rest of the code.

With askSaveFile we have a convenience function used every time we need to ask whether to save the current file. This uses a Buefy dialog to ask the question. Another convenience function, saveContentToFile simply saves the content (in the input object) to the named file. And yet another, saveAsGetFileName , uses the Electron File Save dialog to get the file name for saving the content.

Between those two dialogs we’ve seen two ways to create dialogs. In one case we have Buefy dialogs, and in the other case we’re using Electron dialogs. Buefy does not supply file open or file save dialogs while Electron does. Normally Electron dialogs are started from the Main process, but here we are starting them from the Renderer process. Electron supports a remote object that supports accessing Main process resources, like dialogs, from the Renderer process.

In openNewFile we handle opening files. We have to consider whether the current edit buffer is a “new” file, meaning it has or hasn’t been associated with a file, whether the buffer has been modified. Each consideration causes a different set of dialogs to appear. In the first stage the code is determining whether to save the current edit buffer, and if so what file name to save it to. In the second stage it asks the user what file to open. If the user did select a file, that file is read and is assigned to the data objects.

By assigning values to the data objects a series of side effects are triggered, such as making the text appear in the editor, and then causing the preview to be generated into the <iframe> .

In saveCurrentFile we are simply concerned with saving the file. So far we are only supporting Save, not Save As. Therefore the only time we ask what file name to save to is when the editor has not been associated with a file. Otherwise the program simply saves the editor buffer to the associated file.

In newFile2Edit we are simply concerned with opening a new file. A consideration is whether the existing editor buffer has been changed, and what to do such as saving that buffer to a file. If the buffer is not associated with a file, then we must use the Save As dialog to request a file name.

Toolbar, Statusbar

Applications of this sort often have a toolbar with convenience buttons, and a status bar showing information. In this case we might want a few Markdown shortcuts, and we certainly want to show the current file name and editing status.

For the toolbar buttons we’ll be using the Material Design icons package we loaded earlier. To put it into use, add the following code to src/renderer/main.js :

import “vue-material-design-icons/styles.css”

import FilePlus from “vue-material-design-icons/file-plus.vue”

import ContentSave from “vue-material-design-icons/content-save.vue”

import FolderOpen from “vue-material-design-icons/folder-open.vue”

import ContentCut from “vue-material-design-icons/content-cut.vue”

import FormatBold from “vue-material-design-icons/format-bold.vue”

import FormatItalic from “vue-material-design-icons/format-italic.vue” Vue.component(“content-save”, ContentSave);

Vue.component(“file-plus”, FilePlus);

Vue.component(“folder-open”, FolderOpen);

Vue.component(“content-cut”, ContentCut);

Vue.component(“format-bold”, FormatBold);

Vue.component(“format-italic”, FormatItalic);

The vue-material-design-icons package implements one Vue component per icon. The icon names are as shown on https://materialdesignicons.com/. For each component you wish to use, import it and then call Vue.component with the imported object, and your desired tag name. For each a global Vue component is created that can be used anywhere in the application.

In src/renderer/components/EditorPage.vue change the <template> to add a row of buttons:

<div id=”wrapper”>

<b-tooltip label=”New file” position=”is-right”>

<button class=”button” @click=”newFile2Edit”>

<file-plus></file-plus>

</button>

</b-tooltip>

<b-tooltip label=”Save file” position=”is-right”>

<button class=”button” @click=”saveCurrentFile”>

<content-save></content-save>

</button>

</b-tooltip>

<b-tooltip label=”Open file” position=”is-bottom”>

<button class=”button” @click=”openNewFile”>

<folder-open></folder-open>

</button>

</b-tooltip>

<b-tooltip label=”Cut” position=”is-bottom”>

<button class=”button” @click=”editorContentCut”>

<content-cut></content-cut>

</button>

</b-tooltip>

<b-tooltip label=”Insert bold” position=”is-bottom”>

<button class=”button” @click=”editorFormatBold”>

<format-bold></format-bold>

</button>

</b-tooltip>

<b-tooltip label=”Insert italic” position=”is-bottom”>

<button class=”button” @click=”editorFormatItalic”>

<format-italic></format-italic>

</button>

</b-tooltip>

..

</div>

The rest of the template will stay the same, we are adding this row of buttons to the top of the page. Each has an on-click event handler and in most cases the handler calls functions which already exist. We do have a few new handler functions to implement.

We have also added tooltips to make the application a little friendlier. Because each of the buttons is simply using an icon, the user does need some words to go along with the icon imagery.

The three new event handler functions are:

editorContentCut() {

let selected = this.editor.editor.getSelection();

if (! selected.isEmpty()) {

let selectedRange = this.editor.editor.getSelectionRange();

this.editor.editor.getSession()

.getDocument().replace(selectedRange, ‘’);

}

this.$nextTick(() => { this.editor.editor.focus(); });

},

editorFormatBold() {

let selected = this.editor.editor.getSelection();

if (! selected.isEmpty()) {

let selectedRange = this.editor.editor.getSelectionRange();

let selectedText = this.editor.editor.getSession()

.getDocument().getTextRange(selectedRange);

this.editor.editor.getSession()

.getDocument()

.replace(selectedRange, `**${selectedText}**`);

} else {

this.editor.editor.insert(‘**BOLD**’);

}

this.$nextTick(() => { this.editor.editor.focus(); });

},

editorFormatItalic() {

let selected = this.editor.editor.getSelection();

if (! selected.isEmpty()) {

let selectedRange = this.editor.editor.getSelectionRange();

let selectedText = this.editor.editor.getSession()

.getDocument().getTextRange(selectedRange);

this.editor.editor.getSession()

.getDocument()

.replace(selectedRange, `_${selectedText}_`);

} else {

this.editor.editor.insert(‘_Italic_’);

}

this.$nextTick(() => { this.editor.editor.focus(); });

},

While we aren’t exactly implementing a complete set of toolbar buttons, we have demonstrated some useful patterns in this sort of application. With these three functions we show how to access and modify the content in the Ace editor component.

Another detail is calling the focus method. It was observed you might be entering text in the editor, then click a button with the mouse, and then you want to keep typing away. Since the focus is on the button the keyboard events triggers more button presses. By changing the focus, focus stays in the editor rather than transferring to the button.

Next we need to adjust the layout of the window to accommodate for the toolbar.

<style scoped>

#wrapper {

height: 100%;

min-height: 100%;

}

.button-bar {

margin-bottom: 0px !important;

height: 40px;

} .button-bar button,

.button-bar a.navbar-item {

padding: 0px;

} #editor {

position: absolute;

top: 40px;

bottom: 0px;

left: 0px;

right: 0px;

} #aceeditor {

height: 100%;

min-height: 100%;

} #previewor {

margin-left: 2px;

height: 100%;

min-height: 100%;

}

</style>

We have a little complexity introduced by the toolbar. Previously layout was accomplished by setting height:100%; on everything. But with the toolbar in place, the editor section went below the bottom of the window, and scrolling behavior was less than desirable.

After a lot of research looking for a more modern way to accomplish this layout, this old standby of static positioning was used. Several variants of the <nav> markup supported by Bulma was attempted. Instead, statically position the toolbar at the top of the window with a fixed height, and then statically position the editor area so its top matches the fixed height of the toolbar.

So far the application has progressed a little bit. We now have a functioning toolbar that can be built upon. Obviously we could easily add more buttons to the toolbar, but we have a higher priority to implement a status bar at the bottom.

Back in EditorPage.vue , add the following to the bottom of the template

<section class=”status-bar columns”>

<span class=”status-item column tag is-info”>{{ fileName }}</span>

<span class=”status-item column tag is-info”>

{{ input.length }} bytes</span>

<span class=”status-item column tag is-info”>

{{ isChangedFile ? “CHANGED” : “” }}</span>

<span class=”status-item column tag is-info”>

{{ isNewFile ? “NEW” : “” }}</span>

</section>

All we’ve done is to cause certain data values to be displayed. Vue.js will automatically update the display as the values change, no need to write any further code. We have used Bulma tag elements for useful coloring.

Then change the <style> section to the following:

<style scoped>

#wrapper { .. }

.button-bar { .. }

.button-bar button,

.button-bar a.navbar-item { .. }

#editor {

position: absolute;

top: 40px;

bottom: 30px;

left: 0px;

right: 0px;

margin: 0px;

}

#aceeditor { .. }

#previewor { .. }

.status-bar {

position: absolute;

height: 30px;

right: 0px;

left: 0px;

bottom: 0px;

margin: 0px;

}

.status-bar .status-item {

vertical-align: middle;

}

</style>

Continuing the same static layout approach, we’ve stuck the status-bar to the bottom of the window with a height of 30 pixels. That means the bottom of the editor must be 30 pixels above the bottom of the window.

We now have a status bar at the bottom of the screen.

Generating an installable application

All the commercial applications built with Electron are available as a neatly packaged file which is installed like any other application. How many folks using Postman know or care that it is built using Electron? Installing any of those applications requires nothing out of the ordinary — just download the installer, and run it as one does on the given platform.

The electron-vue framework lets us easily create such installation packages.

Look in the scripts section of package.json and you’ll see several with the name “ build ”. Because we configured Electron-Vue at the outset to use electron-builder .

To build the Mac package, run:

$ npm run build > electron-vue-buefy-editor@0.0.0 build /Volumes/Extra/sourcerer/004-electron/electron-vue-buefy-editor

> node .electron-vue/build.js && electron-builder

.. ✔ building main process

✔ building renderer process .. • building target=macOS zip arch=x64 file=build/electron-vue-buefy-editor-0.0.0-mac.zip

• building target=DMG arch=x64 file=build/electron-vue-buefy-editor-0.0.0.dmg

• building block map blockMapFile=build/electron-vue-buefy-editor-0.0.0.dmg.blockmap .. $ ls -l build/

total 196760

-rw-r — r — 1 david admin 412 Jul 13 14:15 electron-builder.yaml

-rw-r — r — 1 david admin 49188099 Jul 13 14:17 electron-vue-buefy-editor-0.0.0-mac.zip

-rw-r — r — @ 1 david admin 51490606 Jul 13 14:16 electron-vue-buefy-editor-0.0.0.dmg

-rw-r — r — 1 david admin 55383 Jul 13 14:16 electron-vue-buefy-editor-0.0.0.dmg.blockmap

drwxr-xr-x 5 david admin 170 Jul 13 13:55 icons

drwxr-xr-x 3 david admin 102 Jul 13 14:15 mac

Since it created a DMG file, let’s take a look. Double-clicking on the DMG file opens up a familiar Finder window that supports installing an application:

We can directly double-click the application here, and voila the application launches.

Electron supports cross-platform applications. We just proved we can build an app on MacOSX, but what about Windows?

Take the same source code to a Windows machine. Install Node.js, follow the installation instructions, follow the packaging instructions, and you end up with an EXE file which is an application installer. Double-click on that EXE, and the application is installed. It will show up in the Start menu, and you can launch the application. You can go into the Windows control panel, and find the application in the list of installed applications. The application will look the same as we showed earlier — except that the menu bar is at the top of the application window, just like the norm on Windows. Electron automatically takes care of many of these details.

Conclusion

We’ve come a long way in this tutorial, and developed a useful sample application. Along the way we’ve seen how easy it can be to put together a Vue.js application in Electron that can be delivered on multiple desktop computer environments.

Obviously these parts can be put together for any application you have in mind. It’s up to you, and Electron makes it possible to deliver a high fidelity application on multiple platforms with relative ease. If nothing else, the success of Electron-based apps like Atom, Visual Studio Code, Postman, and the others, demonstrate what is possible.