Skip to content

Aquent | DEV6

Text Input Context Menu in an Electron Angular App

Written by: Pavlo Leheta

In this blog post I am going to show you how to easily add a context menu in all text input fields throughout an Angular in Electron application.

For those who aren’t familiar, Electron is a popular framework which makes it easy to build desktop applications for Mac OS, Windows and Linux by utilizing web technologies (HTML, CSS and JavaScript). Electron uses Chromium for the UI rendering which runs in a so-called renderer process and Node.js for operations related to the operating system such as filesystem access which runs in a so-called main process. Since Electron provides a desktop shell for web apps, we can use any front-end JavaScript framework to develop desktop apps. In this case Angular web application will be used in Electron. This is what I meant by saying “Angular in Electron application”.

We will create a new Angular application which we can run on our desktop by using Electron. We will add a simple form with text input fields to demonstrate how we could easily add context menus, because, unlike a web browser built in context menus, Electron does not provide them by default, but allows to add custom ones. Let’s get started.

Open up your terminal and globally install the latest version of Angular CLI:

npm   i -g @angular/[email protected]   

Run ng version command to make sure you are using a compatible version of Node. If not, install the recommended version. At the moment of this post the latest version of Angular is 8.3.21 and it requires at least Node 10.9.0.

Navigate to a directory where you would like your project to live and let’s create a new Angular application called electron-angular-input-context-menu:

ng new electron-angular-input-context-menu
cd electron-angular-input-context-menu
npm i -D [email protected]

So, above, we create the Angular app (press N for Angular routing when prompted by CLI) , change the current directory to the created app’s directory and then install the latest version of Electron as a dev dependency.

Let’s create a main.ts file in the root of our project. This file is the entry point for our Electron app and will hold the main API for our desktop app:

import { app, BrowserWindow } from 'electron';
import * as path from 'path';

let mainWindow: Electron.BrowserWindow;

function createWindow() {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    height: 600,
    width: 800,
    webPreferences: {
      nodeIntegration: true
    }
  });

  // and load the index.html of the app.
  mainWindow.loadFile(path.join(__dirname, '/dist/index.html'));

  // Open the DevTools.
  mainWindow.webContents.openDevTools();

  // Emitted when the window is closed.
  mainWindow.on('closed', () => {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    mainWindow = null;
  });
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);

// Quit when all windows are closed.
app.on('window-all-closed', () => {
  // On OS X it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // On OS X it"s common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (mainWindow === null) {
    createWindow();
  }
});

// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.

Now update your package.json file to indicate the main entry point for the Electron app like so:

