cool#8648 clipboard: try to use navigator.clipboard.write()

For one, getting the selection HTML all the time when we create a text
selection is a waste of resources, since only a subsequent copy needs
that HTML. For another, the complex selection case required a confusing
"two step copy" workflow, where first you press Ctrl-C, then you
download the large selection, finally you press Ctrl-C again.

The underlying problem is the same: the document.execCommand() API for
copy (and cut) is synchronous, but network operations are async, which
don't play well together.

Fix the problem by trying to use navigator.clipboard.write() instead:
the write() call still has to happen inside a magic security context
(keyboard used, click happened), but it can take a callback as a
parameter, and inside that callback it's fine to perform async network
operations, which allows both using a one-step copy and getting rid of
the HTML download on text selection change (when most of the time we
don't need it).

Tested:

- Chrome and Safari; the behavior for Firefox is unchanged, unless
  about:config opts in to expose the new Clipboard API.

- HTML, plain text and image copy.

- Cut, not only copy.

- Doing this with the notebookbar button & keyboard.

- A single cypress test now uses a fake clipboard to assert copy. The
  rest of the tests are left unchanged for now, but likely we need to
  get rid of this implicit assumption that the copy container is updated
  on selection change: different behavior for automated vs manual testing
  is ugly.

Signed-off-by: Miklos Vajna <vmiklos@collabora.com>
Change-Id: Ifcf16474a339f3f1dae3dc99181836e645340048
pull/8709/head
Miklos Vajna 2024-04-04 09:29:29 +02:00 committed by Caolán McNamara
parent 6b37c158d1
commit 75251f9496
3 changed files with 96 additions and 6 deletions

View File

@ -3265,11 +3265,17 @@ L.CanvasTileLayer = L.Layer.extend({
this._cellCSelections.setPointSet(pointSet);
this._map.removeLayer(this._map._textInput._cursorHandler); // User selected a text, we remove the carret marker.
if (this._selectionContentRequest) {
clearTimeout(this._selectionContentRequest);
// Keep fetching the text selection during testing, for now: too many tests
// depend on this behavior currently.
if (navigator.clipboard.write && !L.Browser.cypressTest) {
this._map._clip.setTextSelectionType('text');
} else {
if (this._selectionContentRequest) {
clearTimeout(this._selectionContentRequest);
}
this._selectionContentRequest = setTimeout(L.bind(function () {
app.socket.sendMessage('gettextselection mimetype=text/html,text/plain;charset=utf-8');}, this), 100);
}
this._selectionContentRequest = setTimeout(L.bind(function () {
app.socket.sendMessage('gettextselection mimetype=text/html,text/plain;charset=utf-8');}, this), 100);
}
else {
this._textCSelections.clear();

View File

@ -13,7 +13,7 @@
* local & remote clipboard data.
*/
/* global app _ brandProductName $ */
/* global app _ brandProductName $ ClipboardItem */
// Get all interesting clipboard related events here, and handle
// download logic in one place ...
@ -513,6 +513,12 @@ L.Clipboard = L.Class.extend({
populateClipboard: function(ev) {
this._checkSelection();
if (this._navigatorClipboardWrite()) {
// This is the codepath where the browser initiates the clipboard operation,
// e.g. the keyboard is used.
return true;
}
var text = this._getHtmlForClipboard();
var plainText = this.stripHTML(text);
@ -640,6 +646,13 @@ L.Clipboard = L.Class.extend({
var serial = this._clipboardSerial;
this._unoCommandForCopyCutPaste = cmd;
if (operation !== 'paste' && this._navigatorClipboardWrite()) {
// This is the codepath where an UNO command initiates the clipboard
// operation.
return;
}
if (document.execCommand(operation) &&
serial !== this._clipboardSerial) {
window.app.console.log('copied successfully');
@ -779,6 +792,49 @@ L.Clipboard = L.Class.extend({
this.paste(ev);
},
// Executes the navigator.clipboard.write() call, if it's available.
_navigatorClipboardWrite: function() {
if (navigator.clipboard.write === undefined) {
return false;
}
if (this._selectionType !== 'text') {
return false;
}
app.socket.sendMessage('uno ' + this._unoCommandForCopyCutPaste);
const url = this.getMetaURL() + '&MimeType=text/html,text/plain;charset=utf-8';
const that = this;
const text = new ClipboardItem({
'text/html': fetch(url)
.then(response => response.text())
.then(function(text) {
const type = "text/html";
const content = that.parseClipboard(text)['html'];
const blob = new Blob([content], { 'type': type });
return blob;
}),
'text/plain': fetch(url)
.then(response => response.text())
.then(function(text) {
const type = 'text/plain';
const content = that.parseClipboard(text)['plain'];
const blob = new Blob([content], { 'type': type });
return blob;
}),
});
let clipboard = navigator.clipboard;
if (L.Browser.cypressTest) {
clipboard = this._dummyClipboard;
}
clipboard.write([text]).then(function() {
}, function(error) {
window.app.console.log('navigator.clipboard.write() failed: ' + error.message);
});
return true;
},
// Parses the result from the clipboard endpoint into HTML and plain text.
parseClipboard: function(text) {
let textHtml;
@ -971,6 +1027,11 @@ L.Clipboard = L.Class.extend({
this._scheduleHideDownload();
},
// Sets the selection type without having the selection content (async clipboard).
setTextSelectionType: function(selectionType) {
this._selectionType = selectionType;
},
// sets the selection to some (cell formula) text)
setTextSelectionText: function(text) {
// Usually 'text' is what we see in the formulabar

View File

@ -13,6 +13,27 @@ describe(['tagdesktop', 'tagnextcloud', 'tagproxy'], 'Clipboard operations.', fu
helper.afterAll(testFileName, this.currentTest.state);
});
function setDummyClipboard() {
cy.window().then(win => {
const app = win['0'].app;
const clipboard = app.map._clip;
clipboard._dummyClipboard = {
write: function(clipboardItems) {
const clipboardItem = clipboardItems[0];
clipboardItem.getType('text/html').then(blob => blob.text())
.then(function (text) {
clipboard._dummyDiv.innerHTML = text;
});
return {
then: function(resolve/*, reject*/) {
resolve();
},
};
},
};
});
}
it('Copy and Paste text.', function() {
before('copy_paste.odt');
// Select some text
@ -27,10 +48,12 @@ describe(['tagdesktop', 'tagnextcloud', 'tagproxy'], 'Clipboard operations.', fu
cy.cGet('body').rightclick(XPos, YPos);
});
setDummyClipboard();
cy.cGet('body').contains('.context-menu-link', 'Copy')
.click();
cy.cGet('#copy_paste_warning-box').should('exist');
cy.cGet('#copy-paste-container div p').should('have.text', 'text');
});
it('Copy plain text.', function() {