Chapter 6. Working with files

This chapter covers

  • Determining if the content has been edited and is unsaved
  • Modifying the window’s title based on the state of the currently active document
  • Using custom interactions available to windows in applications running on macOS
  • Implementing append documents to the operating system’s list of recent documents
  • Watching for changes to the current file from the operating system

Over the previous two chapters, we implemented the ability to read a file from the filesystem and display it in a browser window of our application. This exercise demonstrated how interprocess communication works in Electron, as well as Electron’s ability to bridge the gap between a traditional browser-based application and a Node.js application. In the previous chapter, we also added support for multiple browser windows.

In the name of clarity, I kept our initial implementation naively simple. It turns out that users interact with files in a surprising number of different ways—even in a simple note-taking application like Fire Sale. A user might start writing a new note from the empty window spawned when the application initially launches, or they may choose to open an existing file from the filesystem. A user might click the Open File button we implemented earlier, or they might select the file from a list of recently opened documents. The path a user chooses impacts how our application behaves when they wish to save it. If it’s a new file, then we must prompt the user to provide a location to write the new file. If it’s an existing file, then the application should just overwrite the file the user originally selected from the Open File dialog box.

Users have come to expect several features from modern applications. Our application should provide visual indications that a file has been changed since it was originally opened or the last time it was saved. It should integrate with the operating system’s list of recent documents and follow OS-level conventions for how the window’s title bar should look, depending on the state of the file.

In this chapter, we rethink our approach to managing files as we implement the ability to save the Markdown text of our application, export the rendered HTML, and revert changes to unsaved files. We also explore additional ways to open files, such as using the HTML5 File API to implement a drag-and-drop feature to the left pane of the application. Finally, we don’t want our users to end up in a situation where they edit a Markdown file in some other application and accidentally overwrite the changes when they save the file in Fire Sale. Therefore, we listen for external changes to the current file by other applications. Throughout this chapter, we implement all the functionality outlined in figure 6.1.

Figure 6.1. Users expect to interact with files in several ways. In this chapter, we implement all the necessary features to meet our users’ expectations.

I’ll be starting from the chapter-5 branch of the repository as a starting point. You can also find the completed example in the chapter-6 branch.

6.1. Keeping track of the current file

As we begin working with different files in our application, it’s helpful to track the file with which we’re currently working. This way, we know which file a user is working on if they ask to save it. Suppose that Mildred has an important stroke of inspiration and wants to record her genius idea into Fire Sale. She opens her list of important thoughts and jots down notes. Right now, Fire Sale doesn’t know if Mildred is editing a new or existing file. The application also doesn’t have a way to track what file Mildred is working on when she selects a file to open.

To solve this problem, we need to implement the ability to keep track of what file Fire Sale is currently working with. In chapter 4, whenever a new file was opened, we sent the contents of the file as well as the path of the file that was just opened from the main process to the renderer process that requested the file. We populated the right and left panes of the application with the content. Our next step is to enable the renderer process to track the path of the file that is currently being displayed to the user. Doing so allows us to save changes without prompting the user for a file location.

We also have a Revert button in the UI. If the user clicks this button, it should roll back any unsaved changes and return the content to its last saved state. A simple way to handle this action is to store a copy of the content whenever a file is opened. If the user clicks Revert, Fire Sale replaces the content in the UI with the cached content from the last time the file was opened.

Let’s start with some sensible defaults for the case where the user opens a new window that isn’t yet tied to a given file. We declare two variables in the top level of the renderer process to track the original content of the current file and its file path.

Listing 6.1. Declaring global variables for keeping track of the current file: ./app/renderer.js
let filePath = null;
let originalContent = '';

I chose to use an empty string instead of null for originalContent because that’s the value of an empty input or textarea in the browser. Later in the chapter, this setting makes it easier for us to see if a new document has been edited.

Whenever a file has been opened and sent to the renderer process, we need to update these values. We take care of this in the IPC event listener on the file-opened channel we set up in chapter 4.

Listing 6.2. Updating global variables when a new file is opened: ./app/renderer.js
ipcRenderer.on('file-opened', (event, file, content) => {
  filePath = file;                                         1
  originalContent = content;                               2

  markdownView.value = content;                            3
  renderMarkdownToHtml(content);                           4
});

  • 1 Updates the path of the currently opened file stored in the top-level scope
  • 2 Updates the original content to determine if the file has unsaved changes
  • 3 Updates the Markdown content in the UI
  • 4 Updates the HTML content in the UI

6.1.1. Updating the window title based on the current file

Mildred can open a file on her computer, edit it, and save the changes, but she currently has no way of knowing what file she is working with. Are these the meeting minutes from this week, or the meeting she missed while on vacation in Greenland? She has multiple windows open and can’t tell which is which. In the previous section, we implemented a feature that allowed each window in Fire Sale to keep a reference to the current file, but we did not add anything to the UI to share that information with the user. The common pattern for desktop applications is to show the name of the file that is currently active in the title bar of the window, as shown in figure 6.2. In this section, we’ll follow best practices and implement this pattern in Fire Sale.

Figure 6.2. The name of file in the filesystem is displayed in the window’s title.

