/**
* Used by {@link XMLEditor} to synchronize the contents of
* the local, system clipboard with the remote, server-side,
* private clipboard of the XML editor.
*/
class ClipboardIntegration {
constructor(editor) {
this._xmlEditor = editor;
this._level = ClipboardIntegration.UNKNOWN;
this._systemClipboardText = null;
}
getLevel() {
if (this._level === ClipboardIntegration.UNKNOWN) {
// The Clipboard API is available only in secure contexts.
if (window.isSecureContext) {
return ClipboardIntegration.doGetLevel(
[ ClipboardIntegration.NONE ])
.then((level) => {
this._level = level;
return Promise.resolve(level);
});
} else {
this._level = ClipboardIntegration.NONE;
console.warn(`Current context not secure; \
SYSTEM CLIPBOARD INTEGRATION NOT AVAILABLE`);
return Promise.resolve(this._level);
}
} else {
return Promise.resolve(this._level);
}
}
static doGetLevel(level) {
return ClipboardIntegration.queryPermission("clipboard-write")
.then((canWrite) => {
if (canWrite.state === "denied") {
throw new Error('"clipboard-write" permission is denied');
} else {
// When supported, the "clipboard-write" permission is
// granted automatically.
level[0] = ClipboardIntegration.CAN_WRITE;
return ClipboardIntegration.queryPermission("clipboard-read");
}
})
.then((canRead) => {
if (canRead.state === "denied") {
throw new Error('"clipboard-read" permission is denied');
} else {
// May be "granted" or "prompt". Here we assume that the
// user will grant this permission.
level[0] = ClipboardIntegration.CAN_READ_WRITE;
return Promise.resolve(level[0]);
}
})
.catch((error) => {
console.warn(`${error}; \
SYSTEM CLIPBOARD INTEGRATION NOT AVAILABLE`);
return Promise.resolve(level[0]);
});
}
static queryPermission(name) {
if (BROWSER_ENGINE_IS_GECKO) {
// Firefox does not support permissions "clipboard-write" and
// "clipboard-read".
let state;
if (name === "clipboard-write") {
state = "granted";
} else {
// Firefox does not support read/readText normally even if
// about:config, dom.events.asyncClipboard.read/readText is
// set to true.
state = "denied";
}
return Promise.resolve({ name: name, state: state });
} else {
return navigator.permissions.query({ name: name });
}
}
// -----------------------------------
// Invoked by DocumentView
// -----------------------------------
autoUpdatePrivateClipboard(docView) {
if (this._level === ClipboardIntegration.CAN_READ_WRITE) {
docView.addEventListener("focusin", (event) => {
if (this._xmlEditor.documentIsOpened) {
this.updatePrivateClipboard(docView);
}
});
// Some browsers like Brave, unlike Chrome, will refuse to read
// the clipboard on a "focusin". They want a "user gesture" to do
// that.
docView.addEventListener("click", (event) => {
if (this._xmlEditor.documentIsOpened &&
event.button === 0 && // primary button
event.detail === 1) { // clickCount
this.updatePrivateClipboard(docView);
}
}, true); // useCapture
}
}
updatePrivateClipboard(docView) {
// The question here is: is the readText() below reading a new text
// regardless of what happens during its execution.
// (See comment in readText() below about how
// this._systemClipboardText could be changed by server-side
// clipboardUpdated() during its execution.)
const systemClipboardText = this._systemClipboardText;
let hasHTML = [ false ];
ClipboardIntegration.readText(ClipboardIntegration.MAX_READ_SIZE,
hasHTML)
.then((text) => {
if (text && text !== systemClipboardText) {
this._systemClipboardText = text;
if (hasHTML[0]) {
text = ClipboardIntegration.HTML_MAGIC_STRING + text;
}
docView.sendSetClipboard(text);
// An editing context change should follow to confirm
// that text has been copied to the private clipboard.
//
// (This is why updatePrivateClipboard is invoked
// when xmlEditor.documentIsOpened and not just when
// xmlEditor.connected.)
}
});
}
static readText(maxReadSize, hasHTML) {
// Not equivalent to navigator.clipboard.readText().
// Server-side clipboardUpdated() may occur between
// any of the .then() below.
// In other words, this._systemClipboardText may be changed
// by clipboardUpdated() during the execution of this function.
hasHTML[0] = false;
return navigator.clipboard.read()
.then((items) => {
let item = null;
for (let i of items) {
const types = i.types;
if (types.includes("text/plain")) {
item = i;
if (types.includes("text/html")) {
hasHTML[0] = true;
}
// Done.
break;
}
}
return item;
})
.then((item) => {
if (item === null) {
return null;
} else {
return item.getType("text/plain");
}
})
.then((blob) => {
if (blob === null) {
return null;
} else {
if (maxReadSize > 0 && blob.size > maxReadSize) {
throw new Error(`system clipboard content is \
too large (size=${blob.size}b > max=${maxReadSize}b)`);
}
return blob.text();
}
})
.catch((error) => {
// Generally a transient error related to focus or user
// gesture, e.g. SecurityError, "must be handling a user
// gesture to do that".
const msg =
`Could not read text from the system clipboard: ${error}`;
if (error.name === "SecurityError") {
console.warn(msg);
} else {
console.error(msg);
}
return null;
});
}
// -----------------------------------
// Invoked by XMLEditor
// -----------------------------------
clipboardUpdated(clipboardUpdate) {
if (this._level >= ClipboardIntegration.CAN_WRITE) {
if (clipboardUpdate !== null) {
this.updateSystemClipboard(clipboardUpdate.source);
} else {
// Otherwise, editing context changed but clipboard not updated
// OR the document being edited has been opened or closed.
if (!this._xmlEditor.documentIsOpened) {
this._systemClipboardText = null;
}
}
}
}
updateSystemClipboard(newText) {
if (newText && newText !== this._systemClipboardText) {
navigator.clipboard.writeText(newText)
.then(() => {
this._systemClipboardText = newText;
})
.catch((error) => {
// A transient error?
console.warn(`Could not write text to \
the system clipboard: ${error}`);
});
}
}
}
ClipboardIntegration.UNKNOWN = -1;
ClipboardIntegration.NONE = 0;
ClipboardIntegration.CAN_WRITE = 1;
ClipboardIntegration.CAN_READ_WRITE = 2;
ClipboardIntegration.HTML_MAGIC_STRING = "\uEFED\uEFEC\uEFEB\uEFEA";
ClipboardIntegration.MAX_READ_SIZE = 500 * 1024; // 500Kb