In the last tutorial the basic structure was created. We were able to read files from a directory, list their titles in the sidebar, and we were able to read their contents on the screen.
In this tutorial, we are going to add more interaction. To start with let's talk about the menu. Since we haven't specified a menu of our own, Electron gives us one by default, but inside ./main.js
we can create our own buttons and have them do what we need. Let's see an example.
const { app, BrowserWindow, Menu } = require('electron')
...
app.on('ready', function(){
devtools = new BrowserWindow()
window = new BrowserWindow({ x: 0, y: 0, width:800, height:600})
window.loadURL(path.join('file://', __dirname, 'static/index.html'))
window.setTitle('Texty')
Menu.setApplicationMenu(Menu.buildFromTemplate([
{
label: app.getName(),
submenu: [
{
label: `Hello`,
click: () => console.log("Hello world")
}
]
}
]))
})
We first require the Menu
component from Electron. Then we use it to create the menu for the application that's about to load. Above is just an example. As per usual, the first label simply opens the submenu. So for the label, we are using the app name, and then we're creating a Hello
button which consoles a message.
Let's expand that menu. However, since the object can be huge let's add the menu in a separate component.
// ./main.js
const menu = require('./components/Menu')
app.on('ready', function(){
window = new BrowserWindow({ x: 0, y: 0, width:800, height:600})
...
Menu.setApplicationMenu(menu(window))
})
That's how navigation can be split.
Let's create the ./components/Menu.js
file which would return a function.
const {app, Menu } = require('electron')
module.exports = function(win){
return Menu.buildFromTemplate([
{
label: app.getName(),
submenu: [
{ label: `Hello`, click: () => console.log("Hello world") }
]
},
{
label: 'Edit',
submenu: [
{label: 'Undo', role: 'undo' },
{label: 'Redo', role: 'redo' },
{label: 'Cut', role: 'cut' },
{label: 'Copy', role: 'copy' },
{label: 'Paste', role:'paste' },
]
},
{
label: 'Custom Menu',
submenu: [/* We'll add more actions */]
}
])
}
Electron gives us a set of roles which do the heavy lifting under the hood. Follow the link to see all the roles available.
From this point forward we are going to add all our navigation as a submenu of Custom Menu
- to keep it interesting!
Creating a new document
So far the state of our application is such that it reads files from the disc and displays the content. (The pitfalls on this approach are discussed at the end)
Let's add the functionality of adding new documents.
We start by adding a button to our navigation. So in ./components/Menu.js
add the following:
const { NEW_DOCUMENT_NEEDED } = require('../actions/types')
module.exports = function(window){
...
{
label: 'Custom Menu',
submenu: [
{
label: 'New',
accelerator: 'cmd+N',
click: () => {
window.webContents.send(NEW_DOCUMENT_NEEDED, 'Create new document')
}
}
]
That creates a New
button on the menu, accelerator
property is to give the button a shortcut. Then upon clicking the button, we are sending a message to the rendering part of the application!
Some tutorials I've read state that this is complicated to grasp, but think of redux, the only way to communicate with the store is via listening and dispatching messages. That's precisely the same here.
The ./main.js
deals with the back end. It gives us access to electron's modules (like the menu, access to the webcam if wanted and all sorts).
Everything in ./static/scripts/*.js
doesn't have access to the above features. This portion of the code is only concerned with manipulating the DOM. There's even a strong case against using this part of the code for any fs operations (more on that below).
Back in ./static/scripts/index.js
we would listen for NEW_DOCUMENT_NEEDED
.
const { ipcRenderer } = require('electron');
const { NEW_DOCUMENT_NEEDED } = require(path.resolve('actions/types'))
ipcRenderer.on(NEW_DOCUMENT_NEEDED, (event , data) => {
let form = document.getElementById('form')
form.classList.toggle('show')
document.getElementById('title_input').focus()
form.addEventListener('submit', function(e){
e.preventDefault()
// write file here ?
})
})
We listen for the NEW_DOCUMENT_NEEDED
transmission. When we hear it, we show a form (usual CSS class toggle).
Then when the form is submitted, we need to write a new file.
For this simple application, we would use fs.writeFile
just below // write file here ?
. However, if this were a big project, we would not want to do any file system operations on the rendering side. If the application is huge even ./main.js
wouldn't be able to handle the operation (apparently you'd need a new window which is beyond our scope). However, mainly to explore how it might be done, we'll let the ./main.js
write to system.
const { ipcRenderer } = require('electron');
const { WRITE_NEW_FILE_NEEDED } = require(path.resolve('actions/types'))
...
form.addEventListener('submit', function(e){
e.preventDefault()
// write file here ?
ipcRenderer.send(WRITE_NEW_FILE_NEEDED, {
dir: `./data/${fileName}.md`
})
})
Above we are sending an object to WRITE_NEW_FILE_NEEDED
channel (that channel name can be anything you like)
Heading over to ./main.js
we create the file and then send a message back:
ipcMain.on(WRITE_NEW_FILE_NEEDED, (event, {dir}) => {
fs.writeFile(dir, `Start editing ${dir}`, function(err){
if(err){ return console.log('error is writing new file') }
window.webContents.send(NEW_FILE_WRITTEN, `Start editing ${dir}`)
});
})
Exactly the same idea when WRITE_NEW_FILE_NEEDED
has been transmitted, get the dir
that's been sent through that channel, write the file on that directory and send back a message that the writing process has been completed.
Finally, back to ./statics/scripts/index.js
form.addEventListener('submit', function(e){
e.preventDefault()
let fileName = e.target[0].value
...
ipcRenderer.on(NEW_FILE_WRITTEN, function (event, message) {
handleNewFile(e, `./data/${fileName}.md`, message)
});
})
And that's that.
Of course, you should clone the repository to get the full picture. The handleNewFile
hides merely the form, handles click event for the time the app is open. And displays the content on the page.
const handleNewFile = function(form, dir, content){
let fileName =form.target[0].value
form.target.classList.remove('show')
let elChild = document.createElement('li')
elChild.innerText = fileName
readFileContentOnClick(dir, elChild) // read file on click
form.target[0].value = ''
form.target.parentNode.insertBefore(elChild,form.target.nextSibling);
document.getElementById('content').innerHTML = content;
}
The way I am getting my head around the communication between ipcRenderer and ipcMain is by thinking of the basics of redux. The way we communicate with a redux store is exactly the same.
Here's a diagram for the code we have so far
As you can see, this dance between the two processes is an overkill for what we're doing, but this kind of thing would have to happen in order not to block the UI. As I said, chances are even this wouldn't be enough in a bigger application. I think it's not a feature, it's a bug.
Saving changes
Finally, for this part of the series, we need to save changes.
Following the Mac pattern, I want a visual indication the file needs saving and for that indication to be removed after the file is saved. Starting in ./static/scripts/index.js
document.getElementById('content').onkeyup = e => {
if(!document.title.endsWith("*")){
document.title += ' *'
};
ipcRenderer.send(SAVE_NEEDED, { // alerting ./component/Menu.js
content: e.target.innerHTML,
fileDir
})
}
onkeyup
means that something has been typed, if that's the case add an asterisk to the title and then transmit SAVE_NEEDED
up to the main process. It will need the information that has been typed and the file directory that is being affected.
This time we aren't going to listen in ./main.js
but in ./components/Menu.js
(which of course is part of the same process).
let contentToSave = ''
ipcMain.on(SAVE_NEEDED, (event, content) => {
contentToSave = content
})
module.exports = function(window){
return Menu.buildFromTemplate([
...
{
label: 'Save',
click: () => {
if(contentToSave != ''){
fs.writeFile(contentToSave.fileDir, contentToSave.content, (err) => {
if (err) throw err;
window.webContents.send(SAVED, 'File Saved')
});
}
},
accelerator: 'cmd+S'
}
On SAVE_NEEDED
we get the content transmitted. Then every time Save
is selected we check for that content, and if it exists, we write to file. Then, once the file is written, we sent an alert to the render section, with the message File Saved
, where we deal with it in ./static/scripts/index.js
ipcRenderer.on(SAVED, (event , data) => { // when saved show notification on screen
el = document.createElement("p");
text = document.createTextNode(data);
el.appendChild(text)
el.setAttribute("id", "flash");
document.querySelector('body').prepend(el)
setTimeout(function() { // remove notification after 1 second
document.querySelector('body').removeChild(el);
document.title = document.title.slice(0,-1) // remove asterisk from title
}, 1000);
});
And the end result being:
That's it for today!
However, I feel I need to state the obvious. I intend to focus on the basics of Electron. Hence, as you've noticed, I did not focus at all on validation.
Few of many things we would need to do to get this to meet minimum standards for production:
- Checking whether a file already exists.
- Dealing with unsaved files when moving between them.
- Actually convert content to markdown.
- Store content using
innerText
rather thaninnerHTML
(as @simonhaisz pointed out in the last tutorial). - And many more things which might be even more important than the above.
However, none of those is electron specific hence I chose not to spend time on writing and explaining code which doesn't contribute to learning Electron.
There will be one more tutorial in this mini-series where we'll look at adding another window and working on user preferences.
Meanwhile, check out the project at github, branch: part2
Top comments (5)
Awesome read! Aurel. Is it possible to customize with CSS the title bar? Including the icons?
Thanks David.
The icons, yes you can add your own icon, I think you do that the moment you build the app.
As for the title bar, you can remove it completely - with Frameless Window, then add your own
I might do something like that in part three because it seems to be a pretty cool idea, though never tried it before.
Thank you, Go for it, and lastly if possible how to do a small loading screen like in Postman,Discord. Will be really greatful!
The loading screen isn't specific to Electron. You achieve it with the usual javascript.
I did a simple version in my Creating a movie website with GraphQL and React tutorial, where whilst waiting for the resources to load, show the word "loading" (I didn't bother to style it).
Since it's not specific to Electron I am not going to cover it here.
You can see the demo of the loading (all be it with no styles) here
I included a customized title part in the third part