Source: xxe/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() {
        let notSupported = this.firstElementChild;
        if (notSupported !== null &&
            notSupported.classList.contains("xxe-not-supported")) {
            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>`;
                }
                
                notSupported.innerHTML = reason;
                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.xuiMenuItem.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");
            }
        }
    }
    
    // =======================================================================
    // 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.5.0";

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

XMLEditorApp.SERVER_VERSION = "10.10.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>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);