# Source: editor/XMLEditor.js

    /**
     * A web-based XML editor which is implemented as a client of
     * <code>xxeserver</code>, the
     * <a href="https://www.xmlmind.com/xmleditor/">XMLmind XML Editor</a>
     * WebSocket server.
     */
    export class XMLEditor extends HTMLElement {
        /**
         * Constructs an XML editor, the implementation of custom
         * HTML element <code>&lt;xxe-client&gt;</code>.
         */
        constructor() {
            super();

            this._attrBeingChanged = null;
            this._client = null; // Initialized by setting attribute "serverurl".

            this._clipboardIntegration = new ClipboardIntegration(this);

            this._toolBar = null;
            this._searchReplace = null;
            this._nodePath = null;
            this._validateTool = null;
            this._statusTool = null;
            this._clipboardTool = null;
            this._docView = null;

            this._extensionModules = {}

            this.clearDocumentState();

            this._resourceStorage = new ResourceStorage(this); // Dummy impl.
            this._remoteFileStorage = new RemoteFiles(this);
            this._resourceCache = {};

            let listeners = {};
            for (let type of XMLEditor.EVENT_TYPES) {
                listeners[type] = [];
            }
            this._eventListeners = listeners;
            this._requestListeners = [];
        }

        clearDocumentState() {
            this._documentUID = null;
            this._documentURL = null;
            this._namespacePrefixes = null;
            this._readOnlyDocument = false;
            this._saveNeeded = false;
            this._saveAsNeeded = false;
            this._diffSupport = 0;
            this._configurationName = null;
            this._contextualCmdStrings = null;
            this._commandStates = null;
            this._documentFileHandle = null;
        }

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

        connectedCallback() {
            if (this.firstChild === null) {
                if (!this.hasAttribute("button2pastestext")) {
                    this.button2PastesText = false;
                }

                if (!this.hasAttribute("autoconnect")) {
                    this.autoConnect = true;
                }

                if (!this.hasAttribute("autorecover")) {
                    this.autoRecover = true;
                }

                this.serverURL = this.getAttribute("serverurl");

                this.appendChild(XMLEditor.TEMPLATE.content.cloneNode(true));

                this._toolBar = this.querySelector("xxe-tool-bar");
                this._toolBar.xmlEditor = this;

                this._searchReplace = null; // Created and displayed on demand.

                this._nodePath = this.querySelector("xxe-node-path");
                this._nodePath.xmlEditor = this;

                this._validateTool = this.querySelector("xxe-validate-tool");
                this._validateTool.xmlEditor = this;

                this._statusTool = this.querySelector("xxe-status-tool");
                this._statusTool.xmlEditor = this;

                this._clipboardTool = this.querySelector("xxe-clipboard-tool");
                this._clipboardTool.xmlEditor = this;

                this._docView = this.querySelector("xxe-document-view");
                this._docView.xmlEditor = this;

                this.reset();

                let openFileArgs = null;
                if (window.name && window.name.startsWith("[")) {
                    try {
                        openFileArgs = JSON.parse(window.name);
                        if (Array.isArray(openFileArgs) &&
                            openFileArgs.length === 3 &&
                            openFileArgs[0] === "openRemoteFile") {
                            window.name = ""; // No longer needed.
                        } else {
                            openFileArgs = null;
                        }
                    } catch {}
                }

                // IMPORTANT! give time to client to configure this editor in
                // window.onload.
                setTimeout(() => {
                    if (this.autoRecover) {
                        this.initAutoRecoverInfo();
                    }

                    if (openFileArgs !== null) {
                        this.openRemoteFile(openFileArgs[1], openFileArgs[2]);
                    } else {
                        if (this.autoRecover) {
                            this.autoRecoverDocument();
                        }
                    }
                }, 250 /*ms*/);
            }
            // Otherwise, already connected.
        }

        reset() {
            this.unconfigure();
        }

        unconfigure() {
            this._resourceCache = {};

            this.clearDocumentState();

            this._docView.setView(null);
            this._toolBar.toolSetsChanged(/*toolSetSpecs*/ null);
            if (this._searchReplace !== null) {
                this._searchReplace.documentEdited(/*docUID*/ null, /*R/O*/ false);
            }
            this._nodePath.nodePathChanged(/*items*/ null);
            this._validateTool.validityStateChanged(/*event*/ null);
            this._clipboardTool.clipboardUpdated(/*update*/ null);
            this._clipboardIntegration.clipboardUpdated(/*update*/ null);
        }

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

        allClientProperties() {
            // For use by Client.js.
            // Add "system" client properties to user specified client properties.
            let props = this.clientProperties;

            let addedProps = [];
            const info = Intl.DateTimeFormat().resolvedOptions();
            let value = info.locale; // e.g. "en-US".
            if (value) {
                addedProps.push("XXE_CLIENT_LOCALE", value);
            }
            value = info.timeZone; // e.g. "Europe/Paris".
            if (value) {
                addedProps.push("XXE_CLIENT_TIME_ZONE", value);
            }
            const addedPropCount = addedProps.length;
            if (addedPropCount === 0) {
                return props;
            }

            if (props === null) {
                props = "";
            }
            for (let i = 0; i < addedPropCount; i += 2) {
                const key = addedProps[i] + "=";
                const value = addedProps[i+1];

                if (props.indexOf(key) < 0) {
                    if (props.length > 0) {
                        props += ";";
                    }
                    props += key + value;
                }
            }

            return props;
        }

        /**
         * Get/set the <code>clientProperties</code> property of this XML editor.
         * <p>Client properties consists in a number of property name/property
         * value pairs which are typically used to associate the user of
         * this XML editor with the XXE server connection (<code>XMLEditor</code>
         * server-side peer).
         * <p>On the server side, these client properties are seen by the
         * <code>XMLEditor</code> server-side  peer as Java system properties,
         * which makes them usable in different contexts (macros,
         * access to remote file systems, etc).
         * <p>This syntax of the <code>clientProperties</code> property is:
         * <pre>properties = property [ ';' property ]*
         *property = name '=' value</pre>
         * <p>In a property value, character <code>';'</code> may be escaped
         * as <code>'\u003B'</code>. Example:
         * <pre>user=john;group=reviewers\u003Bauthors;DAV.password=changeit</pre>
         * <p>Default value: no client properties.
         *
         * @type {string}
         */
        set clientProperties(props) {
            if (props === null || (props = props.trim()).length === 0) {
                this.removeAttribute("clientproperties");
            } else {
                this.setAttribute("clientproperties", props);
            }
        }

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

        /**
         * Get/set the <code>button2PastesText</code> property of this XML editor.
         * <p>If <code>true</code>, selecting text by dragging the mouse
         * automatically copies this text to a dedicated private clipboard. Then
         * clicking button #2 (middle button) elsewhere pastes copied text
         * at the clicked location. This allows to emulate the
         * <dfn>X Window Primary Selection</dfn> on all platforms.
         * <p>Note that the
         * <a href="https://en.wikipedia.org/wiki/X_Window_selection">X Window
         * Primary Selection</a> is not natively supported on platforms
         * where it should be (e.g. Linux) because there is no JavaScript API
         * or clever trick allowing to update the Primary Selection
         * without updating the System Clipboard at the same time.
         * <p>Default value: <code>false</code>.
         *
         * @type {boolean}
         */
        set button2PastesText(pastes) {
            this.setAttribute("button2pastestext", pastes? "true" : "false");
            if (this._docView !== null) {
                this._docView.button2PastesText = pastes;
            }
        }

        get autoConnect() {
            return this.getAttribute("autoconnect") === "true";
        }

        /**
         * Get/set the <code>autoConnect</code> property of this XML editor.
         * <p>If <code>true</code>, automatically connect to the server
         * when the first time any <code>listDocumentTemplates</code>,
         * <code>newDocument</code>, <code>newRemoteFile</code>,
         * <code>openDocument</code> or <code>openRemoteFile</code> is invoked.
         * <p>Default value: <code>true</code>.
         *
         * @type {boolean}
         */
        set autoConnect(connect) {
            this.setAttribute("autoconnect", connect? "true" : "false");
        }

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

        /**
         * Get/set the <code>autoRecover</code> property of this XML editor.
         * <p>If <code>true</code>, automatically recover the document being
         * edited when the connection was lost by this XMLEditor without first
         * explicitely closing the document. This happens for example, when
         * the user clicks the <b>Reload</b> button of the web browser.
         * <p>Default value: <code>true</code>.
         *
         * @type {boolean}
         */
        set autoRecover(recover) {
            this.setAttribute("autorecover", recover? "true" : "false");
        }

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

        /**
         * Get/set the <code>serverURL</code> property of this XML editor,
         * which specifies the <code>ws://</code> or <code>wss://</code> URL
         * of the XMLmind XML Editor websocket server.
         * <p>Default value: automatically determined using the URL of
         * the HTML page containing this XMLEditor.
         *
         * @type {string}
         */
        set serverURL(url) {
            this._attrBeingChanged = "serverurl";

            if (!url || !/^((wss?)|(\$\{protocol\})):\/\//.test(url)) {
                url = "${protocol}://${hostname}:${port}/xxe/ws";
            }

            if (url.indexOf("${") >= 0) {
                const secure = (window.location.protocol === "https:");
                let wsProtocol = secure? "wss" : "ws";
                let wsHostname = window.location.hostname;
                let wsPort = window.location.port;
                if (!wsPort) {
                    wsPort = secure? "443" : "80";
                }
                let wsDefaultPort = secure? "18079" : "18078";

                const pairs = [
                    [ "${protocol}", wsProtocol ],
                    [ "${hostname}", wsHostname ],
                    [ "${port}", wsPort ],
                    [ "${defaultPort}", wsDefaultPort ]
                ];
                for (let [ref, value] of pairs) {
                    if (url.indexOf(ref) >= 0) {
                        url = url.replaceAll(ref, value);
                    }
                }
            }

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

            if (!this._client || url !== this._client.serverURL) {
                if (this._client && this._client.connected) {
                    this._client.disconnect();
                }
                this._client = new Client(this, url);
            }

            this._attrBeingChanged = null;
        }

        initAutoRecoverInfo() {
            // We really need an ID for this element.
            if (!this.id) {
                // The elements in the NodeList are in document order.
                const instances = document.querySelectorAll("xxe-client");
                for (let i = 0; i < instances.length; ++i) {
                    if (Object.is(instances[i], this)) {
                        this.id = `__XXE_${String(1+i)}`;
                        break;
                    }
                }
                if (!this.id) {
                    // Should not happen.
                    this.id = "__XXE";
                }
            }

            // All window.localStorage databases are private to a web browser.
            // There is one window.localStorage database per scheme/host/port tuple.
            this._documentUIDKey =
                window.location.pathname + "#" + this.id + ";documentUID";
        }

        autoRecoverDocument() {
            if (!this.autoConnect || !this.autoRecover) {
                // Options seem to have changed.
                window.localStorage.removeItem(this._documentUIDKey);
                return;
            }

            let info = this.getAutoRecoverInfo();
            /*
            this.logAutoRecoverInfo("autoRecoverDocument>>>", info);
            */
            this.pruneAutoRecoverInfo(info);
            /*
            this.logAutoRecoverInfo("autoRecoverDocument>>>(pruned)>>>", info);
            */
            if (info.length > 0) {
                this.doAutoRecoverDoc(info, 0);
            }
        }

        getAutoRecoverInfo() {
            let info = [];
            let value = window.localStorage.getItem(this._documentUIDKey);
            if (value !== null) {
                let parsed = null;
                try {
                    parsed = JSON.parse(value);
                    if (!Array.isArray(parsed) ||
                        parsed.length === 0 || !Array.isArray(parsed[0])) {
                        parsed = null;
                    }
                } catch {}

                if (parsed === null) {
                    // Get rid of incompatible data. (Comes from an old version?)
                    window.localStorage.removeItem(this._documentUIDKey);
                } else {
                    info = parsed;
                }
            }

            return info;
        }

        pruneAutoRecoverInfo(info) {
            const prevLength = info.length;
            const now = Date.now(); // ms since Epoch.
            const maxDelta = 24*3600*1000; // 24 hours in ms.

            while (info.length > 0) {
                let entry = info[0];
                if (now - entry[1] < maxDelta) {
                    // Done.
                    break;
                }
                info.splice(0, 1); // Too old. Discard.
            }

            if (info.length !== prevLength) {
                this.storeAutoRecoverInfo(info);
            }
        }

        storeAutoRecoverInfo(info) {
            if (info.length === 0) {
                window.localStorage.removeItem(this._documentUIDKey);
            } else {
                window.localStorage.setItem(this._documentUIDKey,
                                            JSON.stringify(info));
            }
        }

        logAutoRecoverInfo(message, info) {
            console.log(message + "; '" + this._documentUIDKey + "'=\n" +
                        XMLEditor.formatAutoRecoverInfo(info));
        }

        static formatAutoRecoverInfo(info) {
            let formatted = "---";
            for (let i = 0; i < info.length; ++i) {
                formatted += "\n#" + i.toString() + " " +
                    XMLEditor.formatAutoRecoverEntry(info[i]);
            }
            formatted += "\n---"
            return formatted;
        }

        static formatAutoRecoverEntry(entry) {
            return `${entry[0]} ${(new Date(entry[1])).toLocaleString()}`;
        }

        doAutoRecoverDoc(info, from) {
            if (from >= info.length) {
                // Done.
                return;
            }
            /*
            this.logAutoRecoverInfo("doAutoRecoverDoc(info," + from + ")>>>", info);
            */
            const entry = info[from];

            this.recoverDocument(entry[0])
                .then((recovered) => {
                    if (!recovered) {
                        // May be this one corresponds to a document currently
                        // opened in another XMLEditor.
                        // Ignore this one and try to recover next one.
                        this.doAutoRecoverDoc(info, from+1);
                    }
                    // Otherwise, done.
                }, (error) => {
                    // Otherwise, an error. May be the server is
                    // temporarily down or unreachable. Give up.

                    const xmlEditorLocation =
                          window.location.href + "#" + this.id;
                    console.error(`XMLEditor[${xmlEditorLocation}]: \
    cannot recover document "${entry[0]}": ${error}`);
                });
        }

        static get observedAttributes() {
            return [ "serverurl" ];
        }

        attributeChangedCallback(attrName, oldVal, newVal) {
            if (this._attrBeingChanged !== null) {
                return;
            }

            if (attrName === "serverurl") {
                this.serverURL = newVal;
            }
        }

        // -----------------------------------------------------------------------
        // API
        // -----------------------------------------------------------------------

        /**
         * Explicitly connect to the XMLmind XML Editor websocket server
         * (XXE server for short).
         * <p>Normally invoking this method is not useful because the
         * {@link XMLEditor#autoConnect} property being <code>true</code>
         * by default, this XML editor will automatically connect to the
         * websocket server when needed to.
         *
         * @returns {Promise} A Promise containing <code>true</code>
         * (already connected or successful connection) or an <code>Error</code>.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see ConnectedEvent
         */
        connect() {
            if (this._client === null) {
                // Just in case.
                return Promise.resolve(false);
            } else {
                return this._client.connect();
            }
        }

        /**
         * Explicitly disconnect from the XMLmind XML Editor websocket server
         * (XXE server for short).
         * <p>Normally invoking this method is not useful. This is done when
         * the web browser tab or window containing this XMLEditor is closed.
         *
         * @returns {Promise} A Promise containing <code>true</code>
         * (already disconnected or successful disconnection) or
         * an <code>Error</code>.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DisconnectedEvent
         */
        disconnect() {
            if (this._client === null) {
                // Just in case.
                return Promise.resolve(false);
            } else {
                return this._client.disconnect();
            }
        }

        /**
         * Get the <code>connected</code> property of this XML editor which
         * tests whether this editor is connected to the XMLmind XML Editor
         * websocket server.
         *
         * @type {boolean}
         */
        get connected() {
            return (this._client !== null && this._client.connected);
        }

        /**
         * Creates and opens a new document whose storage is to be managed
         * by client code.
         *
         * @param {string|Blob|ArrayBuffer} templateContent -
         * the XML contents of the document template.
         * @param {string} docURI - the URI (an identifier) of the newly
         * created document.  May be <code>null</code> in which case
         * an "untitled" URI is automatically computed.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if new document
         * has successfully been opened; <code>false</code> if currently opened
         * document first needs to be saved.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentCreatedEvent
         */
        newDocumentFromTemplate(templateContent, docURI=null) {
            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "newDocumentFromTemplate",
                                                  templateContent, docURI);
                    } else {
                      return false;
                    }
                });
        }

        syncDocumentView() {
            if (this._docView) {
                return this._docView.sync();
            } else {
                return Promise.resolve(false);
            }
        }

        doSendRequest(autoConnect, requestName, ...args) {
            const hasListeners = (this._requestListeners.length > 0);
            if (hasListeners) {
                this.notifyRequestListeners(autoConnect, requestName, args,
                                           /*response*/ undefined);
            }

            let result =
                this._client.sendRequest(autoConnect, requestName, ...args);

            if (hasListeners) {
                return result.then((response) => {
                                       this.notifyRequestListeners(
                                           autoConnect, requestName, args,
                                           response);

                                       return response;
                                   },
                                   (error) => {
                                       this.notifyRequestListeners(
                                           autoConnect, requestName, args,
                                           error);

                                       throw error;
                                   });
            } else {
                return result;
            }
        }

        notifyRequestListeners(autoConnect, requestName, requestArgs, response) {
            const listeners = this._requestListeners;
            for (let listener of listeners) {
                listener(autoConnect, requestName, requestArgs, response);
            }
        }

        /**
         * Lists available document templates.
         *
         * @returns {Promise} A Promise containing an array or
         * an <code>Error</code>.
         * The value of the promise is an array of arrays (branches) or
         * strings (leafs), organized as a hierarchical structure.
         * <ul>
         * <li>A leaf is a template name.
         * <li>A branch always starts with a "<code><b>+</b></code>" or
         * "<code><b>-</b></code>" "<code><i>CATEGORY_NAME</i></code>" string
         * and contains sub-arrays (sub-branches) and/or strings (leafs).
         * <li>"<code><b>+</b></code>" specifies that the branch should be
         * initially expanded. "<code><b>-</b></code>" specifies that
         * the branch should be initially collapsed.
         * </ul>
         * <p>An error is reported if, for any reason, the operation fails.
         */
        listDocumentTemplates() {
            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "listDocumentTemplates");
                    } else {
                        return [];
                    }
                });
        }

        /**
         * Creates and opens a new document whose storage is to be managed
         * by client code.
         *
         * @param {string} categoryName - the category of the document template.
         * For example, "<code>DocBook v5+/5.1</code>".
         * @param {string} templateName - the name of the document template.
         * For example, "<code>Book</code>".
         * @param {string} docURI - the URI (an identifier) of the newly
         * created document.  May be <code>null</code> in which case
         * an "untitled" URI is automatically computed.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if new document
         * has successfully been opened; <code>false</code> if currently opened
         * document first needs to be saved.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentCreatedEvent
         */
        newDocument(categoryName, templateName, docURI=null) {
            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "newDocument", categoryName,
                                                  templateName, docURI);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Creates and opens a new document whose storage is to be managed
         * by XXE server.
         *
         * @param {string|Blob|ArrayBuffer} templateContent -
         * the XML contents of the document template.
         * @param {string} docURL - the URL (a location) of the newly
         * created document. May be <code>null</code> in which case
         * an "untitled" URL is automatically computed.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if new document
         * has successfully been opened; <code>false</code> if currently opened
         * document first needs to be saved.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentCreatedEvent
         */
        newRemoteFileFromTemplate(templateContent, docURL=null) {
            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "newFileFromTemplate",
                                                  templateContent, docURL);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Creates and opens a new document whose storage is to be managed
         * by XXE server.
         *
         * @param {string} categoryName - the category of the document template.
         * For example, "<code>DocBook v5+/5.1</code>".
         * @param {string} templateName - the name of the document template.
         * For example, "<code>Book</code>".
         * @param {string} docURL - the URL (a location) of the newly
         * created document. May be <code>null</code> in which case
         * an "untitled" URL is automatically computed.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if new document
         * has successfully been opened; <code>false</code> if currently opened
         * document first needs to be saved.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentCreatedEvent
         */
        newRemoteFile(categoryName, templateName, docURL=null) {
            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "newFile", categoryName,
                                                  templateName, docURL);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Opens a document, whose storage is managed by client code,
         * whose content is <tt>docContent</tt>.
         *
         * @param {string|Blob|ArrayBuffer} docContent -
         * the XML contents of the document.
         * @param {string} docURI - the URI (an identifier) of the document.
         * May not be <code>null</code>.
         * @param {boolean} [readOnly=false] - if <code>true</code>,
         * the document is opened in read-only mode.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if the document
         * has successfully been opened; <code>false</code> if currently opened
         * document first needs to be saved.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentOpenedEvent
         */
        openDocument(docContent, docURI, readOnly=false) {
            if (docURI === null) {
                throw new Error("null docURI");
            }

            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "openDocument", docContent,
                                                  docURI, readOnly);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Lists the root directories on the XXE server side
         * which may be accessed by this XML editor.
         *
         * @returns {Promise} A Promise containing (possibly empty) array or
         * an <code>Error</code>.
         * <p>An empty array means that file access on the XXE server side is
         * not allowed.
         * <p>Otherwise an array contains one object per root directory.
         * <p>An object contains:
         * <dl>
         * <dt><code>label</code></dt>
         * <dd>A short label for use in the UI (e.g. "Home", "Computer").</dd>
         * <dt><code>uri</code></dt>
         * <dd>The absolute URI of the root directory. Ends with "/".</dd>
         * <dt><code>readonly</code></dt>
         * <dd>If <code>true</code>, file modifications of any kind
         * anywhere inside this root directory will be denied.</dd>
         * </dl>
         */
        listRootDirectories() {
            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "listRootDirectories");
                    } else {
                        return [];
                    }
                });
        }

        /**
         * List the content of the directory on the XXE server side
         * having specified location.
         *
         * @param {string} url - the URL (absolute location) of the file
         * to be listed.
         * @returns {Promise} A Promise containing an (possibly empty) object
         * or array of objects or an <code>Error</code>.
         * <p>An empty object means that specified file does not exist.
         * <p>A non empty object means that specified file is a file
         * and not a directory.
         * <p>Otherwise the result is an array containing one object
         * per directory entry. The directory itself is not included.
         * The objects are lexicographically sorted by their names.
         * <p>An object contains:
         * <dl>
         * <dt><code>name</code></dt>
         * <dd>Name of file or subdirectory.</dd>
         * <dt><code>directory</code></dt>
         * <dd><code>true</code> if it is a subdirectory;
         * <code>false</code> otherwise.</dd>
         * <dt><code>size</code></dt>
         * <dd>The size in bytes of the file; -1 for a subdirectory.</dd>
         * <dt><code>date</code></dt>
         * <dd>The date of the last modification of the file or subdirectory,
         * the number of seconds since the ECMAScript/UNIX Epoch
         * (January 1, 1970, 00:00:00 UTC); -1 if unknown.</dd>
         * </dl>
         */
        listFiles(url) {
            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "listFiles", url);
                    } else {
                        return [];
                    }
                });
        }

        /**
         * Create the directory on the XXE server side having specified location.
         * <p>Parent directories are automatically created if needed to.
         *
         * @param {string} url - the URL (absolute location) of the directory
         * to be created.
         * @returns {Promise} A Promise containing <code>true</code> if
         * the directory has been created, <code>false</code> if it already
         * existed or an <code>Error</code>.
         */
        createDirectory(url) {
            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "createDirectory", url);
                    } else {
                        return [];
                    }
                });
        }

        /**
         * Opens a document, whose storage is managed by XXE server,
         * whose content is found in <tt>docURL</tt>.
         *
         * @param {string} docURL - the URL (absolute location) of the document
         * to be opened. May not be <code>null</code>.
         * @param {boolean} [readOnly=false] - if <code>true</code>,
         * the document is opened in read-only mode.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if the document
         * has successfully been opened; <code>false</code> if currently opened
         * document first needs to be saved.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentOpenedEvent
         */
        openRemoteFile(docURL, readOnly=false) {
            if (docURL === null) {
                throw new Error("null docURL");
            }

            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "openFile", docURL, readOnly);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Opens a document which was previously opened in this XMLEditor
         * but for which the connection to the server was lost before
         * the document was cleanly closed (using {@link XMLEditor#closeDocument}).
         *
         * <p>The connection to the server is lost quite easily; for example
         * when the user clicks the <b>Reload</b> button of the web browser. That's
         * why by default, an XMLEditor has an auto-recover feature.
         * So in principle, there is no need to invoke this method.
         *
         * @param {string} documentUID - the unique ID of the document
         * to be recovered.
         * This ID is found in {@link DocumentOpenedEvent} and
         * all events derived from it.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if the document
         * has successfully been recovered; <code>false</code> if currently opened
         * document first needs to be saved and also when specified document
         * cannot be recovered anymore because it has been automatically closed
         * by the server.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentRecoveredEvent
         */
        recoverDocument(documentUID) {
            if (this.documentIsOpened && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.doSendRequest(this.autoConnect,
                                                  "recoverDocument", documentUID);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Check the validity of the document being edited.
         *
         * @returns {Promise} A Promise containing <code>null</code>,
         * a <dfn>diagnostics object</dfn> or an <code>Error</code>.
         * The value of the promise is <code>null</code> if there is no document
         * currently opened in this editor and also when opened document
         * does not conform to any schema and thus cannot be checked for validity
         * (when this is the case, {@link DocumentValidatedEvent} is
         * <em>not</em> fired by the server).
         * An error is reported if, for any reason, the operation fails.
         *
         * <p>A <dfn>diagnostics object</dfn> has the following keys:
         * <dl>
         * <dt><code>severity</code></dt>
         * <dd>The overall severity of the diagnostics.</dd>
         * <dt><code>diagnostics</code></dt>
         * <dd>A possibly empty array of <dfn>diagnostic objects</dfn>.
         * See description in {@link DocumentValidatedEvent}.</dd>
         * </dl>
         *
         * @see DocumentValidatedEvent
         */
        validateDocument() {
            if (!this.documentIsOpened) {
                return Promise.resolve(null);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.sendRequest("validateDocument");
                    } else {
                        return null;
                    }
                });
        }

        sendRequest(requestName, ...args) {
            return this.doSendRequest(/*autoConnect*/ false,
                                      requestName, ...args);
        }

        /**
         * Get the XML source of the document being edited.
         * <p>By default, the XML source is formatted using the save options
         * found in the user preferences and the save options found
         * in the configuration of the document being edited (if any). However
         * parameter <code>formattingOptions</code> may be used to change
         * this default formatting to a certain extent. See below.
         *
         * @param {object} [formattingOptions=null] - Changes the formatting
         * defaults described above.
         * <table>
         * <tr><th>Key</th><th>Value</th><th>Description</th></tr>
         * <tr>
         * <td><code>indent</code></td>
         * <td>Integer</td>
         * <td>Any strictly negative values means: do not indent.
         * Otherwise (even 0), indent the XML source.</td>
         * </tr>
         * <tr>
         * <td><code>maxLineLength</code></td>
         * <td>Strictly positive integer</td>
         * <td>Specifies the maximum line length for elements containing text
         * interspersed with child elements.
         * Ignored if <code>indent&lt;0</code>.</td>
         * </tr>
         * <tr>
         * <td><code>addOpenLines</code></td>
         * <td>boolean</td>
         * <td>If <code>true</code>, an open line is added between
         * the child elements of a parent element (if the content model of
         * the parent only allows child elements).
         * Ignored if <code>indent&lt;0</code>.</td>
         * </tr>
         * <tr>
         * <td><code>favorInteroperability</code></td>
         * <td>boolean</td>
         * <td>If <code>true</code>, favor the interoperability with HTML
         * as recommended by the XHTML spec. Use this option only
         * for XHTML documents.</td>
         * </tr>
         * </table>
         * @returns {Promise} A Promise containing containing the XML source,
         * <code>null</code> or an <code>Error</code>.
         * The value of the promise is <code>null</code> if there is no document
         * currently opened in this editor.
         * An error is reported if, for any reason, the operation fails.
         * <p>Note that returned XML source is a string which generally
         * (but not always, depending on <code>favorInteroperability</code>)
         * starts with an XML declaration
         * (<code>&lt;?xml version= encoding= ?&gt;</code>), so you may need
         * to remove it or modify it before serializing this source.
         */
        getDocument(formattingOptions=null) {
            if (!this.documentIsOpened) {
                return Promise.resolve(null);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.sendRequest("getDocument", formattingOptions);
                    } else {
                        return null;
                    }
                });
        }

        /**
         * Saves the document being edited.
         *
         * <dl>
         * <dt>Document having a storage managed by client code<dt>
         * <dd>Merely changes the save state of the document.
         * That is, this method does not actually save the document to disk.
         * </dd>
         * <dt>Document having a storage managed by XXE server</dt>
         * <dd>The document is saved to disk by XXE server.
         * Save file is overwritten if it already exists.
         * </dd>
         * </dl>
         *
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if the document
         * has successfully been saved; <code>false</code>
         * if there is no document currently opened in this editor or
         * if the document doesn't need to be saved or
         * if the modified document needs to be "saved as".
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentSavedEvent
         */
        saveDocument() {
            if (!this.documentIsOpened || !this.saveNeeded || this.saveAsNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.sendRequest("saveDocument");
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Saves the document being edited, changing its identifier or location.
         * <p><b>Notes:</b>
         * <ul>
         * <li>"Saving as" a read-only document will make it read-write.
         * <li>"Saving as" a document cannot change the origin of its storage
         * (that is, change from storage managed by the client to
         * storage managed by the server, or the other way round).
         * </ul>
         *
         * @param {string} location - the new location of the document.
         * May not be <code>null</code>.
         * <dl>
         * <dt>Document having a storage managed by client code<dt>
         * <dd><code>location</code> is an URI (an identifier).
         * Merely changes the save state of the document.
         * That is, this method does not actually save the document to disk.
         * </dd>
         * <dt>Document having a storage managed by XXE server</dt>
         * <dd><code>location</code> is an URL (absolute location).
         * The document is saved to disk by XXE server.
         * Save file is overwritten if it already exists.
         * </dd>
         * </dl>
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if the document
         * has been successfully saved; <code>false</code> if no document
         * is currently opened in the editor.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentSavedAsEvent
         */
        saveDocumentAs(location) {
            if (location === null) {
                throw new Error("null location");
            }

            if (!this.documentIsOpened) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.sendRequest("saveDocumentAs", location);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Closes the document being edited.
         *
         * @param {boolean} [discardChanges=false] - specifies whether
         * the changes made to the document should be discarded.
         * @returns {Promise} A Promise containing a boolean or
         * an <code>Error</code>.
         * The value of the promise is <code>true</code> if no document is
         * currently opened in the editor or if the document has been
         * successfully closed; <code>false</code> if the document has been
         * modified and <tt>discardChanges===false</tt>.
         * An error is reported if, for any reason, the operation fails.
         *
         * @see DocumentClosedEvent
         */
        closeDocument(discardChanges=false) {
            if (!this.documentIsOpened) {
                return Promise.resolve(true);
            }
            if (!discardChanges && this.saveNeeded) {
                return Promise.resolve(false);
            }

            return this.syncDocumentView()
                .then((synced) => {
                    if (synced) {
                        return this.sendRequest("closeDocument", discardChanges);
                    } else {
                        return false;
                    }
                });
        }

        /**
         * Get the <code>documentIsOpened</code> property of this XML editor
         * which tests whether a document is currently opened in the editor.
         *
         * @type {boolean}
         */
        get documentIsOpened() {
            return (this._documentUID !== null);
        }

        /**
         * Get the <code>documentUID</code> property of this XML editor
         * which contains the UID of the document being edited if any;
         * <code>null</code> otherwise.
         *
         * @type {string}
         */
        get documentUID() {
            // Just in case this is useful to client code.
            return this._documentUID;
        }

        /**
         * Get the <code>documentURI</code> property of this XML editor
         * which contains the URI of the document being edited if any;
         * <code>null</code> otherwise.
         * <dl>
         * <dt>Document having a storage managed by client code<dt>
         * <dd>The URI (identifier) of the document
         * which is used by client code.</dd>
         * <dt>Document having a storage managed by XXE server</dt>
         * <dd>The URL (location) of the document
         * which is used by XXE server.</dd>
         * </dl>
         *
         * @type {string}
         */
        get documentURI() {
            return URIUtil.csriURLToURI(this._documentURL);
        }

        /**
         * Get the <code>documentURL</code> property of this XML editor
         * which contains the URL of the document being edited if any;
         * <code>null</code> otherwise.
         * <dl>
         * <dt>Document having a storage managed by client code<dt>
         * <dd>An <em>internal</em>, <em>private</em>, <em>URL</em> representation
         * of the URI (identifier) of the document, which is useful only to
         * XXE server.
         * Cannot be used by XXE server to actually access the document,
         * as the storage of the document is managed by client code.
         * (This URL is needed because XXE server exclusively uses
         * hierarchical URLs and not URIs.)</dd>
         * <dt>Document having a storage managed by XXE server</dt>
         * <dd>The URL (location) of the document which is used by XXE server.
         * Identical to {@link XMLEditor#documentURI documentURI}.</dd>
         * </dl>
         *
         * @type {string}
         */
        get documentURL() {
            return this._documentURL;
        }

        /**
         * Get the <code>isRemoteFile</code> property of this XML editor
         * which tests whether the storage of the document being edited
         * is managed by XXE server.
         * <p>Value is <code>true</code> if the storage is managed by XXE server;
         * <code>false</code> if the storage managed by client code
         * (or if no document is being edited).
         *
         * @type {boolean}
         */
        get isRemoteFile() {
            return (this._documentURL === null)? false :
                !this._documentURL.startsWith("csri:");
        }

        /**
         * Get the <code>namespacePrefixes</code> property of this XML editor
         * which contains an (possibly empty) array containing namespace
         * prefix/URI pairs; <code>null</code> if no document is currently
         * being edited.
         * <p>The default namespace (having an empty prefix), if any, is always the
         * last pair of returned array.
         *
         * @type {array}
         */
        get namespacePrefixes() {
            return this._namespacePrefixes;
        }

        /**
         * Get the <code>configurationName</code> property of this XML editor
         * which contains the name of the configuration associated to
         * the document being edited; <code>null</code> if no configuration
         * is associated to the document or if no document is currently
         * being edited.
         *
         * @type {string}
         */
        get configurationName() {
            return this._configurationName;
        }

        /**
         * Get the <code>readOnlyDocument</code> property of this XML editor
         * which tests whether the document has been opened in read-only mode.
         *
         * @type {boolean}
         */
        get readOnlyDocument() {
            return this._readOnlyDocument;
        }

        /**
         * Get the <code>saveNeeded</code> property of this XML editor
         * which tests whether the document being edited
         * has been modified and thus needs to be saved.
         *
         * @type {boolean}
         */
        get saveNeeded() {
            return this._saveNeeded;
        }

        /**
         * Get the <code>saveAsNeeded</code> property of this XML editor
         * which tests whether plain save or save as is needed to
         * save the document being edited.
         * <p>If <code>saveNeeded===true</code> and
         * <code>saveAsNeeded===true</code>,
         * then the client code must prompt the user for a save URI or URL and
         * invoke <code>saveDocumentAs</code> rather
         * than <code>saveDocument</code>.
         *
         * @type {boolean}
         */
        get saveAsNeeded() {
            return this._saveAsNeeded;
        }

        /**
         * Get the <code>diffSupport</code> property of this XML editor
         * which tests whether the comparison of the revisions of the
         * document being edited has been enabled.
         * <ul>
         * <li>0: not enabled. (Or no document is currently being edited.)
         * <li>1: enabled.
         * <li>2: enabled and all the revisions are stored in the document.
         * </ul>
         *
         * @type {number}
         */
        get diffSupport() {
            return this._diffSupport;
        }

        get documentFileHandle() {
            return this._documentFileHandle;
        }

        /**
         * Get/set the <code>documentFileHandle</code> property of this XML editor
         * which contains the local file handle of edited document;
         * <code>null</code> if there is no document currently being edited or
         * if edited document has no local file handle (newly created
         * document, not a local file or local file handles not supported).
         * <p>This <code>FileSystemFileHandle</code> is needed by the application
         * hosting this XML editor to save the document content to disk. See
         * <a href="https://fs.spec.whatwg.org/#filesystemfilehandle">The
         * File System Access API</a>.
         *
         * @type {FileSystemFileHandle}
         */
        set documentFileHandle(handle) {
            this._documentFileHandle = handle;
        }

        /**
         * Register specified event listener with this XMLEditor.
         *
         * @param {string} type - the type of an {@link XMLEditorEvent}, for
         * example: "documentOpened", "documentClosed", etc.
         * @param {function} listener - the event handler, a function accepting
         * an {@link XMLEditorEvent} as its argument.
         */
        addEventListener(type, listener) {
            this.removeEventListener(type, listener);

            let listeners = this._eventListeners[type];
            if (listeners) {
                listeners.push(listener);
            }
            // Otherwise, unknown type.
        }

        /**
         * Unregister specified event listener with this XMLEditor.
         * Specified listener must have been registered with this XMLEditor
         * by invoking {@link XMLEditor#addEventListener}.
         *
         * @param {string} type - the type of an {@link XMLEditorEvent}, for
         * example: "documentOpened", "documentClosed", etc.
         * @param {function} listener - the event handler, a function accepting
         * an {@link XMLEditorEvent} as its argument.
         * <p>This function is called <strong>before</strong> the request
         * is actually sent to the server.
         */
        removeEventListener(type, listener) {
            let listeners = this._eventListeners[type];
            if (listeners) {
                let i = listeners.indexOf(listener); // Uses "===".
                if (i >= 0) {
                    listeners.splice(i, 1);
                }
            }
            // Otherwise, unknown type.
        }

        /**
         * Register specified request listener with this XMLEditor.
         * <p>Not for general use; only to debug an XMLEditor.
         *
         * <p>A request listener is a function having 3 arguments:
         * boolean <i>auto_connect</i>, string <i>request_name</i>,
         * array <i>request_arguments</i>, <i>response</i>.
         * <p>For each request, this function is called twice:
         * first time with a <code>undefined</code> <i>response</i>,
         * before the request is sent to the server;
         * second time with a <i>response</i>, after the response to the request
         * is received from the server. This response may be any value
         * (including <code>null</code>) or an <code>Error</code>.
         *
         * @param {function} listener - the listener to be registered.
         */
        addRequestListener(listener) {
            this.removeRequestListener(listener);

            this._requestListeners.push(listener);
        }

        /**
         * Unregister specified request listener with this XMLEditor.
         * <p>Not for general use; only to debug an XMLEditor.
         *
         * @param {function} listener - the listener to be unregistered.
         */
        removeRequestListener(listener) {
            let listeners = this._requestListeners;
            let i = listeners.indexOf(listener); // Uses "===".
            if (i >= 0) {
                listeners.splice(i, 1);
            }
        }

        /**
         * Get the <code>clipboardIntegration</code> property of this XML editor
         * which contains the object implementing the system clipboard integration.
         *
         * @type {ClipboardIntegration}
         */
        get clipboardIntegration() {
            return this._clipboardIntegration;
        }

        /**
         * Get the <code>documentView</code> property of this XML editor
         * which contains the {@link DocumentView} contained in this XMLEditor.
         *
         * @type {DocumentView}
         */
        get documentView() {
            return this._docView;
        }

        /**
         * Display specified message in the status area of this XMLEditor.
         *
         * @param {string} text - the message to be displayed.
         * May be <code>null</code> or the empty string.
         * @param {boolean} [autoErase=true] autoErase - if <code>true</code>,
         * automatically erase the message after 10s.
         */
        showStatus(text, autoErase=true) {
            this._statusTool.showStatus(text, autoErase);
        }

        /**
         * Get the <code>searchReplaceIsVisible</code> property of this XML editor
         * which tests whether the text search/replace pane is currently displayed.
         *
         * @type {boolean}
         */
        get searchReplaceIsVisible() {
            return (this._searchReplace !== null &&
                    this._searchReplace.style.display !== "none");
        }

        /**
         * Show or hide the text search/replace pane.
         */
        showSearchReplace(show) {
            let visiblityChanged = false;

            if (this._searchReplace === null) {
                this._searchReplace = document.createElement("xxe-search-replace");
                this._nodePath.parentNode.insertBefore(this._searchReplace,
                                                           this._nodePath);

                this._searchReplace.xmlEditor = this;
                this._searchReplace.documentEdited(this._documentUID,
                                                   this._readOnlyDocument);
                visiblityChanged = true;
            }

            if (show) {
                if (this._searchReplace.style.display === "none") {
                    this._searchReplace.style.display = "block";
                    visiblityChanged = true;
                }
            } else {
                if (this._searchReplace.style.display !== "none") {
                    this._searchReplace.style.display = "none";
                    visiblityChanged = true;
                }
            }

            if (visiblityChanged) {
                this._searchReplace.visiblityChanged(show);
            }

            return this._searchReplace;
        }

        /**
         * Returns the state of specified command. This state is automatically
         * updated after each received {@link EditingContextChangedEvent}.
         * <p>Used by some XMLEditor parts and also by some commands
         * (e.g. {@link ContextualMenuCmd}).
         *
         * @param {string} cmdName - command name.
         * @param {string}  cmdParam - command parameter. May be <code>null</code>.
         * @return {array} an array containing 3 items:
         * <ul>
         * <li><code>enabled</code>, a boolean,
         * <li><code>detail</code>, an object (generally <code>null</code>)
         * which depends on the command,
         * <li><code>inSelectedState</code>, <code>null</code> if the command
         * has no selected state; <code>true</code> if the command
         * is in selected state, <code>false</code> if the command
         * is in non-selected state.
         * </ul>
         * Returns <code>null</code> if specified command is not one of the
         * "contextual commands" registered with this XMLEditor.
         */
        getCommandState(cmdName, cmdParam) {
            if (this._commandStates === null) {
                return null;
            }

            let cmd = Command.joinCmdString(cmdName, cmdParam,
                                            XMLEditor.CMD_STRING_SEPAR);
            if (cmd === null) {
                return null;
            }

            let state = this._commandStates[cmd];
            return !state? null : state;
        }

        // ------------------------------------
        // API related to document resources
        // ------------------------------------

        get resourceStorage() {
            return this._resourceStorage;
        }

        /**
         * Get/set the <code>resourceStorage</code> property of this XML editor.
         * <p>This resource storage object will be used only for documents
         * whose storage is managed by client code.
         * <p>Default value: dummy resource storage object {@link ResourceStorage}.
         *
         * @type {ResourceStorage}
         */
        set resourceStorage(storage) {
            this._resourceStorage = storage;
        }

        /**
         * Same as {@link XMLEditor#loadResource} except that this method
         * first attempts to fetch the resource from the cache.
         * <p>Note that resources actuall loaded by this method are
         * automatically cached.
         *
         * @param {string} uri - absolute URI of the resource to be obtained.
         * @return {Promise} A Promise containing a {@link Resource}
         * or an <code>Error</code>.
         * @see XMLEditor#fetchResource
         * @see XMLEditor#cacheResource
         */
        getResource(uri) {
            let res = this.fetchResource(uri);
            if (res) {
                return Promise.resolve(res);
            }

            return this.loadResource(uri)
                .then((res) => {
                    if (res) {
                        this.cacheResource(res);
                    }
                    return res;
                });
        }

        /**
         * Fetch resource having specified URI from resource cache.
         * <p>In principle, there is no need to directly invoke this method.
         *
         * @param {string} uri - absolute URI of the resource to be fetched.
         * @return {Promise} a {@link Resource}  if found in cache;
         * <code>null</code> otherwise.
         * @see XMLEditor#cacheResource
         */
        fetchResource(uri) {
            uri = URIUtil.normalizeFileURI(uri);
            const res = this._resourceCache[uri];
            return !res? null : res;
        }

        /**
         * Cache specified resource.
         * <p>Useful to cache the (non-cached) resources returned by
         * {@link XMLEditor#loadResource}, {@link XMLEditor#loadResource} and
         * {@link XMLEditor#openResource}.
         *
         * @param {Resource} resource - the resource to be cache.
         * @see XMLEditor#fetchResource
         */
        cacheResource(resource) {
            const uri = URIUtil.normalizeFileURI(resource.uri);
            this._resourceCache[uri] = resource;
        }

        /**
         * Load document resource having specified URI.
         *
         * @param {string} uri - absolute URI of the resource to be loaded.
         * @return {Promise} A Promise containing a {@link Resource}
         * or an <code>Error</code>.
         * <p><strong>IMPORTANT:</strong> the resource data is <code>null</code>
         * if there is no way load a resource knowing just its URI. This is
         * for example the case of local files.
         */
        loadResource(uri) {
            if (!this.documentIsOpened) {
                return Promise.reject(new Error("no opened document"));
            }

            let loaded;
            if (this.isRemoteFile) {
                loaded = this._remoteFileStorage.loadResource(uri);
            } else {
                loaded = this._resourceStorage.loadResource(uri);
            }

            return loaded.then((resource) => {
                if (resource === null) {
                    // May be it's an external global resource. Try to download it
                    // from the web.
                    return this.downloadResource(uri);
                } else {
                    return resource;
                }
            });
        }

        downloadResource(uri) {
            // Not very useful due to CORS. Will generally fail.
            // Default options are "normal":
            // { method: "GET", mode: "cors", redirect: "follow" }.

            return fetch(uri)
                .then((response) => {
                    if (!response.ok) {
                        throw new Error(`HTTP error ${response.status} \
    ${response.statusText}`);
                    }
                    return response.blob();
                })
                .then((data) => {
                    return new Resource(uri, data);
                })
                .catch((error) => {
                  throw new Error(`could not download resource "${uri}": ${error}`);
                });
        }

        /**
         * Same as {@link XMLEditor#storeResource} except that this method
         * automatically caches the newly stored resource.
         *
         * @param {Blob} data - the data to be stored.
         * @param {string} uri - absolute URI of the resource to be created
         * (or overwritten if it already exists).
         * @return {Promise} A Promise containing a {@link Resource}
         * or an <code>Error</code>.
         * @see XMLEditor#cacheResource
         */
        putResource(data, uri) {
            return this.storeResource(data, uri)
                .then((res) => {
                    if (res) {
                        this.cacheResource(res);
                    }
                    return res;
                });
        }

        /**
         * Create (or overwrite if it already exists) resource having specified URI.
         * <p>Any parent directory of specified URI which does not already exist
         * is automatically created.
         *
         * @param {Blob} data - the data to be stored.
         * @param {string} uri - absolute URI of the resource to be created
         * (or overwritten if it already exists).
         * @return {Promise} A Promise containing a {@link Resource}
         * or an <code>Error</code>.
         * <p><strong>IMPORTANT:</strong> the resource data is not actually
         * saved to disk if there is no way save a resource
         * knowing just its URI. This is for example the case of local files.
         */
        storeResource(data, uri) {
            if (!this.documentIsOpened) {
                return Promise.reject(new Error("no opened document"));
            }

            let stored;
            if (this.isRemoteFile) {
                stored = this._remoteFileStorage.storeResource(data, uri);
            } else {
                stored = this._resourceStorage.storeResource(data, uri);
            }

            return stored.then((resource) => {
                if (resource === null) {
                    // May be it's an external global resource. Don't know how to
                    // store it.
                    throw new Error(`do not know how to store data into "${uri}"`);
                } else {
                    return resource;
                }
            });
        }

        /**
         * Prompt user to choose an existing document resource.
         *
         * @param {Object} options - options for use by the resource chooser
         * dialog box.
         * <p>Dialog box options are:
         * <dl>
         * <dt><code>title</code>
         * <dd>The title of the dialog box. Defaults to "Open" or "Save".
         * <dt><code>extensions</code>
         * <dd>An array of <code>[<i>description</i>, <i>MIME_type</i>,
         * <i>extension</i>, ... ,<i>extension</i>]</code> arrays.
         * File extensions must not start with <code>'.'</code>. Example:
         * <pre>[
         *    ["PNG images", "image/png", "png"],
         *    ["JPEG images", "image/jpeg", "jpg", "jpeg"]
         *]</pre>
         * <p>Default: none (accept all files).</p></dd>
         * <dt><code>templateURI</code>
         * <dd>Suggested choice (absolute URI).
         * Default: <code>null</code>.
         * <dt><code>option</code>
         * <dd>An array of 3 items: <code>[<i>option_name</i>,
         * <i>initial_boolean_value</i>, <i>option_label</i>]</code> specifying
         * a check box to be added as an accessory to the dialog box.
         * Default: none (no dialog box "accessory").
         * </dl>
         * @return {Promise} A Promise containing a ready-to-use {@link Resource}
         * or <code>null</code> if user canceled this operation or
         * an <code>Error</code>.
         * @see XMLEditor#cacheResource
         */
        openResource(options) {
            if (!this.documentIsOpened) {
                return Promise.reject(new Error("no opened document"));
            }

            if (this.isRemoteFile) {
                return this._remoteFileStorage.openResource(options);
            } else {
                return this._resourceStorage.openResource(options);
            }
        }

        // -----------------------------------------------------------------------
        // Events received from the server
        // -----------------------------------------------------------------------

        notifyEventListeners(type, data) {
            let cls = XMLEditor._EVENT_CLASSES[type];
            if (!cls) {
                console.error(`XMLEditor.notifyEventListeners: INTERNAL ERROR: \
    ${type}, unknown event type`);
                return;
            }

            const event = new cls(this, data);

            // All the events, received after a user action or some client code
            // using the API, change something in this editor.
            // For example, SaveStateChangedEvent changes the save needed indicator.
            switch (type) {
            case "connected":
                this.onConnected(event);
                break;
            case "disconnected":
                this.onDisconnected(event);
                break;
            case "documentCreated":
            case "documentOpened":
            case "documentRecovered":
                this.onDocumentOpened(event);
                break;
            case "documentValidated":
                this.onDocumentValidated(event);
                break;
            case "documentSaved":
                this.onDocumentSaved(event);
                break;
            case "documentSavedAs":
                this.onDocumentSavedAs(event);
                break;
            case "documentClosed":
                this.onDocumentClosed(event);
                break;
            case "saveStateChanged":
                this.onSaveStateChanged(event);
                break;
            case "undoStateChanged":
                this.onUndoStateChanged(event);
                break;
            case "editingContextChanged":
                this.onEditingContextChanged(event);
                break;
            case "documentViewChanged":
                this._docView.onDocumentViewChanged(event);
                break;
            case "documentMarksChanged":
                this._docView.onDocumentMarksChanged(event);
                break;
            case "namespacePrefixesChanged":
                this.onNamespacePrefixesChanged(event);
                break;
            case "readOnlyStateChanged":
                this.onReadOnlyStateChanged(event);
                break;
            case "showAlert":
                this.onShowAlert(event);
                break;
            case "showStatus":
                this.onShowStatus(event);
                break;
            }

            const listeners = this._eventListeners[type];
            if (listeners) {
                for (const listener of listeners) {
                    listener(event);
                }
            }
        }

        onConnected(event) {}

        onDisconnected(event) {
            this.reset();
        }

        onDocumentOpened(event) {
            this.configure(event);

            if (this.autoConnect && this.autoRecover) {
                this.addAutoRecoverInfo(event.uid);
            }

            // Asynchronously load extension modules.
            //
            // For now, extension modules may only contain configuration-specific
            // interactive local commands invoking non-interactive remote commands
            // having the same names.
            //
            // For example, this way, an interactive local
            // "docb.linkCallouts" found in module "./docbook.js" is
            // asynchronously added to ALL_LOCAL_COMMANDS.

            if (event.extensionModules) {
                this.loadExtensionModules(event.extensionModules);
            }
        }

        addAutoRecoverInfo(docUID) {
            let info = this.getAutoRecoverInfo();
            XMLEditor.removeAutoRecoverEntry(info, docUID);

            while (info.length >= 20) { // Too many entries?
                info.splice(0, 1);
            }
            info.push([docUID, Date.now()]);

            this.storeAutoRecoverInfo(info);
            /*
            this.logAutoRecoverInfo("<<<addAutoRecoverInfo(" + docUID + ")", info);
            */
        }

        static removeAutoRecoverEntry(info, docUID) {
            let index = -1;
            const count = info.length;
            for (let i = 0; i < count; ++i) {
                const entry = info[i];
                if (entry[0] === docUID) {
                    index = i;
                    break;
                }
            }
            if (index >= 0) {
                info.splice(index, 1);
            }
        }

        configure(event) {
            this._resourceCache = {};

            this._documentUID = event.uid;
            this._documentURL = event.url;
            this._namespacePrefixes = event.nsPrefixes;
            this._readOnlyDocument = event.readOnly;
            this._saveNeeded = event.saveNeeded;
            this._saveAsNeeded = event.saveAsNeeded;
            this._diffSupport = event.diffSupport;

            let contextualCmdStrings = [];
            let cmdStates = {};
            const contextualCmdPairs = event.contextualCommands;
            if (contextualCmdPairs !== null) {
                const pairCount = contextualCmdPairs.length;
                for (let i = 0; i < pairCount; i +=2) {
                    let cmd = Command.joinCmdString(contextualCmdPairs[i],
                                                    contextualCmdPairs[i+1],
                                                    XMLEditor.CMD_STRING_SEPAR);

                    contextualCmdStrings.push(cmd);
                    // enabled,detail,inSelectedState
                    cmdStates[cmd] = [false, null, null];
                }
            }
            this._contextualCmdStrings = contextualCmdStrings;
            this._commandStates = cmdStates;

            this._configurationName = event.configurationName;
            this._documentFileHandle = null;

            this._docView.setView({
                view: event.view,
                styles: event.styles,
                marks: event.marks,
                bindings: event.bindings });

            this._toolBar.toolSetsChanged(event.toolSets);
            if (this._searchReplace !== null) {
                this._searchReplace.documentEdited(this._documentUID,
                                                   this._readOnlyDocument);
            }
            this._nodePath.nodePathChanged(/*items*/ []);
            this._validateTool.validityStateChanged(/*event*/ null);
            this._clipboardTool.clipboardUpdated(/*update*/ null);
            this._clipboardIntegration.clipboardUpdated(/*update*/ null);
        }

        loadExtensionModules(moduleURIs) {
            if (moduleURIs.length === 0) {
                return Promise.resolve(true);
            }
            const moduleURI = moduleURIs.shift();

            let loadingExtensionModules;
            if (!this._extensionModules[moduleURI]) {
                // Add a stylesheet link element to the head element
                // loading the accompanying CSS file.

                const headElem = document.documentElement.firstElementChild;
                if (headElem !== null && moduleURI.endsWith(".js")) {
                    // A relative module URI is relative to the URI of
                    // "xxeclient.js".

                    let cssURL = new URL(
                        moduleURI.substring(0, moduleURI.length-3) + ".css",
                        import.meta.url);
                    XUI.Util.addStylesheetLink(headElem, cssURL.toString());
                }

                loadingExtensionModules = import(moduleURI);
            } else {
                loadingExtensionModules = Promise.resolve(null);
            }

            return loadingExtensionModules
                .then((module) => {
                    if (module !== null) {
                        this._extensionModules[moduleURI] = module;
                        /*
                        console.log(`Module "${moduleURI}" imported.`);
                        */
                    }
                    return this.loadExtensionModules(moduleURIs);
                })
                .catch((error) => {
                    console.error(`Cannot import module "${moduleURI}": ${error}`);
                    return this.loadExtensionModules(moduleURIs);
                });
        }

        onDocumentValidated(event) {
            this._validateTool.validityStateChanged(event);
        }

        onDocumentSaved(event) {
            // What follows is implicit.
            this._saveNeeded = false;
            this._saveAsNeeded = false;

            this._nodePath.documentStateChanged();
        }

        onDocumentSavedAs(event) {
            this._documentURL = event.url;
            // "Saving as" a read-only document will make it read-write.
            this._readOnlyDocument = event.readOnly;
            // What follows is implicit.
            this._saveNeeded = false;
            this._saveAsNeeded = false;

            this._nodePath.documentStateChanged();
        }

        onDocumentClosed(event) {
            const docUID = this._documentUID;
            this.unconfigure();

            if (this.autoConnect && this.autoRecover) {
                this.removeAutoRecoverInfo(docUID);
            }
        }

        removeAutoRecoverInfo(docUID) {
            let info = this.getAutoRecoverInfo();
            XMLEditor.removeAutoRecoverEntry(info, docUID);
            this.storeAutoRecoverInfo(info);
            /*
            this.logAutoRecoverInfo("<<<removeAutoRecoverInfo(" + docUID + ")",
                                    info);
            */
        }

        onSaveStateChanged(event) {
            this._saveNeeded = event.saveNeeded;

            this._nodePath.documentStateChanged();
            this._docView.saveStateChanged();
        }

        onUndoStateChanged(event) {
            this.updateCommandStates(event.commandStates);

            this._toolBar.undoStateChanged();
        }

        updateCommandStates(cmdStates) {
            if (cmdStates === null ||
                this._contextualCmdStrings === null) {
                console.error(`XMLEditor.updateCommandStates: INTERNAL ERROR: \
    cannot update: cmdStates=${cmdStates}, \
    this._contextualCmdStrings=${this._contextualCmdStrings}`);
                this.clearCommandStates();
                return;
            }

            const count = cmdStates.length;
            for (let i = 0; i < count; ++i) {
                let cmdState = cmdStates[i];

                let enabled = (cmdState.charAt(0) === '1');
                let inSelectedState;
                switch (cmdState.charAt(1)) {
                case '0':
                    inSelectedState = false;
                    break;
                case '1':
                    inSelectedState = true;
                    break;
                default:
                    inSelectedState = null;
                }
                let detail = (cmdState.length > 2)? cmdState.substring(2) : null;

                let cmd = this._contextualCmdStrings[i];
                if (!cmd) {
                    console.error(`XMLEditor.updateCommandStates: INTERNAL ERROR: \
    command index ${i} out of range 0-${this._contextualCmdStrings.length}`);
                    this.clearCommandStates();
                    break;
                }

                let state = this._commandStates[cmd];
                if (!state) {
                    console.error(`XMLEditor.updateCommandStates: INTERNAL ERROR: \
    command "${cmd}" not registered`);
                    this.clearCommandStates();
                    break;
                }

                state[0] = enabled;
                state[1] = detail;
                state[2] = inSelectedState;
            }
        }

        clearCommandStates() {
            for (let cmd of this._contextualCmdStrings) {
                let state = this._commandStates[cmd];
                state[0] = false;
                state[1] = null;
                state[2] = null;
            }
        }

        onEditingContextChanged(event) {
            this.updateCommandStates(event.commandStates);
            this._toolBar.commandStatesChanged();
            this._nodePath.nodePathChanged(event.nodePathItems);
            this._clipboardTool.clipboardUpdated(event.clipboardUpdate);
            this._clipboardIntegration.clipboardUpdated(event.clipboardUpdate);
        }

        onNamespacePrefixesChanged(event) {
            this._namespacePrefixes = event.nsPrefixes;

            // Expected now to be sent by the server:
            // * A rebuilt document view.
            // * A EditingContextChangedEvent.
        }

        onReadOnlyStateChanged(event) {
            this._readOnlyDocument = event.readOnly;

            // Expected now to be sent by the server:
            // * A rebuilt document view.
            // * A EditingContextChangedEvent.

            if (this._searchReplace !== null) {
                this._searchReplace.documentEdited(this._documentUID,
                                                   this._readOnlyDocument);
            }
        }

        onShowAlert(event) {
            XUI.Alert.showAlert(event.messageType, event.message, this);
        }

        onShowStatus(event) {
            this._statusTool.showStatus(event.message, event.autoErase);
        }
    }

    // A command name may contain space characters, e.g. "{DITA Map}promote".
    // (BMP PUA: U+E000..U+F8FF)
    XMLEditor.CMD_STRING_SEPAR = '\uF876';

    XMLEditor.TEMPLATE = document.createElement("template");
    XMLEditor.TEMPLATE.innerHTML = `
    <div class="xxe-tool-pane">
      <xxe-tool-bar></xxe-tool-bar>
      <xxe-node-path></xxe-node-path>
    </div>
    <xxe-document-view></xxe-document-view>
    <div class="xxe-status-pane">
      <xxe-validate-tool></xxe-validate-tool>
      <xxe-status-tool></xxe-status-tool>
      <xxe-clipboard-tool></xxe-clipboard-tool>
    </div>
    `;

    XMLEditor.EVENT_TYPES = [
        "connected",
        "disconnected",
        "documentCreated",
        "documentOpened",
        "documentRecovered",
        "documentValidated",
        "documentSaved",
        "documentSavedAs",
        "documentClosed",
        "saveStateChanged",
        "undoStateChanged",
        "editingContextChanged",
        "documentViewChanged",
        "documentMarksChanged",
        "namespacePrefixesChanged",
        "readOnlyStateChanged",
        "showAlert",
        "showStatus",
    ];

    XMLEditor._EVENT_CLASSES = {
        connected: ConnectedEvent,
        disconnected: DisconnectedEvent,
        documentCreated: DocumentCreatedEvent,
        documentOpened: DocumentOpenedEvent,
        documentRecovered: DocumentRecoveredEvent,
        documentValidated: DocumentValidatedEvent,
        documentSaved: DocumentSavedEvent,
        documentSavedAs: DocumentSavedAsEvent,
        documentClosed: DocumentClosedEvent,
        saveStateChanged: SaveStateChangedEvent,
        undoStateChanged: UndoStateChangedEvent,
        editingContextChanged: EditingContextChangedEvent,
        documentViewChanged: DocumentViewChangedEvent,
        documentMarksChanged: DocumentMarksChangedEvent,
        namespacePrefixesChanged: NamespacePrefixesChangedEvent,
        readOnlyStateChanged: ReadOnlyStateChangedEvent,
        showAlert: ShowAlertEvent,
        showStatus: ShowStatusEvent,
    };

    window.customElements.define("xxe-client", XMLEditor);