From c25d00181602de2283af3b034401770c6d48903e Mon Sep 17 00:00:00 2001
From: "mathias.chouet" <mathias.chouet@irstea.fr>
Date: Fri, 23 Aug 2019 17:23:45 +0200
Subject: [PATCH] Fix #192 - add keyboard shortcuts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Alt + S save current session
Alt + O open session file
Alt + Q empty current session
Alt + N add new module
Alt + ↵ trigger calculation
Alt + D duplicate current module
Alt + W close current module
---
 package-lock.json                             | 19 +++++++
 package.json                                  |  3 +-
 src/app/app.component.html                    |  2 +-
 src/app/app.component.ts                      | 56 +++++++++++--------
 src/app/app.module.ts                         |  4 +-
 .../app-setup/app-setup.component.html        |  5 ++
 .../app-setup/app-setup.component.ts          | 13 +++++
 .../calculator.component.ts                   | 11 +++-
 src/app/config.json                           |  1 +
 .../services/app-setup/app-setup.service.ts   |  8 +++
 src/locale/messages.en.json                   |  1 +
 src/locale/messages.fr.json                   |  1 +
 12 files changed, 96 insertions(+), 28 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 46e495dd6..4c9a9bbd2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1087,6 +1087,11 @@
       "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
       "dev": true
     },
+    "@types/mousetrap": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.3.tgz",
+      "integrity": "sha512-13gmo3M2qVvjQrWNseqM3+cR6S2Ss3grbR2NZltgMq94wOwqJYQdgn8qzwDshzgXqMlSUtyPZjysImmktu22ew=="
+    },
     "@types/node": {
       "version": "12.6.8",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.6.8.tgz",
@@ -1456,6 +1461,15 @@
         "chart.js": "^2.3.0"
       }
     },
+    "angular2-hotkeys": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/angular2-hotkeys/-/angular2-hotkeys-2.1.4.tgz",
+      "integrity": "sha512-/KzgsrFjodoeZosXqsx1IvUo3rWBalSJ3QyVz2EALj1C0Woz84iNtXPZnlzuPNHrCmHcfOu28BNvIGBa+9Ving==",
+      "requires": {
+        "@types/mousetrap": "^1.6.0",
+        "mousetrap": "^1.6.0"
+      }
+    },
     "ansi": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz",
@@ -9713,6 +9727,11 @@
         "on-headers": "~1.0.1"
       }
     },
+    "mousetrap": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.3.tgz",
+      "integrity": "sha512-bd+nzwhhs9ifsUrC2tWaSgm24/oo2c83zaRyZQF06hYA6sANfsXHtnZ19AbbbDXCDzeH5nZBSQ4NvCjgD62tJA=="
+    },
     "move-concurrently": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
diff --git a/package.json b/package.json
index 54d37af7c..2fd54de46 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
     "@types/pako": "^1.0.1",
     "@types/sprintf-js": "^1.1.2",
     "angular2-chartjs": "^0.5.1",
+    "angular2-hotkeys": "^2.1.4",
     "chartjs-plugin-zoom": "^0.7.3",
     "cordova-android": "^8.0.0",
     "cordova-plugin-device": "^2.0.3",
@@ -104,4 +105,4 @@
       "android"
     ]
   }
-}
\ No newline at end of file
+}
diff --git a/src/app/app.component.html b/src/app/app.component.html
index b735b02bf..64b3c9ae9 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -130,7 +130,7 @@
 
     <mat-sidenav-content class="sidenav-content" fxFlexFill>
       <div id="app-content">
-        <router-outlet (activate)="onRouterOutletActivated($event)"></router-outlet>
+        <router-outlet></router-outlet>
       </div>
 
     </mat-sidenav-content>
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index b0dd4500e..9121bbd12 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,10 +1,8 @@
 import { Component, ApplicationRef, OnInit, OnDestroy, HostListener, ViewChild, ComponentRef } from "@angular/core";
 import { Router, Event, NavigationEnd, ActivationEnd, NavigationStart, NavigationCancel, NavigationError } from "@angular/router";
 import { MatDialog } from "@angular/material/dialog";
-import { MatIconRegistry } from "@angular/material/icon";
 import { MatSidenav } from "@angular/material/sidenav";
 import { MatToolbar } from "@angular/material/toolbar";
