Chapter 5. Working with multiple windows

This chapter covers

  • Tracking multiple windows using the JavaScript Set data structure
  • Facilitating communication between the main process and multiple renderer processes
  • Using Node APIs to detect what platform the application is running on

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.

Figure 5.1. In chapter 4, we set up communication between the main process and one renderer process.

Figure 5.2. In this chapter, we update Fire Sale to support multiple windows and facilitate communication between them.

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.

5.1. Creating and managing multiple windows

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.

Listing 5.1. Creating a Set to keep track of new windows: ./app/main.js
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.

Listing 5.2. Implementing a function to create new windows: ./app/main.js
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;
};

  • 1 Removes the reference from the windows set when it has been closed
  • 2 Adds the window to the windows set when it has been opened

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.

Listing 5.3. Creating a window when the application is ready: ./app/main.js
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.

5.1.1. Communicating between the main process and multiple windows

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.

Figure 5.3. To figure out to which window to send the file’s content, the renderer process must send a reference to itself when communicating to the main process to call getFileFromUser().

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.

Listing 5.4. Refactoring getFileFromUser() to work with a specific window: ./app/main.js
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
};

  • 1 Takes a reference to a browser window to determine which window should display the file dialog and subsequently load the file selected by the user.
  • 2 dialog.showOpenDialog() takes a reference to a browser window object
  • 3 The openFile() function takes a reference to a browser window object to determine which window should receive the contents of the file opened by the user.

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.

Listing 5.5. Refactoring openFile() to work with a specific window: ./app/main.js
const openFile = exports.openFile = (targetWindow, file) => {    1
  const content = fs.readFileSync(file).toString();
  targetWindow.webContents.send('file-opened', file, content);   2
};

  • 1 Accepts a reference to a browser window object
  • 2 Sends the contents of the file to the browser window provided

5.1.2. Passing a reference to the current window to the main process

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.

Listing 5.6. Getting a reference to the current window in the renderer process: ./app/renderer.js
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.

Listing 5.7. Passing a reference to the current window to the main process: ./app/renderer.js
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.

Listing 5.8. Adding listener to newFileButton: ./app/renderer.js
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.

Listing 5.9. newFileButton implemented in the renderer process: ./app/renderer.js
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);
});

5.2. Improving the user experience of creating new windows

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.

Listing 5.10. Offsetting new windows based on the currently focused window: ./app/main.js
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;
};

  • 1 Gets the browser window that is currently active.
  • 2 If there is a currently active window from the previous step, sets the coordinates of the next window down and to the right of the currently active window.
  • 3 Creates the new window, hiding it at first with the x- and y-coordinates. These are set if the code in the previous step ran and are undefined if it did not, in which case the window is created in the default position.

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.

Figure 5.4. New windows are offset from the current window.

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.

5.3. Integrating with macOS

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.

Listing 5.11. Keeping the application alive when all windows are closed: ./app/main.js
app.on('window-all-closed', () => {
  if (process.platform === 'darwin') {     1
    return false;                          2
  }
  app.quit();                              3
});

  • 1 Checks to see if the application is running on macOS
  • 2 If it is, returns false to prevent the default action
  • 3 If it isn’t, quits the application

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.

Listing 5.12. Creating a window when application is opened and there are no windows: ./app/main.js
app.on('activate', (event, hasVisibleWindows) => {      1
  if (!hasVisibleWindows) { createWindow(); }           2
});

  • 1 Electron provides the hasVisibleWindows argument, which will be a Boolean.
  • 2 If there are no visible windows when the user activates the application, creates one.

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.

Listing 5.13. Multiple windows implemented in the main process: ./app/main.js
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);
};

Summary

  • When creating an Electron application with multiple windows, we can no longer hard-code a window for the main process to send data to.
  • We can use Electron’s remote module to ask the window in the renderer process for a reference to itself and send that reference along when communicating with the main process.
  • Applications on macOS do not always quit when all the windows are closed. We can use Node’s process object to determine on what platform the application is running.
  • If process.platform is darwin, then the application is running on macOS.
  • Returning false in a function that listens for app’s windows-all-closed event prevents the application from quitting.
  • On macOS, app fires an activate event when the user clicks the dock icon.
  • The activate event includes a Boolean called hasVisibleWindows as the second argument passed to the callback function. This is true if any windows are currently open, and false if there are none. We can use this to determine if a new window should be opened.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.174.174