/**
* A sample XML editor application which leverages {@link XMLEditor}.
* <p>Also implements a number of interactive actions (static methods)
* like <code>newDocument</code>, <code>closeDocument</code>, etc,
* which may be used independently from <code>XMLEditorApp</code>.
*/
export class XMLEditorApp extends HTMLElement {
/**
* Constructs a sample XML editor application, the implementation
* of custom HTML element <code><xxe-app></code>.
*/
constructor() {
super();
this._indicator = null;
this._title = null;
this._menuButton = null;
this._onButtonMenuItemSelected =
this.onButtonMenuItemSelected.bind(this);
this._newButton = null;
this._openButton = null;
this._saveButton = null;
this._saveAsButton = null;
this._closeButton = null;
this._xmlEditor = null;
this._autoSave = null;
this._checkLeaveApp = false;
this._onBeforeUnload = this.onBeforeUnload.bind(this);
this._onAnyRequest = this.onAnyRequest.bind(this);
this._onAnyEvent = this.onAnyEvent.bind(this);
}
// -----------------------------------------------------------------------
// Custom element
// -----------------------------------------------------------------------
connectedCallback() {
let notSupported = this.firstElementChild;
if (notSupported !== null &&
notSupported.classList.contains("xxe-not-supported")) {
if (!browserEngineIsSupported()) {
let reason = `<strong>Sorry but your web browser
is not supported.</strong><br /><br />
In order to be supported, your web browser must run on <em>desktop</em>
computer and must use the same browser engine as <em>recent</em> versions
of <em>Chrome</em> or <em>Firefox</em>.`;
const userAgent = window.navigator.userAgent;
if (userAgent) {
reason += `<br />
<small>(Your web browser identifies itself as:
"<code>${userAgent}</code>".)</small>`;
}
notSupported.innerHTML = reason;
return;
}
XUI.Util.removeAllChildren(this);
}
// ---
if (this.firstChild === null) {
this.appendChild(XMLEditorApp.TEMPLATE.content.cloneNode(true));
let div = this.firstElementChild;
let children = div.children;
this._indicator = children.item(0);
this._indicator.onclick = this.onIndicatorClick.bind(this);
this._title = children.item(1);
this._menuButton = children.item(2);
this._menuButton.textContent = XUI.StockIcon["menu"];
this._menuButton.onclick = this.onMenuButtonClick.bind(this);
// ---
children = div.nextElementSibling.children;
this._newButton = children.item(0);
this._newButton.onclick = this.onNewButtonClick.bind(this);
this._openButton = children.item(1);
this._openButton.onclick = this.onOpenButtonClick.bind(this);
this._saveButton = children.item(2);
this._saveButton.onclick = this.onSaveButtonClick.bind(this);
this._saveAsButton = children.item(3);
this._saveAsButton.onclick = this.onSaveAsButtonClick.bind(this);
this._closeButton = children.item(4);
this._closeButton.onclick = this.onCloseButtonClick.bind(this);
this.documentStorage = this.getAttribute("documentstorage");
// ---
const xxe = this.lastElementChild;
this._xmlEditor = xxe;
xxe.resourceStorage = new LocalFiles(xxe);
xxe.addEventListener("connected", this.onConnected.bind(this));
xxe.addEventListener("disconnected",
this.onDisconnected.bind(this));
const docOpenedHandler = this.onDocumentOpened.bind(this);
xxe.addEventListener("documentCreated", docOpenedHandler);
xxe.addEventListener("documentOpened", docOpenedHandler);
xxe.addEventListener("documentRecovered", docOpenedHandler);
xxe.addEventListener("documentSavedAs",
this.onDocumentSavedAs.bind(this));
xxe.addEventListener("documentClosed",
this.onDocumentClosed.bind(this));
xxe.addEventListener("saveStateChanged",
this.onSaveStateChanged.bind(this));
xxe.addEventListener("readOnlyStateChanged",
this.onReadOnlyStateChanged.bind(this));
this.onDisconnected(/*event*/ null);
this.onDocumentClosed(/*event*/ null);
window.addEventListener("unhandledrejection", (event) => {
XUI.Alert.showWarning(`SHOULD NOT HAPPEN: \
unhandled promise rejection:\n${event.reason}`, xxe);
});
// ---
this.serverURL = this.getAttribute("serverurl");
this.clientProperties = this.getAttribute("clientproperties");
this.autoRecover = this.hasAttribute("autorecover")?
(this.getAttribute("autorecover") === "true") : true;
this.checkLeaveApp = this.hasAttribute("checkleaveapp")?
(this.getAttribute("checkleaveapp") === "true") : true;
this.button2PastesText =
(this.getAttribute("button2pastestext") === "true");
this.initAutoSave();
}
// Otherwise, already connected.
}
// -------------------------------
// documentStorage
// -------------------------------
get documentStorage() {
return this.getAttribute("documentstorage");
}
/**
* Get/set the <code>documentStorage</code> property
* of this XML editor application.
* <p>The values of the <code>documentStorage</code> property are:
* <dl>
* <dt>"local"
* <dd>Default value.
* Create and edit only <i>local files</i>, that is, files which
* are found on the computer running the web browser hosting this
* XML editor application.
* <dt>"remote"
* <dd>Create and edit only <i>remote files</i>, that is, files which
* are accessed on the server side by <code>xxeserver</code>.
* <dt>"both"
* <dd>Local files and remote files are both supported.
* </dl>
*
* @type {string}
*/
set documentStorage(storage) {
if (storage) {
storage = storage.trim();
}
if (!storage ||
!(storage === "local" || storage === "remote" ||
storage === "both")) {
storage = "local";
}
if (storage !== this.getAttribute("documentstorage")) {
this.setAttribute("documentstorage", storage);
}
const buttons = [this._newButton, this._openButton];
for (let button of buttons) {
let detail = button.lastElementChild; // A span
let newDetail = null;
switch (storage) {
case "local":
case "remote":
if (detail.classList.contains("xui-small-icon")) {
newDetail = document.createElement("span");
newDetail.appendChild(document.createTextNode("\u2026"));
}
break;
default: // "both"
if (!detail.classList.contains("xui-small-icon")) {
newDetail = document.createElement("span");
newDetail.setAttribute("class",
"xui-small-icon xxe-app-button-menu");
newDetail.appendChild(
document.createTextNode(XUI.StockIcon["down-dir"]));
}
break;
}
if (newDetail !== null) {
button.replaceChild(newDetail, detail);
}
}
}
// -------------------------------
// serverURL
// -------------------------------
get serverURL() {
return this.getAttribute("serverurl");
}
/**
* A cover for {@link XMLEditor#serverURL}.
*
* @type {string}
*/
set serverURL(url) {
this._xmlEditor.serverURL = url;
url = this._xmlEditor.serverURL;
if (url !== this.getAttribute("serverurl")) {
this.setAttribute("serverurl", url);
}
}
// -------------------------------
// clientProperties
// -------------------------------
get clientProperties() {
let props = this.getAttribute("clientproperties");
if (props !== null && (props = props.trim()).length === 0) {
props = null;
}
return props;
}
/**
* A cover for {@link XMLEditor#clientProperties}.
*
* @type {string}
*/
set clientProperties(props) {
this._xmlEditor.clientProperties = props;
props = this._xmlEditor.clientProperties;
if (props === null) {
this.removeAttribute("clientproperties");
} else {
this.setAttribute("clientproperties", props);
}
}
// -------------------------------
// autoRecover
// -------------------------------
get autoRecover() {
return this.getAttribute("autorecover") === "true";
}
/**
* A cover for {@link XMLEditor#autoRecover}.
*
* @type {boolean}
*/
set autoRecover(recover) {
this._xmlEditor.autoRecover = recover;
const recoverValue = recover? "true" : "false";
if (recoverValue !== this.getAttribute("autorecover")) {
this.setAttribute("autorecover", recoverValue);
}
}
// -------------------------------
// checkLeaveApp
// -------------------------------
get checkLeaveApp() {
// Quicker than accessing the attribute value.
return this._checkLeaveApp;
}
/**
* Get/set the <code>checkLeaveApp</code> property of this
* XML editor application.
* <p>Default: <code>true</code>. If <code>true</code> and
* the document being edited has unsaved changes then
* ask users to confirm if they really want to leave the page
* and loose their changes.
*
* @type {boolean}
*/
set checkLeaveApp(check) {
this._checkLeaveApp = check;
this.setBeforeUnloadListener(check && this._xmlEditor &&
this._xmlEditor.saveNeeded);
const checkValue = check? "true" : "false";
if (checkValue !== this.getAttribute("checkleaveapp")) {
this.setAttribute("checkleaveapp", checkValue);
}
}
setBeforeUnloadListener(add) {
/*
console.log(`setBeforeUnloadListener(${add})`);
*/
if (add) {
window.addEventListener("beforeunload", this._onBeforeUnload);
} else {
window.removeEventListener("beforeunload", this._onBeforeUnload);
}
}
onBeforeUnload(event) {
if (this._xmlEditor && this._xmlEditor.saveNeeded) {
event.preventDefault();
// Legacy support for older browsers.
// A function that returns true if the page has unsaved changes.
return (event.returnValue = true);
}
}
setLeaveAppChecker() {
// Corresponds to best practice. See
// https://developer.chrome.com/docs/web-platform/page-lifecycle-api
// #the_beforeunload_event
if (this._checkLeaveApp) {
this.setBeforeUnloadListener(this._xmlEditor.saveNeeded);
}
}
// -------------------------------
// button2PastesText
// -------------------------------
get button2PastesText() {
return this.getAttribute("button2pastestext") === "true";
}
/**
* A cover for {@link XMLEditor#button2PastesText}.
*
* @type {boolean}
*/
set button2PastesText(pastes) {
this._xmlEditor.button2PastesText = pastes;
const pastesValue = pastes? "true" : "false";
if (pastesValue !== this.getAttribute("button2pastestext")) {
this.setAttribute("button2pastestext", pastesValue);
}
}
// -------------------------------
// autoSave
// -------------------------------
initAutoSave() {
let spec = this.autoSave;
let [ mode, interval, intervalMs, enabled ] =
XMLEditorApp.parseAutoSave(spec);
if (mode === null) {
spec = null;
} else {
enabled = XMLEditorApp.getPreference("autoSave", enabled);
spec = `${mode} ${interval} ${enabled? "on" : "off"}`;
}
this.autoSave = spec;
}
static parseAutoSave(spec) {
let mode = null;
let interval = null;
let intervalMs = -1;
let enabled = false;
if (spec) {
spec = spec.trim();
for (let m of [ "both", "remote", "local" ]) {
if (spec.startsWith(m)) {
mode = m;
interval = spec.substring(m.length).trim(); // May be empty.
if (interval.endsWith("off")) {
enabled = false;
interval =
interval.substring(0, interval.length-3).trim();
} else if (interval.endsWith("on")) {
enabled = true;
interval =
interval.substring(0, interval.length-2).trim();
} else {
enabled = true;
}
break;
}
}
if (!FSAccess.isAvailable() &&
(mode === "both" || mode === "local")) {
mode = (mode === "local")? null : "remote";
}
if (mode !== null) {
if (!interval) {
interval = "30s";
}
// parseFloat ignores trailing char.
let intervalMs = parseFloat(interval);
if (!isNaN(intervalMs) && intervalMs > 0) {
if (interval.endsWith("s")) {
intervalMs *= 1000;
} else if (interval.endsWith("m")) {
intervalMs *= 1000 * 60;
} else if (interval.endsWith("h")) {
intervalMs *= 1000 * 60 * 60;
} else {
intervalMs = -1;
}
} else {
intervalMs = -1;
}
if (intervalMs <= 0) {
mode = null;
} else {
if (intervalMs < 10000) {
intervalMs = 10000;
interval = "10s";
}
}
}
}
return [ mode, interval, intervalMs, enabled ];
}
get autoSave() {
return this.getAttribute("autosave");
}
/**
* Get/set the <code>autoSave</code> property of this
* XML editor application.
* <p>Default: none; files are not automatically saved.
* <p>The value of this property is a string which has
* the following syntax:
* <pre>value -> mode [ S interval ]? [ S enabled ]?
*mode -> local|remote|both
*interval -> strictly_positive_number s|m|h
*enabled -> on|off</pre>
* <p>Examples: <code>"remote"</code>, <code>"both 2m"</code>,
* <code>"remote 30s on"</code>, <code>"both off"</code>.
* <p>Autosave modes are:
* <ul>
* <li><b>local</b>: automatically save local files
* (when this is technically possible, i.e. on Chrome, not on Firefox).
* <li><b>remote</b>: automatically save remote files.
* <li><b>both</b>: automatically save both local and remote files.
* </ul>
* <p>Autosave interval units are:
* <ul>
* <li><b>s</b>: seconds.
* <li><b>m</b>: minutes.
* <li><b>h</b>: hours.
* </ul>
* <p>Default interval is <code>"30s"</code>.
* Minimal interval is <code>"10s"</code>.
* <p>The default value of <i>enabled</i> is <code>"on"</code>. This flag
* specifies whether the autosave feature is <em>initially</em> enabled.
* User may change this setting at anytime using the UI.
*
* @type {string}
*/
set autoSave(spec) {
if (this._autoSave !== null) {
this._autoSave.dispose();
this._autoSave = null;
}
// ---
let [ mode, interval, intervalMs, enabled ] =
XMLEditorApp.parseAutoSave(spec);
if (mode === null) {
this.removeAttribute("autosave");
XMLEditorApp.setPreference("autoSave", null);
} else {
spec = `${mode} ${interval} ${enabled? "on" : "off"}`;
this.setAttribute("autosave", spec);
XMLEditorApp.setPreference("autoSave", enabled);
if (enabled) {
this._autoSave = new AutoSave(this._xmlEditor, mode, intervalMs);
}
}
}
// -------------------------------
// User preferences
// -------------------------------
static getPreference(key, defaultValue) {
let prefs = XMLEditorApp.getPreferences();
if (key in prefs) {
return prefs[key];
} else {
return defaultValue;
}
}
static getPreferences() {
let prefs = null;
let data = window.localStorage.getItem("XXE.XMLEditorApp.preferences");
if (data !== null) {
prefs = JSON.parse(data);
}
if (prefs === null || typeof prefs !== "object") {
prefs = {};
}
return prefs;
}
static setPreference(key, value) {
let prefs = XMLEditorApp.getPreferences();
if (value === null) {
delete prefs[key];
} else {
prefs[key] = value;
}
if (Object.keys(prefs).length === 0) {
window.localStorage.removeItem("XXE.XMLEditorApp.preferences");
} else {
window.localStorage.setItem("XXE.XMLEditorApp.preferences",
JSON.stringify(prefs));
}
}
// -----------------------------------------------------------------------
// Implementation
// -----------------------------------------------------------------------
// -------------------------------
// The indicator as a button
// -------------------------------
onIndicatorClick(event) {
const mod = PLATFORM_IS_MAC_OS? event.metaKey : event.ctrlKey;
if (mod) {
if (this._indicator.classList.contains("xxe-app-logging")) {
this._indicator.classList.remove("xxe-app-logging");
this._xmlEditor.removeRequestListener(this._onAnyRequest);
for (let eventType of XMLEditor.EVENT_TYPES) {
this._xmlEditor.removeEventListener(eventType,
this._onAnyEvent);
}
} else {
this._indicator.classList.add("xxe-app-logging");
this._xmlEditor.addRequestListener(this._onAnyRequest);
for (let eventType of XMLEditor.EVENT_TYPES) {
this._xmlEditor.addEventListener(eventType,
this._onAnyEvent);
}
}
}
}
onAnyRequest(autoConnect, requestName, requestArgs, response) {
let reqInfo;
if (response !== undefined) {
reqInfo = "\u25C0 " + requestName; // BLACK LEFT-POINTING TRIANGLE
} else {
if (autoConnect) {
reqInfo = "autoConnect\u25B6 ";
} else {
reqInfo = "\u25B6 "; // BLACK RIGHT-POINTING TRIANGLE
}
reqInfo += requestName;
}
reqInfo += JSON.stringify(requestArgs, XMLEditorApp.jsonReplacer, 4);
if (response !== undefined) {
reqInfo += " \u2192 "; // RIGHT ARROW
if (response instanceof Error) {
reqInfo += "Error: " + response.toString();
} else {
reqInfo +=
JSON.stringify(response, XMLEditorApp.jsonReplacer, 4);
}
}
this._xmlEditor.showStatus(reqInfo, /*autoErase*/ true);
}
static jsonReplacer(key, value) {
if (value instanceof Blob) {
let info = "(" + value.size + " bytes";
if (value.type) {
info += ";" + value.type;
}
info += ")";
return info;
} else if (value instanceof ArrayBuffer ||
ArrayBuffer.isView(value)) { // Typed array or DataView
return "[" + value.byteLength + " bytes]";
} else {
return value;
}
}
onAnyEvent(event) {
// Some events like DocumentOpenedEvent may be quite big.
let text = event.toString();
if (text.length > 10000) {
text = text.substring(0, 5000) + "\u2026" +
text.substring(text.length-4999);
}
// BLACK UP-POINTING TRIANGLE
this._xmlEditor.showStatus("\u25B2 " + text, /*autoErase*/ true);
}
// -------------------------------
// App menu
// -------------------------------
onMenuButtonClick(event) {
const menuItems = [
// #0
{ type: "submenu", text: "Options", name: "optionsMenu",
items: [
{ type: "checkbox", text: "Autosave",
name: "autoSaveToggle", selected: false, enabled: false }
] },
// #1
{ separator: true,
text: "Help", name: "help" },
// #2
{ separator: true,
text: "Show Element Reference", name: "elementReference" },
// #3
{ text: "Show Content Model", name: "contentModel" },
// #4
{ separator: true,
text: "Mouse and Keyboard Bindings", name: "bindings" },
// #5
{ separator: true,
text: `About ${XMLEditorApp.NAME}`, name: "about" }
];
let autoSaving = this.autoSave;
if (autoSaving !== null) {
const optionItems = menuItems[0].items;
optionItems[0].enabled = true; // autoSaveToggle
if (autoSaving.endsWith("on")) {
optionItems[0].selected = true;
}
}
if (this._xmlEditor.documentIsOpened) {
if (!this._xmlEditor.configurationName) {
// Simplification: assume that all opened documents having
// a configuration have their "$c elementReference"
// system property set.
menuItems[2].enabled = false;
}
// Simplification: showContentModel always enabled.
// Should be disabled when opened document is not
// contrained by a grammar.
} else {
menuItems[2].enabled = false; // elementReference
menuItems[3].enabled = false; // contentModel
menuItems[4].enabled = false; // bindings
}
this.showButtonMenu(menuItems, this._menuButton, "comboboxmenu");
}
showButtonMenu(menuItems, button, position="menu") {
const menu = XUI.Menu.create(menuItems);
menu.addEventListener("menuitemselected",
this._onButtonMenuItemSelected);
menu.open(position, button);
}
onButtonMenuItemSelected(event) {
switch (event.xuiMenuItem.name) {
case "newDocument":
XMLEditorApp.newDocument(this._xmlEditor);
break;
case "newRemoteFile":
XMLEditorApp.newRemoteFile(this._xmlEditor);
break;
case "openDocument":
XMLEditorApp.openDocument(this._xmlEditor);
break;
case "openRemoteFile":
XMLEditorApp.openRemoteFile(this._xmlEditor);
break;
case "autoSaveToggle":
this.toggleAutoSave();
break;
case "help":
this.showHelp();
break;
case "elementReference":
this.showElementReference();
break;
case "contentModel":
this.showContentModel();
break;
case "bindings":
XUI.Alert.showInfo(this.getBindingsText(), this);
break;
case "about":
XUI.Alert.showInfo(XMLEditorApp.ABOUT, this);
break;
}
}
toggleAutoSave() {
let autoSaving = this.autoSave;
if (autoSaving !== null) {
let toggled = false;
if (autoSaving.endsWith("on")) {
autoSaving =
autoSaving.substring(0, autoSaving.length-2) + "off";
toggled = true;
} else if (autoSaving.endsWith("off")) {
autoSaving =
autoSaving.substring(0, autoSaving.length-3) + "on";
toggled = true;
}
if (toggled) {
this.autoSave = autoSaving;
if (this._xmlEditor.documentIsOpened) {
this.showAutoSaving(autoSaving.endsWith("on"));
}
}
}
}
showHelp() {
const helpURL =
"https://www.xmlmind.com/xmleditor/_web/doc/manual/wh/basics.html";
// Direct user action: no problem with the popup blocker of the browser.
window.open(helpURL, "xxewOnlineHelp");
}
showElementReference() {
// Doing it this way prevents window.open from being blocked by the
// popup blocker of the browser (Firefox only?).
const helpWin = window.open("", "xxewElementReference");
if (!helpWin) {
docView.showStatus(
"Could not open window containing element reference.",
/*autoErase*/ true);
return;
}
helpWin.focus(); // If "xxewElementReference" already opened.
const docView = this._xmlEditor.documentView;
docView.executeCommand(EXECUTE_NORMAL, "showElementReference", null)
.then((result) => {
if (CommandResult.isDone(result) && result.value !== null) {
helpWin.location = result.value;
} else {
docView.showStatus("Element reference not available.",
/*autoErase*/ true);
helpWin.close();
}
});
// Catch not useful. DocumentView.executeCommand handles errors
// by returning a null Promise.
}
showContentModel() {
const docView = this._xmlEditor.documentView;
docView.executeCommand(EXECUTE_NORMAL, "showContentModel", null)
.then((result) => {
if (result === null ||
result.status === COMMAND_RESULT_FAILED) {
docView.showStatus(
"Cannot show the content model of selected element.",
/*autoErase*/ true);
}
});
}
getBindingsText() {
const bindings = this._xmlEditor.documentView.bindings;
if (bindings === null) {
// Should not happen if a document is opened.
return "???";
}
let rows = [];
let rows2 = [];
for (let binding of bindings) {
let row = [ binding.getUserInputLabel(),
binding.commandName, binding.commandParams ];
if (binding.userInput instanceof AppEvent) {
rows2.push(row);
} else {
rows.push(row);
}
}
rows.sort((r1, r2) => { return r1[0].localeCompare(r2[0], "en"); });
if (rows2.length > 0) {
rows2.sort((r1, r2) => {return r1[0].localeCompare(r2[0], "en");});
rows.push(...rows2);
}
// ---
let html = XMLEditorApp.BINDINGS;
let pos = html.indexOf("</tbody>");
if (pos > 0) {
const tdStartTag = `<td ${XMLEditorApp.BINDINGS_TD_STYLE}>`;
let tr = "";
let trCount = 0;
for (let row of rows) {
tr += "<tr";
if (trCount % 2 === 1) {
tr += " style=\"background-color:#FFFFE0;\""; //Light yellow
}
tr += ">\n";
tr += tdStartTag;
tr += XUI.Util.escapeHTML(row[0]);
tr += "</td>";
tr += tdStartTag;
tr += XUI.Util.escapeHTML(row[1]);
tr += "</td>";
tr += tdStartTag;
if (row[2] === null) {
tr += "\u00A0"; // nbsp
} else {
tr += XUI.Util.escapeHTML(row[2]);
}
tr += "</td>";
tr += "</tr>\n";
++trCount;
}
html = html.substring(0, pos) + tr + html.substring(pos);
}
return html;
}
// -------------------------------
// New button
// -------------------------------
onNewButtonClick(event) {
switch (this.documentStorage) {
case "local":
XMLEditorApp.newDocument(this._xmlEditor);
break;
case "remote":
XMLEditorApp.newRemoteFile(this._xmlEditor);
break;
default:
{
const menuItems = [
{ text: "New Local Document\u2026", name: "newDocument" },
{ text: "New Remote Document\u2026", name: "newRemoteFile" }
];
this.showButtonMenu(menuItems, this._newButton);
}
break;
}
}
// -------------------------------
// Open button
// -------------------------------
onOpenButtonClick(event) {
switch (this.documentStorage) {
case "local":
XMLEditorApp.openDocument(this._xmlEditor);
break;
case "remote":
XMLEditorApp.openRemoteFile(this._xmlEditor);
break;
default:
{
const menuItems = [
{ text: "Open Local Document\u2026", name: "openDocument" },
{ text: "Open Remote Document\u2026", name: "openRemoteFile" }
];
this.showButtonMenu(menuItems, this._openButton);
}
break;
}
}
// -------------------------------
// Save button
// -------------------------------
onSaveButtonClick(event) {
XMLEditorApp.saveDocument(this._xmlEditor)
.then((saved) => {
if (saved) {
this._xmlEditor.showStatus(
`Document saved to "${this._xmlEditor.documentURI}".`);
}
});
}
// -------------------------------
// Save As button
// -------------------------------
onSaveAsButtonClick(event) {
XMLEditorApp.saveDocumentAs(this._xmlEditor)
.then((savedAs) => {
if (savedAs) {
this._xmlEditor.showStatus(
`Document saved to "${this._xmlEditor.documentURI}".`);
}
});
}
// -------------------------------
// Close button
// -------------------------------
onCloseButtonClick(event) {
XMLEditorApp.closeDocument(this._xmlEditor);
}
// -------------------------------
// XMLEditor event handlers
// -------------------------------
onConnected(event) {
this._indicator.classList.add("xxe-app-connected");
this._indicator.setAttribute("title", `Connected to
${this._xmlEditor.serverURL}
(${(PLATFORM_IS_MAC_OS? "Command" : "Ctrl")}-click to toggle logging \
requests, responses and events.)`);
}
onDisconnected(event) {
this._indicator.classList.remove("xxe-app-connected");
this._indicator.setAttribute("title", `Disconnected from
${this._xmlEditor.serverURL}
(${(PLATFORM_IS_MAC_OS? "Command" : "Ctrl")}-click to toggle logging \
requests, responses and events.)`);
this.enableButtons();
}
onDocumentOpened(event) {
// On document opened or created or recovered or shared.
this.showAutoSaving(true);
this.removeSaveHint();
this.addSaveHint();
this.updateTitle();
this.enableButtons();
this.setLeaveAppChecker();
}
showAutoSaving(show) {
let saveIcon = this._saveButton.firstElementChild;
if (show && this._autoSave !== null && this._autoSave.activable) {
saveIcon.classList.replace("xui-saveDocument-16",
"xui-autoSaveDocument-16");
saveIcon.setAttribute("title", "Autosave enabled.");
} else {
saveIcon.classList.replace("xui-autoSaveDocument-16",
"xui-saveDocument-16");
saveIcon.removeAttribute("title");
}
}
addSaveHint() {
if (!this._xmlEditor.isRemoteFile) {
let tooltip;
let addClass = null;
if (FSAccess.isAvailable()) {
tooltip = `Please note that \
the first time you'll use this "Save" button,\n\
you may be asked to grant your permission to save this file to disk.`
addClass = "xxe-app-save-info";
} else {
tooltip = `Please note that \
DIRECTLY SAVING A FILE TO DISK IS NOT SUPPORTED\n\
by this browser. Therefore "Save" is here equivalent to "Save As".`
addClass = "xxe-app-save-warn";
}
let saveHint = XMLEditorApp.SAVE_HINT_TEMPLATE.content
.cloneNode(true).firstElementChild;
saveHint.textContent = XUI.StockIcon["comment"];
saveHint.classList.add(addClass);
this._saveButton.appendChild(saveHint);
this._saveButton.setAttribute("title", tooltip);
}
}
removeSaveHint() {
let saveHint = this._saveButton.querySelector(".xxe-app-save-hint");
if (saveHint !== null) {
this._saveButton.removeChild(saveHint);
this._saveButton.removeAttribute("title");
}
}
onDocumentSavedAs(event) {
this.updateTitle();
this.enableButtons();
}
onDocumentClosed(event) {
this.updateTitle();
this.enableButtons();
this.setLeaveAppChecker();
this.showAutoSaving(false);
this.removeSaveHint();
}
onSaveStateChanged(event) {
this.updateTitle();
this.enableButtons();
this.setLeaveAppChecker();
}
onReadOnlyStateChanged(event) {
this.updateTitle();
}
updateTitle() {
let title = XMLEditorApp.TITLE;
let style = "bold";
if (this._xmlEditor.documentIsOpened) {
title = this._xmlEditor.documentURI;
style = "normal";
if (this._xmlEditor.saveNeeded) {
title += " (modified)";
}
if (this._xmlEditor.readOnlyDocument) {
title += " (read-only)";
}
}
if (title !== this._title.textContent) {
this._title.textContent = title;
this._title.style.fontWeight = style;
}
}
enableButtons() {
const connected = this._xmlEditor.connected;
const docOpened = this._xmlEditor.documentIsOpened;
XMLEditorApp.enableButton(this._saveButton,
connected && docOpened &&
this._xmlEditor.saveNeeded);
XMLEditorApp.enableButton(this._saveAsButton, connected && docOpened);
XMLEditorApp.enableButton(this._closeButton, connected && docOpened);
}
static enableButton(button, enabled) {
if (enabled) {
if (button.hasAttribute("disabled")) {
button.removeAttribute("disabled");
button.classList.remove("xui-control-disabled");
}
} else {
if (!button.hasAttribute("disabled")) {
button.setAttribute("disabled", "disabled");
button.classList.add("xui-control-disabled");
}
}
}
// =======================================================================
// Interactive actions (may be used independently from XMLEditorApp)
// =======================================================================
// ------------------------------------
// newDocument, newRemoteFile
// ------------------------------------
/**
* Open a newly created <em>local</em> document in specified XML editor.
* <p>This static method in invoked by
* <b>New</b>|<b>New Local Document</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static newDocument(xmlEditor) {
return XMLEditorApp.doNewDocument(xmlEditor, /*remote*/ false);
}
/**
* Open a newly created <em>remote</em> document in specified XML editor.
* <p>This static method in invoked by
* <b>New</b>|<b>New Remote Document</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static newRemoteFile(xmlEditor) {
return XMLEditorApp.doNewDocument(xmlEditor, /*remote*/ true);
}
static doNewDocument(xmlEditor, remote) {
let selectedTemplate = [];
return XMLEditorApp.confirmDiscardChanges(xmlEditor)
.then((confirmed) => {
if (confirmed) {
return NewDocumentDialog.showDialog(xmlEditor);
} else {
return null; // Template not selected.
}
})
.then((tmpl) => {
if (tmpl === null) {
// Canceled by user during any of the 2 previous steps.
return false; // Document not closed.
} else {
selectedTemplate.push(...tmpl);
return XMLEditorApp.doCloseDocument(xmlEditor);
}
})
.then((closed) => {
if (closed) {
let [category, template] = selectedTemplate;
if (remote) {
return xmlEditor.newRemoteFile(category, template);
} else {
return xmlEditor.newDocument(category, template);
}
} else {
return false;
}
})
.catch((error) => {
XUI.Alert.showError(`Cannot create document:\n${error}`,
xmlEditor);
return false;
});
}
static confirmDiscardChanges(xmlEditor) {
if (!xmlEditor.documentIsOpened || !xmlEditor.saveNeeded) {
// No changes.
return Promise.resolve(true);
}
return XUI.Confirm.showConfirm(
`"${xmlEditor.documentURI}" has been modified\nDiscard changes?`,
/*reference*/ xmlEditor);
}
// ------------------------------------
// openDocument, openRemoteFile
// ------------------------------------
/**
* Open an existing <em>local</em> document in specified XML editor.
* <p>This static method in invoked by
* <b>Open</b>|<b>Open Local Document</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static openDocument(xmlEditor) {
return XMLEditorApp.doOpenDocument(xmlEditor, /*remote*/ false);
}
/**
* Open an existing <em>remote</em> document in specified XML editor.
* <p>This static method in invoked by
* <b>Open</b>|<b>Open Remote Document</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static openRemoteFile(xmlEditor) {
return XMLEditorApp.doOpenDocument(xmlEditor, /*remote*/ true);
}
static doOpenDocument(xmlEditor, remote) {
const options = {
title: "Open Document",
extensions: XMLEditorApp.ACCEPTED_FILE_EXTENSIONS,
option: [ "readOnly", false,
"Open corresponding document in read-only mode" ]
};
let chosenFile = {};
return XMLEditorApp.confirmDiscardChanges(xmlEditor)
.then((confirmed) => {
if (confirmed) {
if (remote) {
return RemoteFileDialog.showDialog(xmlEditor, options);
} else {
return LocalFileDialog.showDialog(xmlEditor, options);
}
} else {
return null; // File not chosen.
}
})
.then((choice) => {
if (choice === null) {
// Canceled by user during any of the 2 previous steps.
return false; // Document not closed.
} else {
Object.assign(chosenFile, choice);
return XMLEditorApp.doCloseDocument(xmlEditor);
}
})
.then((closed) => {
if (closed) {
let readOnly =
!chosenFile.readOnly? false : chosenFile.readOnly;
if (remote) {
return xmlEditor.openRemoteFile(chosenFile.uri,
readOnly);
} else {
return xmlEditor.openDocument(chosenFile.file,
chosenFile.uri, readOnly);
}
} else {
return false; // Old doc not closed => new doc not opened.
}
})
.then((opened) => {
if (opened) {
if (!remote && chosenFile.file.handle) {
// chosenFile.file.handle is needed to save a local
// file without prompting the user.
// (This handle is lost when recovering a
// local document. This just means that the user
// will be prompted at the first save.)
// This "set handle" works because
// DocumentOpenedEvents (initalizing the state of
// newly opened document state) are received *before*
// the result of request openDocument.
xmlEditor.documentFileHandle = chosenFile.file.handle;
} else {
xmlEditor.documentFileHandle = null;
}
}
return opened;
})
.catch((error) => {
XUI.Alert.showError(`Cannot open document:\n${error}`,
xmlEditor);
return false;
});
}
// ------------------------------------
// saveDocument
// ------------------------------------
/**
* Save the document being edited in specified XML editor.
* <p>This static method in invoked by button <b>Save</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static saveDocument(xmlEditor) {
if (!xmlEditor.documentIsOpened || !xmlEditor.saveNeeded) {
// Nothing to do.
return Promise.resolve(false);
}
const remote = xmlEditor.isRemoteFile;
if (xmlEditor.saveAsNeeded ||
(!remote && xmlEditor.documentFileHandle === null)) {
return XMLEditorApp.saveDocumentAs(xmlEditor);
}
return XMLEditorApp.doSaveDocument(xmlEditor, remote)
.catch((error) => {
XUI.Alert.showError(`Could not save document:\n${error}`,
xmlEditor);
return false;
});
}
static doSaveDocument(xmlEditor, remote) {
return XMLEditorApp.getDocumentContent(xmlEditor, remote)
.then((docContent) => {
if (remote) {
// N/A.
return null;
} else {
// Non interactive.
return FSAccess.fileSave(docContent, /*options*/ {},
xmlEditor.documentFileHandle,
/*throwIfUnusableHandle*/ true);
}
})
.then((savedFileHandle) => {
if (!remote &&
savedFileHandle !== xmlEditor.documentFileHandle) {
throw new Error(`INTERNAL ERROR: expected \
document file handle=${xmlEditor.documentFileHandle}, \
got handle=${savedFileHandle}`);
}
return xmlEditor.saveDocument();
})
}
static getDocumentContent(xmlEditor, remote) {
if (remote) {
// N/A.
return Promise.resolve(null);
}
return xmlEditor.getDocument() // formattingOptions are found in config.
.then((xmlSource) => {
if (xmlSource) {
// We only support "UTF-8" here.
xmlSource = xmlSource.replace(
/encoding=(('[^']*')|("[^"]*"))\s*\?>/,
"encoding=\"UTF-8\"?>");
return new Blob([ xmlSource ]);
} else {
// Should not happen.
throw new Error("no document content");
}
});
}
// ------------------------------------
// saveDocumentAs
// ------------------------------------
/**
* Save the document being edited in specified XML editor
* to a different location, the user being prompted to specify
* this location.
* <p>This static method in invoked by button <b>Save As</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static saveDocumentAs(xmlEditor) {
const remote = xmlEditor.isRemoteFile;
const options = {
title: "Save Document",
extensions: XMLEditorApp.ACCEPTED_FILE_EXTENSIONS,
templateURI: xmlEditor.documentURI
};
let chosenFile = {};
return XMLEditorApp.getDocumentContent(xmlEditor, remote)
.then((docContent) => {
if (remote) {
return RemoteFileDialog.showDialog(xmlEditor, options,
/*saveMode*/ true);
} else {
return LocalFileDialog.showDialog(xmlEditor, options,
/*savedData*/ docContent);
}
})
.then((choice) => {
if (choice === null) {
// Canceled by user.
return false; // Document not saved as.
} else {
Object.assign(chosenFile, choice);
return xmlEditor.saveDocumentAs(chosenFile.uri);
}
})
.then((savedAs) => {
if (savedAs) {
if (!remote && chosenFile.fileHandle) {
xmlEditor.documentFileHandle = chosenFile.fileHandle;
} else {
xmlEditor.documentFileHandle = null;
}
}
return savedAs;
})
.catch((error) => {
let where = !chosenFile.uri? "" : ` "${chosenFile.uri}"`;
XUI.Alert.showError(`Cannot save document as${where}:\n${error}`,
xmlEditor);
return false;
});
}
// ------------------------------------
// closeDocument
// ------------------------------------
/**
* Close the document being edited in specified XML editor.
* <p>If this document has unsaved changes, the user will have to confirm
* whether she/he really wants to discard these changes.
* <p>This static method in invoked by button <b>Close</b>
* but may be used independently from <code>XMLEditorApp</code>.
*
* @param {XMLEditor} xmlEditor - the XML editor.
*/
static closeDocument(xmlEditor) {
return XMLEditorApp.confirmDiscardChanges(xmlEditor)
.then((confirmed) => {
if (confirmed) {
return XMLEditorApp.doCloseDocument(xmlEditor);
} else {
return false; // Document not closed.
}
})
.catch((error) => {
XUI.Alert.showError(`Cannot close document:\n${error}`,
xmlEditor);
return false;
});
}
static doCloseDocument(xmlEditor) {
if (!xmlEditor.documentIsOpened) {
return Promise.resolve(true);
}
return xmlEditor.closeDocument(/*discardChanges*/ true);
}
}
XMLEditorApp.NAME = "XMLmind XML Editor";
XMLEditorApp.CLIENT_VERSION = "1.5.0";
XMLEditorApp.TITLE =
`${XMLEditorApp.NAME} Web Edition ${XMLEditorApp.CLIENT_VERSION}`;
XMLEditorApp.SERVER_VERSION = "10.10.0";
XMLEditorApp.ABOUT = `<html><h3 style="margin-top:0;">${XMLEditorApp.NAME}
<span style="font-size:smaller;">client ${XMLEditorApp.CLIENT_VERSION} / \
server ${XMLEditorApp.SERVER_VERSION}</span></h3>
<p>Copyright (c) 2017-${(new Date()).getFullYear()} XMLmind Software,
all rights reserved.</p>
<p>For more information, please visit<br />
www.xmlmind.com/xmleditor/</p>
`;
XMLEditorApp.BINDINGS_TD_STYLE =
"style=\"border:1px solid #C0C0C0;padding:0.25em 0.5em;\"";
XMLEditorApp.BINDINGS = `<html><h3 style="margin-top:0;">Mouse and keyboard \
bindings</h3>
<table style="border:1px solid #C0C0C0;border-collapse:collapse;">
<thead style="background-color:#F0F0F0;">
<tr>
<th ${XMLEditorApp.BINDINGS_TD_STYLE}>User input</th>
<th ${XMLEditorApp.BINDINGS_TD_STYLE}>Command</th>
<th ${XMLEditorApp.BINDINGS_TD_STYLE}>Parameter</th>
</tr>
</thead>
<tbody></tbody>
</table>
`;
XMLEditorApp.TEMPLATE = document.createElement("template");
XMLEditorApp.TEMPLATE.innerHTML = `
<div class="xxe-app-title-bar">
<span class="xui-control xxe-app-indicator"></span>
<span class="xxe-app-title">XMLmind XML Editor</span>
<span class="xxe-tool-button xxe-app-menu"></span>
</div>
<div class="xxe-app-button-bar">
<button type="button" class="xui-control xxe-app-button"><span
class="xui-edit-icon-16
xui-newDocument-16"></span><span>New</span><span>\u2026</span></button>
<button type="button" class="xui-control xxe-app-button"><span
class="xui-edit-icon-16
xui-openDocument-16"></span><span>Open</span><span>\u2026</span></button>
<button type="button" class="xui-control xxe-app-button"><span
class="xui-edit-icon-16
xui-saveDocument-16"></span><span>Save</span></button>
<button type="button" class="xui-control xxe-app-button"><span
class="xui-edit-icon-16
xui-saveDocumentAs-16"></span><span>Save As\u2026</span></button>
<button type="button" class="xui-control xxe-app-button"><span
class="xui-edit-icon-16
xui-closeDocument-16"></span><span>Close</span></button>
</div>
<xxe-client></xxe-client>
`;
XMLEditorApp.SAVE_HINT_TEMPLATE = document.createElement("template");
XMLEditorApp.SAVE_HINT_TEMPLATE.innerHTML = `
<span class="xui-small-icon xxe-app-save-hint"></span>
`;
XMLEditorApp.ACCEPTED_FILE_EXTENSIONS = [
["XML files",
"application/xml",
"xml"],
["DocBook files",
"application/docbook+xml",
"xml", "dbk", "docb"],
["XHTML files",
"application/xhtml+xml",
"xhtml", "xht",
"html", "shtml", "htm"],
["DITA files",
"application/dita+xml",
"dita", "ditamap",
"bookmap", "ditaval"],
["MathML files",
"application/mathml+xml",
"mml"]
];
window.customElements.define("xxe-app", XMLEditorApp);