Welcome to the last part of the series where we explore the basics of Electron by building a text editor. If you like to get a full picture of what we're doing here, be sure to read part one, and part two
A convention that we see in almost all the apps we use is the ability to allow users to make their editor their own. That's what we will do in this section. We'll let the users set the theme of the application. By the end we'll have introduced a new window which will look like this:
Custom header
Before we get into the meat of the tutorial lets, change the boring default header.
(It's all about making readers happy 🤪).
It's pretty simple. When we define the window (new BrowserWindow
), we can give it options for the frame. If we set the frame
to false, it would delete it- including the three buttons on the left-hand side. So we want to remove the default styling but not the buttons. At ./main.js
change the BrowserWindow
definition to include the relevant option:
window = new BrowserWindow({
width:800,
height:600,
titleBarStyle: 'hidden'
})
If we run the app we see the three buttons but no header, which means we would have to create our own. So in ./static/index.html
we would do something like this:
<header id="customtitle_wrap">
<h1 id="customtitle">Texty</h1>
</header>
In part two we added the ability for an asterisk to appear in the title tag to indicate when a file needs saving. Now, we need to add that functionality to #customtitle
rather than the title tag.
The CSS can now be whatever you'd like, however -webkit-app-region: drag
should be applied to #customtitle
so that it would be a handle from which to drag the window around the screen. Read the documentation for frameless windows to see all the options.
I grabbed a font from google fonts; however, in a real app, we would at least download the font so that users don't need to be connected to the internet.
Initializing the preferences window
Just as we've done on the first tutorial we need to load an HTML page into a new window. Let's create the page at ./static/preferences.html
:
<body>
<p class="notification">Here you're able to personalise the interface by picking the colors you'd like to see. The changes will be saved automatically upon window being closed</p>
<div class="container">
<div id="content" >
<form action="">
<button id="defaultValues">Reset</button>
<p><span>Background</span> <label for="background"></label> <span>
<input type="text" name="background" value="#FFFFFF"></span>
</p>
<p><span>Border Color</span> <label for="border-color"></label> <span>
<input type="text" name="border-color" value="#50C68A"></span>
</p>
<p><span>Text Color</span> <label for="text-color"></label> <span>
<input type="text" name="text-color" value="#232323"></span>
</p>
<p><span>Sidebar Background</span> <label for="sidebar-color"></label> <span>
<input type="text" name="sidebar-color" value="#2F3235"></span>
</p>
<p><span>Sidebar Text</span> <label for="sidebar-text"></label> <span>
<input type="text" name="sidebar-text" value="#939395"></span>
</p>
</form>
</div>
</div>
<script src="scripts/preferences.js"></script>
</body>
This page has to launch when a menu button gets clicked. Let's add that button at ./components/Menu.js
{
label: app.getName(),
submenu: [
{
label: 'Preferences',
accelerator: 'cmd+,', // shortcut
click: _ => {
const htmlPath = path.join('file://', __dirname, '../static/preferences.html')
let prefWindow = new BrowserWindow({ width: 500, height: 300, resizable: false })
prefWindow.loadURL(htmlPath)
prefWindow.show()
// on window closed
},
},
]
}
When Preferences
is selected the ./static/preferences.html
page loads in a new browser window. This time we are making sure users are unable to resize it.
With some CSS applied, we get this:
As specified in the HTML above, the default colours are hardcoded in the form. With Javascript, we want to apply those colour values as the background colour for the labels, and when users enter new colour values have them be reflected in the labels. We could have fun with colour pickers, but we'll keep it basic and assume users want to input their preferred colours. In which case we need to listen to input changes.
This functionality needs to go in ./static/scripts/preferences.js
.
Let's remember the HTML:
<p>
<span>Sidebar Text</span>
<label for="sidebar-text"></label> <span>
<input type="text" name="sidebar-text" value="#939395"></span>
</p>
Hence the javascript can be as simple as looping through the inputs and changing the labels:
var inputs = document.getElementsByTagName('input')
for(var i = 0 ; i < inputs.length; i++){
document.querySelector(`label[for="${inputs[i].name}"]`).style.backgroundColor = inputs[i].value
inputs[i].onkeyup = e => {
document.querySelector(`label[for="${e.target.name}"]`).style.backgroundColor = e.target.value
}
}
The code loops through each input element, apply their values as label background colours, then on input changes re-applies the colours.
Saving the colour preferences
The point of this window is that these colours persist when the application closes, so they have to be stored somewhere. Electron gives us a path to store user data. The documentation states that we access this through electron.app.getPath('userData')
The directory for storing your app's configuration files, which by default it is the appData directory appended with your app's name.
Within that folder, we want to store our colours as JSON. We do this using the same messaging from Render process to the Main process pattern as we've done in part two.
First, let's collect all the colours then send them to the Main process.
let preferences = {};
for(var i = 0 ; i < inputs.length; i++){
...
preferences[inputs[i].name] = inputs[i].value
inputs[i].onkeyup = e => {
preferences[e.target.name] = e.target.value
...
ipcRenderer.send(PREFERENCE_SAVE_DATA_NEEDED, preferences)
}
}
The preferences
object is populated with all the default colours. Then whenever one of the inputs changes, the corresponding object key is changed. Lastly, we send a PREFERENCE_SAVE_DATA_NEEDED
message to the Main process with the preferences
object as the message body.
At the top of ./components/Menu.js
we can listen for the message and collect its data
let inputs;
ipcMain.on(PREFERENCE_SAVE_DATA_NEEDED, (event, preferences) => {
inputs = preferences
})
Finally, for the menu, a pattern which I've seen in almost all the mac apps is that preferences are saved without needing a "save" button. We can do the same thing here by acting upon window getting closed.
In the Menu page, we can write the logic on window close
.
{
label: 'Preferences',
accelerator: 'cmd+,', // shortcut
click: _ => {
....
prefWindow.on('close', function () {
prefWindow = null
userDataPath = app.getPath('userData');
filePath = path.join(userDataPath, 'preferences.json')
inputs && fs.writeFileSync(filePath, JSON.stringify(inputs));
window.webContents.send(PREFERENCE_SAVED, inputs);
})
},
}
The userDataPath
is located at /Users/YourUserName/Library/Application Support/Electron/
and in there you'll find our preferences.json
which holds the colours.
When that's done, the PREFERENCE_SAVED
message is sent to the Render process of our original window
.
Now we need to read the colours from the preferences.json
file and apply them in the UI.
First, let's do it in the ./static/scripts/preferences.js
const fs = require('fs')
let userDataPath = remote.app.getPath('userData');
let filePath = path.join(userDataPath, 'preferences.json')
let usersStyles = JSON.parse( fs.readFileSync(filePath) )
for(let style in usersStyles) {
document.querySelector(`input[name="${style}"]`).value = usersStyles[style]
document.querySelector(`label[for="${style}"]`).style.backgroundColor = usersStyles[style]
}
The process is reversed there. We read the saved data from preferences.json
, loop through the colours and apply them as the input values and label background colours.
Reseting colors.
The reason why we'd want to hardcode the colours in the HTML form is so that we'd access them at any time with defaultValue
in javascript. We'll do so upon clicking the reset button:
<button id="defaultValues">Reset</button>
On click, loop through the input fields and apply the default values accordingly.
document.getElementById('defaultValues').addEventListener('click', function(e) { // reset
e.preventDefault();
preferences = {};
for(var i = 0 ; i < inputs.length; i++){
preferences[inputs[i].name] = inputs[i].defaultValue
document.querySelector(`label[for="${inputs[i].name}"]`).style.backgroundColor = inputs[i].defaultValue
inputs[i].value = inputs[i].defaultValue
}
ipcRenderer.send(PREFERENCE_SAVE_DATA_NEEDED, preferences)
} )
The above code does the following
- Resets the
preferences
object. - Re-populates the
preferences
object with inputdefaultValue
. - Changes the background colour of labels with input
defaultValue
. - Changes the input value with input
defaultValue
. - Sends a message to the Main process.
Applying the saved colour to the main window
Upon closing the preferences window, a message gets transmitted.
window.webContents.send(PREFERENCE_SAVED, inputs);
We can listen to it in the main window and use the content sent with the message.
Before doing so let's talk CSS.
The most important bit of CSS are the variables:
:root {
--background: #FFFFFF;
--border-color: #50C68A;
--text-color: #232323;
--sidebar-color: #2F3235;
--sidebar-text: #939395;
}
Whenever we change those variables with javascript, the appearance of every element where we applied those variables to would change.
We can do this at ./static/scripts/index.js
let userDataPath = remote.app.getPath('userData');
let filePath = path.join(userDataPath, 'preferences.json')
let usersStyles = JSON.parse( fs.readFileSync(filePath) )
for(let style in usersStyles) {
document.documentElement.style.setProperty(`--${style}`, usersStyles[style]);
}
ipcRenderer.on(PREFERENCE_SAVED, function (event, inputs) {
for(let style in inputs) {
document.documentElement.style.setProperty(`--${style}`, inputs[style]);
}
});
There you have it. Now every element that uses those variables will be changed automatically.
And the end result looks like this
You can clone the repository at GitHub
Conclusion
That's it for this series. As I tried to make it clear there are a lot of things missing. The code can definitely be refactored, there can be storage improvements, and error handling is none existent.
While I was working on this demo I thought about my current writing workflow which consists of node generated markdown pages hosted as GitLab wiki and I feel adding an Electron interface might make the process of note-taking slightly smoother. That might be my next personal project.
Hope you've been equally inspired 💡
Top comments (7)
Hello & Thanks;
This Tutorial is far too advanced for me , But I downloaded it and ran it anyways .
I get the following errors:
What I really need is a Tutorial on 'How to create a webBrowser using BrowserWindow & BrowserView' , Not 'webview' .
There are gazillions of tutorials on webview , but none on using BrowserView . Any one know of one or are willing to create such a Tutorial ?
Oops , turns out dev.to cant upload images , I'll try to OCR it :
A JavaScript error occurred in the main process
Uncaught Exception:
Error: Cannot find module ' electron'
Require stack:
C:\Electron-js\Texty Editor-Tutorial\Collect-All-Source\main.js
C:\Users\vmars\AppData\Roaming\npm\node modules\electron\dist\resources\def...\main.js
at Module. resolveFilename (internal/modules/cjs/loader.js:961:15)
at Function.o._resolveFilename (electron/js2c/browser_init.js:257:921)
at Module._load (internal/modules/cjs/loader.js:844:27)
at Function.Module._load (electron/js2c/asar.js:769:28)
at Module.require (internal/modules/cjs/loader.js: 1023: 1 9)
at require (internal/modules/cjs/helpers.js:77:18)
at Object.
(C:\Electron-js\Texty Editor-Tutorial\Collect-All-Source\main.js:2:47)
at Module. compile (internal/modules/cjs/loader.js:1145:30)
at Object.Module. extensions..js (internal/modules/cjs/loader.js:1166: 10)
at Module.load (internal/modules/cjs/loader.js:981 :32)
Hello & Thanks;
I have a working Electron webBrowser example app .
It works great but I need a webBrowser app example using BrowserWindow & BrowserView (Not webview) .
Would it be inappropriate for me to offer to pay someone 'convert my (actually Hokein's Example) working 'webview app into a BrowserView' ?
Thanks
Hi great tutorial. I cloned the repo and the first 2 parts of the tutorial works fine but with the last one I get the following window and quite don't understand why... I am completly new to npm (programming in c++ for several years) and I can't get any feedback on what is goind wrong.
Thanks for the help
How about using this:
microsoft.github.io/monaco-editor/
Well, yes, of course.
At some point, I want to convert the way I currently take notes into a react app and something like that would be ideal. Though I'm sure there are plenty of options. After learning those basics, now adding things to it is just a matter of preference.
Thank you.