By default, the application’s window displays the title of the HTML page, which is defined in app/index.html. This is a reasonable default, but many native desktop applications display the name or path of the current file. One approach is to update the title of the window whenever the user opens a new file. In addition to displaying the name of the currently open file (as in listing 6.3), we may want to display other information in the window’s title such as whether the current file has been edited since it was saved. We also need to update the window’s title for a few different contexts, such as editing and saving the file.

All BrowserWindow instances have a method called setTitle() that allows us to programmatically manipulate the window’s title. Later in this chapter, we display information about whether the file has been edited since the last time it was saved or since it was opened. We create a method called updateUserInterface() that eventually encapsulates all of this logic as well as some other features down the road like enabling the Save File and Revert buttons if the file contains unsaved changes.

Listing 6.3. Updating the window title based on the current file: ./app/renderer.js
const path = require('path');

const updateUserInterface = () => {
  let title = 'Fire Sale';
  if (filePath) { title = `${path.basename(filePath)} - ${title}`; }   1
  currentWindow.setTitle(title);                                       2
};

  • 1 If a file is open, prepends the name of that file to the title
  • 2 Updates the title of the window

We start with the default title. If a file is currently open, we modify the title to include the file path. The path can be long and most of the information, such as the root of the filesystem or where the users’ folders are stored, is not important to our users. We use path.basename() to extract the name of the file itself from the full file path. Finally, we take the reference to the current window that we defined in chapter 5 and set its title. We’ll call this function as the last step whenever a new file is opened.

Listing 6.4. Calling updateUserInterface() when a new file is opened: ./app/renderer.js
ipcRenderer.on('file-opened', (event, file, content) => {
  filePath = file;
  originalContent = content;

  markdownView.value = content;
  renderMarkdownToHtml(content);

  updateUserInterface();              1
});

  • 1 Calls the method that updates the window’s title bar whenever a new file is opened.

6.1.2. Determining whether the current file has changed

In the midst of taking important notes in Fire Sale, Mildred realizes that she’s 20 minutes late for a meeting. She wants to close Fire Sale before rushing down to the fifth floor, but she is unsure if she has saved her recent changes to the file she was working on. We have lots of good reasons to track whether the user has edited the file since they opened it. We might want to prompt the user if they attempt to close the window and they have unsaved changes. Or, we might want to show only certain UI elements if the file has been modified (see figure 6.3).

Figure 6.3. The Fire Sale UI when the file has not been modified. Notice that the Save File and Revert buttons have been disabled.

In this section, we add a visual cue in the UI. The Save File button is enabled only if the file has been modified. In addition, we append (Edited) to the title bar (see figure 6.4). To add this feature, we’ll take advantage of the abstraction we began building earlier in this chapter and add functionality to detect if the file has been modified. We have a few approaches we could take.

Figure 6.4. The Fire Sale UI when the file has been modified. Notice that the title bar content has (Edited) appended to it and the Save File and Revert buttons are no longer disabled.

A naive—and flawed—way to check if a file has changed is to listen for either a keyup or change event in our UI. If the user adds a character and then removes it, this approach still considers the file modified, which is not consistent with how other native desktop applications behave.

To determine whether the file has been modified, we need two pieces of information: the original and current contents of the file. We were crafty enough to store the original contents in listing 6.2. If those two pieces of information are identical, then the file has not changed. But if they differ, even slightly, then we know we have a modified file on our hands.

To implement this feature, modify updateUserInterface() to take an argument called isEdited. On keyup, we compare the current value of the textarea with the originalContent and call updateUserInterface() with the result. BrowserWindow instances have a setDocumentEdited() method, which takes a Boolean. This will subtly modify the window on macOS; for Windows and Linux users, we append (Edited) to the window title.

Listing 6.5. Updating the UI if the document has unsaved changes: ./app/renderer.js
const updateUserInterface = (isEdited) => {                    1
  let title = 'Fire Sale';

  if (filePath) { title = `${path.basename(filePath)} - ${title}`; }
  if (isEdited) { title = `${title} (Edited)`; }

  currentWindow.setTitle(title);
  currentWindow.setDocumentEdited(isEdited);                   2
};

  • 1 Passes in a Boolean that represents whether the document has unsaved changes
  • 2 If isEdited is true, then updates the window accordingly

The last step is to have the renderer process call the updateUserInterface() method every time the user lifts their finger from a key while typing.

Listing 6.6. Checking for changes whenever the user types: ./app/renderer.js
markdownView.addEventListener('keyup', (event) => {
  const currentContent = event.target.value;
  renderMarkdownToHtml(currentContent);
  updateUserInterface(currentContent !== originalContent);       1
});

  • 1 Whenever the user inputs a keystroke into the Markdown view, checks to see if the current content matches the content that we stored in a variable and updates the UI accordingly.

6.1.3. Enabling the Save and Revert buttons in the UI

With these steps in place, your application can tell if it is in an edited and unsaved state. But we have a problem. The Save File and Revert buttons are still disabled. These buttons should be enabled only if there are unsaved changes. It’s easy to take care of this as we update the window itself.