-import { DomSanitizer } from "@angular/platform-browser";
 import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
 
 import { Observer, jalhydDateRev, jalhydVersion, CalculatorType, Session } from "jalhyd";
@@ -25,6 +23,8 @@ import { DialogLoadSessionComponent } from "./components/dialog-load-session/dia
 import { DialogSaveSessionComponent } from "./components/dialog-save-session/dialog-save-session.component";
 import { NotificationsService } from "./services/notifications/notifications.service";
 
+import { HotkeysService, Hotkey } from "angular2-hotkeys";
+
 import * as pako from "pako";
 
 @Component({
@@ -70,15 +70,9 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
 
   private _innerWidth: number;
 
-  /**
-   * composant actuellement affiché par l'élément <router-outlet>
-   */
-  private _routerCurrentComponent: Component;
-
   constructor(
     private intlService: I18nService,
     private appSetupService: ApplicationSetupService,
-    private appRef: ApplicationRef,
     private errorService: ErrorService,
     private router: Router,
     private formulaireService: FormulaireService,
@@ -87,9 +81,8 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
     private confirmEmptySessionDialog: MatDialog,
     private saveSessionDialog: MatDialog,
     private loadSessionDialog: MatDialog,
-    private matIconRegistry: MatIconRegistry,
-    private domSanitizer: DomSanitizer,
-    private confirmCloseCalcDialog: MatDialog
+    private confirmCloseCalcDialog: MatDialog,
+    private hotkeysService: HotkeysService
   ) {
     ServiceFactory.instance.httpService = httpService;
     ServiceFactory.instance.applicationSetupService = appSetupService;
@@ -128,6 +121,28 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
         this.showLoading(false);
       }
     });
+
+    // hotkeys listeners
+    this.hotkeysService.add(new Hotkey("alt+s", AppComponent.onHotkey(this.saveForm, this)));
+    this.hotkeysService.add(new Hotkey("alt+o", AppComponent.onHotkey(this.loadSession, this)));
+    this.hotkeysService.add(new Hotkey("alt+q", AppComponent.onHotkey(this.emptySession, this)));
+    this.hotkeysService.add(new Hotkey("alt+n", AppComponent.onHotkey(this.toList, this)));
+  }
+
+  /**
+   * Wrapper for hotkeys triggers, that executes given function only if
+   * hotkeys are enabled in app preferences
+   * @param func function to execute when hotkey is entered
+   */
+  public static onHotkey(func: any, that: any) {
+    return (event: KeyboardEvent): boolean => {
+      if (ServiceFactory.instance.applicationSetupService.enableHotkeys) {
+        func.call(that);
+        return false; // Prevent bubbling
+      } else {
+        console.log("Hotkeys are disabled in app preferences");
+      }
+    };
   }
 
   /**
@@ -226,12 +241,12 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
             },
             disableClose: true
         }
-    );
-    dialogRef.afterClosed().subscribe(result => {
-        if (result) {
-            this.formulaireService.requestCloseForm(uid);
-        }
-    });
+      );
+      dialogRef.afterClosed().subscribe(result => {
+          if (result) {
+              this.formulaireService.requestCloseForm(uid);
+          }
+      });
     }
   }
 
@@ -426,13 +441,6 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
     this.setActiveCalc(id);
   }
 
-  /**
-   * récupération du composant affiché par le routeur
-   */
-  public onRouterOutletActivated(a) {
-    this._routerCurrentComponent = a;
-  }
-
   /**
    * restarts a fresh session by closing all calculators
    */
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index e1fb55a8b..26158718f 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -46,6 +46,7 @@ import { ChartModule } from "angular2-chartjs";
 import { RouterModule, Routes } from "@angular/router";
 import { NgxMdModule } from "ngx-md";
 import { StorageServiceModule } from "ngx-webstorage-service";
+import { HotkeyModule } from "angular2-hotkeys";
 
 import { FormulaireService } from "./services/formulaire/formulaire.service";
 import { I18nService } from "./services/internationalisation/internationalisation.service";
