/**
* 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><xxe-client></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<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<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><?xml version= encoding= ?></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);