Listing 6.7. Enabling the Save and Revert buttons when there are unsaved changes: ./app/renderer.js
const updateUserInterface = (isEdited) => {
  let title = 'Fire Sale';

  if (filePath) { title = `${path.basename(filePath)} - ${title}`; }
  if (isEdited) { title = `${title} (Edited)`; }

  currentWindow.setTitle(title);
  currentWindow.setDocumentEdited(isEdited);

  saveMarkdownButton.disabled = !isEdited;         1
  revertButton.disabled = !isEdited;               2
};

  • 1 If the document is unedited, disables the Save button
  • 2 If the document has no unsaved changes, disables the button that reverts unsaved changes

We implement the functionality for these buttons later in the chapter.

6.1.4. Updating the represented file on macOS

macOS windows support small representations of the current file in the window’s menu bar. Hold and press Command while clicking the file icon to trigger a drop-down menu showing where the file exists in the filesystem’s hierarchy, as shown in figure 6.5. You can also click and drag the icon—it acts as if you dragged the file from Finder. All BrowserWindow instances have a method called setRepresentedFilename(), which accepts a valid file path as an argument. This method has no effect in Windows. Let’s add this feature to our updateWindowTitle() method. Then we check to see if we have a valid path property and—if so—set it as the represented file for macOS windows.

Figure 6.5. Pressing and holding Command while clicking the file icon in the title bar allows us to see its location on the filesystem. We can also drag and drop the icon as if it were the file itself.

This is unlike updating the title of the window with additional information, like whether the file has been edited. The represented file remains the same until the user opens another file in the same window. We don’t need to update this value on keyup. We have two options: We could set the represented file in the main process before sending the path and content to the renderer process, or we could use the current-Window reference in the renderer process after it has received the file. I’m going to go with the former, but both approaches are acceptable.

Listing 6.8. Setting the represented file in macOS: ./app/main.js
const openFile = exports.openFile = (targetWindow, file) => {
  const content = fs.readFileSync(file).toString();
  targetWindow.setRepresentedFilename(file);                         1
  targetWindow.webContents.send('file-opened', file, content);
};

  • 1 BrowserWindow instances have a method that allows you to set the represented file.

6.2. Tracking recently opened files

Now that Mildred is back from her meeting, she wants to get back to work. It was a long meeting, and she doesn’t quite remember what notes she’s been working on recently. Our current implementation doesn’t have a way to help her out either. The operating system, however, does this stuff all the time. Whenever the user opens a file, let’s have Electron notify the operating system that it should add the file to the list of recently opened files.

When opening a file in either macOS or Windows, Electron can add the file path to the operating system’s list of recently opened documents. This list is available by right-clicking the Dock icon in macOS (figure 6.6) or the Taskbar icon in Windows (figure 6.7).

Figure 6.6. Recent files in macOS

Figure 6.7. Recent files in Windows 10

The operating system tracks the files opened by each application. It also provides a master list of recently opened files. In Windows, this list is found in the File Explorer. In macOS, you can find the list of Recent Items in the Apple menu (figure 6.8). When a document is selected from the global list of recent documents, it is opened in the default application for that type of document. In this chapter, we primarily concern ourselves with the list of recent documents specific to our application.

Figure 6.8. System-wide recent documents in macOS

You can add a file path to the recent documents list in Electron using app.add-RecentDocument() and providing the file path as an argument. In the next listing, we add the path of the file we’re opening with openFile() to the list of recent documents.

Listing 6.9. Appending to the list of recent documents: ./app/main.js
const openFile = exports.openFile = (targetWindow, file) => {
  const content = fs.readFileSync(file).toString();
  app.addRecentDocument(file);                                  1
  targetWindow.setRepresentedFilename(file);
  targetWindow.webContents.send('file-opened', file, content);
};

  • 1 Electron’s app module provides a method for appending to the operating system’s list of recently opened documents.

If you open a few files in Fire Sale, you see that they’re being appended to the list of recent files. But if you try to select any of the entries in this list, nothing happens. The operating system is asking Fire Sale to open the file, but our application doesn’t know how to yet. We must implement this feature ourselves.

In chapter 4 we added the ability to trigger an Open File dialog from the UI. When a user selects a file using the dialog, the file path is passed to an open file, which reads the contents of the file and sends those contents to the renderer process to be displayed in the left pane, which—finally—triggers the right pane to be updated with the resulting HTML. At the time, we briefly discussed that there were other ways a user might open a file, and that’s why it makes sense to separate the process of triggering the dialog box from the process of opening the file.

Selecting an item from the Recent Documents menu is one instance where the user wants to open a file without being prompted with an Open File dialog box. Whenever a file is opened from outside of the application, Electron’s app module fires an open-file event. In our code, we can listen for the open-file event and handle it accordingly. We should, however, wait until the application is fully up and running, so we’ll set up our listener once the application fires its will-finish-launching event in app/main.js.

Listing 6.10. Responding to external requests to open a file: ./app/main.js
app.on('will-finish-launching', () => {
  app.on('open-file', (event, file) => {       1
    const win = createWindow();
    win.once('ready-to-show', () => {
      openFile(win, file);
    });
  });
});

  • 1 Listens for the open-file event, which provides the path of the externally opened file, and then passes that file path to our openFile() function.

