cool#8465 clipboard: improve handling of plain text copy, simple case

Currently the current selection is always requested as HTML by the
browser, and then we ask the browser to convert it to plain text.

The problem is that e.g. Writer can produce much better plain text from
its model, compared to the plain text by the browser, e.g. bullet
characters for bullet points.

Fix the problem by:

- CanvasTileLayer.js, _onTextSelectionMsg(): requesting both HTML and
  plain text. Use ',' as a separator, as that's already established,
  e.g. the HTTP Accept header does that already

- Switching the textselectioncontent protocol message from just HTML to
  JSON that contains both HTML and plain text. This is produced in
  ChildSession::getTextSelection() and parsed in CanvasTileLayer.js,
  _onMessage()

- Clipboard.js, setTextSelectionHTML(): allowing setting both HTML and
  plain text.

- ClientSession::postProcessCopyPayload(): knowing if the content to be
  processed is HTML-in-JSON or just HTML, do additional escaping in the
  JSON / textselectioncontent case, but leave the other clipboardcontent
  case unchanged.

So far this only handles the simple case, the behavior for complex
selections are left unchanged for now. The payload is also unchanged
when a single format is requested, as many tests depend on test.

Signed-off-by: Miklos Vajna <vmiklos@collabora.com>
Change-Id: I2fe1378a8d50b7901ac9e808eb78858cd8ff8575
pull/7857/head
Miklos Vajna 2024-03-07 14:18:43 +01:00 committed by Caolán McNamara
parent 971b235514
commit 7f9de46688
7 changed files with 92 additions and 22 deletions

View File