@@ -121,8 +122,9 @@ const appRoutes: Routes = [
     BrowserModule,
     ChartModule,
     DragDropModule,
-    HttpClientModule,
     FlexLayoutModule,
+    HotkeyModule.forRoot(),
+    HttpClientModule,
     MatBadgeModule,
     MatButtonModule,
     MatButtonToggleModule,
diff --git a/src/app/components/app-setup/app-setup.component.html b/src/app/components/app-setup/app-setup.component.html
index a8bef4b4c..d7f7a0ae6 100644
--- a/src/app/components/app-setup/app-setup.component.html
+++ b/src/app/components/app-setup/app-setup.component.html
@@ -72,6 +72,11 @@
           {{ uitextEnableNotifications }}
         </mat-checkbox>
 
+        <!-- hotkeys -->
+        <mat-checkbox name="hotkeys" [(ngModel)]="enableHotkeys" [ngModelOptions]="{standalone: true}">
+          {{ uitextEnableHotkeys }}
+        </mat-checkbox>
+
         <!-- langue -->
         <mat-form-field>
             <mat-select [placeholder]="uitextLanguage" [(value)]="currentLanguageCode" data-testid="language-select">
diff --git a/src/app/components/app-setup/app-setup.component.ts b/src/app/components/app-setup/app-setup.component.ts
index c1eafaed8..dff9caef6 100644
--- a/src/app/components/app-setup/app-setup.component.ts
+++ b/src/app/components/app-setup/app-setup.component.ts
@@ -61,6 +61,15 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer
         this.appSetupService.enableNotifications = v;
     }
 
+    /** hotkeys (keyboard shortcuts) */
+    public get enableHotkeys(): boolean {
+        return this.appSetupService.enableHotkeys;
+    }
+
+    public set enableHotkeys(v: boolean) {
+        this.appSetupService.enableHotkeys = v;
+    }
+
     public get uitextTitle(): string {
         return this.intlService.localizeText("INFO_SETUP_TITLE");
     }
@@ -85,6 +94,10 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer
         return this.intlService.localizeText("INFO_SETUP_ENABLE_NOTIFICATIONS");
     }
 
+    public get uitextEnableHotkeys(): string {
+        return this.intlService.localizeText("INFO_SETUP_ENABLE_HOTKEYS");
+    }
+
     public get uitextMustBeANumber(): string {
         return this.intlService.localizeText("ERROR_PARAM_MUST_BE_A_NUMBER");
     }
diff --git a/src/app/components/generic-calculator/calculator.component.ts b/src/app/components/generic-calculator/calculator.component.ts
index ef034b6f0..7b4ae6960 100644
--- a/src/app/components/generic-calculator/calculator.component.ts
+++ b/src/app/components/generic-calculator/calculator.component.ts
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router";
 
 import { Observer, Session, Cloisons, Pab, ParamValueMode, CalculatorType } from "jalhyd";
 
+import { AppComponent } from "../../app.component";
 import { FormulaireService } from "../../services/formulaire/formulaire.service";
 import { I18nService } from "../../services/internationalisation/internationalisation.service";
 import { FieldSet } from "../../formulaire/fieldset";
@@ -23,6 +24,8 @@ import { DialogConfirmCloseCalcComponent } from "../dialog-confirm-close-calc/di
 import { DialogGeneratePABComponent } from "../dialog-generate-pab/dialog-generate-pab.component";
 import { PabTable } from "../../formulaire/pab-table";
 