Now when the user selects a file in Fire Sale’s list of recent documents, the application creates a new window and opens the file path in the new window—just as if you’d selected it using the Open File button in the UI that we implemented in chapter 4.

6.3. Saving files

Saving files with Electron is similar to opening files, but with one difference: users might want to save changes to the Markdown file, or they might want to export the HTML that was generated by their application.

If the user is saving a new file for the first time, then the application should ask where the user wants to save the file and what name they want to give it. After that, it should keep track of that name and update the window title as it if had originally opened the file from the filesystem. If the user is saving changes to an existing Markdown file, then the application does not need to prompt to specify a location and filename for the file. Implementing the ability to save files is more than just writing content to the filesystem. We must also update the UI to show where the current file is being saved and if it has been modified since the last time it was saved to the filesystem.

In the Fire Sale case, saving the HTML output is a bit more straightforward since the application doesn’t allow the user to edit the HTML output after it has been saved. Exporting the generated HTML is a lot like saving a file for the first time, but we don’t need to track where it was saved or reflect its new location in the UI. It’s the easiest of the three to implement, so let’s take care of that feature first.

6.3.1. Exporting the rendered HTML output

To allow for exporting the generated HTML, we add a saveHtml() function to the main process that asks the user where they’d like to save the HTML file, grabs the content from the HTML view, and then writes the file to the filesystem.

As you might expect, triggering the native dialog box for saving files is similar to triggering one for opening files. The biggest difference is that instead of prompting the user to select a specific file to open, we will ask the user for a filename and location to write to the filesystem. The contents of the file is passed as an argument to the showSaveFileDialog() function.

In the app/main.js, add the function shown in listing 6.11. When this function is run, Electron presents a dialog box asking the user to select a file path where the contents should be written. Once the user selects a file path, we use Node’s built-in fs module to write the contents of the file to the filesystem.

Listing 6.11. Saving the generated output: ./app/main.js
const saveHtml = exports.saveHtml = (targetWindow, content) => {
  const file = dialog.showSaveDialog(targetWindow, {
    title: 'Save HTML',
    defaultPath: app.getPath('documents'),                     1
    filters: [
      { name: 'HTML Files', extensions: ['html', 'htm'] }
    ]
  });

  if (!file) return;                                           2

  fs.writeFileSync(file, content);
};

  • 1 Defaults to the user’s “documents” directory as defined by the operating system
  • 2 If the user selects cancel in the File dialog box, aborts the function.

In the example, dialog.showSaveDialog() takes two arguments. The first is a reference to a BrowserWindow, which is used to display the dialog box as a sheet in macOS only. The second argument is an options object that allows you to pass keys and values to configure the dialog box itself. The object provided to dialog.showSaveDialog() works with the following options:

  • title: Sets the title of the dialog box. This will not appear in macOS.
  • defaultPath: Sets the default directory for the Save dialog box.
  • buttonLabel: Allows you to set custom text for the Save button.
  • filters: Sets what files are enabled to select to overwrite. Electron also uses this option to set a default file extension, if the user does not provide one.

6.3.2. Common paths

We’re implementing the ability to save files, but where should we prompt the user to save those files and how does it differ depending on which operating system they’re using? Windows, macOS, and Linux organize their files differently. Ideally, a cross-platform Electron application should default to show the correct directory on each platform. Electron provides app.getPath(), which automatically returns the correct file path based on the user’s platform, saving the developer from having to write error-prone conditional logic. In listing 6.11, we set the default path to app.getPath(’documents’), which will be My Documents on Windows and the Documents folder in the user’s home directory on macOS. Electron provides the following additional paths:

  • home resolves to the user’s home directory.
  • desktop, documents, downloads, pictures, music, and videos each resolve to the corresponding path within the user’s home directory.
  • temp resolves to the operating system’s temporary file directory.
  • exe resolves to the location of the current executable.
  • appData resolves to the user’s application data directory. This would be %APPDATA% on Windows, ~/Library/Application/Support on macOS, and either $XDG_CONFIG_HOME or ~/.config on Linux.
  • userData resolves to appData with the name of the application appended. For example, on macOS, userData would resolve to ~/Library/Application/Support/fire sale for the application in this chapter. This name comes from the name entry in your package.json.

You might want your application to override one of the defaults provided by app.getPath(). You can do this using app.setPath(), which takes two arguments: the name from the previous list and the new path to which you’d like it to resolve. It’s important to note that you can override only paths from the previous list. If you’re going to override one of these paths, you must do it before the application fires its “ready” event.

We didn’t implement a default path in the showOpenFile() function in the previous chapter, but that’s a good candidate for using this approach as well. It would make sense for a music player to default to the directory where users typically store their music, or a photograph management application to default to app.getPath(’pictures’).

6.3.3. Saving files from the renderer process

Now we enable the Fire Sale application to save the rendered HTML content, shown in figure 6.9. To focus on learning the fundamentals of Electron, we’ll simply take the HTML content of the right pane and pass it to showSaveFileDialog(). A more robust approach would be to add a doctype as well as <html>, <head>, and <body> tags to make it a valid HTML document. Additionally, we could add metadata about the document and a default style sheet, but that is beyond the scope of this book. In the next chapter, we add in the ability to trigger this functionality from the application’s menu as well.