{
  "name": "electron-angular-input-context-menu",
  "version": "0.0.0",
  "main": "main.js",
. . . . 

Note that even though our main file is a TypeScript file (to be consistent with Angular files) it’s important so specify it as a js file as our TypeScript file will eventually compile to a js file before launching the app.

Let’s also add a script to build the Angular app and the main file and launch the Electron app:

"scripts": {
    "electron": "tsc main.ts && ng build --base-href ./ && electron .",
. . . .

The –base-href flag is important here to indicate to the Angular CLI that the base tag’s href attribute should have the value of ./ in the index.html file.

Change the name in the output path of our Angular project in angular.json file by removing the second part of the path, so that it corresponds to what we have in our main.js when loading a file:

. . . .
"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist",
. . . . 

Also install Node types for TypeScript by running:

npm install @types/[email protected]

It’s important to install version 12, even though there is a version 13 available, because 12 is the compatible version for the current version of Electron. 13 will give compile errors.

So, we should be ready to try to run the app. Issue the following command in your terminal:

npm run electron

You should now see the Electron app started and loaded with Angular app content.

A screenshot of a cell phone

Description automatically generated

Now we can create a simple contact form which includes some text input fields in order to have a use case to demonstrate. For that, go to app.component.html file (located in src/app folder) and replace its whole contents with the following:

<form>
  <ul>
   <li>
     <label for="name">Name:</label>
     <input type="text" id="name" name="user_name">
   </li>
   <li>
     <label for="mail">E-mail:</label>
     <input type="email" id="mail" name="user_mail">
   </li>
   <li>
     <label for="msg">Message:</label>
     <textarea id="msg" name="user_message"></textarea>
   </li>
  </ul>
 </form>

Also, in order to add some basic styling, add the following to your style file, app.component.scss or app.component.css depending on which style preprocessor was selected when creating the Angular project:

form {
  /* Just to center the form on the page */
  margin: 0 auto;
  width: 400px;
  /* To see the outline of the form */
  padding: 1em;
  border: 1px solid #CCC;
  border-radius: 1em;
}
ul {
  list-style: none;
}
form div + div {
  margin-top: 1em;
}
label {
  /* To make sure that all labels have the same size and are properly aligned */
  display: inline-block;
  width: 90px;
  text-align: right;
}
input, textarea {
  /* To make sure that all text fields have the same font settings By default, textareas have a monospace font */
  font: 1em sans-serif;
  /* To give the same size to all text fields */
  width: 300px;
  box-sizing: border-box; /* To harmonize the look & feel of text field border */
  border: 1px solid #999;
}
input:focus, textarea:focus {
  /* To give a little highlight on active elements */
  border-color: #000;
}
textarea {
  /* To properly align multiline text fields with their labels */
  vertical-align: top;
  /* To give enough room to type some text */
  height: 5em;
}
.button {
  /* To position the buttons to the same position of the text fields */
  padding-left: 90px;
  /* same size as the label elements */
}
button {
  /* This extra margin represent roughly the same space as the space between the labels and their text fields */
  margin-left: .5em;

Save, reload your app and you should see something like this:

A screenshot of a cell phone

Description automatically generated

Try to right click in any of the text fields and observe that nothing happens whereas if you did that in a browser, you would have had a context menu to appear.

In order for us to add a functionality of having a basic context menu, which would allow to select text, copy, paste, undo, redo etc., we need to add an Angular directive to our project. In our src/app directory add a file called text-field-context-menu.directive.ts with the contents like so:

import { Directive } from '@angular/core';

// tslint:disable-next-line: directive-selector
@Directive({ selector: 'input[type=text],input[type=email],textarea' })
export class TextFieldContextMenuDirective {

Note our selector is a CSS selector and this is a very powerful feature as it allows us to apply this directive on all text inputs and text areas throughout the application. In particular, we are targeting all input elements of type text and email as well as all textarea elements. We can use any CSS selector and multiple CSS selectors in our directive.

The next thing we need to do is to make Angular aware of our directive. Go to app.module.ts file and add TextFieldContextMenuDirective as a declaration.

. . . .
import { TextFieldContextMenuDirective } from './text-field-context-menu.directive';

@NgModule({
  declarations: [
    AppComponent,
    TextFieldContextMenuDirective
  ],
. . . .

Install the ngx-electron package, a small module for Angular which makes it easier to call native Electron APIs from our Angular app (renderer process in Electron):

npm i --save-dev ngx-electron

Import NgxElectronModule in our app.module :

. . . .
import { NgxElectronModule } from 'ngx-electron';

@NgModule({
  declarations: [
    AppComponent,
    TextFieldContextMenuDirective
  ],
  imports: [
    BrowserModule,
    NgxElectronModule
  ],
. . . .

Next, we inject ElectronService, which is a part of our imported module, into our directive as well as ElementRef which will give a reference to elements the directive applies to. Thus, our directive looks like this:

import { Directive, ElementRef } from '@angular/core';
import { ElectronService } from 'ngx-electron';

// tslint:disable-next-line: directive-selector
@Directive({ selector: 'input[type=text],input[type=email],textarea' })
export class TextFieldContextMenuDirective {

  constructor(private elementRef: ElementRef, private electronService: ElectronService) {}
}

The next step would be to use Electron Menu API and create the context menu. We are adding a method buildMenu() which looks like the following:

  private buildMenu() {
    const menu = new this.electronService.remote.Menu();
    const MenuItem = this.electronService.remote.MenuItem;
    menu.append(new MenuItem({ role: 'undo' }));
    menu.append(new MenuItem({ role: 'redo' }));
    menu.append(new MenuItem({ type: 'separator' }));
    menu.append(new MenuItem({ role: 'cut' }));
    menu.append(new MenuItem({ role: 'copy' }));
    menu.append(new MenuItem({ role: 'paste' }));
    menu.append(new MenuItem({ type: 'separator' }));
    menu.append(new MenuItem({ role: 'selectAll' }));
    return menu;
  }

Our next step will be to use this menu in the directive. We need to add an Angular lifecycle hook ngOnInit which will run every time the directive is initialized, meaning for all our selected elements. In order to add this hook we have to implement OnInit interface. To make our context menu to be shown every time a right-click is done on text fields, we will utilize RxJS fromEvent operator in order to listen to contextmenu event on the elements selected by the directive. This is how it looks like:

ngOnInit() {
    const menu = this.buildMenu();
    this._subscription = fromEvent(this.elementRef.nativeElement, 'contextmenu')
    .subscribe((e: Event) => {
      e.preventDefault();
      e.stopPropagation();
      let node: HTMLElement = e.target as HTMLElement;
      while (node) {
        if (node.nodeName.match(/^(input|textarea)$/i) || node.isContentEditable) {
          menu.popup({ window: this.electronService.remote.getCurrentWindow() });
          break;
        }
        node = node.parentNode as HTMLElement;
      }
    });
  }

We are creating an event listener, subscribing to it and our subscribe callback function gets the Event object as a parameter which is used to get the node it is applied on. In the loop we double-check if our node is actually an input or textarea node and if its content is editable, then use Electron menu object’s popup method to invoke the context menu. The method takes a config object parameter where we provide the current window for the menu to show up in.

The last finishing touch will be to properly clean up resources, in particular, to unsubscribe from our event listener when the directive is destroyed. We will use another Angular lifecycle hook called ngOnDestroy which is called when a directive or a component gets removed from the memory. The full directive will look like the following:

import { Directive, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { ElectronService } from 'ngx-electron';
import { fromEvent, Subscription } from 'rxjs';

// tslint:disable-next-line: directive-selector
@Directive({ selector: 'input[type=text],input[type=email],textarea' })
export class TextFieldContextMenuDirective implements OnInit, OnDestroy {

  private _subscription: Subscription;

  constructor(private elementRef: ElementRef, private electronService: ElectronService) {}

  ngOnInit() {
    const menu = this.buildMenu();
    this._subscription = fromEvent(this.elementRef.nativeElement, 'contextmenu')
    .subscribe((e: Event) => {
      e.preventDefault();
      e.stopPropagation();
      let node: HTMLElement = e.target as HTMLElement;
      while (node) {
        if (node.nodeName.match(/^(input|textarea)$/i) || node.isContentEditable) {
          menu.popup({ window: this.electronService.remote.getCurrentWindow() });
          break;
        }
        node = node.parentNode as HTMLElement;
      }
    });
  }

  ngOnDestroy() {
    this._subscription.unsubscribe();
  }

  private buildMenu() {
    const menu = new this.electronService.remote.Menu();
    const MenuItem = this.electronService.remote.MenuItem;
    menu.append(new MenuItem({ role: 'undo' }));
    menu.append(new MenuItem({ role: 'redo' }));
    menu.append(new MenuItem({ type: 'separator' }));
    menu.append(new MenuItem({ role: 'cut' }));
    menu.append(new MenuItem({ role: 'copy' }));
    menu.append(new MenuItem({ role: 'paste' }));
    menu.append(new MenuItem({ type: 'separator' }));
    menu.append(new MenuItem({ role: 'selectAll' }));
    return menu;
  }
}

So now when you right-click on any of our text boxes, you should see a context menu.

To conclude, we have created an Electron desktop application with an Angular application as its renderer process. By default, Electron does not provide context menu on text fields. To demonstrate how it can be easily achieved, we created an Angular directive and applied it to all required text input elements. We also saw a power of using multiple CSS selectors inside a directive to be able to selectively choose needed elements and apply logic to them. If you need to refer to a source code of this application, here is a link: https://github.com/pavloldev6/electron-angular-input-context-menu

References

https://alligator.io/angular/electron/

https://www.electronjs.org/