# Source: app/XMLEditorApp.js

    /**
     * A sample XML editor application which leverages {@link XMLEditor}.
     * <p>Also implements a number of interactive actions (static methods)
     * like <code>newDocument</code>, <code>closeDocument</code>, etc,
     * which may be used independently from <code>XMLEditorApp</code>.
     */
    export class XMLEditorApp extends HTMLElement {
        /**
         * Constructs a sample XML editor application, the implementation
         * of custom HTML element <code>&lt;xxe-app&gt;</code>.
         */
        constructor() {
            super();

            this._indicator = null;
            this._title = null;
            this._menuButton = null;
            this._onButtonMenuItemSelected =
                this.onButtonMenuItemSelected.bind(this);
            this._newButton = null;
            this._openButton = null;
            this._saveButton = null;
            this._saveAsButton = null;
            this._closeButton = null;
            this._xmlEditor = null;
            this._autoSave = null;
            this._checkLeaveApp = false;

            this._onBeforeUnload = this.onBeforeUnload.bind(this);
            this._onAnyRequest = this.onAnyRequest.bind(this);
            this._onAnyEvent = this.onAnyEvent.bind(this);
        }

        // -----------------------------------------------------------------------
        // Custom element
        // -----------------------------------------------------------------------

        connectedCallback() {
            const firstChild = this.firstElementChild;
            let cls = null;
            if (firstChild === null ||
                (cls = firstChild.getAttribute("class")) === null ||
                cls.indexOf("xxe-app-") < 0) {
                // A kind of "NOT SUPPORTED" message: remove it if it's useless.

                if (!browserEngineIsSupported()) {
                    let reason = `<strong>Sorry but your web browser
    is not supported.</strong><br /><br />
    In order to be supported, your web browser must run on <em>desktop</em>
    computer and must use the same browser engine as <em>recent</em> versions
    of <em>Chrome</em> or <em>Firefox</em>.`;

                    const userAgent = window.navigator.userAgent;
                    if (userAgent) {
                        reason += `<br />
    <small>(Your web browser identifies itself as:
    "<code>${userAgent}</code>".)</small>`;
                    }

                    if (firstChild === null) {
                        XUI.Util.removeAllChildren(this); // Remove text if any.
                        const para = document.createElement("p");
                        para.innerHTML = reason;
                        this.appendChild(para);
                    } else if (firstChild.classList.contains("xxe-not-supported")) {
                        firstChild.innerHTML = reason;
                    }
                    // Otherwise, keep the "NOT SUPPORTED" message as is.

                    // Give up. Do not attempt to load the app.
                    return;
                }

                XUI.Util.removeAllChildren(this);
            }

            // ---

            if (this.firstChild === null) {
                this.appendChild(XMLEditorApp.TEMPLATE.content.cloneNode(true));

                let div = this.firstElementChild;
                let children = div.children;
                this._indicator = children.item(0);
                this._indicator.onclick = this.onIndicatorClick.bind(this);

                this._title = children.item(1);

                this._menuButton = children.item(2);
                this._menuButton.textContent = XUI.StockIcon["menu"];
                this._menuButton.onclick = this.onMenuButtonClick.bind(this);

                // ---

                children = div.nextElementSibling.children;
                this._newButton = children.item(0);
                this._newButton.onclick = this.onNewButtonClick.bind(this);

                this._openButton = children.item(1);
                this._openButton.onclick = this.onOpenButtonClick.bind(this);

                this._saveButton = children.item(2);
                this._saveButton.onclick = this.onSaveButtonClick.bind(this);

                this._saveAsButton = children.item(3);
                this._saveAsButton.onclick = this.onSaveAsButtonClick.bind(this);

                this._closeButton = children.item(4);
                this._closeButton.onclick = this.onCloseButtonClick.bind(this);

                this.documentStorage = this.getAttribute("documentstorage");

                // ---

                const xxe = this.lastElementChild;
                this._xmlEditor = xxe;
                xxe.resourceStorage = new LocalFiles(xxe);
                xxe.addEventListener("connected", this.onConnected.bind(this));
                xxe.addEventListener("disconnected",
                                     this.onDisconnected.bind(this));
                const docOpenedHandler = this.onDocumentOpened.bind(this);
                xxe.addEventListener("documentCreated", docOpenedHandler);
                xxe.addEventListener("documentOpened", docOpenedHandler);
                xxe.addEventListener("documentRecovered", docOpenedHandler);
                xxe.addEventListener("documentSavedAs",
                                     this.onDocumentSavedAs.bind(this));
                xxe.addEventListener("documentClosed",
                                     this.onDocumentClosed.bind(this));
                xxe.addEventListener("saveStateChanged",
                                     this.onSaveStateChanged.bind(this));
                xxe.addEventListener("readOnlyStateChanged",
                                     this.onReadOnlyStateChanged.bind(this));

                this.onDisconnected(/*event*/ null);
                this.onDocumentClosed(/*event*/ null);

                window.addEventListener("unhandledrejection", (event) => {
                    XUI.Alert.showWarning(`SHOULD NOT HAPPEN: \
    unhandled promise rejection:\n${event.reason}`, xxe);
                });

                // ---

                this.serverURL = this.getAttribute("serverurl");
                this.clientProperties = this.getAttribute("clientproperties");
                this.autoRecover = this.hasAttribute("autorecover")?
                    (this.getAttribute("autorecover") === "true") : true;
                this.checkLeaveApp = this.hasAttribute("checkleaveapp")?
                    (this.getAttribute("checkleaveapp") === "true") : true;
                this.button2PastesText =
                    (this.getAttribute("button2pastestext") === "true");
                this.initAutoSave();
            }
            // Otherwise, already connected.
        }

        // -------------------------------
        // documentStorage
        // -------------------------------

        get documentStorage() {
            return this.getAttribute("documentstorage");
        }

        /**
         * Get/set the <code>documentStorage</code> property
         * of this XML editor application.
         * <p>The values of the <code>documentStorage</code> property are:
         * <dl>
         * <dt>"local"
         * <dd>Default value.
         * Create and edit only <i>local files</i>, that is, files which
         * are found on the computer running the web browser hosting this
         * XML editor application.
         * <dt>"remote"
         * <dd>Create and edit only <i>remote files</i>, that is, files which
         * are accessed on the server side by <code>xxeserver</code>.
         * <dt>"both"
         * <dd>Local files and remote files are both supported.
         * </dl>
         *
         * @type {string}
         */
        set documentStorage(storage) {
            if (storage) {
                storage = storage.trim();
            }
            if (!storage ||
                !(storage === "local" || storage === "remote" ||
                  storage === "both")) {
                storage = "local";
            }

            if (storage !== this.getAttribute("documentstorage")) {
                this.setAttribute("documentstorage", storage);
            }

            const buttons = [this._newButton, this._openButton];
            for (let button of buttons) {
                let detail = button.lastElementChild; // A span
                let newDetail = null;

                switch (storage) {
                case "local":
                case "remote":
                    if (detail.classList.contains("xui-small-icon")) {
                        newDetail = document.createElement("span");
                        newDetail.appendChild(document.createTextNode("\u2026"));
                    }
                    break;
                default: // "both"
                    if (!detail.classList.contains("xui-small-icon")) {
                        newDetail = document.createElement("span");
                        newDetail.setAttribute("class",
                                              "xui-small-icon xxe-app-button-menu");
                        newDetail.appendChild(
                            document.createTextNode(XUI.StockIcon["down-dir"]));
                    }
                    break;
                }

                if (newDetail !== null) {
                    button.replaceChild(newDetail, detail);
                }
            }
        }

        // -------------------------------
        // serverURL
        // -------------------------------

        get serverURL() {
            return this.getAttribute("serverurl");
        }

        /**
         * A cover for {@link XMLEditor#serverURL}.
         *
         * @type {string}
         */
        set serverURL(url) {
            this._xmlEditor.serverURL = url;

            url = this._xmlEditor.serverURL;
            if (url !== this.getAttribute("serverurl")) {
                this.setAttribute("serverurl", url);
            }
        }

        // -------------------------------
        // clientProperties
        // -------------------------------

        get clientProperties() {
            let props = this.getAttribute("clientproperties");
            if (props !== null && (props = props.trim()).length === 0) {
                props = null;
            }
            return props;
        }

        /**
         * A cover for {@link XMLEditor#clientProperties}.
         *
         * @type {string}
         */
        set clientProperties(props) {
            this._xmlEditor.clientProperties = props;

            props = this._xmlEditor.clientProperties;
            if (props === null) {
                this.removeAttribute("clientproperties");
            } else {
                this.setAttribute("clientproperties", props);
            }
        }

        // -------------------------------
        // autoRecover
        // -------------------------------

        get autoRecover() {
            return this.getAttribute("autorecover") === "true";
        }

        /**
         * A cover for {@link XMLEditor#autoRecover}.
         *
         * @type {boolean}
         */
        set autoRecover(recover) {
            this._xmlEditor.autoRecover = recover;

            const recoverValue = recover? "true" : "false";
            if (recoverValue !== this.getAttribute("autorecover")) {
                this.setAttribute("autorecover", recoverValue);
            }
        }

        // -------------------------------
        // checkLeaveApp
        // -------------------------------

        get checkLeaveApp() {
            // Quicker than accessing the attribute value.
            return this._checkLeaveApp;
        }

        /**
         * Get/set the <code>checkLeaveApp</code> property of this
         * XML editor application.
         * <p>Default: <code>true</code>. If <code>true</code> and
         * the document being edited has unsaved changes then
         * ask users to confirm if they really want to leave the page
         * and loose their changes.
         *
         * @type {boolean}
         */
        set checkLeaveApp(check) {
            this._checkLeaveApp = check;
            this.setBeforeUnloadListener(check && this._xmlEditor &&
                                         this._xmlEditor.saveNeeded);

            const checkValue = check? "true" : "false";
            if (checkValue !== this.getAttribute("checkleaveapp")) {
                this.setAttribute("checkleaveapp", checkValue);
            }
        }

        setBeforeUnloadListener(add) {
            /*
            console.log(`setBeforeUnloadListener(${add})`);
            */
            if (add) {
                window.addEventListener("beforeunload", this._onBeforeUnload);
            } else {
                window.removeEventListener("beforeunload", this._onBeforeUnload);
            }
        }

        onBeforeUnload(event) {
            if (this._xmlEditor && this._xmlEditor.saveNeeded) {
                event.preventDefault();
                // Legacy support for older browsers.
                // A function that returns true if the page has unsaved changes.
                return (event.returnValue = true);
            }
        }

        setLeaveAppChecker() {
            // Corresponds to best practice. See
            // https://developer.chrome.com/docs/web-platform/page-lifecycle-api
            //     #the_beforeunload_event
            if (this._checkLeaveApp) {
                this.setBeforeUnloadListener(this._xmlEditor.saveNeeded);
            }
        }

        // -------------------------------
        // button2PastesText
        // -------------------------------

        get button2PastesText() {
            return this.getAttribute("button2pastestext") === "true";
        }

        /**
         * A cover for {@link XMLEditor#button2PastesText}.
         *
         * @type {boolean}
         */
        set button2PastesText(pastes) {
            this._xmlEditor.button2PastesText = pastes;

            const pastesValue = pastes? "true" : "false";
            if (pastesValue !== this.getAttribute("button2pastestext")) {
                this.setAttribute("button2pastestext", pastesValue);
            }
        }

        // -------------------------------
        // autoSave
        // -------------------------------

        initAutoSave() {
            let spec = this.autoSave;
            let [ mode, interval, intervalMs, enabled ] =
                XMLEditorApp.parseAutoSave(spec);

            if (mode === null) {
                spec = null;
            } else {
                enabled = XMLEditorApp.getPreference("autoSave", enabled);
                spec = `${mode} ${interval} ${enabled? "on" : "off"}`;
            }
            this.autoSave = spec;
        }

        static parseAutoSave(spec) {
            let mode = null;
            let interval = null;
            let intervalMs = -1;
            let enabled = false;

            if (spec) {
                spec = spec.trim();

                for (let m of [ "both", "remote", "local" ]) {
                    if (spec.startsWith(m)) {
                        mode = m;
                        interval = spec.substring(m.length).trim(); // May be empty.
                        if (interval.endsWith("off")) {
                            enabled = false;
                            interval =
                                interval.substring(0, interval.length-3).trim();
                        } else if (interval.endsWith("on")) {
                            enabled = true;
                            interval =
                                interval.substring(0, interval.length-2).trim();
                        } else {
                            enabled = true;
                        }
                        break;
                    }
                }

                if (!FSAccess.isAvailable() &&
                    (mode === "both" || mode === "local")) {
                    mode = (mode === "local")? null : "remote";
                }

                if (mode !== null) {
                    if (!interval) {
                        interval = "30s";
                    }

                    // parseFloat ignores trailing char.
                    let intervalMs = parseFloat(interval);
                    if (!isNaN(intervalMs) && intervalMs > 0) {
                        if (interval.endsWith("s")) {
                            intervalMs *= 1000;
                        } else if (interval.endsWith("m")) {
                            intervalMs *= 1000 * 60;
                        } else if (interval.endsWith("h")) {
                            intervalMs *= 1000 * 60 * 60;
                        } else {
                            intervalMs = -1;
                        }
                    } else {
                        intervalMs = -1;
                    }

                    if (intervalMs <= 0) {
                        mode = null;
                    } else {
                        if (intervalMs < 10000) {
                            intervalMs = 10000;
                            interval = "10s";
                        }
                    }
                }
            }

            return [ mode, interval, intervalMs, enabled ];
        }

        get autoSave() {
            return this.getAttribute("autosave");
        }

        /**
         * Get/set the <code>autoSave</code> property of this
         * XML editor application.
         * <p>Default: none; files are not automatically saved.
         * <p>The value of this property is a string which has
         * the following syntax:
         * <pre>value -> mode [ S interval ]? [ S enabled ]?
         *mode -> local|remote|both
         *interval -> strictly_positive_number s|m|h
         *enabled -> on|off</pre>
         * <p>Examples: <code>"remote"</code>, <code>"both 2m"</code>,
         * <code>"remote 30s on"</code>, <code>"both off"</code>.
         * <p>Autosave modes are:
         * <ul>
         * <li><b>local</b>: automatically save local files
         * (when this is technically possible, i.e. on Chrome, not on Firefox).
         * <li><b>remote</b>: automatically save remote files.
         * <li><b>both</b>: automatically save both local and remote files.
         * </ul>
          * <p>Autosave interval units are:
         * <ul>
         * <li><b>s</b>: seconds.
         * <li><b>m</b>: minutes.
         * <li><b>h</b>: hours.
         * </ul>
         * <p>Default interval is <code>"30s"</code>.
         * Minimal interval is <code>"10s"</code>.
         * <p>The default value of <i>enabled</i> is <code>"on"</code>. This flag
         * specifies whether the autosave feature is <em>initially</em> enabled.
         * User may change this setting at anytime using the UI.
         *
         * @type {string}
         */
        set autoSave(spec) {
            if (this._autoSave !== null) {
                this._autoSave.dispose();
                this._autoSave = null;
            }

            // ---

            let [ mode, interval, intervalMs, enabled ] =
                XMLEditorApp.parseAutoSave(spec);

            if (mode === null) {
                this.removeAttribute("autosave");
                XMLEditorApp.setPreference("autoSave", null);
            } else {
                spec = `${mode} ${interval} ${enabled? "on" : "off"}`;
                this.setAttribute("autosave", spec);
                XMLEditorApp.setPreference("autoSave", enabled);

                if (enabled) {
                   this._autoSave = new AutoSave(this._xmlEditor, mode, intervalMs);
                }
            }
        }

        // -------------------------------
        // User preferences
        // -------------------------------

        static getPreference(key, defaultValue) {
            let prefs = XMLEditorApp.getPreferences();
            if (key in prefs) {
                return prefs[key];
            } else {
                return defaultValue;
            }
        }

        static getPreferences() {
            let prefs = null;
            let data = window.localStorage.getItem("XXE.XMLEditorApp.preferences");
            if (data !== null) {
                prefs = JSON.parse(data);
            }
            if (prefs === null || typeof prefs !== "object") {
                prefs = {};
            }

            return prefs;
        }

        static setPreference(key, value) {
            let prefs = XMLEditorApp.getPreferences();
            if (value === null) {
                delete prefs[key];
            } else {
                prefs[key] = value;
            }

            if (Object.keys(prefs).length === 0) {
                window.localStorage.removeItem("XXE.XMLEditorApp.preferences");
            } else {
                window.localStorage.setItem("XXE.XMLEditorApp.preferences",
                                            JSON.stringify(prefs));
            }
        }

        // -----------------------------------------------------------------------
        // Implementation
        // -----------------------------------------------------------------------

        // -------------------------------
        // The indicator as a button
        // -------------------------------

        onIndicatorClick(event) {
            const mod = PLATFORM_IS_MAC_OS? event.metaKey : event.ctrlKey;
            if (mod) {
                if (this._indicator.classList.contains("xxe-app-logging")) {
                    this._indicator.classList.remove("xxe-app-logging");

                    this._xmlEditor.removeRequestListener(this._onAnyRequest);
                    for (let eventType of XMLEditor.EVENT_TYPES) {
                        this._xmlEditor.removeEventListener(eventType,
                                                            this._onAnyEvent);
                    }
                } else {
                    this._indicator.classList.add("xxe-app-logging");

                    this._xmlEditor.addRequestListener(this._onAnyRequest);
                    for (let eventType of XMLEditor.EVENT_TYPES) {
                        this._xmlEditor.addEventListener(eventType,
                                                         this._onAnyEvent);
                    }
                }
            }
        }

        onAnyRequest(autoConnect, requestName, requestArgs, response) {
            let reqInfo;
            if (response !== undefined) {
                reqInfo = "\u25C0 " + requestName; // BLACK LEFT-POINTING TRIANGLE
            } else {
                if (autoConnect) {
                    reqInfo = "autoConnect\u25B6 ";
                } else {
                    reqInfo = "\u25B6 "; // BLACK RIGHT-POINTING TRIANGLE
                }
                reqInfo += requestName;
            }

            reqInfo += JSON.stringify(requestArgs, XMLEditorApp.jsonReplacer, 4);

            if (response !== undefined) {
                reqInfo += " \u2192 "; // RIGHT ARROW
                if (response instanceof Error) {
                    reqInfo += "Error: " + response.toString();
                } else {
                    reqInfo +=
                        JSON.stringify(response, XMLEditorApp.jsonReplacer, 4);
                }
            }

            this._xmlEditor.showStatus(reqInfo, /*autoErase*/ true);
        }

        static jsonReplacer(key, value) {
            if (value instanceof Blob) {
                let info = "(" + value.size + " bytes";
                if (value.type) {
                    info += ";" + value.type;
                }
                info += ")";
                return info;
            } else if (value instanceof ArrayBuffer ||
                       ArrayBuffer.isView(value)) { // Typed array or DataView
                return "[" + value.byteLength + " bytes]";
            } else {
                return value;
            }
        }

        onAnyEvent(event) {
            // Some events like DocumentOpenedEvent may be quite big.
            let text = event.toString();
            if (text.length > 10000) {
                text = text.substring(0, 5000) + "\u2026" +
                    text.substring(text.length-4999);
            }

            // BLACK UP-POINTING TRIANGLE
            this._xmlEditor.showStatus("\u25B2 " + text, /*autoErase*/ true);
        }

        // -------------------------------
        // App menu
        // -------------------------------

        onMenuButtonClick(event) {
            const menuItems = [
                // #0
                { type: "submenu", text: "Options", name: "optionsMenu",
                  items: [
                      { type: "checkbox", text: "Autosave",
                        name: "autoSaveToggle", selected: false, enabled: false }
                  ] },
                // #1
                { separator: true,
                  text: "Help", name: "help" },
                // #2
                { separator: true,
                  text: "Show Element Reference", name: "elementReference" },
                // #3
                { text: "Show Content Model", name: "contentModel" },
                // #4
                { separator: true,
                  text: "Mouse and Keyboard Bindings", name: "bindings" },
                // #5
                { separator: true,
                  text: `About ${XMLEditorApp.NAME}`, name: "about" }
            ];

            let autoSaving = this.autoSave;
            if (autoSaving !== null) {
                const optionItems = menuItems[0].items;
                optionItems[0].enabled = true; // autoSaveToggle
                if (autoSaving.endsWith("on")) {
                    optionItems[0].selected = true;
                }
            }

            if (this._xmlEditor.documentIsOpened) {
                if (!this._xmlEditor.configurationName) {
                    // Simplification: assume that all opened documents having
                    // a configuration have their "$c elementReference"
                    // system property set.
                    menuItems[2].enabled = false;
                }
                // Simplification: showContentModel always enabled.
                // Should be disabled when opened document is not
                // contrained by a grammar.
            } else {
                menuItems[2].enabled = false; // elementReference
                menuItems[3].enabled = false; // contentModel
                menuItems[4].enabled = false; // bindings
            }

            this.showButtonMenu(menuItems, this._menuButton, "comboboxmenu");
        }

        showButtonMenu(menuItems, button, position="menu") {
            const menu = XUI.Menu.create(menuItems);
            menu.addEventListener("menuitemselected",
                                  this._onButtonMenuItemSelected);
            menu.open(position, button);
        }

        onButtonMenuItemSelected(event) {
            switch (event.detail.menuItem.name) {
            case "newDocument":
                XMLEditorApp.newDocument(this._xmlEditor);
                break;
            case "newRemoteFile":
                XMLEditorApp.newRemoteFile(this._xmlEditor);
                break;

            case "openDocument":
                XMLEditorApp.openDocument(this._xmlEditor);
                break;
            case "openRemoteFile":
                XMLEditorApp.openRemoteFile(this._xmlEditor);
                break;

            case "autoSaveToggle":
                this.toggleAutoSave();
                break;
            case "help":
                this.showHelp();
                break;
            case "elementReference":
                this.showElementReference();
                break;
            case "contentModel":
                this.showContentModel();
                break;
            case "bindings":
                XUI.Alert.showInfo(this.getBindingsText(), this);
                break;
            case "about":
                XUI.Alert.showInfo(XMLEditorApp.ABOUT, this);
                break;
            }
        }

        toggleAutoSave() {
            let autoSaving = this.autoSave;
            if (autoSaving !== null) {
                let toggled = false;
                if (autoSaving.endsWith("on")) {
                    autoSaving =
                        autoSaving.substring(0, autoSaving.length-2) + "off";
                    toggled = true;
                } else if (autoSaving.endsWith("off")) {
                    autoSaving =
                        autoSaving.substring(0, autoSaving.length-3) + "on";
                    toggled = true;
                }

                if (toggled) {
                    this.autoSave = autoSaving;
                    if (this._xmlEditor.documentIsOpened) {
                        this.showAutoSaving(autoSaving.endsWith("on"));
                    }
                }
            }
        }

        showHelp() {
            const helpURL =
                "https://www.xmlmind.com/xmleditor/_web/doc/manual/wh/basics.html";

            // Direct user action: no problem with the popup blocker of the browser.
            window.open(helpURL, "xxewOnlineHelp");
        }

        showElementReference() {
            // Doing it this way prevents window.open from being blocked by the
            // popup blocker of the browser (Firefox only?).
            const helpWin = window.open("", "xxewElementReference");
            if (!helpWin) {
                docView.showStatus(
                    "Could not open window containing element reference.",
                    /*autoErase*/ true);
                return;
            }
            helpWin.focus(); // If "xxewElementReference" already opened.

            const docView = this._xmlEditor.documentView;
            docView.executeCommand(EXECUTE_NORMAL, "showElementReference", null)
                .then((result) => {
                    if (CommandResult.isDone(result) && result.value !== null) {
                        helpWin.location = result.value;
                    } else {
                        docView.showStatus("Element reference not available.",
                                           /*autoErase*/ true);
                        helpWin.close();
                    }
                });
                // Catch not useful. DocumentView.executeCommand handles errors
                // by returning a null Promise.
        }

        showContentModel() {
            const docView = this._xmlEditor.documentView;
            docView.executeCommand(EXECUTE_NORMAL, "showContentModel", null)
                .then((result) => {
                    if (result === null ||
                        result.status === COMMAND_RESULT_FAILED) {
                        docView.showStatus(
                            "Cannot show the content model of selected element.",
                            /*autoErase*/ true);
                    }
                });
        }

        getBindingsText() {
            const bindings = this._xmlEditor.documentView.bindings;
            if (bindings === null) {
                // Should not happen if a document is opened.
                return "???";
            }

            let rows = [];
            let rows2 = [];
            for (let binding of bindings) {
                let row = [ binding.getUserInputLabel(),
                            binding.commandName, binding.commandParams ];
                if (binding.userInput instanceof AppEvent) {
                    rows2.push(row);
                } else {
                    rows.push(row);
                }
            }
            rows.sort((r1, r2) => { return r1[0].localeCompare(r2[0], "en"); });
            if (rows2.length > 0) {
                rows2.sort((r1, r2) => {return r1[0].localeCompare(r2[0], "en");});
                rows.push(...rows2);
            }

            // ---

            let html = XMLEditorApp.BINDINGS;
            let pos = html.indexOf("</tbody>");
            if (pos > 0) {
                const tdStartTag = `<td ${XMLEditorApp.BINDINGS_TD_STYLE}>`;

                let tr = "";
                let trCount = 0;
                for (let row of rows) {
                    tr += "<tr";
                    if (trCount % 2 === 1) {
                        tr += " style=\"background-color:#FFFFE0;\""; //Light yellow
                    }
                    tr += ">\n";

                    tr += tdStartTag;
                    tr += XUI.Util.escapeHTML(row[0]);
                    tr += "</td>";
                    tr += tdStartTag;
                    tr += XUI.Util.escapeHTML(row[1]);
                    tr += "</td>";
                    tr += tdStartTag;
                    if (row[2] === null) {
                        tr += "\u00A0"; // nbsp
                    } else {
                        tr += XUI.Util.escapeHTML(row[2]);
                    }
                    tr += "</td>";
                    tr += "</tr>\n";

                    ++trCount;
                }

                html = html.substring(0, pos) + tr + html.substring(pos);
            }

            return html;
        }

        // -------------------------------
        // New button
        // -------------------------------

        onNewButtonClick(event) {
            switch (this.documentStorage) {
            case "local":
                XMLEditorApp.newDocument(this._xmlEditor);
                break;
            case "remote":
                XMLEditorApp.newRemoteFile(this._xmlEditor);
                break;
            default:
                {
                    const menuItems = [
                        { text: "New Local Document\u2026", name: "newDocument" },
                        { text: "New Remote Document\u2026", name: "newRemoteFile" }
                    ];
                    this.showButtonMenu(menuItems, this._newButton);
                }
                break;
            }
        }

        // -------------------------------
        // Open button
        // -------------------------------

        onOpenButtonClick(event) {
            switch (this.documentStorage) {
            case "local":
                XMLEditorApp.openDocument(this._xmlEditor);
                break;
            case "remote":
                XMLEditorApp.openRemoteFile(this._xmlEditor);
                break;
            default:
                {
                    const menuItems = [
                      { text: "Open Local Document\u2026", name: "openDocument" },
                      { text: "Open Remote Document\u2026", name: "openRemoteFile" }
                    ];
                    this.showButtonMenu(menuItems, this._openButton);
                }
                break;
            }
        }

        // -------------------------------
        // Save button
        // -------------------------------

        onSaveButtonClick(event) {
            XMLEditorApp.saveDocument(this._xmlEditor)
                .then((saved) => {
                    if (saved) {
                        this._xmlEditor.showStatus(
                            `Document saved to "${this._xmlEditor.documentURI}".`);
                    }
                });
        }

        // -------------------------------
        // Save As button
        // -------------------------------

        onSaveAsButtonClick(event) {
            XMLEditorApp.saveDocumentAs(this._xmlEditor)
                .then((savedAs) => {
                    if (savedAs) {
                        this._xmlEditor.showStatus(
                            `Document saved to "${this._xmlEditor.documentURI}".`);
                    }
                });
        }

        // -------------------------------
        // Close button
        // -------------------------------

        onCloseButtonClick(event) {
            XMLEditorApp.closeDocument(this._xmlEditor);
        }

        // -------------------------------
        // XMLEditor event handlers
        // -------------------------------

        onConnected(event) {
            this._indicator.classList.add("xxe-app-connected");

            this._indicator.setAttribute("title", `Connected to
    ${this._xmlEditor.serverURL}
    (${(PLATFORM_IS_MAC_OS? "Command" : "Ctrl")}-click to toggle logging \
    requests, responses and events.)`);
        }

        onDisconnected(event) {
            this._indicator.classList.remove("xxe-app-connected");

            this._indicator.setAttribute("title", `Disconnected from
    ${this._xmlEditor.serverURL}
    (${(PLATFORM_IS_MAC_OS? "Command" : "Ctrl")}-click to toggle logging \
    requests, responses and events.)`);

            this.enableButtons();
        }

        onDocumentOpened(event) {
            // On document opened or created or recovered or shared.
            this.showAutoSaving(true);

            this.removeSaveHint();
            this.addSaveHint();

            this.updateTitle();
            this.enableButtons();
            this.setLeaveAppChecker();
        }

        showAutoSaving(show) {
            let saveIcon = this._saveButton.firstElementChild;
            if (show && this._autoSave !== null && this._autoSave.activable) {
                saveIcon.classList.replace("xui-saveDocument-16",
                                           "xui-autoSaveDocument-16");
                saveIcon.setAttribute("title", "Autosave enabled.");
            } else {
                saveIcon.classList.replace("xui-autoSaveDocument-16",
                                           "xui-saveDocument-16");
                saveIcon.removeAttribute("title");
            }
        }

        addSaveHint() {
            if (!this._xmlEditor.isRemoteFile) {
                let tooltip;
                let addClass = null;
                if (FSAccess.isAvailable()) {
                    tooltip = `Please note that \
    the first time you'll use this "Save" button,\n\
    you may be asked to grant your permission to save this file to disk.`
                    addClass = "xxe-app-save-info";
                } else {
                    tooltip = `Please note that \
    DIRECTLY SAVING A FILE TO DISK IS NOT SUPPORTED\n\
    by this browser. Therefore "Save" is here equivalent to "Save As".`
                    addClass = "xxe-app-save-warn";
                }

                let saveHint = XMLEditorApp.SAVE_HINT_TEMPLATE.content
                    .cloneNode(true).firstElementChild;
                saveHint.textContent = XUI.StockIcon["comment"];
                saveHint.classList.add(addClass);
                this._saveButton.appendChild(saveHint);
                this._saveButton.setAttribute("title", tooltip);
            }
        }

        removeSaveHint() {
            let saveHint = this._saveButton.querySelector(".xxe-app-save-hint");
            if (saveHint !== null) {
                this._saveButton.removeChild(saveHint);
                this._saveButton.removeAttribute("title");
            }
        }

        onDocumentSavedAs(event) {
            this.updateTitle();
            this.enableButtons();
        }

        onDocumentClosed(event) {
            this.updateTitle();
            this.enableButtons();
            this.setLeaveAppChecker();

            this.showAutoSaving(false);

            this.removeSaveHint();
        }

        onSaveStateChanged(event) {
            this.updateTitle();
            this.enableButtons();
            this.setLeaveAppChecker();
        }

        onReadOnlyStateChanged(event) {
            this.updateTitle();
        }

        updateTitle() {
            let title = XMLEditorApp.TITLE;
            let style = "bold";

            if (this._xmlEditor.documentIsOpened) {
                title = this._xmlEditor.documentURI;
                style = "normal";

                if (this._xmlEditor.saveNeeded) {
                    title += " (modified)";
                }

                if (this._xmlEditor.readOnlyDocument) {
                    title += " (read-only)";
                }
            }

            if (title !== this._title.textContent) {
                this._title.textContent = title;
                this._title.style.fontWeight = style;
            }
        }

        enableButtons() {
            const connected = this._xmlEditor.connected;
            const docOpened = this._xmlEditor.documentIsOpened;

            XMLEditorApp.enableButton(this._saveButton,
                                      connected && docOpened &&
                                      this._xmlEditor.saveNeeded);
            XMLEditorApp.enableButton(this._saveAsButton, connected && docOpened);
            XMLEditorApp.enableButton(this._closeButton, connected && docOpened);
        }

        static enableButton(button, enabled) {
            if (enabled) {
                if (button.hasAttribute("disabled")) {
                    button.removeAttribute("disabled");
                    button.classList.remove("xui-control-disabled");
                }
            } else {
                if (!button.hasAttribute("disabled")) {
                    button.setAttribute("disabled", "disabled");
                    button.classList.add("xui-control-disabled");
                }
            }
        }

        // =======================================================================
        // API
        // =======================================================================

        /**
         * Get the {@link XMLEditor} used by this application.
         *
         * @type {XMLEditor}
         */
        get xmlEditor() {
            return this._xmlEditor;
        }

        // -----------------------------------------------------------------------
        // Interactive actions (may be used independently from XMLEditorApp)
        // -----------------------------------------------------------------------

        // ------------------------------------
        // newDocument, newRemoteFile
        // ------------------------------------

        /**
         * Open a newly created <em>local</em> document in specified XML editor.
         * <p>This static method in invoked by
         * <b>New</b>|<b>New Local Document</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static newDocument(xmlEditor) {
            return XMLEditorApp.doNewDocument(xmlEditor, /*remote*/ false);
        }

        /**
         * Open a newly created <em>remote</em> document in specified XML editor.
         * <p>This static method in invoked by
         * <b>New</b>|<b>New Remote Document</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static newRemoteFile(xmlEditor) {
            return XMLEditorApp.doNewDocument(xmlEditor, /*remote*/ true);
        }

        static doNewDocument(xmlEditor, remote) {
            let selectedTemplate = [];

            return XMLEditorApp.confirmDiscardChanges(xmlEditor)
                .then((confirmed) => {
                    if (confirmed) {
                        return NewDocumentDialog.showDialog(xmlEditor);
                    } else {
                        return null; // Template not selected.
                    }
                })
                .then((tmpl) => {
                    if (tmpl === null) {
                        // Canceled by user during any of the 2 previous steps.
                        return false; // Document not closed.
                    } else {
                        selectedTemplate.push(...tmpl);

                        return XMLEditorApp.doCloseDocument(xmlEditor);
                    }
                })
                .then((closed) => {
                    if (closed) {
                        let [category, template] = selectedTemplate;
                        if (remote) {
                            return xmlEditor.newRemoteFile(category, template);
                        } else {
                            return xmlEditor.newDocument(category, template);
                        }
                    } else {
                        return false;
                    }
                })
                .catch((error) => {
                    XUI.Alert.showError(`Cannot create document:\n${error}`,
                                        xmlEditor);
                    return false;
                });
        }

        static confirmDiscardChanges(xmlEditor) {
            if (!xmlEditor.documentIsOpened || !xmlEditor.saveNeeded) {
                // No changes.
                return Promise.resolve(true);
            }

            return XUI.Confirm.showConfirm(
                `"${xmlEditor.documentURI}" has been modified\nDiscard changes?`,
                /*reference*/ xmlEditor);
        }

        // ------------------------------------
        // openDocument, openRemoteFile
        // ------------------------------------

        /**
         * Open an existing <em>local</em> document in specified XML editor.
         * <p>This static method in invoked by
         * <b>Open</b>|<b>Open Local Document</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static openDocument(xmlEditor) {
            return XMLEditorApp.doOpenDocument(xmlEditor, /*remote*/ false);
        }

        /**
         * Open an existing <em>remote</em> document in specified XML editor.
         * <p>This static method in invoked by
         * <b>Open</b>|<b>Open Remote Document</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static openRemoteFile(xmlEditor) {
            return XMLEditorApp.doOpenDocument(xmlEditor, /*remote*/ true);
        }

        static doOpenDocument(xmlEditor, remote) {
            const options = {
                title: "Open Document",
                extensions: XMLEditorApp.ACCEPTED_FILE_EXTENSIONS,
                option: [ "readOnly", false,
                          "Open corresponding document in read-only mode" ]
            };

            let chosenFile = {};

            return XMLEditorApp.confirmDiscardChanges(xmlEditor)
                .then((confirmed) => {
                    if (confirmed) {
                        if (remote) {
                            return RemoteFileDialog.showDialog(xmlEditor, options);
                        } else {
                            return LocalFileDialog.showDialog(xmlEditor, options);
                        }
                    } else {
                        return null; // File not chosen.
                    }
                })
                .then((choice) => {
                    if (choice === null) {
                        // Canceled by user during any of the 2 previous steps.
                        return false; // Document not closed.
                    } else {
                        Object.assign(chosenFile, choice);

                        return XMLEditorApp.doCloseDocument(xmlEditor);
                    }
                })
                .then((closed) => {
                    if (closed) {
                        let readOnly =
                            !chosenFile.readOnly? false : chosenFile.readOnly;
                        if (remote) {
                            return xmlEditor.openRemoteFile(chosenFile.uri,
                                                            readOnly);
                        } else {
                            return xmlEditor.openDocument(chosenFile.file,
                                                          chosenFile.uri, readOnly);
                        }
                    } else {
                        return false; // Old doc not closed => new doc not opened.
                    }
                })
                .then((opened) => {
                    if (opened) {
                        if (!remote && chosenFile.file.handle) {
                            // chosenFile.file.handle is needed to save a local
                            // file without prompting the user.
                            // (This handle is lost when recovering a
                            // local document. This just means that the user
                            // will be prompted at the first save.)

                            // This "set handle" works because
                            // DocumentOpenedEvents (initalizing the state of
                            // newly opened document state) are received *before*
                            // the result of request openDocument.
                            xmlEditor.documentFileHandle = chosenFile.file.handle;
                        } else {
                            xmlEditor.documentFileHandle = null;
                        }
                    }
                    return opened;
                })
                .catch((error) => {
                    XUI.Alert.showError(`Cannot open document:\n${error}`,
                                        xmlEditor);
                    return false;
                });
        }

        // ------------------------------------
        // saveDocument
        // ------------------------------------

        /**
         * Save the document being edited in specified XML editor.
         * <p>This static method in invoked by button <b>Save</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static saveDocument(xmlEditor) {
            if (!xmlEditor.documentIsOpened || !xmlEditor.saveNeeded) {
                // Nothing to do.
                return Promise.resolve(false);
            }

            const remote = xmlEditor.isRemoteFile;
            if (xmlEditor.saveAsNeeded ||
                (!remote && xmlEditor.documentFileHandle === null)) {
                return XMLEditorApp.saveDocumentAs(xmlEditor);
            }

            return XMLEditorApp.doSaveDocument(xmlEditor, remote)
                .catch((error) => {
                    XUI.Alert.showError(`Could not save document:\n${error}`,
                                        xmlEditor);
                    return false;
                });
        }

        static doSaveDocument(xmlEditor, remote) {
            return XMLEditorApp.getDocumentContent(xmlEditor, remote)
                .then((docContent) => {
                    if (remote) {
                        // N/A.
                        return null;
                    } else {
                        // Non interactive.
                        return FSAccess.fileSave(docContent, /*options*/ {},
                                                 xmlEditor.documentFileHandle,
                                                 /*throwIfUnusableHandle*/ true);
                    }
                })
                .then((savedFileHandle) => {
                    if (!remote &&
                        savedFileHandle !== xmlEditor.documentFileHandle) {
                        throw new Error(`INTERNAL ERROR: expected \
    document file handle=${xmlEditor.documentFileHandle}, \
    got handle=${savedFileHandle}`);
                    }

                    return xmlEditor.saveDocument();
                })
        }

        static getDocumentContent(xmlEditor, remote) {
            if (remote) {
                // N/A.
                return Promise.resolve(null);
            }

            return xmlEditor.getDocument() // formattingOptions are found in config.
                .then((xmlSource) => {
                    if (xmlSource) {
                        // We only support "UTF-8" here.
                        xmlSource = xmlSource.replace(
                            /encoding=(('[^']*')|("[^"]*"))\s*\?>/,
                            "encoding=\"UTF-8\"?>");

                        return new Blob([ xmlSource ]);
                    } else {
                        // Should not happen.
                        throw new Error("no document content");
                    }
                });
        }

        // ------------------------------------
        // saveDocumentAs
        // ------------------------------------

        /**
         * Save the document being edited in specified XML editor
         * to a different location, the user being prompted to specify
         * this location.
         * <p>This static method in invoked by button <b>Save As</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static saveDocumentAs(xmlEditor) {
            const remote = xmlEditor.isRemoteFile;
            const options = {
                title: "Save Document",
                extensions: XMLEditorApp.ACCEPTED_FILE_EXTENSIONS,
                templateURI: xmlEditor.documentURI
            };
            let chosenFile = {};

            return XMLEditorApp.getDocumentContent(xmlEditor, remote)
                .then((docContent) => {
                    if (remote) {
                        return RemoteFileDialog.showDialog(xmlEditor, options,
                                                          /*saveMode*/ true);
                    } else {
                        return LocalFileDialog.showDialog(xmlEditor, options,
                                                          /*savedData*/ docContent);
                    }
                })
                .then((choice) => {
                    if (choice === null) {
                        // Canceled by user.
                        return false; // Document not saved as.
                    } else {
                        Object.assign(chosenFile, choice);

                        return xmlEditor.saveDocumentAs(chosenFile.uri);
                    }
                })
                .then((savedAs) => {
                    if (savedAs) {
                        if (!remote && chosenFile.fileHandle) {
                            xmlEditor.documentFileHandle = chosenFile.fileHandle;
                        } else {
                            xmlEditor.documentFileHandle = null;
                        }
                    }
                    return savedAs;
                })
                .catch((error) => {
                   let where = !chosenFile.uri? "" : ` "${chosenFile.uri}"`;
                   XUI.Alert.showError(`Cannot save document as${where}:\n${error}`,
                                       xmlEditor);
                   return false;
                });
        }

        // ------------------------------------
        // closeDocument
        // ------------------------------------

        /**
         * Close the document being edited in specified XML editor.
         * <p>If this document has unsaved changes, the user will have to confirm
         * whether she/he really wants to discard these changes.
         * <p>This static method in invoked by button <b>Close</b>
         * but may be used independently from <code>XMLEditorApp</code>.
         *
         * @param {XMLEditor} xmlEditor - the XML editor.
         */
        static closeDocument(xmlEditor) {
            return XMLEditorApp.confirmDiscardChanges(xmlEditor)
                .then((confirmed) => {
                    if (confirmed) {
                        return XMLEditorApp.doCloseDocument(xmlEditor);
                    } else {
                        return false; // Document not closed.
                    }
                })
                .catch((error) => {
                    XUI.Alert.showError(`Cannot close document:\n${error}`,
                                        xmlEditor);
                    return false;
                });
        }

        static doCloseDocument(xmlEditor) {
            if (!xmlEditor.documentIsOpened) {
                return Promise.resolve(true);
            }

            return xmlEditor.closeDocument(/*discardChanges*/ true);
        }
    }

    XMLEditorApp.NAME = "XMLmind XML Editor";

    XMLEditorApp.CLIENT_VERSION = "1.8.1";

    XMLEditorApp.TITLE =
        `${XMLEditorApp.NAME} Web Edition ${XMLEditorApp.CLIENT_VERSION}`;

    XMLEditorApp.SERVER_VERSION = "11.2.0";

    XMLEditorApp.ABOUT = `<html><h3 style="margin-top:0;">${XMLEditorApp.NAME}
    <span style="font-size:smaller;">client ${XMLEditorApp.CLIENT_VERSION} / \
    server ${XMLEditorApp.SERVER_VERSION}</span></h3>
    <p>Copyright (c) 2017-${(new Date()).getFullYear()} XMLmind Software,
    all rights reserved.</p>
    <p><b style="color:maroon;">&#x2023; Product end-of-life planned for \
    April 3, 2027.</b><br />
    For more information, please visit<br />
    www.xmlmind.com/xmleditor/</p>
    `;

    XMLEditorApp.BINDINGS_TD_STYLE =
        "style=\"border:1px solid #C0C0C0;padding:0.25em 0.5em;\"";

    XMLEditorApp.BINDINGS = `<html><h3 style="margin-top:0;">Mouse and keyboard \
    bindings</h3>
    <table style="border:1px solid #C0C0C0;border-collapse:collapse;">
      <thead style="background-color:#F0F0F0;">
        <tr>
          <th ${XMLEditorApp.BINDINGS_TD_STYLE}>User input</th>
          <th ${XMLEditorApp.BINDINGS_TD_STYLE}>Command</th>
          <th ${XMLEditorApp.BINDINGS_TD_STYLE}>Parameter</th>
        </tr>
      </thead>
      <tbody></tbody>
    </table>
    `;

    XMLEditorApp.TEMPLATE = document.createElement("template");
    XMLEditorApp.TEMPLATE.innerHTML = `
    <div class="xxe-app-title-bar">
      <span class="xui-control xxe-app-indicator"></span>
      <span class="xxe-app-title">XMLmind XML Editor</span>
      <span class="xxe-tool-button xxe-app-menu"></span>
    </div>
    <div class="xxe-app-button-bar">
      <button type="button" class="xui-control xxe-app-button"><span
        class="xui-edit-icon-16
            xui-newDocument-16"></span><span>New</span><span>\u2026</span></button>
      <button type="button" class="xui-control xxe-app-button"><span
        class="xui-edit-icon-16
          xui-openDocument-16"></span><span>Open</span><span>\u2026</span></button>
      <button type="button" class="xui-control xxe-app-button"><span
        class="xui-edit-icon-16
               xui-saveDocument-16"></span><span>Save</span></button>
      <button type="button" class="xui-control xxe-app-button"><span
        class="xui-edit-icon-16
             xui-saveDocumentAs-16"></span><span>Save As\u2026</span></button>
      <button type="button" class="xui-control xxe-app-button"><span
        class="xui-edit-icon-16
               xui-closeDocument-16"></span><span>Close</span></button>
    </div>
    <xxe-client></xxe-client>
    `;

    XMLEditorApp.SAVE_HINT_TEMPLATE = document.createElement("template");
    XMLEditorApp.SAVE_HINT_TEMPLATE.innerHTML = `
    <span class="xui-small-icon xxe-app-save-hint"></span>
    `;

    XMLEditorApp.ACCEPTED_FILE_EXTENSIONS = [
       ["XML files",
        "application/xml",
        "xml"],
       ["DocBook files",
        "application/docbook+xml",
        "xml", "dbk", "docb"],
       ["XHTML files",
        "application/xhtml+xml",
        "xhtml", "xht",
        "html", "shtml", "htm"],
       ["DITA files",
        "application/dita+xml",
        "dita", "ditamap",
        "bookmap", "ditaval"],
       ["MathML files",
        "application/mathml+xml",
        "mml"]
    ];

    window.customElements.define("xxe-app", XMLEditorApp);