Figure 6.9. Fire Sale allows users to save their Markdown content in the left pane as well as the rendered HTML output shown in the right pane.

Listing 6.12. Triggering the Save File dialog box from the renderer process: ./app/renderer.js
saveHtmlButton.addEventListener('click', () => {
  mainProcess.saveHtml(currentWindow, htmlView.innerHTML);
});

6.3.4. Saving the current file

Saving the current file is like saving the HTML output with one small difference: if the file was opened from the filesystem, then the application does not need to prompt the user for a file path. Instead, the application just uses filePath as the location where the file should be written.

Listing 6.13. Saving the current file: ./app/main.js
const saveMarkdown = exports.saveMarkdown = (targetWindow, file, content) => {
  if (!file) {                                                  1
    file = dialog.showSaveDialog(targetWindow, {
      title: 'Save Markdown',
      defaultPath: app.getPath('documents'),
      filters: [
        { name: 'Markdown Files', extensions: ['md', 'markdown'] }
      ]
    });
  }

  if (!file) return;                                            2

  fs.writeFileSync(file, content);                              3
  openFile(targetWindow, file);
};

  • 1 If this is a new file without a file path, prompts the user to select a file path with a dialog box
  • 2 If the user selects Cancel in the File dialog box, aborts the function
  • 3 Writes the contents of the buffer to the filesystem

This code is flexible enough to handle the case where we’re saving a new file, as well as the case where we’re updating an existing file. If you recall from earlier in the chapter, the filePath property’s value defaults to null until the user opens a file. If the filePath property is false, then Electron prompts the user to select a file path using the native Save File dialog. It then saves the user-selected location to the filePath property.

Conversely, if it already knows where the file is located, then it moves forward and skips directly to writing the content to the filesystem. For the sake of brevity, I called openFile() on the file we just saved. If it’s a new file that we’re saving for the first time, then we would want to add it to the operating system’s list of recent documents and set it as the represented file. Abstracting that out into its own function that we can call without having to read the file from disk again is an exercise that I leave to the reader.

No functionality is complete if there is no way to trigger it. The application needs an event listener on the Save File button that triggers our new functionality.

Listing 6.14. Adding an event listener to the Save File button: ./app/renderer.js
saveMarkdownButton.addEventListener('click', () => {
  mainProcess.saveMarkdown(currentWindow, filePath, markdownView.value);
});

6.3.5. Reverting files

Given the way we’ve structured our application so far, adding a feature is easy. When the user clicks the Revert button, we replace the value of the Markdown view with the original content of the file that we cached when we last opened or saved it, and then trigger the HTML view to be re-rendered with the cached content as well.

Listing 6.15. Reverting content in UI to last saved content: ./app/renderer.js
revertButton.addEventListener('click', () => {
  markdownView.value = originalContent;
  renderMarkdownToHtml(originalContent);
});

Later in the chapter, we’ll prompt the user to make sure that they want to blow away all their changes before moving forward.

6.4. Opening files using drag and drop

Electron applications support the HTML5 File API, which allows us to create a feature for our application where users can drag a file onto certain elements in the DOM and drop them. You have seen this API used for uploading photographs in Twitter’s web application or attaching a file to an email in Gmail. In Fire Sale, we take advantage of this API to allow the user to open files by dragging them onto the Markdown view of the UI.

6.4.1. Ignoring dropped files everywhere else

The default action of a web browser when a file is dropped into the browser window is to open the file in the browser itself. We can drag files onto our application and watch in terror as the contents of the file completely replace the UI. This is even more problematic when you consider that we don’t have a Back button to rely on.

The first step to creating a drag-and-drop feature for our application is to disable the default behavior by adding an event listener to the document itself that prevents the default action. Later, we’ll opt back in and customize the behavior for the Markdown pane in our UI.

Listing 6.16. Setting up foundation for drag-and-drop events: ./app/renderer.js
document.addEventListener('dragstart', event => event.preventDefault());
document.addEventListener('dragover', event => event.preventDefault());
document.addEventListener('dragleave', event => event.preventDefault());
document.addEventListener('drop', event => event.preventDefault());

6.4.2. Providing visual feedback

Though it’s not necessary to implement the feature itself, it’s often helpful to give the user a visual indication that they can drag a file onto an area within your application. We define two additional CSS classes that can be added and removed with JavaScript, depending upon whether the item being dragged is valid.

Listing 6.17. Adding styles for drag-and-drop functionality: ./app/style.css
.raw-markdown.drag-over {                 1
  background-color: rgb(181, 220, 216);
  border-color: rgb(75, 160, 151);
}

.raw-markdown.drag-error {                2
  background-color: rgba(170, 57, 57,1);
  border-color: rgba(255,170,170,1);
}

  • 1 This deep teal color signifies to the user that this a valid drop target.
  • 2 This red color indicates that there is a problem with the file the user is dropping.