@ -1679,11 +1679,23 @@ L.CanvasTileLayer = L.Layer.extend({
this._onTextSelectionMsg(textMsg);
}
else if (textMsg.startsWith('textselectioncontent:')) {
if (this._map._clip)
this._map._clip.setTextSelectionHTML(textMsg.substr(22));
else
let textMsgContent = textMsg.substr(22);
let textMsgHtml = '';
let textMsgPlainText = '';
if (textMsgContent.startsWith('{')) {
// Multiple formats: JSON.
let textMsgJson = JSON.parse(textMsgContent);
textMsgHtml = textMsgJson['text/html'];
textMsgPlainText = textMsgJson['text/plain;charset=utf-8'];
} else {
// Single format: as-is.
textMsgHtml = textMsgContent;
}
if (this._map._clip) {
this._map._clip.setTextSelectionHTML(textMsgHtml, textMsgPlainText);
} else
// hack for ios and android to get selected text into hyperlink insertion dialog
this._selectedTextContent = textMsg.substr(22);
this._selectedTextContent = textMsgHtml;
}
else if (textMsg.startsWith('clipboardchanged')) {
var jMessage = textMsg.substr(17);
@ -3235,7 +3247,7 @@ L.CanvasTileLayer = L.Layer.extend({
clearTimeout(this._selectionContentRequest);
}
this._selectionContentRequest = setTimeout(L.bind(function () {
app.socket.sendMessage('gettextselection mimetype=text/html');}, this), 100);
app.socket.sendMessage('gettextselection mimetype=text/html,text/plain;charset=utf-8');}, this), 100);
}
else {
this._textCSelections.clear();

View File

@ -23,6 +23,7 @@ L.Clipboard = L.Class.extend({
initialize: function(map) {
this._map = map;
this._selectionContent = '';
this._selectionPlainTextContent = '';
this._selectionType = null;
this._accessKey = [ '', '' ];
this._clipboardSerial = 0; // incremented on each operation
@ -34,6 +35,7 @@ L.Clipboard = L.Class.extend({
var div = document.createElement('div');
this._dummyDiv = div;
this._dummyPlainDiv = null;
this._dummyClipboard = {};
div.setAttribute('id', this._dummyDivName);
@ -49,6 +51,12 @@ L.Clipboard = L.Class.extend({
var parent = document.getElementById('map');
parent.appendChild(div);
if (L.Browser.cypressTest) {
this._dummyPlainDiv = document.createElement('div');
this._dummyPlainDiv.id = 'copy-plain-container';
parent.appendChild(this._dummyPlainDiv);
}
// sensible default content.
this._resetDiv();
@ -503,6 +511,9 @@ L.Clipboard = L.Class.extend({
var text = this._getHtmlForClipboard();
var plainText = this.stripHTML(text);
if (text == this._selectionContent && this._selectionPlainTextContent != '') {
plainText = this._selectionPlainTextContent;
}
if (ev.clipboardData) { // Standard
if (this._unoCommandForCopyCutPaste === '.uno:CopyHyperlinkLocation') {
var ess = 's';
@ -915,16 +926,19 @@ L.Clipboard = L.Class.extend({
clearSelection: function() {
this._selectionContent = '';
this._selectionPlainTextContent = '';
this._selectionType = null;
this._scheduleHideDownload();
},
// textselectioncontent: message
setTextSelectionHTML: function(html) {
setTextSelectionHTML: function(html, plainText = '') {
this._selectionType = 'text';
this._selectionContent = html;
this._selectionPlainTextContent = plainText;
if (L.Browser.cypressTest) {
this._dummyDiv.innerHTML = html;
this._dummyPlainDiv.innerText = plainText;
}
this._scheduleHideDownload();
},
@ -941,6 +955,7 @@ L.Clipboard = L.Class.extend({
}
this._selectionType = 'text';
this._selectionContent = this._originWrapBody(text);
this._selectionPlainTextContent = text;
this._scheduleHideDownload();
},

View File

@ -1,20 +1,20 @@
/* global describe it cy beforeEach require expect afterEach*/
/* global describe it cy require expect afterEach*/
var helper = require('../../common/helper');
describe(['tagdesktop', 'tagnextcloud', 'tagproxy'], 'Clipboard operations.', function() {
var origTestFileName = 'copy_paste.odt';
var testFileName;
beforeEach(function() {
testFileName = helper.beforeAll(origTestFileName, 'writer');
});
function before(filename) {
testFileName = helper.beforeAll(filename, 'writer');
}
afterEach(function() {
helper.afterAll(testFileName, this.currentTest.state);
});
it('Copy and Paste text.', function() {
before('copy_paste.odt');
// Select some text
helper.selectAllText();
@ -32,4 +32,13 @@ describe(['tagdesktop', 'tagnextcloud', 'tagproxy'], 'Clipboard operations.', fu
cy.cGet('#copy_paste_warning-box').should('exist');
});
it('Copy plain text.', function() {
before('copy_paste_simple.odt');
helper.selectAllText();
let expected = ' • first\n • second\n • third\n';
cy.cGet('#copy-plain-container').should('have.text', expected.replaceAll('\n', ''));
});
});

View File

@ -1170,15 +1170,23 @@ std::string ChildSession::getTextSelectionInternal(const std::string& mimeType)
bool ChildSession::getTextSelection(const StringVector& tokens)
{
std::string mimeType;
std::string mimeTypeList;
if (tokens.size() != 2 ||
!getTokenString(tokens[1], "mimetype", mimeType))
!getTokenString(tokens[1], "mimetype", mimeTypeList))
{
sendTextFrameAndLogError("error: cmd=gettextselection kind=syntax");
return false;
}
std::vector<std::string> mimeTypes = Util::splitStringToVector(mimeTypeList, ',');
if (mimeTypes.empty())
{
sendTextFrameAndLogError("error: cmd=gettextselection kind=syntax");
return false;
}
std::string mimeType = mimeTypes[0];
SigUtil::addActivity(getId(), "getTextSelection");
if (getLOKitDocument()->getDocumentType() != LOK_DOCTYPE_TEXT &&
@ -1197,18 +1205,34 @@ bool ChildSession::getTextSelection(const StringVector& tokens)
}
getLOKitDocument()->setView(_viewId);
char* textSelection = nullptr;
const int selectionType = getLOKitDocument()->getSelectionTypeAndText(mimeType.c_str(), &textSelection);
std::string selection(textSelection ? textSelection : "");
free(textSelection);
if (selectionType == LOK_SELTYPE_LARGE_TEXT || selectionType == LOK_SELTYPE_COMPLEX)
Poco::JSON::Object selectionObject;
for (const auto& type : mimeTypes)
{
// Flag complex data so the client will download async.
sendTextFrame("complexselection:");
return true;
char* textSelection = nullptr;
const int selectionType = getLOKitDocument()->getSelectionTypeAndText(type.c_str(), &textSelection);
std::string selection(textSelection ? textSelection : "");
free(textSelection);
if (selectionType == LOK_SELTYPE_LARGE_TEXT || selectionType == LOK_SELTYPE_COMPLEX)
{
// Flag complex data so the client will download async.
sendTextFrame("complexselection:");
return true;
}
if (mimeTypes.size() == 1)
{
// Single format: send that as-is.
sendTextFrame("textselectioncontent: " + selection);
return true;
}
selectionObject.set(type, selection);
}
sendTextFrame("textselectioncontent: " + selection);
// Multiple formats: send in JSON.
std::stringstream selectionStream;
selectionObject.stringify(selectionStream);
std::string selection = selectionStream.str();
sendTextFrame("textselectioncontent:\n" + selection);
return true;
}

View File

@ -1599,6 +1599,7 @@ void ClientSession::postProcessCopyPayload(const std::shared_ptr<Message>& paylo
{
// Insert our meta origin if we can
payload->rewriteDataBody([this](std::vector<char>& data) {
bool json = Util::findInVector(data, "textselectioncontent:\n{") == 0;
std::size_t pos = Util::findInVector(data, "<body");
if (pos != std::string::npos)
{
@ -1611,6 +1612,10 @@ void ClientSession::postProcessCopyPayload(const std::shared_ptr<Message>& paylo
const std::string meta = getClipboardURI();
LOG_TRC("Inject clipboard cool origin of '" << meta << "'");
std::string origin = "<div id=\"meta-origin\" data-coolorigin=\"" + meta + "\">\n";
if (json)
{
origin = "<div id=\\\"meta-origin\\\" data-coolorigin=\\\"" + meta + "\\\">\\n";
}
data.insert(data.begin() + pos + strlen(">"), origin.begin(), origin.end());
const char* end = "</body>";

View File

@ -499,9 +499,14 @@ statechanged: <key>=<value>
Eg: 'statechanged: .uno:Undo=enabled'
textselectioncontent:
<JSON>
Current selection's content, only for text-only selections of a reasonably limited size.
For complex selection and large texts, selectioncontent message is returned.
The keys of the JSON are MIME types (text/html, text/plain;charset=utf-8),
the values are the content of the selection in that format.
For debug/test purposes, in case a single MIME type is requested, that's
returned as-is, not inside a JSON.
complexselection: