DEV Community

aurel kurtula
aurel kurtula

Posted on • Updated on

Creating a text editor in Electron: part 2 - writing files

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")
                }
            ]
        }
    ]))

})
Enter fullscreen mode Exit fullscreen mode

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

})
Enter fullscreen mode Exit fullscreen mode

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 */]
        }

    ])    
}
Enter fullscreen mode Exit fullscreen mode

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')
            }
        }
    ]
Enter fullscreen mode Exit fullscreen mode

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 ?
    })
})
Enter fullscreen mode Exit fullscreen mode

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`
    })
})
Enter fullscreen mode Exit fullscreen mode

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}`)
    });
})
Enter fullscreen mode Exit fullscreen mode

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)
    });
})
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    })
}
Enter fullscreen mode Exit fullscreen mode

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'
        }
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 than innerHTML (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

Latest comments (5)

Collapse
 
davidamunga profile image
David Amunga

Awesome read! Aurel. Is it possible to customize with CSS the title bar? Including the icons?

Collapse
 
aurelkurtula profile image
aurel kurtula

I included a customized title part in the third part

Collapse
 
aurelkurtula profile image
aurel kurtula

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

Electron/webkit provides CSS properties that allow you to make any element draggable, like a titlebar - stackoverflow

I might do something like that in part three because it seems to be a pretty cool idea, though never tried it before.

Collapse
 
davidamunga profile image
David Amunga

Thank you, Go for it, and lastly if possible how to do a small loading screen like in Postman,Discord. Will be really greatful!

Thread Thread
 
aurelkurtula profile image
aurel kurtula

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