Before we begin implementing this feature, shown in listing 6.18, it would be nice to have some helper functions. I’m going to create two suspiciously similar functions: getDraggedFile() and getDroppedFile(). One important distinction between the two: When a user is dragging a file, we have access only to its metadata. Only after the user officially drops the file do we have access to the File object. getDraggedFile() will pick the file’s metadata—in the form of a DataTransferItem object—out of the event object, which has a large number of other properties, such as where the mouse was when the event was fired and much more. getDroppedFile() pulls the first element from the files array, which was empty when the user was simply dragging the file.

This process might seem arduous, but it is all in the name of security. You might pass over windows that should not know about the file you’re attempting to drop on your way to your intended application because that file could very well contain sensitive information. But once you’ve let go and dropped the file, then the browser assumes that this action is intentional and allows the application to read the file. fileTypeIsSupported() checks to see if the type of file being dragged is either of the two types supported by Fire Sale and returns a Boolean based on the result.

Listing 6.18. Helper methods: ./app/renderer.js
const getDraggedFile = (event) => event.dataTransfer.items[0];      1
const getDroppedFile = (event) => event.dataTransfer.files[0];      2

const fileTypeIsSupported = (file) => {
  return ['text/plain', 'text/markdown'].includes(file.type);       3
};

  • 1 This will always be an array in case the user selects multiple items. The application supports only one file at a time. We grab the first item in the array.
  • 2 This is similar to the getDraggedFile(), but after the user has officially dropped the file, we have access to the file itself, not just its metadata.
  • 3 This helper function returns true or false if the file’s type is in the array of supported file types.

When the user drags a file over the browser window, it rapid-fires dragover events until the user either leaves the target area—in which case, a dragleave event—or the user lifts their finger from the mouse or trackpad and drops the file onto the target area, which triggers a drop event.

During the dragover phase, we can give the user a visual clue as to whether the drop is going to be successful, as shown in figure 6.10. If the user is dragging a file type that we’re not prepared to support, we can add the .drag-error class to the element (see figure 6.11). Otherwise, we’ll add the .drag-over class to indicate that the user can drop a file here. When the user removes the file from the target area, we’ll clean up any classes that were added and restore the UI to its default state.

Figure 6.10. Adding a CSS class to the Markdown view provides a visual cue to the user that this is a valid place to drop this file.

Figure 6.11. In the same vein, Fire Sale does not support images. The code we’re about to write will reject any file that is not one of our supported types, but visually showing the user that the file won’t be accepted allows them to cancel their action in advance.

Listing 6.19. Adding and removing classes on dragover and dragleave: ./app/renderer.js
markdownView.addEventListener('dragover', (event) => {
  const file = getDraggedFile(event);

  if (fileTypeIsSupported(file)) {
    markdownView.classList.add('drag-over');              1
  } else {
    markdownView.classList.add('drag-error');             2
  }
});

markdownView.addEventListener('dragleave', () => {        3
  markdownView.classList.remove('drag-over');
  markdownView.classList.remove('drag-error');
});

  • 1 If the file type is supported, adds a CSS class to indicate this is a valid place to drop the file.
  • 2 If the file type is not supported, adds a CSS class to indicate that although this is a valid place to drop a file, this file is not accepted.
  • 3 If the user takes the file from the Markdown view, takes off the classes we added earlier.

6.4.3. Opening dropped files

When the user successfully drops the file onto the left pane, the code in listing 6.20 once again confirms that Fire Sale supports this type of file. If it does, we pass it over to the activeFile object in the main process that we created earlier, to be opened as if we selected it from the dialog box or from the recent documents list.

Listing 6.20. Drag-and-drop functionality: ./app/renderer.js
markdownView.addEventListener('drop', (event) => {
  const file = getDroppedFile(event);

  if (fileTypeIsSupported(file)) {
    mainProcess.openFile(currentWindow, file.path);      1
  } else {
    alert('That file type is not supported');            2
  }

  markdownView.classList.remove('drag-over');
  markdownView.classList.remove('drag-error');
});

  • 1 If the file type is supported, the renderer process communicates with the main process.
  • 2 If the file type is not supported, the application alerts the user.

With very little new code, we’ve successfully implemented a drag-and-drop feature reminiscent of a native desktop application.

6.5. Watching files for changes

A potentially dangerous edge case exists in our application right now. If we open a Markdown file in some other editor and make changes to it, Fire Sale is blissfully unaware. This means that if we save the file in Fire Sale after making changes in our other editor, it will destructively overwrite those changes.

We have a few ways around this. We could, for example, reread the file when Fire Sale comes back into focus. We could regularly check the file and see if its contents have changed. The way we approach it for now is to take advantage of Node’s fs.watchFile feature, which uses operating system-specific libraries to monitor a file or directory and emit an event if the file changes.

The one caveat of this approach is that we must be careful to stop watching files when we open new ones. Otherwise, we keep adding watchers without letting any go, which is a memory leak. This is further complicated by the fact that we have multiple windows. If a file changes, we must make sure that we send it to the correct window. Our feature will work as follows:

  1. Sets up a data structure for tracking our file watchers and the window that they’re associated with.
  2. Begins watching for file changes upon opening a file.
  3. When opening subsequent files, closes the existing watcher before creating a new one.
  4. Closes the watcher when the window is closed.

