Source: xxe/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) {
            const instances = document.querySelectorAll("xxe-client");
            for (let i = 0; i < instances.length; ++i) {
                if (Object.is(instances[i], this)) {
                    this.id = "__XXE_" + (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);