+import { HotkeysService, Hotkey } from "angular2-hotkeys";
+
 @Component({
     selector: "hydrocalc",
     templateUrl: "./calculator.component.html",
@@ -110,11 +113,17 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit,
         private router: Router,
         private confirmCloseCalcDialog: MatDialog,
         private generatePABDialog: MatDialog,
-        private _elementRef: ElementRef
+        private _elementRef: ElementRef,
+        private hotkeysService: HotkeysService
     ) {
         super();
         this.intlService = ServiceFactory.instance.i18nService;
         this.formulaireService = ServiceFactory.instance.formulaireService;
+
+        // hotkeys listeners
+        this.hotkeysService.add(new Hotkey("alt+w", AppComponent.onHotkey(this.closeCalculator, this)));
+        this.hotkeysService.add(new Hotkey("alt+d", AppComponent.onHotkey(this.cloneCalculator, this)));
+        this.hotkeysService.add(new Hotkey("alt+enter", AppComponent.onHotkey(this.doCompute, this)));
     }
 
     public get formElements(): FormulaireElement[] {
diff --git a/src/app/config.json b/src/app/config.json
index 0eb401ba2..5160a729c 100644
--- a/src/app/config.json
+++ b/src/app/config.json
@@ -4,6 +4,7 @@
         "computePrecision": 0.0001,
         "newtonMaxIterations": 50,
         "enableNotifications": true,
+        "enableHotkeys": false,
         "language": "fr"
     },
     "themes": [
diff --git a/src/app/services/app-setup/app-setup.service.ts b/src/app/services/app-setup/app-setup.service.ts
index fc2a72ede..8ac7a2ee7 100644
--- a/src/app/services/app-setup/app-setup.service.ts
+++ b/src/app/services/app-setup/app-setup.service.ts
@@ -20,6 +20,7 @@ export class ApplicationSetupService extends Observable {
     public computePrecision = 0.0001;
     public newtonMaxIterations = 50;
     public enableNotifications = true;
+    public enableHotkeys = false;
 
     /**
      * just stores the current language preference, does not transmit it to I18nService, that is
@@ -85,6 +86,7 @@ export class ApplicationSetupService extends Observable {
         this.storage.set(this.LOCAL_STORAGE_PREFIX + "computePrecision", this.computePrecision);
         this.storage.set(this.LOCAL_STORAGE_PREFIX + "newtonMaxIterations", this.newtonMaxIterations);
         this.storage.set(this.LOCAL_STORAGE_PREFIX + "enableNotifications", this.enableNotifications);
+        this.storage.set(this.LOCAL_STORAGE_PREFIX + "enableHotkeys", this.enableHotkeys);
         this.storage.set(this.LOCAL_STORAGE_PREFIX + "language", this.language);
     }
 
@@ -127,6 +129,11 @@ export class ApplicationSetupService extends Observable {
             this.enableNotifications = enableNotifications;
             loadedKeys.push("enableNotifications");
         }
+        const enableHotkeys = this.storage.get(this.LOCAL_STORAGE_PREFIX + "enableHotkeys");
+        if (enableHotkeys !== undefined) {
+            this.enableHotkeys = enableHotkeys;
+            loadedKeys.push("enableHotkeys");
+        }
         const language = this.storage.get(this.LOCAL_STORAGE_PREFIX + "language");
         if (language !== undefined) {
             this.language = language;
@@ -145,6 +152,7 @@ export class ApplicationSetupService extends Observable {
             this.computePrecision = data.params.computePrecision;
             this.newtonMaxIterations = data.params.newtonMaxIterations;
             this.enableNotifications = data.params.enableNotifications;
+            this.enableHotkeys = data.params.enableHotkeys;
             this.language = data.params.language;
             // load themes for calculators list page
             this.themes = data.themes;
diff --git a/src/locale/messages.en.json b/src/locale/messages.en.json
index c107256cb..08c4f8864 100644
--- a/src/locale/messages.en.json
+++ b/src/locale/messages.en.json
@@ -371,6 +371,7 @@
     "INFO_RESULTS_EXPORT_AS_SPREADSHEET": "Export as XLSX",
     "INFO_SECTIONPARAMETREE_TITRE_COURT": "Param. section",
     "INFO_SECTIONPARAMETREE_TITRE": "Parametric section",
+    "INFO_SETUP_ENABLE_HOTKEYS": "Enable keyboard shortcuts",
     "INFO_SETUP_ENABLE_NOTIFICATIONS": "Enable on-screen notifications",
     "INFO_SETUP_LANGUAGE": "Language",
     "INFO_SETUP_NEWTON_MAX_ITER": "Newton iteration limit",
diff --git a/src/locale/messages.fr.json b/src/locale/messages.fr.json
index 478772b1b..de9d932ff 100644
--- a/src/locale/messages.fr.json
+++ b/src/locale/messages.fr.json
@@ -370,6 +370,7 @@
     "INFO_RESULTS_EXPORT_AS_SPREADSHEET": "Exporter en XLSX",
     "INFO_SECTIONPARAMETREE_TITRE_COURT": "Sec. param.",
     "INFO_SECTIONPARAMETREE_TITRE": "Section paramétrée",
+    "INFO_SETUP_ENABLE_HOTKEYS": "Activer les raccourcis clavier",
     "INFO_SETUP_ENABLE_NOTIFICATIONS": "Activer les notifications à l'écran",
     "INFO_SETUP_LANGUAGE": "Langue",
     "INFO_SETUP_NEWTON_MAX_ITER": "Newton : nombre d'itérations maximum",
-- 
GitLab