Our first task will be to figure out a way to manage the relationship between a window, the file currently displayed in the window, and/or the watcher for the file. In chapter 5, we used a Set to keep track of all the windows currently open in the application. In this section, we’ll use another recent addition to the JavaScript language—a Map.

Maps are key-value stores, much like regular objects in JavaScript, with an important distinction. Objects can have only strings and numbers as keys. Maps can use any type of object or value as a key. To implement this feature, we instantiate a Map that uses BrowserWindow instances as keys and file watchers as values. When the user closes a window, we find the watcher associated with that window and stop it.

Listing 6.21. Setting up a Map to watch files: ./app/main.js
const openFiles = new Map();

As I mentioned earlier, we want to start watching a file path when it’s opened and stop watching when either the window has been closed or the user opens a different file in the window. Let’s set up two functions: startWatchingFile() and stopWatchingFile().

Listing 6.22. Setting up a listener: ./app/main.js
const startWatchingFile = (targetWindow, file) => {
  stopWatchingFile(targetWindow);                                    1

  const watcher = fs.watchFile(file, (event) => {
    if (event === 'change') {                                        2
      const content = fs.readFileSync(file);
      targetWindow.webContents.send('file-opened', file, content);   3
    }
  });

  openFiles.set(targetWindow, watcher);                              4
};

const stopWatchingFile = (targetWindow) => {
  if (openFiles.has(targetWindow)) {                                 5
    openFiles.get(targetWindow).stop();                              6
    openFiles.delete(targetWindow);                                  7
  }
};

  • 1 Closes the existing watcher if there is one.
  • 2 If the watcher fires a change event, rereads the file.
  • 3 Sends a message to the renderer process with the content of the file.
  • 4 Tracks the watcher so we can stop it later.
  • 5 Checks if we have a watcher running for this window.
  • 6 Stops the watcher.
  • 7 Deletes the watcher from the maps of open windows.

In the listing, we start watching a given file path. If the watcher fires a “change” event, we send a message to the window alerting it to the fact that the file has changed. Lastly, add the window and the associated watcher to the openFiles Map, which allows us to find it later when it comes time to close the watcher. As an added precaution, let’s close any existing watcher for that window before creating a new one. Doing so is helpful if the user opens a file window that was already watching a file.

Listing 6.23. Closing the watcher when the browser window closes: ./app/main.js
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('close', (event) => {
    if (newWindow.isDocumentEdited()) {
  // ...
    }
  });

  newWindow.on('closed', () => {
    windows.delete(newWindow);
    stopWatchingFile(newWindow);        1
    newWindow = null;
  });

  windows.add(newWindow);
  return newWindow;
};

  • 1 When the window is closed, stops the watcher for the file associated with that window.

6.6. Prompting the user before discarding changes

Right now, the window’s title reflects whether the user has made changes to the current document that haven’t been saved. But what if the user tries to close the window? The changes are gone and unrecoverable. In the previous section, we added the ability to watch the currently active file path. If the file is changed by another application, Fire Sale overwrites the changes without warning. In both cases, this is unacceptable behavior for a desktop application. Users expect to be prompted if they are about to lose their work. In this section, we implement those safeguards.

In chapter 5, we set up a listener for the closed event, which is fired when the window has successfully been closed. Electron also supports a close event, which fires when the user attempts to close the window. If the user has unsaved changes, we can intervene and prompt the user to confirm that they—in fact—want to close the window and lose their changes.

Listing 6.24. Prompting the user if they try to close a window with unsaved changes: ./app/main.js
  newWindow.on('close', (event) => {
    if (newWindow.isDocumentEdited()) {                           1
      event.preventDefault();                                     2

      const result = dialog.showMessageBox(newWindow, {           3
        type: 'warning',
        title: 'Quit with Unsaved Changes?',
        message: 'Your changes will be lost if you do not save.',
        buttons: [                                                4
          'Quit Anyway',
          'Cancel',
        ],
        defaultId: 0,                                             5
        cancelId: 1                                               6
         });

      if (result === 0) newWindow.destroy();                      7
    }
  });

  • 1 Checks if the document has been edited. We set this in the renderer process on every keyup in the Markdown view by comparing the current content with the original content.
  • 2 If the window has unsaved changes, prevents it from closing.
  • 3 Prompts the user with a custom message box asking if they are sure they’d like to close the window and lose their changes. Saves their selection into “result”.
  • 4 Provides a list of button labels.
  • 5 Sets the first option as the default option if the user hits the Return key.
  • 6 Sets the second button as the button selected if the user dismisses the message box.
  • 7 If the user selects “Quit Anyway,” forces the window to close.

In previous chapters, we used dialog.showOpenDialog() and dialog.showSave-Dialog() for prompting the user to select a file. dialog.showMessageBox() is a general-purpose, customizable dialog box. You can provide a list of button labels to the buttons array. dialog.showMessageBox() returns the index of the button that the user selected. If the user selects the first button, dialog.showMessageBox() returns 0. We can use the return value to figure out how to proceed in our application based on the user’s preference. dialog.showMessageBox() also takes additional options that allow us to specify what the default action should be if the user clicks the Return button and what option should be returned if the user dismisses the dialog box.

