diff --git a/desktop/main.js b/desktop/main.js
index 0eb944fca509..f7bd55411f4d 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -31,7 +31,17 @@ app.commandLine.appendSwitch('enable-network-information-downlink-max');
// Initialize the right click menu
// See https://github.com/sindresorhus/electron-context-menu
-contextMenu();
+// Add the Paste and Match Style command to the context menu
+contextMenu({
+ append: (defaultActions, parameters) => [
+ new MenuItem({
+ // Only enable the menu item for Editable context which supports paste
+ visible: parameters.isEditable && parameters.editFlags.canPaste,
+ role: 'pasteAndMatchStyle',
+ accelerator: 'CmdOrCtrl+Shift+V',
+ }),
+ ],
+});
// Send all autoUpdater logs to a log file: ~/Library/Logs/new.expensify.desktop/main.log
// See https://www.npmjs.com/package/electron-log
@@ -202,6 +212,13 @@ const mainWindow = (() => {
}],
}));
+ // Register the custom Paste and Match Style command and place it near the default shortcut of the same role.
+ const editMenu = _.find(systemMenu.items, item => item.role === 'editmenu');
+ editMenu.submenu.insert(6, new MenuItem({
+ role: 'pasteAndMatchStyle',
+ accelerator: 'CmdOrCtrl+Shift+V',
+ }));
+
const appMenu = _.find(systemMenu.items, item => item.role === 'appmenu');
appMenu.submenu.insert(1, updateAppMenuItem);
appMenu.submenu.insert(2, keyboardShortcutsMenu);
diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js
index 5f00bab3146b..119910bb4c73 100644
--- a/src/components/CopySelectionHelper.js
+++ b/src/components/CopySelectionHelper.js
@@ -1,4 +1,5 @@
import React from 'react';
+import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import CONST from '../CONST';
import KeyboardShortcut from '../libs/KeyboardShortcut';
import Clipboard from '../libs/Clipboard';
@@ -25,8 +26,16 @@ class CopySelectionHelper extends React.Component {
}
copySelectionToClipboard() {
- const selectionMarkdown = SelectionScraper.getAsMarkdown();
- Clipboard.setString(selectionMarkdown);
+ const selection = SelectionScraper.getCurrentSelection();
+ if (!selection) {
+ return;
+ }
+ const parser = new ExpensiMark();
+ if (!Clipboard.canSetHtml()) {
+ Clipboard.setString(parser.htmlToMarkdown(selection));
+ return;
+ }
+ Clipboard.setHtml(selection, parser.htmlToText(selection));
}
render() {
diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js
index 6dd1495f9380..a6b21a59e1cb 100644
--- a/src/components/PressableWithSecondaryInteraction/index.js
+++ b/src/components/PressableWithSecondaryInteraction/index.js
@@ -2,7 +2,6 @@ import _ from 'underscore';
import React, {Component} from 'react';
import {Pressable} from 'react-native';
import {LongPressGestureHandler, State} from 'react-native-gesture-handler';
-import SelectionScraper from '../../libs/SelectionScraper';
import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes';
import styles from '../../styles/styles';
import hasHoverSupport from '../../libs/hasHoverSupport';
@@ -54,12 +53,11 @@ class PressableWithSecondaryInteraction extends Component {
* https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event
*/
executeSecondaryInteractionOnContextMenu(e) {
- const selection = SelectionScraper.getAsMarkdown();
e.stopPropagation();
if (this.props.preventDefaultContentMenu) {
e.preventDefault();
}
- this.props.onSecondaryInteraction(e, selection);
+ this.props.onSecondaryInteraction(e);
}
render() {
diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.js
index 808e75b765ee..f50b53e36d4f 100644
--- a/src/libs/Clipboard/index.js
+++ b/src/libs/Clipboard/index.js
@@ -1,4 +1,34 @@
// on Web/desktop this import will be replaced with `react-native-web`
import {Clipboard} from 'react-native-web';
+import lodashGet from 'lodash/get';
-export default Clipboard;
+const canSetHtml = () => lodashGet(navigator, 'clipboard.write');
+
+/**
+ * Writes the content as HTML if the web client supports it.
+ * @param {String} html HTML representation
+ * @param {String} text Plain text representation
+ */
+const setHtml = (html, text) => {
+ if (!html || !text) {
+ return;
+ }
+
+ if (!canSetHtml()) {
+ throw new Error('clipboard.write is not supported on this platform, thus HTML cannot be copied.');
+ }
+
+ navigator.clipboard.write([
+ // eslint-disable-next-line no-undef
+ new ClipboardItem({
+ 'text/html': new Blob([html], {type: 'text/html'}),
+ 'text/plain': new Blob([text], {type: 'text/plain'}),
+ }),
+ ]);
+};
+
+export default {
+ ...Clipboard,
+ canSetHtml,
+ setHtml,
+};
diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js
index db249165a421..c3d4ed69c17e 100644
--- a/src/libs/Clipboard/index.native.js
+++ b/src/libs/Clipboard/index.native.js
@@ -1,3 +1,9 @@
import Clipboard from '@react-native-community/clipboard';
-export default Clipboard;
+export default {
+ ...Clipboard,
+
+ // We don't want to set HTML on native platforms so noop them.
+ canSetHtml: () => false,
+ setHtml: () => {},
+};
diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js
index 7f0a9d69959d..99405259eaea 100644
--- a/src/libs/SelectionScraper/index.js
+++ b/src/libs/SelectionScraper/index.js
@@ -1,10 +1,9 @@
import render from 'dom-serializer';
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import {parseDocument} from 'htmlparser2';
import {Element} from 'domhandler';
import _ from 'underscore';
import Str from 'expensify-common/lib/str';
-import {isCommentTag} from '../../components/HTMLEngineProvider/htmlEngineUtils';
+import * as htmlEngineUtils from '../../components/HTMLEngineProvider/htmlEngineUtils';
const elementsWillBeSkipped = ['html', 'body'];
const tagAttribute = 'data-testid';
@@ -14,70 +13,69 @@ const tagAttribute = 'data-testid';
* @returns {String} HTML of selection as String
*/
const getHTMLOfSelection = () => {
- if (window.getSelection) {
- const selection = window.getSelection();
-
- if (selection.rangeCount > 0) {
- const div = document.createElement('div');
-
- // HTML tag of markdown comments is in data-testid attribute (em, strong, blockquote..). Our goal here is to
- // find that nodes and replace that tag with the one inside data-testid, so ExpensiMark can parse it.
- // Simply, we want to replace this:
- // bold
- // to this:
- // bold
- //
- // We traverse all ranges, and get closest node with data-testid and replace its contents with contents of
- // range.
- for (let i = 0; i < selection.rangeCount; i++) {
- const range = selection.getRangeAt(i);
-
- const clonedSelection = range.cloneContents();
-
- // If clonedSelection has no text content this data has no meaning to us.
- if (clonedSelection.textContent) {
- let node = null;
-
- // If selection starts and ends within same text node we use its parentNode. This is because we can't
- // use closest function on a [Text](https://developer.mozilla.org/en-US/docs/Web/API/Text) node.
- // We are selecting closest node because nodes with data-testid can be one of the parents of the actual node.
- // Assuming we selected only "block" part of following html:
- //
- //
- // this is block code
- //
- //
- // commonAncestorContainer: #text "this is block code"
- // commonAncestorContainer.parentNode:
- //
- // this is block code
- //
- // and finally commonAncestorContainer.parentNode.closest('data-testid') is targeted dom.
- if (range.commonAncestorContainer instanceof HTMLElement) {
- node = range.commonAncestorContainer.closest(`[${tagAttribute}]`);
- } else {
- node = range.commonAncestorContainer.parentNode.closest(`[${tagAttribute}]`);
- }
-
- // This means "range.commonAncestorContainer" is a text node. We simply get its parent node.
- if (!node) {
- node = range.commonAncestorContainer.parentNode;
- }
-
- node = node.cloneNode();
- node.appendChild(clonedSelection);
- div.appendChild(node);
- }
+ // If browser doesn't support Selection API, return an empty string.
+ if (!window.getSelection) {
+ return '';
+ }
+ const selection = window.getSelection();
+
+ if (selection.rangeCount <= 0) {
+ return window.getSelection().toString();
+ }
+
+ const div = document.createElement('div');
+
+ // HTML tag of markdown comments is in data-testid attribute (em, strong, blockquote..). Our goal here is to
+ // find that nodes and replace that tag with the one inside data-testid, so ExpensiMark can parse it.
+ // Simply, we want to replace this:
+ // bold
+ // to this:
+ // bold
+ //
+ // We traverse all ranges, and get closest node with data-testid and replace its contents with contents of
+ // range.
+ for (let i = 0; i < selection.rangeCount; i++) {
+ const range = selection.getRangeAt(i);
+
+ const clonedSelection = range.cloneContents();
+
+ // If clonedSelection has no text content this data has no meaning to us.
+ if (clonedSelection.textContent) {
+ let node = null;
+
+ // If selection starts and ends within same text node we use its parentNode. This is because we can't
+ // use closest function on a [Text](https://developer.mozilla.org/en-US/docs/Web/API/Text) node.
+ // We are selecting closest node because nodes with data-testid can be one of the parents of the actual node.
+ // Assuming we selected only "block" part of following html:
+ //
+ //
+ // this is block code
+ //
+ //
+ // commonAncestorContainer: #text "this is block code"
+ // commonAncestorContainer.parentNode:
+ //
+ // this is block code
+ //
+ // and finally commonAncestorContainer.parentNode.closest('data-testid') is targeted dom.
+ if (range.commonAncestorContainer instanceof HTMLElement) {
+ node = range.commonAncestorContainer.closest(`[${tagAttribute}]`);
+ } else {
+ node = range.commonAncestorContainer.parentNode.closest(`[${tagAttribute}]`);
}
- return div.innerHTML;
- }
+ // This means "range.commonAncestorContainer" is a text node. We simply get its parent node.
+ if (!node) {
+ node = range.commonAncestorContainer.parentNode;
+ }
- return window.getSelection().toString();
+ node = node.cloneNode();
+ node.appendChild(clonedSelection);
+ div.appendChild(node);
+ }
}
- // If browser doesn't support Selection API, returns empty string.
- return '';
+ return div.innerHTML;
};
/**
@@ -104,7 +102,7 @@ const replaceNodes = (dom) => {
}
// Adding a new line after each comment here, because adding after each range is not working for chrome.
- if (isCommentTag(dom.attribs[tagAttribute])) {
+ if (htmlEngineUtils.isCommentTag(dom.attribs[tagAttribute])) {
dom.children.push(new Element('br', {}));
}
}
@@ -128,24 +126,17 @@ const replaceNodes = (dom) => {
};
/**
- * Reads html of selection, replaces with proper tags used for markdown, parses to markdown.
- * @returns {String} parsed html as String
+ * Resolves the current selection to values and produces clean HTML.
+ * @returns {String} resolved selection in the HTML format
*/
-const getAsMarkdown = () => {
- const selectionHtml = getHTMLOfSelection();
-
- const domRepresentation = parseDocument(selectionHtml);
- domRepresentation.children = _.map(domRepresentation.children, c => replaceNodes(c));
+const getCurrentSelection = () => {
+ const domRepresentation = parseDocument(getHTMLOfSelection());
+ domRepresentation.children = _.map(domRepresentation.children, replaceNodes);
const newHtml = render(domRepresentation);
-
- const parser = new ExpensiMark();
-
- return parser.htmlToMarkdown(newHtml);
+ return newHtml || '';
};
-const SelectionScraper = {
- getAsMarkdown,
+export default {
+ getCurrentSelection,
};
-
-export default SelectionScraper;
diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js
index 871a23988810..3872ece30b66 100644
--- a/src/libs/SelectionScraper/index.native.js
+++ b/src/libs/SelectionScraper/index.native.js
@@ -1,9 +1,4 @@
-/**
- * This is a no-op component for native devices because they wouldn't be able to support Selection API like
- * a website.
- */
-const SelectionParser = {
- getAsMarkdown: () => '',
+export default {
+ // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website.
+ getCurrentSelection: () => '',
};
-
-export default SelectionParser;
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 635721245c4a..2823d7b31a89 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -1,5 +1,5 @@
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import _ from 'underscore';
+import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import lodashGet from 'lodash/get';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import * as Report from '../../../../libs/actions/Report';
@@ -97,20 +97,23 @@ export default [
// the `text` and `icon`
onPress: (closePopover, {reportAction, selection}) => {
const message = _.last(lodashGet(reportAction, 'message', [{}]));
- const html = lodashGet(message, 'html', '');
-
- const parser = new ExpensiMark();
- const reportMarkdown = parser.htmlToMarkdown(html);
-
- const text = selection || reportMarkdown;
+ const messageHtml = lodashGet(message, 'html', '');
const isAttachment = _.has(reportAction, 'isAttachment')
? reportAction.isAttachment
: ReportUtils.isReportMessageAttachment(message);
if (!isAttachment) {
- Clipboard.setString(text);
+ const content = selection || messageHtml;
+ if (content) {
+ const parser = new ExpensiMark();
+ if (!Clipboard.canSetHtml()) {
+ Clipboard.setString(parser.htmlToMarkdown(content));
+ } else {
+ Clipboard.setHtml(content, parser.htmlToText(content));
+ }
+ }
} else {
- Clipboard.setString(html);
+ Clipboard.setString(messageHtml);
}
if (closePopover) {
hideContextMenu(true, ReportActionComposeFocusManager.focus);
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
index b77599ad9bea..ef0449806415 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
@@ -103,7 +103,7 @@ class PopoverReportActionContextMenu extends React.Component {
*
* @param {string} type - context menu type [EMAIL, LINK, REPORT_ACTION]
* @param {Object} [event] - A press event.
- * @param {string} [selection] - A copy text.
+ * @param {String} [selection] - Copied content.
* @param {Element} contextMenuAnchor - popoverAnchor
* @param {Number} reportID - Active Report Id
* @param {Object} reportAction - ReportAction for ContextMenu
diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js
index 8952287fa850..aa71bcc9aba5 100644
--- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js
@@ -7,7 +7,7 @@ const contextMenuRef = React.createRef();
*
* @param {string} type - the context menu type to display [EMAIL, LINK, REPORT_ACTION]
* @param {Object} [event] - A press event.
- * @param {string} [selection] - A copy text.
+ * @param {String} [selection] - Copied content.
* @param {Element} contextMenuAnchor - popoverAnchor
* @param {Number} reportID - Active Report Id
* @param {Object} reportAction - ReportAction for ContextMenu
diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js
index 933bb4582af4..cb6c759b17df 100644
--- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js
+++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js
@@ -15,7 +15,7 @@ const propTypes = {
/** Controls the visibility of this component. */
isVisible: PropTypes.bool,
- /** The copy selection of text. */
+ /** The copy selection. */
selection: PropTypes.string,
/** Draft message - if this is set the comment is in 'edit' mode */
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index f36440dcabaf..948cac72c2e2 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -28,6 +28,7 @@ import {withNetwork, withReportActionsDrafts} from '../../../components/OnyxProv
import RenameAction from '../../../components/ReportActionItem/RenameAction';
import InlineSystemMessage from '../../../components/InlineSystemMessage';
import styles from '../../../styles/styles';
+import SelectionScraper from '../../../libs/SelectionScraper';
import * as User from '../../../libs/actions/User';
import * as ReportUtils from '../../../libs/ReportUtils';
@@ -98,13 +99,13 @@ class ReportActionItem extends Component {
* Show the ReportActionContextMenu modal popover.
*
* @param {Object} [event] - A press event.
- * @param {string} [selection] - A copy text.
*/
- showPopover(event, selection) {
+ showPopover(event) {
// Block menu on the message being Edited
if (this.props.draftMessage) {
return;
}
+ const selection = SelectionScraper.getCurrentSelection();
ReportActionContextMenu.showContextMenu(
ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION,
event,
diff --git a/src/pages/settings/AppDownloadLinks.js b/src/pages/settings/AppDownloadLinks.js
index a724fe0ddf74..d9db3229ad6d 100644
--- a/src/pages/settings/AppDownloadLinks.js
+++ b/src/pages/settings/AppDownloadLinks.js
@@ -60,7 +60,7 @@ const AppDownloadLinksPage = (props) => {
* Show the ReportActionContextMenu modal popover.
*
* @param {Object} [event] - A press event.
- * @param {string} [selection] - A copy text.
+ * @param {String} [selection] - Copied content.
*/
const showPopover = (event, selection) => {
ReportActionContextMenu.showContextMenu(