Skip to content
Snippets Groups Projects
app.component.ts 30.4 KiB
Newer Older
import { Component, OnInit, OnDestroy, HostListener, ViewChild, ElementRef, isDevMode } from "@angular/core";
import { Router, Event, NavigationEnd, ActivationEnd } from "@angular/router";
mathias.chouet's avatar
mathias.chouet committed
import { MatDialog } from "@angular/material/dialog";
import { MatSidenav } from "@angular/material/sidenav";
import { MatToolbar } from "@angular/material/toolbar";
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
francois.grand's avatar
francois.grand committed

import { Observer, jalhydDateRev, jalhydVersion, CalculatorType, Session } from "jalhyd";
import { I18nService } from "./services/internationalisation.service";
import { FormulaireService } from "./services/formulaire.service";
David Dorchies's avatar
David Dorchies committed
import { FormulaireDefinition } from "./formulaire/definition/form-definition";
import { ServiceFactory } from "./services/service-factory";
import { HttpService } from "./services/http.service";
import { ApplicationSetupService } from "./services/app-setup.service";
import { nghydDateRev, nghydVersion } from "../date_revision";
import { DialogConfirmCloseCalcComponent } from "./components/dialog-confirm-close-calc/dialog-confirm-close-calc.component";
import { DialogConfirmEmptySessionComponent } from "./components/dialog-confirm-empty-session/dialog-confirm-empty-session.component";
import { DialogLoadSessionComponent } from "./components/dialog-load-session/dialog-load-session.component";
import { DialogSaveSessionComponent } from "./components/dialog-save-session/dialog-save-session.component";
import { QuicknavComponent } from "./components/quicknav/quicknav.component";
import { NotificationsService } from "./services/notifications.service";
import { decodeHtml } from "./util/util";
import { HotkeysService, Hotkey } from "angular2-hotkeys";

import { MatomoTracker } from "@ngx-matomo/tracker";
import { saveAs } from "file-saver";

import * as XLSX from "xlsx";

import * as pako from "pako";
import { DialogConfirmComponent } from "./components/dialog-confirm/dialog-confirm.component";
import { UserConfirmationService } from "./services/user-confirmation.service";
import { ServiceWorkerUpdateService } from "./services/service-worker-update.service";
francois.grand's avatar
francois.grand committed
@Component({
mathias.chouet's avatar
mathias.chouet committed
    selector: "nghyd-app",
    templateUrl: "./app.component.html",
    styleUrls: ["./app.component.scss"]
francois.grand's avatar
francois.grand committed
})
export class AppComponent implements OnInit, OnDestroy, Observer {
mathias.chouet's avatar
mathias.chouet committed
    @ViewChild("sidenav")
    public sidenav: MatSidenav;

    @ViewChild("navbar")
    public navbar: MatToolbar;

    /** current calculator, inferred from _currentFormId by setActiveCalc() (used for navbar menu) */
    public currentCalc: any;

    /** shows or hides the progressbar under the navbar */
    public showProgressBar = false;

    /** liste des modules de calcul ouverts */
    private _calculators: Array<{
        title: string,
        type: CalculatorType,
        uid: string,
        active?: boolean,
        latestAnchor?: string
    }> = [];

    /**
     * id du formulaire courant
     * on utilise pas directement FormulaireService.currentFormId pour éviter l'erreur
     * ExpressionChangedAfterItHasBeenCheckedError
     */
    private _currentFormId: string;

    private _innerWidth: number;

