From ad8168b44be12187ddf99a61d4ccb64ae62d851e Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 1 Sep 2019 04:54:28 +0100 Subject: [PATCH 1/6] WIP of smart clipboard copying --- src/components/structures/RoomView.js | 109 +++++++++++++++++- src/components/views/messages/MAudioBody.js | 6 +- src/components/views/messages/MFileBody.js | 10 +- src/components/views/messages/MImageBody.js | 4 +- src/components/views/messages/MVideoBody.js | 6 +- .../views/messages/RoomAvatarEvent.js | 4 +- src/components/views/messages/RoomCreate.js | 2 +- src/components/views/messages/TextualBody.js | 6 +- src/components/views/messages/TextualEvent.js | 2 +- src/components/views/messages/UnknownBody.js | 2 +- 10 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5edf19f3ef9..052056c74bb 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -44,6 +44,7 @@ import ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; +import { formatFullDate } from '../../DateUtils'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; @@ -1449,6 +1450,112 @@ module.exports = React.createClass({ this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); }, + _findEventTileContent: (node) => { + while (node && !node.classList.contains("mx_RoomView_MessageList")) { + if (node.classList.contains("mx_Content")) { + return node; + } + node = node.parentNode; + } + return null; + }, + + _findListItem: (node) => { + while (node.parentNode) { + if (node.parentNode.classList && + node.parentNode.classList.contains("mx_RoomView_MessageList")) + { + return node; + } + node = node.parentNode; + } + return null; + }, + + _pruneStart: (item, startNode, startOffset) { + // todo + return item; + }, + + _pruneEnd: (item, endNode, endOffset) { + // todo + return item; + }, + + onCopy: function(ev) { + const sel = document.getSelection(); + // iterate over all the selected events and build up pretty + // plaintext & HTML representations of them + + // if we're copying a fragment of content then don't do anything funky + if (sel.anchorNode === sel.focusNode || + this.findEventTileContent(sel.anchorNode) === this._findEventTileContent(sel.focusNode)) + { + return; + } + + // otherwise, we must be spanning multiple nodes, so let's prepend + // nice timestamp and sender info. in order to respect the offsets of the anchor + // and focus end of the selection block, we do this by traversing the selection DOM, + // rebuilding the metadata blocks but keeping the data blocks intact. + + const dir = sel.anchorNode.compareDocumentPosition(sel.focusNode); + const startNode = dir & DOCUMENT_POSITION_PRECEDING ? sel.focusNode : sel.anchorNode; + const endNode = dir & DOCUMENT_POSITION_PRECEDING ? sel.anchorNode : sel.focusNode; + const startOffset = dir & DOCUMENT_POSITION_PRECEDING ? sel.focusOffset : sel.anchorOffset; + const endOffset = dir & DOCUMENT_POSITION_PRECEDING ? sel.anchorOffset : sel.focusOffset; + + const startItem = this._findListItem(startNode); + if (!startItem) { + console.warn("Copy selection start isn't within mx_RoomView_MessageList; doing a plain copy instead") + return; + } + + const endItem = this._findListItem(endNode); + if (!endItem) { + console.warn("Copy selection end isn't within mx_RoomView_MessageList; doing a plain copy instead") + return; + } + + let item = startItem; + let html = "
    "; + let text = ""; + while (item !== endItem) { + let node = item; + + if (item === startItem) { + node = this._pruneStart(item, startNode, startOffset); + } + else if (item === endItem) { + node = this._pruneEnd(item, endNode, endOffset); + } + + const eventId = node.getAttribute("data-scroll-tokens"); + if (eventId && this.state.room.findEventById(eventId)) { + const mxEvent = this.state.room.findEventById(eventId); + const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + const ts = formatFullDate(mxEvent.getTs(), showTwelveHour); + const content = node.querySelector("mx_Content"); + html += `
  1. [${ts}] <${mxEvent.getDisplayName()}> ${content.innerHTML}
  2. \n`; + text += `[${ts}] <${mxEvent.getDisplayName()}> ${content.innerText}\n`; + } + else { + // fall back for MELS etc + html += node.innerHTML; + text += node.innerText; + } + + item = item.nextElementSibling; + } + html += "
"; + + + event.clipboardData.setData('text/plain', text); + event.clipboardData.setData('text/html', html); + event.preventDefault(); + }, + onFullscreenClick: function() { dis.dispatch({ action: 'video_fullscreen', @@ -1955,7 +2062,7 @@ module.exports = React.createClass({ >
{ auxPanel } -
+
{ topUnreadMessagesBar } { jumpToBottom } { messagePanel } diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index b4f26d0cbd6..0eab98790b6 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -80,7 +80,7 @@ export default class MAudioBody extends React.Component { if (this.state.error !== null) { return ( - + { _t("Error decrypting audio") } @@ -93,7 +93,7 @@ export default class MAudioBody extends React.Component { // For now add an img tag with a 16x16 spinner. // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - + {content.body} ); @@ -102,7 +102,7 @@ export default class MAudioBody extends React.Component { const contentUrl = this._getContentUrl(); return ( - + diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index d7452632e12..ade3b0f589d 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -326,7 +326,7 @@ module.exports = React.createClass({ }; return ( - +
{ _t("Decrypt %(text)s", { text: text }) } @@ -360,7 +360,7 @@ module.exports = React.createClass({ } renderer_url += "?origin=" + encodeURIComponent(window.location.origin); return ( - +
{ /* @@ -424,7 +424,7 @@ module.exports = React.createClass({ // files in the right hand side of the screen. if (this.props.tileShape === "file_grid") { return ( - +
{ fileName } @@ -437,7 +437,7 @@ module.exports = React.createClass({ ); } else { return ( - +
@@ -449,7 +449,7 @@ module.exports = React.createClass({ } } else { const extra = text ? (': ' + text) : ''; - return + return { _t("Invalid file%(extra)s", { extra: extra }) } ; } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index de19d0026ff..ed7f4f84a77 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -430,7 +430,7 @@ export default class MImageBody extends React.Component { if (this.state.error !== null) { return ( - + { _t("Error decrypting image") } @@ -448,7 +448,7 @@ export default class MImageBody extends React.Component { const thumbnail = this._messageContent(contentUrl, thumbUrl, content); const fileBody = this.getFileBody(); - return + return { thumbnail } { fileBody } ; diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index e864b983d3e..3dd302e465b 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -134,7 +134,7 @@ module.exports = React.createClass({ if (this.state.error !== null) { return ( - + { _t("Error decrypting video") } @@ -146,7 +146,7 @@ module.exports = React.createClass({ // The attachment is decrypted in componentDidMount. // For now add an img tag with a spinner. return ( - +
{content.body}
@@ -174,7 +174,7 @@ module.exports = React.createClass({ } } return ( - +