Right now, when Fire Sale starts up, it creates a single window for the UI. When that window is closed, the application quits. Although this behavior is perfectly acceptable, we typically expect to be able to open multiple, independent windows. In this chapter, we convert Fire Sale from a single-window application to one that supports multiple windows. Along the way, we’ll explore new Electron APIs as well as some of JavaScript’s more recent additions. We also explore solutions to problems that occur when taking a main process that is configured to communicate with one renderer process (see figure 5.1) and refactoring it to manage a variable number of processes (see figure 5.2). The completed code at the end of this chapter can be found at http://mng.bz/V145. We start from the chapter-4 branch, however.
We start by instantiating a Set data structure, which was added to JavaScript in 2015 and tracks all of the user’s windows. Next, we create a function that manages the lifecycle of an individual window. After that’s in place, we modify the functions that we created in chapter 4 for prompting the user to select a file and opening it to target the correct window. In addition, we also take care of some common edge cases and other quirks that arise along the way, such as windows that eclipse each other.
Sets are a new data structure to JavaScript and were added in the ES2015 specification. A set is a collection of unique elements; an array can have duplicate values in it. I chose to use a set rather than an array because it’s easier to remove an element. This listing shows how to create a Set in JavaScript.
const windows = new Set();
With an array, we’d have to either find the index of the window and remove it, or create an array without that window. Neither approach is as simple as calling the delete method on the set and passing it a reference to the window that we want to remove.
With a data structure in place to track all of the application’s windows, the next step is to move the process of creating a BrowserWindow (listing 5.2) out of the application’s “ready” event listener and into its own function.
const createWindow = exports.createWindow = () => { let newWindow = new BrowserWindow({ show: false }); newWindow.loadFile('index.html'); newWindow.once('ready-to-show', () => { newWindow.show(); }); newWindow.on('closed', () => { windows.delete(newWindow); 1 newWindow = null; }); windows.add(newWindow); 2 return newWindow; };
The createWindow() function creates a BrowserWindow instance and adds it to the set of windows that we created in listing 5.1. Next we repeat the steps for creating a new window from the previous chapters. Closing the window removes it from the set. Finally, we return a reference to the window that was just created. We need this reference in the next chapter.
When the application is ready, call the new createWindow() function, shown in the following listing. The application should start in the same manner as it did before we implemented this change, but it also sets the stage to create additional windows in other contexts.
app.on('ready', () => { createWindow(); });
The application starts as before, but if you try to click the Open File button, you’ll notice that it’s broken. This is because we’re still referencing mainWindow in a few places. It’s referenced in dialog.showOpenDialog() to display the dialog box as a sheet in macOS. More importantly, it is referenced in openFile() after the file’s contents have been read from the filesystem and we send it to the window.
Having multiple windows raises the question: to which window do we send the file path and contents? To support multiple windows, these two functions must reference the window where the dialog box should be displayed and the contents sent, as shown in figure 5.3.
In listing 5.4, let’s refactor the getFileFromUser() function to accept a given window as an argument instead of always assuming that there is a mainWindow instance in scope.
const getFileFromUser = exports.getFileFromUser = (targetWindow) => { 1 const files = dialog.showOpenDialog(targetWindow, { 2 properties: ['openFile'], filters: [ { name: 'Text Files', extensions: ['txt'] }, { name: 'Markdown Files', extensions: ['md', 'markdown'] } ] }); if (files) { openFile(targetWindow, files[0]); } 3 };
In the code excerpt, we’ve modified getFileFromUser() to take a reference to a window as an argument. I avoided naming the argument window because it might be confused with the global object in the browser. After the user has selected a file, we pass the targetWindow to openFile() in addition to the file path, shown here.
const openFile = exports.openFile = (targetWindow, file) => { 1 const content = fs.readFileSync(file).toString(); targetWindow.webContents.send('file-opened', file, content); 2 };
After the contents of the file have been read from the filesystem, we send the file’s path and content to the window passed in as the first argument. This raises the question, though: How do we get a reference to the window?
getFileFromUser() is called from the renderer process using the remote module to facilitate communication to the main process. As we saw in the previous chapter, the remote module contains references to all the modules that would otherwise be exclusively available to the main process. It turns out that remote also has a few other methods—notably, remote.getCurrentWindow(), which returns a reference to the BrowserWindow instance from which it was called, shown here.
const currentWindow = remote.getCurrentWindow();
Now that we have a reference to the window, the last step necessary to complete the feature is to pass it along to getFileFromUser(). This lets the functions in the main process know which—of our soon to be many—browser windows they’re working with.
openFileButton.addEventListener('click', () => { mainProcess.getFileFromUser(currentWindow); });
When we implemented the Markup for the UI in chapter 3, we included a New File button. We now have the createWindow() function implemented in and exported from the main process. We can quickly wire up that button as well.
newFileButton.addEventListener('click', () => { mainProcess.createWindow(); });
We can make a few more enhancements to our implementation of multiple windows in the main process, but we’re finished in the renderer process for this chapter. The current state of the code in app/renderer.js follows.
const { remote, ipcRenderer } = require('electron'); const mainProcess = remote.require('./main.js'); const currentWindow = remote.getCurrentWindow(); const marked = require('marked'); const markdownView = document.querySelector('#markdown'); const htmlView = document.querySelector('#html'); const newFileButton = document.querySelector('#new-file'); const openFileButton = document.querySelector('#open-file'); const saveMarkdownButton = document.querySelector('#save-markdown'); const revertButton = document.querySelector('#revert'); const saveHtmlButton = document.querySelector('#save-html'); const showFileButton = document.querySelector('#show-file'); const openInDefaultButton = document.querySelector('#open-in-default'); const renderMarkdownToHtml = (markdown) => { htmlView.innerHTML = marked(markdown, { sanitize: true }); }; markdownView.addEventListener('keyup', (event) => { const currentContent = event.target.value; renderMarkdownToHtml(currentContent); }); newFileButton.addEventListener('click', () => { mainProcess.createWindow(); }); openFileButton.addEventListener('click', () => { mainProcess.getFileFromUser(currentWindow); }); ipcRenderer.on('file-opened', (event, file, content) => { markdownView.value = content; renderMarkdownToHtml(content); });
When clicking the New File button after implementing the event listener in the previous chapter, you might have been confused whether it was working. You may have noticed that the drop shadow around the window got darker, or you may have clicked and dragged the new window and revealed the previous window underneath.
The minor problem that we have right now is that each new window appears in the same default position as the first window and completely eclipses it. It might be more obvious that the new window is created if it is slightly offset from the previous window, as shown in figure 5.4. This listing shows how to offset the window.
const createWindow = exports.createWindow = () => { let x, y; const currentWindow = BrowserWindow.getFocusedWindow(); 1 if (currentWindow) { 2 const [ currentWindowX, currentWindowY ] = currentWindow.getPosition(); x = currentWindowX + 10; y = currentWindowY + 10; } let newWindow = new BrowserWindow({ x, y, show: false }); 3 newWindow.loadFile('index.html'); newWindow.once('ready-to-show', () => { newWindow.show(); }); newWindow.on('closed', () => { windows.delete(newWindow); newWindow = null; }); windows.add(newWindow); return newWindow; };
In addition to instantiating instances with the new keyword, the BrowserWindow module also has methods of its own. We can use BrowserWindow.getFocusedWindow() to get a reference to the window with which the user is currently working. When the application is first ready and we call createWindow(), there isn’t a focused window and BrowserWindow.getFocusedWindow() returns undefined. If there is a window, we call its getWindow() method, which returns an array with the x- and y-coordinates of the window. We’ll store these values in two variables outside of the conditional block and pass them to the BrowserWindow constructor. If they’re still undefined (for example, there was no focused window), then Electron uses the defaults, just as it did before we implemented this feature. Figure 5.4 shows a second window offset from the first.
This isn’t the only way to implement this feature. Alternatively, you could track an initial x- and y-position and increment those values on each new window. Or, you could add a slight bit of randomness to the default x- and y-values so that each window is slightly offset. I leave those methods as exercises to the reader.
In macOS, many—but not all—applications remain open, even when all their windows are closed. For example, if you closed all your windows in Chrome, the application remains active in the dock and still appears in the application switcher. Fire Sale doesn’t do that.
In earlier chapters, this might have been acceptable. We had one window and no way of creating additional windows. In this section, we enable the application to remain open only in macOS. By default, Electron quits the application when it fires its window-all-closed event. If we want to prevent this behavior, we must listen for this event and conditionally stop it from closing if we’re running on macOS.
app.on('window-all-closed', () => { if (process.platform === 'darwin') { 1 return false; 2 } app.quit(); 3 });
The process object is provided by Node and globally available without needing to be required. process.platform returns the name of the platform in which the application is currently executing. As of this writing, process.platform returns one of five strings: darwin, freebsd, linux, sunos, or win32. Darwin is the UNIX operating system that macOS is built on. In listing 5.11, we checked if process.platform is equal to darwin. If it is, then the application is running on macOS and we want to return false to stop the default action from occurring.
Keeping the application alive is half the battle. What happens if the user clicks the application in the dock and no windows are open? In this situation Fire Sale should open a new window and display it to the user as shown here.
app.on('activate', (event, hasVisibleWindows) => { 1 if (!hasVisibleWindows) { createWindow(); } 2 });
The activate event passes two arguments to the callback function provided. The first is the event object. The second is a Boolean, which returns true if any windows are visible and false if all the windows are closed. In the case of the latter, we call the createWindow() function that we wrote earlier in the chapter.
The activate event fires only on macOS, but there are plenty of reasons why you might choose to have your application remain open on Windows or Linux, particularly if the application is running background processes that you want to continue even if the window is dismissed. Another possibility is that you have an application that can be hidden, or shown with a global shortcut, or from the tray or menu bar. We implement each of these in later chapters.
With these two additional events, we’ve converted Fire Sale from a single-window application to one that supports multiple windows. This listing shows the code for the main process in its current form.
const { app, BrowserWindow, dialog } = require('electron'); const fs = require('fs'); const windows = new Set(); app.on('ready', () => { createWindow(); }); app.on('window-all-closed', () => { if (process.platform === 'darwin') { return false; } }); app.on('activate', (event, hasVisibleWindows) => { if (!hasVisibleWindows) { createWindow(); } }); const createWindow = exports.createWindow = () => { let x, y; const currentWindow = BrowserWindow.getFocusedWindow(); if (currentWindow) { const [ currentWindowX, currentWindowY ] = currentWindow.getPosition(); x = currentWindowX + 10; y = currentWindowY + 10; } let newWindow = new BrowserWindow({ x, y, show: false }); newWindow.loadFile('index.html'); newWindow.once('ready-to-show', () => { newWindow.show(); }); newWindow.on('closed', () => { windows.delete(newWindow); newWindow = null; }); windows.add(newWindow); return newWindow; }; const getFileFromUser = exports.getFileFromUser = (targetWindow) => { const files = dialog.showOpenDialog(targetWindow, { properties: ['openFile'], filters: [ { name: 'Text Files', extensions: ['txt'] }, { name: 'Markdown Files', extensions: ['md', 'markdown'] } ] }); if (files) { openFile(targetWindow, files[0]); } }; const openFile = exports.openFile = (targetWindow, file) => { const content = fs.readFileSync(file).toString(); targetWindow.webContents.send('file-opened', file, content); };
3.138.174.174