In the previous example, we listen for the close event. If the window has unedited changes, we prompt the user. Based on the user’s response, we either prevent the window from closing or purposely destroy the window.

We also need to safeguard against two other situations where the user could lose their changes. The first is if the user attempts to open another file in the same window when there are unsaved changes; the second is if another application changes the file. The major difference between these two functions is in the message shown to the user. Either way, if the user decides to move forward, then we load the file and replace the content in the UI. In an effort to avoid repeating ourselves, we’ll move this process into its own function to use it in both places.

Listing 6.25. Refactoring the process of displaying a new file: ./app/renderer.js
const renderFile = (file, content) => {
  filePath = file;
  originalContent = content;

  markdownView.value = content;
  renderMarkdownToHtml(content);

  updateUserInterface(false);
};

With our new renderFile() function in place, we can set up two IPC listeners. When the user opens a new file (listing 6.26), we continue to use the file-opened channel. But if the file has been modified by another application (listing 6.27), we send a message over the file-changed channel instead. Based on from which channel we receive the message, we display a different message to the user.

Listing 6.26. Prompting the user when opening a new file if there are unsaved changes: ./app/renderer.js
ipcRenderer.on('file-opened', (event, file, content) => {
  if (currentWindow.isDocumentEdited()) {
    const result = remote.dialog.showMessageBox(currentWindow, {     1
      type: 'warning',
      title: 'Overwrite Current Unsaved Changes?',
      message: 'Opening a new file in this window will overwrite your unsaved
     changes. Open this file anyway?',
      buttons: [
        'Yes',
        'Cancel',
      ],
      defaultId: 0,
      cancelId: 1
    });

    if (result === 1) { return; }                                    2
  }

  renderFile(file, content);                                         3
});

  • 1 Uses the remote module to trigger the dialog box from the main process.
  • 2 If the user cancels, returns from the function early.
  • 3 Sets the window to its unedited state because the user just opened a new file.
Listing 6.27. Prompting the user when a file changes: ./app/renderer.js
ipcRenderer.on('file-changed', (event, file, content) => {
  const result = remote.dialog.showMessageBox(currentWindow, {        1
    type: 'warning',
    title: 'Overwrite Current Unsaved Changes?',
    message: 'Another application has changed this file. Load changes?',
    buttons: [
      'Yes',
      'Cancel',
    ],
    defaultId: 0,
    cancelId: 1
  });

  renderFile(file, content);
});

  • 1 In this situation, we don’t care if the document has been edited. We want to prompt the user regardless.

The last step is to modify the startWatchingFile() function to send a message over the file-changed channel, instead of the file-opened channel, to trigger the correct message box.

Listing 6.28. Sending a message over the file-changed channel: ./app/main.js
const startWatchingFile = (targetWindow, file) => {
  stopWatchingFile(targetWindow);

  const watcher = fs.watch(file, (event) => {
    if (event === 'change') {
      const content = fs.readFileSync(file).toString();
      targetWindow.webContents.send('file-changed', file, content);     1
    }
  });

  openFiles.set(targetWindow, watcher);
};

  • 1 Fires a different event if there has been a change to the current file

And with that, our application now supports drag-and-drop functionality, watches the filesystem for changes, adds files to the operating system’s list of recently opened files, updates the window’s title bar, sets a represented file on macOS, and alerts the user before discarding unsaved changes. The code for the application can be found at https://github.com/electron-in-action/firesale/tree/chapter-6, or the appendix.

Summary

  • When implementing the ability to save a file, we must consider if this is a new or existing file and handle each scenario differently.
  • When saving a new file, we can use dialog.showSaveFileDialog() to prompt the user to select a location to which to write the file.
  • When saving an existing file, Fire Sale writes to the existing file’s current location.
  • By default, Electron windows display the contents of the HTML documents’ <title> tag. The setTitle() method on all BrowserWindow instances allows users to update and customize a window’s title based on the state of the application.
  • Electron provides the ability to further customize windows in macOS.

    • We can set the “represented file” to a given path, and it is added to the menu bar. macOS allows users to drag the file as if they were dragging it from the Finder.
    • We can use the setDocumentedEdited() method on BrowserWindow instances to display a small dot in the window’s close button that signifies to the user that they have unsaved changes.
  • Electron provides the app.addRecentDocument() method, which appends a given file path to the operating system’s list of recently opened documents. This works across all the supported platforms.
  • When the user selects a file from the operating system’s list of recently opened documents, Electron does not know how to handle this by default. We must provide a custom listener on the app object that handles file-open events.
  • Electron provides several shortcuts to common locations where users typically want to save files. This is done under the hood, relieving us from the responsibility of customizing the default location for each supported operating system.
  • In addition to the file selection dialogs provided by Electron, we can also use the HTML File API to support drag-and-drop actions from the user.
  • Node provides the fs.watch() method, which allows us to watch currently open files and alerts us if they have been changed by other applications.
..................Content has been hidden....................

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