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(