    constructor(
        private intlService: I18nService,
        private appSetupService: ApplicationSetupService,
        private router: Router,
        private formulaireService: FormulaireService,
        private httpService: HttpService,
        private notificationsService: NotificationsService,
        private confirmEmptySessionDialog: MatDialog,
        private saveSessionDialog: MatDialog,
        private loadSessionDialog: MatDialog,
        private confirmCloseCalcDialog: MatDialog,
        private hotkeysService: HotkeysService,
        private matomoTracker: MatomoTracker,
        private serviceWorkerUpdateService: ServiceWorkerUpdateService,
        private userConfirmationService: UserConfirmationService
mathias.chouet's avatar
mathias.chouet committed
    ) {
        ServiceFactory.httpService = httpService;
        ServiceFactory.applicationSetupService = appSetupService;
        ServiceFactory.i18nService = intlService;
        ServiceFactory.formulaireService = formulaireService;
        ServiceFactory.notificationsService = notificationsService;
        ServiceFactory.serviceWorkerUpdateService = serviceWorkerUpdateService;
mathias.chouet's avatar
mathias.chouet committed

        if (!isDevMode()) {
            // évite de mettre en place un bandeau RGPD
            this.matomoTracker.disableCookies();
toto's avatar
toto committed
            // Set custom dimension for Electron / pure Web browser
mathias.chouet's avatar
mathias.chouet committed
            this.matomoTracker.setCustomDimension(1, this.getRunningPlatform());
mathias.chouet's avatar
mathias.chouet committed
        }
mathias.chouet's avatar
mathias.chouet committed

        this.router.events.subscribe((event: Event) => {
            // close side navigation when clicking a calculator tab
            if (event instanceof NavigationEnd) {
                this.sidenav.close();
                window.scrollTo(0, 0);
            }
            // [de]activate calc tabs depending on loaded route
            if (event instanceof ActivationEnd) {
                const path = event.snapshot.url[0].path;
                if (path === "calculator") {
                    const calcUid = event.snapshot.params.uid;
                    if (this.calculatorExists(calcUid)) {
                        this.setActiveCalc(calcUid);
                    } else {
                        // if required calculator does not exist, redirect to list page
                        this.toList();
                    }
                } else {
                    this.setActiveCalc(null);
                }
            }
        });

        // 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)));
        this.hotkeysService.add(new Hotkey("alt+g", AppComponent.onHotkey(this.toDiagram, 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.applicationSetupService.enableHotkeys) {
                func.call(that);
                return false; // Prevent bubbling
            } else {
                console.log("Hotkeys are disabled in app preferences");
            }
        };
    }

    public static exportAsImage(canvas: HTMLCanvasElement) {
        canvas.toBlob((blob) => {
            AppComponent.download(blob, "chart.png", "image/png");
        }); // defaults to image/png
    }

    /**
     * Exports a results data table to XLSX format, and removes "help" mentions
     * from the parameters names columns if needed
     * @param table results data table
     * @param namesInFirstCol if true, will look for parameters names in 1st column
     *    (for fixed results), else in 1st row (variable results, by default)
     */
    public static exportAsSpreadsheet(table: ElementRef, namesInFirstCol: boolean = false) {
        const ws: XLSX.WorkSheet = XLSX.utils.table_to_sheet(table);

        let regExCellKey;
        if (namesInFirstCol) {
            regExCellKey = new RegExp("^A\\d$");
        } else {
            regExCellKey = new RegExp("^\\w1$");
mathias.chouet's avatar
mathias.chouet committed
        // browse all cells
        for (const key in ws) {
            // look for 1st row or 1st col
            if (regExCellKey.test(key) === true) {
                const regExCellName = new RegExp("help$");
                const v: string = ws[key].v;
                // remove "help" from cell name's ending
                if (regExCellName.test(v) === true) {
                    const newV = v.substr(0, v.length - 4);
                    ws[key].v = newV;
                }
            }
        AppComponent.downloadSpreadsheet(ws);
    }
    /**
     * Adds the given XLSX worksheet to a new XLSX workbook and triggers its download
     */
    public static downloadSpreadsheet(ws: XLSX.WorkSheet) {
mathias.chouet's avatar
mathias.chouet committed
        const wb: XLSX.WorkBook = XLSX.utils.book_new();
        XLSX.utils.book_append_sheet(wb, ws, "default");
        const wopts: any = { bookType: "xlsx", bookSST: false, type: "array" };
        const wbout = XLSX.write(wb, wopts);
        AppComponent.download(
            new Blob([wbout], { type: "application/octet-stream" }),
            "cassiopee-results.xlsx",
            "application/vnd.ms-excel"
        );
    }

    /**
toto's avatar
toto committed
     * file download method
mathias.chouet's avatar
mathias.chouet committed
     * @param blob binary object to download
     * @param filename
     * @param mimeType
     */
    public static download(blob: Blob, filename: string, mimeType: string) {
toto's avatar
toto committed
        saveAs(blob, filename);
mathias.chouet's avatar
mathias.chouet committed
    }

    /**
     * Triggered at app startup.
     * Preferences are loaded by app setup service
     * @see ApplicationSetupService.construct()
     */
    ngOnInit() {
        this.formulaireService.addObserver(this);
        this._innerWidth = window.innerWidth;
        this.logRevisionInfo();

        // Initialise communication with UserConfirmationService.
        // When receiving a message from it, open a dialog to ask user to confirm.
        // Will then reply to UserConfirmationService with a message holding confirmation status.
        this.userConfirmationService.subscribe(this);
        this.userConfirmationService.addHandler(this, {
            next: (data) => this.displayConfirmationDialog(data["title"], data["body"]),
            error: () => { },
            complete: () => { },
        });
mathias.chouet's avatar
mathias.chouet committed
    }

    ngOnDestroy() {
        this.formulaireService.removeObserver(this);

        // cancel communication link with UserConfirmationService
        this.userConfirmationService.unsubscribe(this);
mathias.chouet's avatar
mathias.chouet committed
    }

    @HostListener("window:resize", ["$event"])
    onResize(event) {
        // keep track of window size for navbar tabs arrangement
        this._innerWidth = window.innerWidth;
    }

    public get uitextSidenavNewCalc() {
        return this.intlService.localizeText("INFO_MENU_NOUVELLE_CALC");
    }

    public get uitextSidenavParams() {
        return this.intlService.localizeText("INFO_SETUP_TITLE");
    }

    public get uitextSidenavLoadSession() {
        return this.intlService.localizeText("INFO_MENU_LOAD_SESSION_TITLE");
    }

    public get uitextSidenavSaveSession() {
        return this.intlService.localizeText("INFO_MENU_SAVE_SESSION_TITLE");
    }

    public get uitextSidenavEmptySession() {
        return this.intlService.localizeText("INFO_MENU_EMPTY_SESSION_TITLE");
    }

    public get uitextSidenavDiagram() {
        return this.intlService.localizeText("INFO_MENU_DIAGRAM_TITLE");
    }

    public get uitextSidenavSessionProps() {
        return this.intlService.localizeText("INFO_MENU_SESSION_PROPS");
    }

    public get uitextSidenavReportBug() {
        return this.intlService.localizeText("INFO_MENU_REPORT_BUG");
    }

    public get uitextSidenavHelp() {
        return this.intlService.localizeText("INFO_MENU_HELP_TITLE");
    }

    public get uitextSelectCalc() {
        return this.intlService.localizeText("INFO_MENU_SELECT_CALC");
    }

mathias.chouet's avatar
mathias.chouet committed
    public get uitextSearch() {
        return this.intlService.localizeText("INFO_MENU_RECHERCHE_MODULES");
    }

mathias.chouet's avatar
mathias.chouet committed
    public getCalculatorLabel(t: CalculatorType) {
        return decodeHtml(this.formulaireService.getLocalisedTitleFromCalculatorType(t));
    }

    public getFullCalculatorTitle(calc: { title: string, type: CalculatorType, active?: boolean }): string {
        return decodeHtml(calc.title + " (" + this.getCalculatorLabel(calc.type) + ")");
    }

    public get calculators() {
        return this._calculators;
    }

    public get currentFormId() {
        return this._currentFormId;
    }

    public get currentRoute(): string {
        return this.router.url;
    }

    public get currentCalcUid(): string {
        return this.currentCalc ? this.currentCalc.uid : undefined;
    }

    public setActiveCalc(uid: string) {
        this._calculators.forEach((calc) => {
            calc.active = (calc.uid === uid);
        });
        // mark current calc for navbar menu
        const index = this.getCalculatorIndexFromId(uid);
        this.currentCalc = this._calculators[index];
    }

    /**
     * Close calculator using middle click on tab
     */
    public onMouseUp(event: any, uid: string) {
        if (event.which === 2) {
            const dialogRef = this.confirmCloseCalcDialog.open(
                DialogConfirmCloseCalcComponent,
                {
                    data: {
                        uid: uid
                    },
                    disableClose: true
                }
mathias.chouet's avatar
mathias.chouet committed
            dialogRef.afterClosed().subscribe(result => {
                if (result) {
                    this.formulaireService.requestCloseForm(uid);
                }
            });
        }
    }

    /**
     * Returns true if sum of open calculator tabs witdh is lower than navbar
     * available space (ie. if navbar is not overflowing), false otherwise
     */
    public get tabsFitInNavbar() {
        // manual breakpoints
        // @WARNING keep in sync with .calculator-buttons sizes in app.component.scss
        let tabsLimit = 0;
        if (this._innerWidth > 480) {
            tabsLimit = 3;
        }
        if (this._innerWidth > 640) {
            tabsLimit = 4;
        }
        if (this._innerWidth > 800) {
            tabsLimit = 6;
        }

        const fits = this._calculators.length <= tabsLimit;
        return fits;
    }

    public get enableHeaderDoc(): boolean {
mathias.chouet's avatar
mathias.chouet committed
        return this.currentRoute.includes("/list") && this._calculators.length === 0;
mathias.chouet's avatar
mathias.chouet committed
    }

    public get enableSaveSessionMenu(): boolean {
        return this._calculators.length > 0;
    }

    public get enableModulesDiagramMenu(): boolean {
        return this._calculators.length > 0;
    }

    public get enableSessionPropertiesMenu(): boolean {
        return this._calculators.length > 0;
    }

    public get enableEmptySessionMenu(): boolean {
        return this._calculators.length > 0;
    }

    // interface Observer

    public update(sender: any, data: any): void {
        if (sender instanceof FormulaireService) {
            switch (data["action"]) {
                case "createForm":
                    // add newly created form to calculators list
                    const f: FormulaireDefinition = data["form"];
                    this._calculators.push(
                        {
                            "title": f.calculatorName,
                            "type": f.calculatorType,
                            "uid": f.uid
                        }
                    );

                    // abonnement en tant qu'observateur du nouveau formulaire
                    f.addObserver(this);
                    break;

                case "invalidFormId":
                    this.toList();
                    break;

                case "currentFormChanged":
                    this._currentFormId = data["formId"];
                    break;

                case "saveForm":
                    this.saveForm(data["form"]);
                    break;

                case "closeForm":
                    const form: FormulaireDefinition = data["form"];
                    this.closeCalculator(form);
                    break;
            }
        } else if (sender instanceof FormulaireDefinition) {
            switch (data["action"]) {
                case "nameChanged":
                    this.updateCalculatorTitle(sender, data["name"]);
                    break;
            }
        }
    }

    /**
     * Returns true if a form having "formUid" as UID exists
     * @param formId UID to look for
     */
    private calculatorExists(formId: string): boolean {
        return (this.getCalculatorIndexFromId(formId) > -1);
    }

    private getCalculatorIndexFromId(formId: string) {
        const index = this._calculators.reduce((resultIndex, calc, currIndex) => {
            if (resultIndex === -1 && calc["uid"] === formId) {
                resultIndex = currIndex;
            }
            return resultIndex;
        }, -1);

        return index;
    }

    private updateCalculatorTitle(f: FormulaireDefinition, title: string) {
        const formIndex = this.getCalculatorIndexFromId(f.uid);
        this._calculators[formIndex]["title"] = title;
    }

    /**
     * Saves a JSON serialised session file, for one or more calc modules
     * @param calcList modules to save
     * @param filename
     */
    private saveSession(calcList: any[], filename: string) {
        const session: string = this.buildSessionFile(calcList);
        if (!isDevMode()) {
            this.matomoTracker.trackEvent("userAction", "saveSession");
        }
        this.formulaireService.downloadTextFile(session, filename);
    }

    /**
     * Builds a session file including Nubs, GUI-specific Nubs metadata,
     * model settings, GUI settings
     * @param calcList Nubs to save
     */
    private buildSessionFile(calcList: any[]): string {
        const serialiseOptions: { [key: string]: {} } = {};
        for (const c of calcList) {
            if (c.selected) {
                serialiseOptions[c.uid] = { // GUI-dependent metadata to add to the session file
                    title: c.title
                };
            }
        }
        const settings = {
            precision: this.appSetupService.computePrecision,
            maxIterations: this.appSetupService.maxIterations,
            displayPrecision: this.appSetupService.displayPrecision,
        };
        return Session.getInstance().serialise(serialiseOptions, settings);
    }

    /**
     * Supprime un module de calcul **de l'interface**
     * ATTENTION, ne supprime pas le module de calcul en mémoire !
     * Pour cela, utiliser FormulaireService.requestCloseForm(form.uid);
     * @param form module de calcul à fermer
     */
    private closeCalculator(form: FormulaireDefinition) {
        const formId: string = form.uid;

        // désabonnement en tant qu'observateur
        form.removeObserver(this);
        // recherche du module de calcul correspondant à formId
        const closedIndex = this.getCalculatorIndexFromId(formId);

        /*
         * détermination du nouveau module de calcul à afficher :
         * - celui après celui supprimé
         * - ou celui avant celui supprimé si on supprime le dernier
         */
        let newId = null;
        const l = this._calculators.length;
        if (l > 1) {
            if (closedIndex === l - 1) {
                newId = this._calculators[closedIndex - 1]["uid"];
            } else {
                newId = this._calculators[closedIndex + 1]["uid"];
            }
        }

        // suppression

        this._calculators = this._calculators.filter(calc => {
            return formId !== calc["uid"];
        });

        // MAJ affichage

        if (newId === null) {
            this.toList();
            this._currentFormId = null;
        } else {
            this.toCalc(newId);
        }
    }

    private toList() {
        this.router.navigate(["/list"]);
    }

    public toDiagram() {
        this.router.navigate(["/diagram"]);
    }

    public toNotes() {
        this.router.navigate(["/properties"]);
    }

mathias.chouet's avatar
mathias.chouet committed
    public toCalc(id: string) {
        this.router.navigate(["/calculator", id]);
        this.setActiveCalc(id);
        setTimeout(() => { // @WARNING clodo trick to wait for Angular refresh
            this.scrollToLatestQuicknav(id);
        }, 50);
    }

    /**
     * restarts a fresh session by closing all calculators
     */
    public emptySession() {
        const dialogRef = this.confirmEmptySessionDialog.open(
            DialogConfirmEmptySessionComponent,
            { disableClose: true }
mathias.chouet's avatar
mathias.chouet committed
        dialogRef.afterClosed().subscribe(result => {
            if (result) {
                this.doEmptySession();
            }
        });
    }

    public doEmptySession() {
        // temporarily disable notifications
        const oldNotifState = this.appSetupService.enableNotifications;
        this.appSetupService.enableNotifications = false;
        // close all calculators
        for (const c of this._calculators) {
            const form = this.formulaireService.getFormulaireFromId(c.uid);
            this.formulaireService.requestCloseForm(form.uid);
        }
        // just to be sure, get rid of any Nub possibly stuck in session without any form attached
        Session.getInstance().clear();
        Session.getInstance().documentation = "";
        // just to be sure, get rid of any Formulaire possibly stuck in the service without any Nub attached
        this.formulaireService.clearFormulaires();
mathias.chouet's avatar
mathias.chouet committed
        // restore notifications
        this.appSetupService.enableNotifications = oldNotifState;
    }

    public loadSession() {
        // création du dialogue de sélection des formulaires à sauver
        const dialogRef = this.loadSessionDialog.open(
            DialogLoadSessionComponent,
            { disableClose: false }
        );
        dialogRef.afterClosed().subscribe(result => {
            if (result) {
                if (result.emptySession) {
                    this.doEmptySession();
                }
                this.loadSessionFile(result.file, result.calculators);
            }
        });
    }

    public async loadSessionFile(f: File|string, info?: any) {
mathias.chouet's avatar
mathias.chouet committed
        // notes merge detection: was there already some notes ?
        const existingNotes = Session.getInstance().documentation;
        // load
        try {
            const data = await this.formulaireService.loadSession(f, info);
            if (data.hasErrors) {
                this.notificationsService.notify(this.intlService.localizeText("ERROR_PROBLEM_LOADING_SESSION"), 3500);
            } else {
                if (data.loaded && data.loaded.length > 0) {
                    if (!isDevMode()) {
                        this.matomoTracker.trackEvent("userAction", "loadSession");
                    }
                    // notes merge detection: was there already some notes ?
                    const currentNotes = Session.getInstance().documentation;
                    if (existingNotes !== "" && currentNotes !== existingNotes) {
                        this.notificationsService.notify(this.intlService.localizeText("WARNING_SESSION_LOAD_NOTES_MERGED"), 3500);
                    }
                    // go to calc or diagram depending on what was loaded
                    if (data.loaded.length > 1 && currentNotes) {
                        this.toNotes();
                    }
                    else if(data.loaded.length > 1 && !currentNotes) {
                        this.toDiagram()
                    }
                     else {
                        this.toCalc(data.loaded[0]);
            }
        } catch (err) {
            this.notificationsService.notify(this.intlService.localizeText("ERROR_LOADING_SESSION"), 3500);
            console.error("error loading session - ", err);
            // rollback to ensure session is clean
            this.doEmptySession();
        }
mathias.chouet's avatar
mathias.chouet committed
    }

    /**
     * Demande au client d'envoyer un email (génère un lien mailto:), pré-rempli
     * avec un texte standard, et le contenu de la session au format JSON
     */
    public reportBug() {
        const recipient = "bug@cassiopee.g-eau.fr";
        const subject = "[ISSUE] " + this.intlService.localizeText("INFO_REPORT_BUG_SUBJECT");
        let body = this.intlService.localizeText("INFO_REPORT_BUG_BODY");

        // add session description

        // get all forms
        const list = [];
        for (const c of this._calculators) {
            list.push({
                title: c.title,
                uid: c.uid,
                selected: true
            });
        }
        let session = this.buildSessionFile(list);

        // compress
        session = pako.deflate(session, { to: "string" }); // gzip (zlib)
        session = btoa(session); // base64

        body += session + "\n";
        body = encodeURIComponent(body);

        const mailtoURL = `mailto:${recipient}?subject=${subject}&body=${body}`;

        // temporarily disable tab closing alert, as tab won't be closed for real
        this.appSetupService.warnBeforeTabClose = false;
        window.location.href = mailtoURL;
        this.appSetupService.warnBeforeTabClose = true;
    }

    public get revisionInfo(): any {
        return {
            jalhyd: {
                date: jalhydDateRev,
                version: jalhydVersion,
mathias.chouet's avatar
mathias.chouet committed
            nghyd: {
                date: nghydDateRev,
                version: nghydVersion
            }
        };
    }

    private logRevisionInfo() {
        const ri = this.revisionInfo;
        console.log("JaLHyd", ri.jalhyd.date, ri.jalhyd.version);
        console.log("ngHyd", ri.nghyd.date, ri.nghyd.version);
    }

mathias.chouet's avatar
mathias.chouet committed
    /**
     * sauvegarde du/des formulaires
     * @param form formulaire à sélectionner par défaut dans la liste
     */
    public saveForm(form?: FormulaireDefinition) {
        // liste des formulaires
        const list = [];
        for (const c of this._calculators) {
            const uid = c["uid"];
            const nub = Session.getInstance().findNubByUid(uid);
            let required = nub.getTargettedNubs().map((req) => {
                return req.uid;
            });
            required = required.filter(
                (item, index) => required.indexOf(item) === index // deduplicate
            );
            list.push({
                "children": nub.getChildren().map((child) => {
                    return child.uid;
                }),
                "requires": required,
                "selected": form ? (uid === form.uid) : true,
                "title": c["title"],
                "uid": uid
            });
mathias.chouet's avatar
mathias.chouet committed
        // dialogue de sélection des formulaires à sauver
        const dialogRef = this.saveSessionDialog.open(
            DialogSaveSessionComponent,
mathias.chouet's avatar
mathias.chouet committed
                data: {
                    calculators: list
                },
                disableClose: false
mathias.chouet's avatar
mathias.chouet committed
        );
        dialogRef.afterClosed().subscribe(result => {
            if (result) {
                let name = result.filename;

                // ajout extension ".json"
                const re = /.+\.json/;
                const match = re.exec(name.toLowerCase());
                if (match === null) {
                    name = name + ".json";
                }

                this.saveSession(result.calculators, name);
            }
        });
    }

    /**
     * Moves the view to one of the Quicknav anchors in the page, and saves this anchor
     * as the latest visited, in _calculators list
     * @param itemId a Quicknav anchor id (ex: "input" or "results")
mathias.chouet's avatar
mathias.chouet committed
    public scrollToQuicknav(itemId: string, behavior: ScrollBehavior = "smooth") {
        const idx = this.getCalculatorIndexFromId(this.currentFormId);
        let succeeded = false;
        if (idx > -1) {
            const id = QuicknavComponent.prefix + itemId;
            // Scroll https://stackoverflow.com/a/56391657/5986614
            const element = document.getElementById(id);
            if (element && element.offsetParent !== null) { // offsetParent is null when element is not visible
                const yCoordinate = element.getBoundingClientRect().top + window.pageYOffset;
                window.scrollTo({
                    top: yCoordinate - 60, // substract a little more than navbar height
                    behavior: behavior
                });
                succeeded = true;
                // Save position
                this._calculators[idx].latestAnchor = itemId;
            }
mathias.chouet's avatar
mathias.chouet committed
        if (!succeeded) {
            // throw an error so that caller CalculatorComponent.scrollToResults()
            // switches to plan B, in case we're trying to scroll to results pane
            // after a module is calculated
            throw Error("unable to scroll to quicknav anchor");
mathias.chouet's avatar
mathias.chouet committed
    }

    /**
     * Moves the view to the latest known Quicknav anchor of the current module
     */
    public scrollToLatestQuicknav(formId: string) {
        // Get position
        const idx = this.getCalculatorIndexFromId(formId);
        if (idx > -1) {
            const itemId = this._calculators[idx].latestAnchor;
            // Scroll
            if (itemId) {
                this.scrollToQuicknav(itemId, "auto");
            }
mathias.chouet's avatar
mathias.chouet committed

mathias.chouet's avatar
mathias.chouet committed
    public dropCalcButton(event: CdkDragDrop<string[]>) {
        moveItemInArray(this.calculators, event.previousIndex, event.currentIndex);
mathias.chouet's avatar
mathias.chouet committed
    public get docIndexPath(): string {
        return "assets/docs/" + this.appSetupService.language + "/index.html";
    }

    /**
     * Returns a string representing the running platform :
toto's avatar
toto committed
     * "electron", or "browser"
mathias.chouet's avatar
mathias.chouet committed
     */
    public getRunningPlatform(): string {
        let runningPlatform = "browser";
        if (navigator.userAgent.toLowerCase().indexOf("electron") > -1) {
            runningPlatform = "electron";
        }
        // console.log(">> running platform: ", runningPlatform);
        return runningPlatform;
    }

mathias.chouet's avatar
mathias.chouet committed
    /**
     * détection de la fermeture de la page/navigateur et demande de confirmation
     */
    @HostListener("window:beforeunload", [ "$event" ]) confirmExit($event) {
        if (
        this.appSetupService.warnBeforeTabClose
        && ! isDevMode() // otherwise prevents dev server to reload app after recompiling
        ) {
        // affecter une valeur différente de null provoque l'affichage d'un dialogue de confirmation, mais le texte n'est pas affiché
        $event.returnValue = "Your data will be lost !";
        }
mathias.chouet's avatar
mathias.chouet committed
    @HostListener("keydown", ["$event"]) onKeyDown($event: any) {
        if ($event.which === 38 || $event.which === 40) { // up / down arrow
            if ($event.srcElement.type === "number") {
                $event.preventDefault();

    /**
     * display a confirmation display upon request from UserConfirmationService
     */
    private displayConfirmationDialog(title: string, text: string) {
        const dialogRef = this.confirmDialog.open(
            DialogConfirmComponent,
            {
                data: {
                    title: title,
                    text: text
                },
                disableClose: true
            }
        );
        dialogRef.afterClosed().subscribe(result => {
            // reply to UserConfirmationService
            this.userConfirmationService.postConfirmation(this, { "confirm": result });
        });
    }