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: I2fe1378a8d50b7901ac9e808eb78858cd8ff8575pull/7857/head
parent
971b235514
commit
7f9de46688
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
|
||||
|
|
Binary file not shown.
|
@ -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', ''));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>";
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
Loading…
Reference in New Issue