From 110057bbc4a8244bbd315d607f87753a87314002 Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Wed, 13 Jul 2022 19:12:51 +0200 Subject: [PATCH 1/8] Add support for the paste and match style command --- desktop/main.js | 19 ++- .../BaseAnchorForCommentsOnly/index.js | 2 +- .../BaseAnchorForCommentsOnly/index.native.js | 2 +- src/components/CopySelectionHelper.js | 7 +- .../index.js | 2 +- src/libs/Clipboard/index.js | 30 +++- src/libs/Clipboard/index.native.js | 14 +- src/libs/SelectionScraper/base.js | 161 ++++++++++++++++++ src/libs/SelectionScraper/index.js | 151 +--------------- src/libs/SelectionScraper/index.native.js | 10 +- .../report/ContextMenu/ContextMenuActions.js | 17 +- .../ContextMenu/ContextMenuUtils/index.js | 4 +- .../ContextMenuUtils/index.native.js | 6 +- .../PopoverReportActionContextMenu.js | 4 +- .../ContextMenu/ReportActionContextMenu.js | 2 +- ...genericReportActionContextMenuPropTypes.js | 9 +- src/pages/home/report/ReportActionItem.js | 2 +- src/pages/settings/AppDownloadLinks.js | 2 +- 18 files changed, 258 insertions(+), 186 deletions(-) create mode 100644 src/libs/SelectionScraper/base.js diff --git a/desktop/main.js b/desktop/main.js index b2ad339d9647..95721efc134a 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -31,7 +31,16 @@ 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: () => [ + { + label: 'Paste and Match Style', + role: 'pasteAndMatchStyle', + accelerator: 'Cmd+Shift+V', + }, + ], +}); // Send all autoUpdater logs to a log file: ~/Library/Logs/new.expensify/main.log // See https://www.npmjs.com/package/electron-log @@ -202,6 +211,14 @@ const mainWindow = (() => { }], })); + // Register the Paste and Match Style command. + const editMenu = _.find(systemMenu.items, item => item.role === 'editmenu'); + editMenu.submenu.insert(2, new MenuItem({ + label: 'Paste and Match Style', + role: 'pasteAndMatchStyle', + accelerator: 'Cmd+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/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js index dab9d2ea718f..691552296dd8 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js @@ -69,7 +69,7 @@ class BaseAnchorForCommentsOnly extends React.Component { ReportActionContextMenu.showContextMenu( Str.isValidEmail(this.props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, event, - this.props.href, + {text: this.props.href}, lodashGet(linkRef, 'current'), ); } diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js index 19e1b28774c2..d63360702e61 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js @@ -67,7 +67,7 @@ class BaseAnchorForCommentsOnly extends React.Component { ReportActionContextMenu.showContextMenu( Str.isValidEmail(this.props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, event, - this.props.href, + {text: this.props.href}, lodashGet(linkRef, 'current'), ); } diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js index 5f00bab3146b..819f164cb0e5 100644 --- a/src/components/CopySelectionHelper.js +++ b/src/components/CopySelectionHelper.js @@ -25,8 +25,11 @@ class CopySelectionHelper extends React.Component { } copySelectionToClipboard() { - const selectionMarkdown = SelectionScraper.getAsMarkdown(); - Clipboard.setString(selectionMarkdown); + const selection = SelectionScraper.getAsTypes(); + if (!selection) { + return; + } + Clipboard.writeTypes(selection); } render() { diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index 6dd1495f9380..f4746a623e9d 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -54,7 +54,7 @@ class PressableWithSecondaryInteraction extends Component { * https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event */ executeSecondaryInteractionOnContextMenu(e) { - const selection = SelectionScraper.getAsMarkdown(); + const selection = SelectionScraper.getAsTypes(); e.stopPropagation(); if (this.props.preventDefaultContentMenu) { e.preventDefault(); diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.js index 808e75b765ee..6d214b9956a9 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.js @@ -1,4 +1,32 @@ // on Web/desktop this import will be replaced with `react-native-web` +import _ from 'lodash'; import {Clipboard} from 'react-native-web'; -export default Clipboard; +/** + * Writes the content as types if available, otherwise as text. + * @param {Object} content Types to write. + * @param {String} content.text Plain text representation of the content. + * @param {String} content.html HTML representation of the content. + * @param {String} content.markdown MD representation of the content. + */ +const writeTypes = (content) => { + if (!content) { + return; + } + if (!_.get(navigator, 'clipboard.write')) { + Clipboard.setString(content.markdown); + return; + } + navigator.clipboard.write([ + // eslint-disable-next-line no-undef + new ClipboardItem({ + 'text/html': new Blob([content.html], {type: 'text/html'}), + 'text/plain': new Blob([content.text], {type: 'text/plain'}), + }), + ]); +}; + +export default { + ...Clipboard, + writeTypes, +}; diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js index db249165a421..e935e5549e1e 100644 --- a/src/libs/Clipboard/index.native.js +++ b/src/libs/Clipboard/index.native.js @@ -1,3 +1,15 @@ import Clipboard from '@react-native-community/clipboard'; -export default Clipboard; +/** + * Writes the content as text on native. + * @param {Object} content Types to write. + * @param {String} content.markdown MD representation of the content. + */ +const writeTypes = (content) => { + Clipboard.setString(content.markdown); +}; + +export default { + ...Clipboard, + writeTypes, +}; diff --git a/src/libs/SelectionScraper/base.js b/src/libs/SelectionScraper/base.js new file mode 100644 index 000000000000..a677e18fb954 --- /dev/null +++ b/src/libs/SelectionScraper/base.js @@ -0,0 +1,161 @@ +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'; + +const elementsWillBeSkipped = ['html', 'body']; +const tagAttribute = 'data-testid'; + +/** + * Reads html of selection. If browser doesn't support Selection API, returns empty string. + * @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); + } + } + + return div.innerHTML; + } + + return window.getSelection().toString(); + } + + // If browser doesn't support Selection API, returns empty string. + return ''; +}; + +/** + * Clears all attributes from dom elements + * @param {Object} dom htmlparser2 dom representation + * @returns {Object} htmlparser2 dom representation + */ +const replaceNodes = (dom) => { + let domName = dom.name; + let domChildren; + const domAttribs = {}; + let data; + + // Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method. + if (dom.type === 'text') { + data = Str.htmlEncode(dom.data); + } + + // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data + // has no meaning for us. + if (dom.attribs && dom.attribs[tagAttribute]) { + if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { + domName = dom.attribs[tagAttribute]; + } + + // Adding a new line after each comment here, because adding after each range is not working for chrome. + if (dom.attribs[tagAttribute] === 'comment') { + dom.children.push(new Element('br', {})); + } + } + + // We need to preserve href attribute in order to copy links. + if (dom.attribs && dom.attribs.href) { + domAttribs.href = dom.attribs.href; + } + + if (dom.children) { + domChildren = _.map(dom.children, c => replaceNodes(c)); + } + + return { + ...dom, + data, + name: domName, + attribs: domAttribs, + children: domChildren, + }; +}; + +/** + * Reads the provided html and converts it to the return types. + * @param {Object} selectedHtml html to parse + * @returns {Object} converted text, html, and markdown + */ +const getCustomAsTypes = (selectedHtml) => { + if (!selectedHtml) { + return null; + } + const domRepresentation = parseDocument(selectedHtml); + domRepresentation.children = _.map(domRepresentation.children, c => replaceNodes(c)); + + const newHtml = render(domRepresentation); + + const parser = new ExpensiMark(); + + const text = parser.htmlToText(newHtml); + const markdown = parser.htmlToMarkdown(newHtml); + return {html: `${newHtml}`, text, markdown}; +}; + +/** + * Reads the selected html and converts it to the return types. + * @returns {Object} selection as text, html, and markdown + */ +const getAsTypes = () => getCustomAsTypes(getHTMLOfSelection()); + +const SelectionScraper = { + getAsTypes, + getCustomAsTypes, +}; + +export default SelectionScraper; diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index 363367072631..c82aa44698b4 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -1,150 +1,3 @@ -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 BaseSelectionScraper from './base'; -const elementsWillBeSkipped = ['html', 'body']; -const tagAttribute = 'data-testid'; - -/** - * Reads html of selection. If browser doesn't support Selection API, returns empty string. - * @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); - } - } - - return div.innerHTML; - } - - return window.getSelection().toString(); - } - - // If browser doesn't support Selection API, returns empty string. - return ''; -}; - -/** - * Clears all attributes from dom elements - * @param {Object} dom htmlparser2 dom representation - * @returns {Object} htmlparser2 dom representation - */ -const replaceNodes = (dom) => { - let domName = dom.name; - let domChildren; - const domAttribs = {}; - let data; - - // Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method. - if (dom.type === 'text') { - data = Str.htmlEncode(dom.data); - } - - // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data - // has no meaning for us. - if (dom.attribs && dom.attribs[tagAttribute]) { - if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { - domName = dom.attribs[tagAttribute]; - } - - // Adding a new line after each comment here, because adding after each range is not working for chrome. - if (dom.attribs[tagAttribute] === 'comment') { - dom.children.push(new Element('br', {})); - } - } - - // We need to preserve href attribute in order to copy links. - if (dom.attribs && dom.attribs.href) { - domAttribs.href = dom.attribs.href; - } - - if (dom.children) { - domChildren = _.map(dom.children, c => replaceNodes(c)); - } - - return { - ...dom, - data, - name: domName, - attribs: domAttribs, - children: domChildren, - }; -}; - -/** - * Reads html of selection, replaces with proper tags used for markdown, parses to markdown. - * @returns {String} parsed html as String - */ -const getAsMarkdown = () => { - const selectionHtml = getHTMLOfSelection(); - - const domRepresentation = parseDocument(selectionHtml); - domRepresentation.children = _.map(domRepresentation.children, c => replaceNodes(c)); - - const newHtml = render(domRepresentation); - - const parser = new ExpensiMark(); - - return parser.htmlToMarkdown(newHtml); -}; - -const SelectionScraper = { - getAsMarkdown, -}; - -export default SelectionScraper; +export default BaseSelectionScraper; diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js index 871a23988810..57f92323a36f 100644 --- a/src/libs/SelectionScraper/index.native.js +++ b/src/libs/SelectionScraper/index.native.js @@ -1,9 +1,9 @@ -/** - * This is a no-op component for native devices because they wouldn't be able to support Selection API like - * a website. - */ +import BaseSelectionScraper from './base'; + const SelectionParser = { - getAsMarkdown: () => '', + // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. + getAsTypes: () => {}, + getCustomAsTypes: BaseSelectionScraper.getCustomAsTypes, }; export default SelectionParser; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 575e2ff1686d..5e89ea66bd3f 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -1,4 +1,3 @@ -import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import * as Expensicons from '../../../../components/Icon/Expensicons'; @@ -13,6 +12,7 @@ import fileDownload from '../../../../libs/fileDownload'; import addEncryptedAuthTokenToURL from '../../../../libs/addEncryptedAuthTokenToURL'; import * as ContextMenuUtils from './ContextMenuUtils'; import * as Environment from '../../../../libs/Environment/Environment'; +import SelectionScraper from '../../../../libs/SelectionScraper'; /** * Gets the HTML version of the message in an action. @@ -65,7 +65,7 @@ export default [ successIcon: Expensicons.Checkmark, shouldShow: type => type === CONTEXT_MENU_TYPES.LINK, onPress: (closePopover, {selection}) => { - Clipboard.setString(selection); + Clipboard.setString(_.get(selection, 'text', '')); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: ContextMenuUtils.getPopoverDescription, @@ -77,7 +77,7 @@ export default [ successIcon: Expensicons.Checkmark, shouldShow: type => type === CONTEXT_MENU_TYPES.EMAIL, onPress: (closePopover, {selection}) => { - Clipboard.setString(selection.replace('mailto:', '')); + Clipboard.setString(_.get(selection, 'text', '').replace('mailto:', '')); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: () => {}, @@ -96,20 +96,15 @@ 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); + Clipboard.writeTypes(selection || SelectionScraper.getCustomAsTypes(messageHtml)); } else { - Clipboard.setString(html); + Clipboard.setString(messageHtml); } if (closePopover) { hideContextMenu(true, ReportActionComposeFocusManager.focus); diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js index f694f413e29a..98320340d29f 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js +++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js @@ -4,12 +4,12 @@ * The popover description will be an empty string on anything but mobile web * because we show link in tooltip instead of popover on web * - * @param {String} selection + * @param {Object} selection * @param {Boolean} isSmallScreenWidth * @returns {String} */ function getPopoverDescription(selection, isSmallScreenWidth) { - return isSmallScreenWidth ? selection : ''; + return isSmallScreenWidth && selection ? selection.text : ''; } export { diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js index 3dbf81926098..0706cc73de80 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js +++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js @@ -1,13 +1,15 @@ /* eslint-disable import/prefer-default-export */ +import _ from 'lodash'; + /** * Always show popover description on native platforms * - * @param {String} selection + * @param {Object} selection * @returns {String} */ function getPopoverDescription(selection) { - return selection; + return _.get(selection, 'text', ''); } export { diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index b77599ad9bea..938cd4d676d9 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -20,7 +20,6 @@ class PopoverReportActionContextMenu extends React.Component { this.state = { reportID: 0, reportAction: {}, - selection: '', reportActionDraftMessage: '', isPopoverVisible: false, isDeleteCommentConfirmModalVisible: false, @@ -103,7 +102,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 {Object} [selection] - Copied content. * @param {Element} contextMenuAnchor - popoverAnchor * @param {Number} reportID - Active Report Id * @param {Object} reportAction - ReportAction for ContextMenu @@ -203,7 +202,6 @@ class PopoverReportActionContextMenu extends React.Component { this.setState({ reportID: 0, reportAction: {}, - selection: '', reportActionDraftMessage: '', isPopoverVisible: false, }); diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js index 8952287fa850..88c2f02972aa 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 {Object} [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..5030a6ec246d 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -15,8 +15,12 @@ const propTypes = { /** Controls the visibility of this component. */ isVisible: PropTypes.bool, - /** The copy selection of text. */ - selection: PropTypes.string, + /** The copy selection. */ + selection: PropTypes.shape({ + html: PropTypes.string, + text: PropTypes.string, + markdown: PropTypes.string, + }), /** Draft message - if this is set the comment is in 'edit' mode */ draftMessage: PropTypes.string, @@ -25,7 +29,6 @@ const propTypes = { const defaultProps = { isMini: false, isVisible: false, - selection: '', draftMessage: '', }; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 10e5b58de3cb..649d6e0b0c75 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -96,7 +96,7 @@ class ReportActionItem extends Component { * Show the ReportActionContextMenu modal popover. * * @param {Object} [event] - A press event. - * @param {string} [selection] - A copy text. + * @param {Object} [selection] - Copied content. */ showPopover(event, selection) { // Block menu on the message being Edited diff --git a/src/pages/settings/AppDownloadLinks.js b/src/pages/settings/AppDownloadLinks.js index a724fe0ddf74..61b6fdc38333 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 {Object} [selection] - Copied content. */ const showPopover = (event, selection) => { ReportActionContextMenu.showContextMenu( From 5731694e3ba3de7bf2a83fa1ee90e7d47b279be1 Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Fri, 15 Jul 2022 20:39:00 +0200 Subject: [PATCH 2/8] Generalize selection & clipboard, minor fixes --- desktop/main.js | 7 +-- src/components/CopySelectionHelper.js | 4 +- .../index.js | 2 +- src/libs/Clipboard/index.js | 40 ++++++++++------- src/libs/Clipboard/index.native.js | 15 ++++--- .../{base.js => BaseSelectionScraper.js} | 31 +++++-------- src/libs/SelectionScraper/index.js | 44 ++++++++++++++++++- src/libs/SelectionScraper/index.native.js | 26 ++++++++--- .../report/ContextMenu/ContextMenuActions.js | 6 +-- ...genericReportActionContextMenuPropTypes.js | 3 +- 10 files changed, 116 insertions(+), 62 deletions(-) rename src/libs/SelectionScraper/{base.js => BaseSelectionScraper.js} (86%) diff --git a/desktop/main.js b/desktop/main.js index 95721efc134a..6ecde70811f6 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -33,12 +33,13 @@ app.commandLine.appendSwitch('enable-network-information-downlink-max'); // See https://github.com/sindresorhus/electron-context-menu // Add the Paste and Match Style command to the context menu contextMenu({ - append: () => [ - { + append: (defaultActions, parameters) => [ + new MenuItem({ + visible: parameters.isEditable, label: 'Paste and Match Style', role: 'pasteAndMatchStyle', accelerator: 'Cmd+Shift+V', - }, + }), ], }); diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js index 819f164cb0e5..76010fdb473b 100644 --- a/src/components/CopySelectionHelper.js +++ b/src/components/CopySelectionHelper.js @@ -25,11 +25,11 @@ class CopySelectionHelper extends React.Component { } copySelectionToClipboard() { - const selection = SelectionScraper.getAsTypes(); + const selection = SelectionScraper.getCurrentSelection(); if (!selection) { return; } - Clipboard.writeTypes(selection); + Clipboard.setContent(selection); } render() { diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index f4746a623e9d..32e502f9bda3 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -54,7 +54,7 @@ class PressableWithSecondaryInteraction extends Component { * https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event */ executeSecondaryInteractionOnContextMenu(e) { - const selection = SelectionScraper.getAsTypes(); + const selection = SelectionScraper.getCurrentSelection(); e.stopPropagation(); if (this.props.preventDefaultContentMenu) { e.preventDefault(); diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.js index 6d214b9956a9..9262b3b45495 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.js @@ -3,30 +3,36 @@ import _ from 'lodash'; import {Clipboard} from 'react-native-web'; /** - * Writes the content as types if available, otherwise as text. - * @param {Object} content Types to write. - * @param {String} content.text Plain text representation of the content. - * @param {String} content.html HTML representation of the content. - * @param {String} content.markdown MD representation of the content. + * Writes the content as rich types if provided and the web client supports it, otherwise as a string. + * @param {Object} content Platfrom-specific content to write + * @param {String} content.text Text representation of the content for web + * @param {String} [content.html] Html representation of the content for web */ -const writeTypes = (content) => { +const setContent = (content) => { if (!content) { return; } - if (!_.get(navigator, 'clipboard.write')) { - Clipboard.setString(content.markdown); - return; + + // If HTML is present, try to write rich types. + if (content.html) { + if (!_.get(navigator, 'clipboard.write')) { + Clipboard.setString(content.text); + throw new Error('Rich types are not supported on this platform'); + } + + navigator.clipboard.write([ + // eslint-disable-next-line no-undef + new ClipboardItem({ + 'text/html': new Blob([content.html], {type: 'text/html'}), + 'text/plain': new Blob([content.text], {type: 'text/plain'}), + }), + ]); + } else { + Clipboard.setString(content.text); } - navigator.clipboard.write([ - // eslint-disable-next-line no-undef - new ClipboardItem({ - 'text/html': new Blob([content.html], {type: 'text/html'}), - 'text/plain': new Blob([content.text], {type: 'text/plain'}), - }), - ]); }; export default { ...Clipboard, - writeTypes, + setContent, }; diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js index e935e5549e1e..c3b205022d20 100644 --- a/src/libs/Clipboard/index.native.js +++ b/src/libs/Clipboard/index.native.js @@ -1,15 +1,18 @@ import Clipboard from '@react-native-community/clipboard'; /** - * Writes the content as text on native. - * @param {Object} content Types to write. - * @param {String} content.markdown MD representation of the content. + * Writes the content as a string on native. + * @param {Object} content Platfrom-specific content to write + * @param {String} content.text Text representation of the content for native */ -const writeTypes = (content) => { - Clipboard.setString(content.markdown); +const setContent = (content) => { + if (!content) { + return; + } + Clipboard.setString(content.text); }; export default { ...Clipboard, - writeTypes, + setContent, }; diff --git a/src/libs/SelectionScraper/base.js b/src/libs/SelectionScraper/BaseSelectionScraper.js similarity index 86% rename from src/libs/SelectionScraper/base.js rename to src/libs/SelectionScraper/BaseSelectionScraper.js index a677e18fb954..f9d76022930f 100644 --- a/src/libs/SelectionScraper/base.js +++ b/src/libs/SelectionScraper/BaseSelectionScraper.js @@ -1,5 +1,4 @@ import render from 'dom-serializer'; -import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import {parseDocument} from 'htmlparser2'; import {Element} from 'domhandler'; import _ from 'underscore'; @@ -127,35 +126,27 @@ const replaceNodes = (dom) => { }; /** - * Reads the provided html and converts it to the return types. + * Resolves the selection to values and produces clean HTML. * @param {Object} selectedHtml html to parse - * @returns {Object} converted text, html, and markdown + * @returns {String} resolved HTML */ -const getCustomAsTypes = (selectedHtml) => { +const getCustomSelectionAsHtml = (selectedHtml) => { if (!selectedHtml) { return null; } const domRepresentation = parseDocument(selectedHtml); - domRepresentation.children = _.map(domRepresentation.children, c => replaceNodes(c)); + domRepresentation.children = _.map(domRepresentation.children, replaceNodes); - const newHtml = render(domRepresentation); - - const parser = new ExpensiMark(); - - const text = parser.htmlToText(newHtml); - const markdown = parser.htmlToMarkdown(newHtml); - return {html: `${newHtml}`, text, markdown}; + return render(domRepresentation); }; /** - * Reads the selected html and converts it to the return types. - * @returns {Object} selection as text, html, and markdown + * Resolves the selection to values and produces clean HTML. + * @returns {String} resolved HTML */ -const getAsTypes = () => getCustomAsTypes(getHTMLOfSelection()); +const getCurrentSelectionAsHtml = () => getCustomSelectionAsHtml(getHTMLOfSelection()); -const SelectionScraper = { - getAsTypes, - getCustomAsTypes, +export default { + getCustomSelectionAsHtml, + getCurrentSelectionAsHtml, }; - -export default SelectionScraper; diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index c82aa44698b4..000d96c87e43 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -1,3 +1,43 @@ -import BaseSelectionScraper from './base'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import _ from 'lodash'; +import BaseSelectionScraper from './BaseSelectionScraper'; -export default BaseSelectionScraper; +/** + * Resolves the selection to values for the platform. + * @param {Object} selectedHtml html to parse + * @returns {Object} selection for web + */ +const getCustomSelection = (selectedHtml) => { + if (!selectedHtml) { + return null; + } + const html = BaseSelectionScraper.getCustomSelectionAsHtml(selectedHtml); + const parser = new ExpensiMark(); + + // If rich types are supported on web, produce them. + if (_.get(navigator, 'clipboard.write')) { + const text = parser.htmlToText(html); + return {text, html}; + } + + // Otherwise, produce MD. + const text = parser.htmlToMarkdown(html); + return {text}; +}; + +/** + * Resolves the selection to values for the platform. + * @returns {Object} selection for web + */ +const getCurrentSelection = () => { + const newHtml = BaseSelectionScraper.getCurrentSelectionAsHtml(); + if (!newHtml) { + return null; + } + return getCustomSelection(newHtml); +}; + +export default { + getCurrentSelection, + getCustomSelection, +}; diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js index 57f92323a36f..e573ef980c91 100644 --- a/src/libs/SelectionScraper/index.native.js +++ b/src/libs/SelectionScraper/index.native.js @@ -1,9 +1,23 @@ -import BaseSelectionScraper from './base'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import BaseSelectionScraper from './BaseSelectionScraper'; -const SelectionParser = { - // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. - getAsTypes: () => {}, - getCustomAsTypes: BaseSelectionScraper.getCustomAsTypes, +/** + * Resolves the selection to values for the platform. + * @param {Object} selectedHtml html to parse + * @returns {Object} selection for native + */ +const getCustomSelection = (selectedHtml) => { + if (!selectedHtml) { + return null; + } + const newHtml = BaseSelectionScraper.getCustomSelectionAsHtml(selectedHtml); + const parser = new ExpensiMark(); + const text = parser.htmlToMarkdown(newHtml); + return {text}; }; -export default SelectionParser; +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: () => {}, + getCustomSelection, +}; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 5e89ea66bd3f..ed03c069ec2d 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -65,7 +65,7 @@ export default [ successIcon: Expensicons.Checkmark, shouldShow: type => type === CONTEXT_MENU_TYPES.LINK, onPress: (closePopover, {selection}) => { - Clipboard.setString(_.get(selection, 'text', '')); + Clipboard.setString(selection.text); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: ContextMenuUtils.getPopoverDescription, @@ -77,7 +77,7 @@ export default [ successIcon: Expensicons.Checkmark, shouldShow: type => type === CONTEXT_MENU_TYPES.EMAIL, onPress: (closePopover, {selection}) => { - Clipboard.setString(_.get(selection, 'text', '').replace('mailto:', '')); + Clipboard.setString(selection.text.replace('mailto:', '')); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: () => {}, @@ -102,7 +102,7 @@ export default [ ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(message); if (!isAttachment) { - Clipboard.writeTypes(selection || SelectionScraper.getCustomAsTypes(messageHtml)); + Clipboard.setContent(selection || SelectionScraper.getCustomSelection(messageHtml)); } else { Clipboard.setString(messageHtml); } diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js index 5030a6ec246d..faac62f0111b 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -17,9 +17,8 @@ const propTypes = { /** The copy selection. */ selection: PropTypes.shape({ + text: PropTypes.string.isRequired, html: PropTypes.string, - text: PropTypes.string, - markdown: PropTypes.string, }), /** Draft message - if this is set the comment is in 'edit' mode */ From 5ae0f7e7e43ca9f505bfcc134e4c3ef1fb7616d3 Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Fri, 22 Jul 2022 13:54:32 +0200 Subject: [PATCH 3/8] Improve paste and match style menus on desktop --- desktop/main.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 6ecde70811f6..d32727aaacb1 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -36,9 +36,8 @@ contextMenu({ append: (defaultActions, parameters) => [ new MenuItem({ visible: parameters.isEditable, - label: 'Paste and Match Style', role: 'pasteAndMatchStyle', - accelerator: 'Cmd+Shift+V', + accelerator: 'CmdOrCtrl+Shift+V', }), ], }); @@ -214,10 +213,9 @@ const mainWindow = (() => { // Register the Paste and Match Style command. const editMenu = _.find(systemMenu.items, item => item.role === 'editmenu'); - editMenu.submenu.insert(2, new MenuItem({ - label: 'Paste and Match Style', + editMenu.submenu.insert(6, new MenuItem({ role: 'pasteAndMatchStyle', - accelerator: 'Cmd+Shift+V', + accelerator: 'CmdOrCtrl+Shift+V', })); const appMenu = _.find(systemMenu.items, item => item.role === 'appmenu'); From 134a9a7c27d286916cce2cc42941d99bcaa2622c Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Sat, 23 Jul 2022 01:48:59 +0200 Subject: [PATCH 4/8] Generalize clipboard & selection further, refactor --- desktop/main.js | 5 +- src/components/CopySelectionHelper.js | 8 +- .../index.js | 4 +- src/libs/Clipboard/index.js | 42 +++--- src/libs/Clipboard/index.native.js | 17 +-- .../SelectionScraper/BaseSelectionScraper.js | 139 +++++++++--------- src/libs/SelectionScraper/index.js | 42 +----- src/libs/SelectionScraper/index.native.js | 19 +-- .../report/ContextMenu/ContextMenuActions.js | 16 +- ...genericReportActionContextMenuPropTypes.js | 2 +- src/pages/home/report/ReportActionItem.js | 5 +- src/pages/settings/AppDownloadLinks.js | 2 +- 12 files changed, 128 insertions(+), 173 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index d32727aaacb1..f8c737212eb7 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -35,7 +35,8 @@ app.commandLine.appendSwitch('enable-network-information-downlink-max'); contextMenu({ append: (defaultActions, parameters) => [ new MenuItem({ - visible: parameters.isEditable, + // Only enable the menu item for Editable context which supports paste + visible: parameters.isEditable && parameters.editFlags.canPaste, role: 'pasteAndMatchStyle', accelerator: 'CmdOrCtrl+Shift+V', }), @@ -211,7 +212,7 @@ const mainWindow = (() => { }], })); - // Register the Paste and Match Style command. + // Register the custom Paste and Match Style command and place it near the default shorcut of the same role. const editMenu = _.find(systemMenu.items, item => item.role === 'editmenu'); editMenu.submenu.insert(6, new MenuItem({ role: 'pasteAndMatchStyle', diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js index 76010fdb473b..7f9323f692cc 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'; @@ -29,7 +30,12 @@ class CopySelectionHelper extends React.Component { if (!selection) { return; } - Clipboard.setContent(selection); + const parser = new ExpensiMark(); + if (!Clipboard.canSetHtml()) { + Clipboard.setString(parser.htmlToMarkdown(selection.html)); + return; + } + Clipboard.setHtml(selection.html, parser.htmlToText(selection.html)); } render() { diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index 32e502f9bda3..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.getCurrentSelection(); 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 9262b3b45495..77c5c5ae0024 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.js @@ -2,37 +2,33 @@ import _ from 'lodash'; import {Clipboard} from 'react-native-web'; +const canSetHtml = () => _.get(navigator, 'clipboard.write'); + /** - * Writes the content as rich types if provided and the web client supports it, otherwise as a string. - * @param {Object} content Platfrom-specific content to write - * @param {String} content.text Text representation of the content for web - * @param {String} [content.html] Html representation of the content for web + * Writes the content as HTML if the web client supports it. + * @param {String} html HTML representation + * @param {String} text Plain text representation */ -const setContent = (content) => { - if (!content) { +const setHtml = (html, text) => { + if (!html || !text) { return; } - // If HTML is present, try to write rich types. - if (content.html) { - if (!_.get(navigator, 'clipboard.write')) { - Clipboard.setString(content.text); - throw new Error('Rich types are not supported on this platform'); - } - - navigator.clipboard.write([ - // eslint-disable-next-line no-undef - new ClipboardItem({ - 'text/html': new Blob([content.html], {type: 'text/html'}), - 'text/plain': new Blob([content.text], {type: 'text/plain'}), - }), - ]); - } else { - Clipboard.setString(content.text); + if (!canSetHtml()) { + throw new Error('HTML is not supported on this platform'); } + + 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, - setContent, + canSetHtml, + setHtml, }; diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js index c3b205022d20..d8935a55c22e 100644 --- a/src/libs/Clipboard/index.native.js +++ b/src/libs/Clipboard/index.native.js @@ -1,18 +1,9 @@ import Clipboard from '@react-native-community/clipboard'; -/** - * Writes the content as a string on native. - * @param {Object} content Platfrom-specific content to write - * @param {String} content.text Text representation of the content for native - */ -const setContent = (content) => { - if (!content) { - return; - } - Clipboard.setString(content.text); -}; - export default { ...Clipboard, - setContent, + + // Native does not support HTML. + canSetHtml: () => false, + setHtml: () => {}, }; diff --git a/src/libs/SelectionScraper/BaseSelectionScraper.js b/src/libs/SelectionScraper/BaseSelectionScraper.js index 9269c46ce069..c6d822698619 100644 --- a/src/libs/SelectionScraper/BaseSelectionScraper.js +++ b/src/libs/SelectionScraper/BaseSelectionScraper.js @@ -3,7 +3,7 @@ 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'; @@ -13,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; }; /** @@ -103,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', {})); } } @@ -129,25 +128,29 @@ const replaceNodes = (dom) => { /** * Resolves the selection to values and produces clean HTML. * @param {Object} selectedHtml html to parse - * @returns {String} resolved HTML + * @returns {Object} resolved HTML in the selection format */ -const getCustomSelectionAsHtml = (selectedHtml) => { +const prepareSelection = (selectedHtml) => { if (!selectedHtml) { return null; } const domRepresentation = parseDocument(selectedHtml); domRepresentation.children = _.map(domRepresentation.children, replaceNodes); - return render(domRepresentation); + const newHtml = render(domRepresentation); + if (!newHtml) { + return null; + } + return {html: newHtml}; }; /** * Resolves the selection to values and produces clean HTML. - * @returns {String} resolved HTML + * @returns {Object} resolved HTML in the selection format */ -const getCurrentSelectionAsHtml = () => getCustomSelectionAsHtml(getHTMLOfSelection()); +const getCurrentSelection = () => prepareSelection(getHTMLOfSelection()); export default { - getCustomSelectionAsHtml, - getCurrentSelectionAsHtml, + prepareSelection, + getCurrentSelection, }; diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index 000d96c87e43..daf0558d1708 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -1,43 +1,3 @@ -import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import _ from 'lodash'; import BaseSelectionScraper from './BaseSelectionScraper'; -/** - * Resolves the selection to values for the platform. - * @param {Object} selectedHtml html to parse - * @returns {Object} selection for web - */ -const getCustomSelection = (selectedHtml) => { - if (!selectedHtml) { - return null; - } - const html = BaseSelectionScraper.getCustomSelectionAsHtml(selectedHtml); - const parser = new ExpensiMark(); - - // If rich types are supported on web, produce them. - if (_.get(navigator, 'clipboard.write')) { - const text = parser.htmlToText(html); - return {text, html}; - } - - // Otherwise, produce MD. - const text = parser.htmlToMarkdown(html); - return {text}; -}; - -/** - * Resolves the selection to values for the platform. - * @returns {Object} selection for web - */ -const getCurrentSelection = () => { - const newHtml = BaseSelectionScraper.getCurrentSelectionAsHtml(); - if (!newHtml) { - return null; - } - return getCustomSelection(newHtml); -}; - -export default { - getCurrentSelection, - getCustomSelection, -}; +export default BaseSelectionScraper; diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js index e573ef980c91..cdbf1979dff7 100644 --- a/src/libs/SelectionScraper/index.native.js +++ b/src/libs/SelectionScraper/index.native.js @@ -1,23 +1,8 @@ -import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import BaseSelectionScraper from './BaseSelectionScraper'; -/** - * Resolves the selection to values for the platform. - * @param {Object} selectedHtml html to parse - * @returns {Object} selection for native - */ -const getCustomSelection = (selectedHtml) => { - if (!selectedHtml) { - return null; - } - const newHtml = BaseSelectionScraper.getCustomSelectionAsHtml(selectedHtml); - const parser = new ExpensiMark(); - const text = parser.htmlToMarkdown(newHtml); - return {text}; -}; - export default { + ...BaseSelectionScraper, + // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. getCurrentSelection: () => {}, - getCustomSelection, }; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index ed03c069ec2d..6b2fab4fc835 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -1,4 +1,5 @@ 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'; @@ -102,7 +103,20 @@ export default [ ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(message); if (!isAttachment) { - Clipboard.setContent(selection || SelectionScraper.getCustomSelection(messageHtml)); + // This error indicates new code that uses the selection object incorrectly. + if (selection && (selection.text || !selection.html)) { + throw new Error('Selection has to either exclusively bear html or be null.'); + } + const resolvedSelection = selection || SelectionScraper.prepareSelection(messageHtml); + if (!resolvedSelection) { + return; + } + const parser = new ExpensiMark(); + if (!Clipboard.canSetHtml()) { + Clipboard.setString(parser.htmlToMarkdown(resolvedSelection.html)); + return; + } + Clipboard.setHtml(resolvedSelection.html, parser.htmlToText(resolvedSelection.html)); } else { Clipboard.setString(messageHtml); } diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js index faac62f0111b..43c93f9cd499 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -17,7 +17,7 @@ const propTypes = { /** The copy selection. */ selection: PropTypes.shape({ - text: PropTypes.string.isRequired, + text: PropTypes.string, html: PropTypes.string, }), diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 649d6e0b0c75..a687e21e256b 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'; const propTypes = { /** The ID of the report this action is on. */ @@ -96,13 +97,13 @@ class ReportActionItem extends Component { * Show the ReportActionContextMenu modal popover. * * @param {Object} [event] - A press event. - * @param {Object} [selection] - Copied content. */ - 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 61b6fdc38333..7bd2e6dd5337 100644 --- a/src/pages/settings/AppDownloadLinks.js +++ b/src/pages/settings/AppDownloadLinks.js @@ -85,7 +85,7 @@ const AppDownloadLinksPage = (props) => { key={item.translationKey} onPressIn={() => props.isSmallScreenWidth && canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={e => showPopover(e, item.link)} + onSecondaryInteraction={e => showPopover(e, {text: item.link})} ref={el => popoverAnchor = el} onKeyDown={(event) => { event.target.blur(); From bb95f0175211211a20e9298a3c41a6db79d40395 Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Sat, 23 Jul 2022 03:25:19 +0200 Subject: [PATCH 5/8] Reduce selection, refactor --- desktop/main.js | 2 +- src/libs/Clipboard/index.js | 6 +- src/libs/Clipboard/index.native.js | 2 +- .../SelectionScraper/BaseSelectionScraper.js | 156 ------------------ src/libs/SelectionScraper/index.js | 146 +++++++++++++++- src/libs/SelectionScraper/index.native.js | 4 - .../report/ContextMenu/ContextMenuActions.js | 18 +- 7 files changed, 157 insertions(+), 177 deletions(-) delete mode 100644 src/libs/SelectionScraper/BaseSelectionScraper.js diff --git a/desktop/main.js b/desktop/main.js index f8c737212eb7..24a38a130823 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -212,7 +212,7 @@ const mainWindow = (() => { }], })); - // Register the custom Paste and Match Style command and place it near the default shorcut of the same role. + // 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', diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.js index 77c5c5ae0024..f50b53e36d4f 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.js @@ -1,8 +1,8 @@ // on Web/desktop this import will be replaced with `react-native-web` -import _ from 'lodash'; import {Clipboard} from 'react-native-web'; +import lodashGet from 'lodash/get'; -const canSetHtml = () => _.get(navigator, 'clipboard.write'); +const canSetHtml = () => lodashGet(navigator, 'clipboard.write'); /** * Writes the content as HTML if the web client supports it. @@ -15,7 +15,7 @@ const setHtml = (html, text) => { } if (!canSetHtml()) { - throw new Error('HTML is not supported on this platform'); + throw new Error('clipboard.write is not supported on this platform, thus HTML cannot be copied.'); } navigator.clipboard.write([ diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js index d8935a55c22e..c3d4ed69c17e 100644 --- a/src/libs/Clipboard/index.native.js +++ b/src/libs/Clipboard/index.native.js @@ -3,7 +3,7 @@ import Clipboard from '@react-native-community/clipboard'; export default { ...Clipboard, - // Native does not support HTML. + // We don't want to set HTML on native platforms so noop them. canSetHtml: () => false, setHtml: () => {}, }; diff --git a/src/libs/SelectionScraper/BaseSelectionScraper.js b/src/libs/SelectionScraper/BaseSelectionScraper.js deleted file mode 100644 index c6d822698619..000000000000 --- a/src/libs/SelectionScraper/BaseSelectionScraper.js +++ /dev/null @@ -1,156 +0,0 @@ -import render from 'dom-serializer'; -import {parseDocument} from 'htmlparser2'; -import {Element} from 'domhandler'; -import _ from 'underscore'; -import Str from 'expensify-common/lib/str'; -import * as htmlEngineUtils from '../../components/HTMLEngineProvider/htmlEngineUtils'; - -const elementsWillBeSkipped = ['html', 'body']; -const tagAttribute = 'data-testid'; - -/** - * Reads html of selection. If browser doesn't support Selection API, returns empty string. - * @returns {String} HTML of selection as String - */ -const getHTMLOfSelection = () => { - // 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}]`); - } - - // 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); - } - } - - return div.innerHTML; -}; - -/** - * Clears all attributes from dom elements - * @param {Object} dom htmlparser2 dom representation - * @returns {Object} htmlparser2 dom representation - */ -const replaceNodes = (dom) => { - let domName = dom.name; - let domChildren; - const domAttribs = {}; - let data; - - // Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method. - if (dom.type === 'text') { - data = Str.htmlEncode(dom.data); - } - - // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data - // has no meaning for us. - if (dom.attribs && dom.attribs[tagAttribute]) { - if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { - domName = dom.attribs[tagAttribute]; - } - - // Adding a new line after each comment here, because adding after each range is not working for chrome. - if (htmlEngineUtils.isCommentTag(dom.attribs[tagAttribute])) { - dom.children.push(new Element('br', {})); - } - } - - // We need to preserve href attribute in order to copy links. - if (dom.attribs && dom.attribs.href) { - domAttribs.href = dom.attribs.href; - } - - if (dom.children) { - domChildren = _.map(dom.children, c => replaceNodes(c)); - } - - return { - ...dom, - data, - name: domName, - attribs: domAttribs, - children: domChildren, - }; -}; - -/** - * Resolves the selection to values and produces clean HTML. - * @param {Object} selectedHtml html to parse - * @returns {Object} resolved HTML in the selection format - */ -const prepareSelection = (selectedHtml) => { - if (!selectedHtml) { - return null; - } - const domRepresentation = parseDocument(selectedHtml); - domRepresentation.children = _.map(domRepresentation.children, replaceNodes); - - const newHtml = render(domRepresentation); - if (!newHtml) { - return null; - } - return {html: newHtml}; -}; - -/** - * Resolves the selection to values and produces clean HTML. - * @returns {Object} resolved HTML in the selection format - */ -const getCurrentSelection = () => prepareSelection(getHTMLOfSelection()); - -export default { - prepareSelection, - getCurrentSelection, -}; diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index daf0558d1708..0912064c9461 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -1,3 +1,145 @@ -import BaseSelectionScraper from './BaseSelectionScraper'; +import render from 'dom-serializer'; +import {parseDocument} from 'htmlparser2'; +import {Element} from 'domhandler'; +import _ from 'underscore'; +import Str from 'expensify-common/lib/str'; +import * as htmlEngineUtils from '../../components/HTMLEngineProvider/htmlEngineUtils'; -export default BaseSelectionScraper; +const elementsWillBeSkipped = ['html', 'body']; +const tagAttribute = 'data-testid'; + +/** + * Reads html of selection. If browser doesn't support Selection API, returns empty string. + * @returns {String} HTML of selection as String + */ +const getHTMLOfSelection = () => { + // 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}]`); + } + + // 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); + } + } + + return div.innerHTML; +}; + +/** + * Clears all attributes from dom elements + * @param {Object} dom htmlparser2 dom representation + * @returns {Object} htmlparser2 dom representation + */ +const replaceNodes = (dom) => { + let domName = dom.name; + let domChildren; + const domAttribs = {}; + let data; + + // Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method. + if (dom.type === 'text') { + data = Str.htmlEncode(dom.data); + } + + // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data + // has no meaning for us. + if (dom.attribs && dom.attribs[tagAttribute]) { + if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { + domName = dom.attribs[tagAttribute]; + } + + // Adding a new line after each comment here, because adding after each range is not working for chrome. + if (htmlEngineUtils.isCommentTag(dom.attribs[tagAttribute])) { + dom.children.push(new Element('br', {})); + } + } + + // We need to preserve href attribute in order to copy links. + if (dom.attribs && dom.attribs.href) { + domAttribs.href = dom.attribs.href; + } + + if (dom.children) { + domChildren = _.map(dom.children, c => replaceNodes(c)); + } + + return { + ...dom, + data, + name: domName, + attribs: domAttribs, + children: domChildren, + }; +}; + +/** + * Resolves the current selection to values and produces clean HTML. + * @returns {?Object} resolved HTML in the selection format + */ +const getCurrentSelection = () => { + const domRepresentation = parseDocument(getHTMLOfSelection()); + domRepresentation.children = _.map(domRepresentation.children, replaceNodes); + + const newHtml = render(domRepresentation); + if (!newHtml) { + return null; + } + return {html: newHtml}; +}; + +export default { + getCurrentSelection, +}; diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js index cdbf1979dff7..426c07b73cfa 100644 --- a/src/libs/SelectionScraper/index.native.js +++ b/src/libs/SelectionScraper/index.native.js @@ -1,8 +1,4 @@ -import BaseSelectionScraper from './BaseSelectionScraper'; - export default { - ...BaseSelectionScraper, - // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. getCurrentSelection: () => {}, }; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 6b2fab4fc835..60aa32465d8a 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -13,7 +13,6 @@ import fileDownload from '../../../../libs/fileDownload'; import addEncryptedAuthTokenToURL from '../../../../libs/addEncryptedAuthTokenToURL'; import * as ContextMenuUtils from './ContextMenuUtils'; import * as Environment from '../../../../libs/Environment/Environment'; -import SelectionScraper from '../../../../libs/SelectionScraper'; /** * Gets the HTML version of the message in an action. @@ -107,16 +106,15 @@ export default [ if (selection && (selection.text || !selection.html)) { throw new Error('Selection has to either exclusively bear html or be null.'); } - const resolvedSelection = selection || SelectionScraper.prepareSelection(messageHtml); - if (!resolvedSelection) { - return; + const resolvedSelection = selection || (messageHtml && {html: messageHtml}); + if (resolvedSelection) { + const parser = new ExpensiMark(); + if (!Clipboard.canSetHtml()) { + Clipboard.setString(parser.htmlToMarkdown(resolvedSelection.html)); + } else { + Clipboard.setHtml(resolvedSelection.html, parser.htmlToText(resolvedSelection.html)); + } } - const parser = new ExpensiMark(); - if (!Clipboard.canSetHtml()) { - Clipboard.setString(parser.htmlToMarkdown(resolvedSelection.html)); - return; - } - Clipboard.setHtml(resolvedSelection.html, parser.htmlToText(resolvedSelection.html)); } else { Clipboard.setString(messageHtml); } From 8315bbb51a339bf87d4f801475d5b0893d1697dc Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Sat, 23 Jul 2022 22:54:15 +0200 Subject: [PATCH 6/8] Fix selection reset, refactor --- .../home/report/ContextMenu/ContextMenuUtils/index.native.js | 4 ++-- .../home/report/ContextMenu/PopoverReportActionContextMenu.js | 1 + .../ContextMenu/genericReportActionContextMenuPropTypes.js | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js index 0706cc73de80..f3393452b69b 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js +++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js @@ -1,6 +1,6 @@ /* eslint-disable import/prefer-default-export */ -import _ from 'lodash'; +import lodashGet from 'lodash/get'; /** * Always show popover description on native platforms @@ -9,7 +9,7 @@ import _ from 'lodash'; * @returns {String} */ function getPopoverDescription(selection) { - return _.get(selection, 'text', ''); + return lodashGet(selection, 'text', ''); } export { diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 938cd4d676d9..47577785251d 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -202,6 +202,7 @@ class PopoverReportActionContextMenu extends React.Component { this.setState({ reportID: 0, reportAction: {}, + selection: null, reportActionDraftMessage: '', isPopoverVisible: false, }); diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js index 43c93f9cd499..3eba2a2acd57 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -29,6 +29,7 @@ const defaultProps = { isMini: false, isVisible: false, draftMessage: '', + selection: null, }; export {propTypes, defaultProps}; From 01906b0447944517080317e161575681b829dc24 Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Fri, 29 Jul 2022 18:13:12 +0200 Subject: [PATCH 7/8] Make selection copy unrestrictive --- .../BaseAnchorForCommentsOnly/index.js | 2 +- .../BaseAnchorForCommentsOnly/index.native.js | 2 +- src/components/CopySelectionHelper.js | 4 ++-- src/libs/SelectionScraper/index.js | 7 ++----- src/libs/SelectionScraper/index.native.js | 2 +- .../report/ContextMenu/ContextMenuActions.js | 16 ++++++---------- .../report/ContextMenu/ContextMenuUtils/index.js | 4 ++-- .../ContextMenu/ContextMenuUtils/index.native.js | 6 ++---- .../PopoverReportActionContextMenu.js | 3 ++- .../ContextMenu/ReportActionContextMenu.js | 2 +- .../genericReportActionContextMenuPropTypes.js | 7 ++----- src/pages/settings/AppDownloadLinks.js | 4 ++-- 12 files changed, 24 insertions(+), 35 deletions(-) diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js index 691552296dd8..dab9d2ea718f 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js @@ -69,7 +69,7 @@ class BaseAnchorForCommentsOnly extends React.Component { ReportActionContextMenu.showContextMenu( Str.isValidEmail(this.props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, event, - {text: this.props.href}, + this.props.href, lodashGet(linkRef, 'current'), ); } diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js index d63360702e61..19e1b28774c2 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.native.js @@ -67,7 +67,7 @@ class BaseAnchorForCommentsOnly extends React.Component { ReportActionContextMenu.showContextMenu( Str.isValidEmail(this.props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, event, - {text: this.props.href}, + this.props.href, lodashGet(linkRef, 'current'), ); } diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js index 7f9323f692cc..119910bb4c73 100644 --- a/src/components/CopySelectionHelper.js +++ b/src/components/CopySelectionHelper.js @@ -32,10 +32,10 @@ class CopySelectionHelper extends React.Component { } const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { - Clipboard.setString(parser.htmlToMarkdown(selection.html)); + Clipboard.setString(parser.htmlToMarkdown(selection)); return; } - Clipboard.setHtml(selection.html, parser.htmlToText(selection.html)); + Clipboard.setHtml(selection, parser.htmlToText(selection)); } render() { diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index 0912064c9461..f59afbef3de6 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -127,17 +127,14 @@ const replaceNodes = (dom) => { /** * Resolves the current selection to values and produces clean HTML. - * @returns {?Object} resolved HTML in the selection format + * @returns {String} resolved HTML in the selection format */ const getCurrentSelection = () => { const domRepresentation = parseDocument(getHTMLOfSelection()); domRepresentation.children = _.map(domRepresentation.children, replaceNodes); const newHtml = render(domRepresentation); - if (!newHtml) { - return null; - } - return {html: newHtml}; + return newHtml || ''; }; export default { diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js index 426c07b73cfa..3872ece30b66 100644 --- a/src/libs/SelectionScraper/index.native.js +++ b/src/libs/SelectionScraper/index.native.js @@ -1,4 +1,4 @@ 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: () => {}, + getCurrentSelection: () => '', }; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index ab9af81d86fb..2823d7b31a89 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -66,7 +66,7 @@ export default [ successIcon: Expensicons.Checkmark, shouldShow: type => type === CONTEXT_MENU_TYPES.LINK, onPress: (closePopover, {selection}) => { - Clipboard.setString(selection.text); + Clipboard.setString(selection); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: ContextMenuUtils.getPopoverDescription, @@ -78,7 +78,7 @@ export default [ successIcon: Expensicons.Checkmark, shouldShow: type => type === CONTEXT_MENU_TYPES.EMAIL, onPress: (closePopover, {selection}) => { - Clipboard.setString(selection.text.replace('mailto:', '')); + Clipboard.setString(selection.replace('mailto:', '')); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: () => {}, @@ -103,17 +103,13 @@ export default [ ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(message); if (!isAttachment) { - // This error indicates new code that uses the selection object incorrectly. - if (selection && (selection.text || !selection.html)) { - throw new Error('Selection has to either exclusively bear html or be null.'); - } - const resolvedSelection = selection || (messageHtml && {html: messageHtml}); - if (resolvedSelection) { + const content = selection || messageHtml; + if (content) { const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { - Clipboard.setString(parser.htmlToMarkdown(resolvedSelection.html)); + Clipboard.setString(parser.htmlToMarkdown(content)); } else { - Clipboard.setHtml(resolvedSelection.html, parser.htmlToText(resolvedSelection.html)); + Clipboard.setHtml(content, parser.htmlToText(content)); } } } else { diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js index 98320340d29f..f694f413e29a 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js +++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js @@ -4,12 +4,12 @@ * The popover description will be an empty string on anything but mobile web * because we show link in tooltip instead of popover on web * - * @param {Object} selection + * @param {String} selection * @param {Boolean} isSmallScreenWidth * @returns {String} */ function getPopoverDescription(selection, isSmallScreenWidth) { - return isSmallScreenWidth && selection ? selection.text : ''; + return isSmallScreenWidth ? selection : ''; } export { diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js index f3393452b69b..3dbf81926098 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js +++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js @@ -1,15 +1,13 @@ /* eslint-disable import/prefer-default-export */ -import lodashGet from 'lodash/get'; - /** * Always show popover description on native platforms * - * @param {Object} selection + * @param {String} selection * @returns {String} */ function getPopoverDescription(selection) { - return lodashGet(selection, 'text', ''); + return selection; } export { diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 47577785251d..08be0300f8f9 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -20,6 +20,7 @@ class PopoverReportActionContextMenu extends React.Component { this.state = { reportID: 0, reportAction: {}, + selection: '', reportActionDraftMessage: '', isPopoverVisible: false, isDeleteCommentConfirmModalVisible: false, @@ -202,7 +203,7 @@ class PopoverReportActionContextMenu extends React.Component { this.setState({ reportID: 0, reportAction: {}, - selection: null, + selection: '', reportActionDraftMessage: '', isPopoverVisible: false, }); diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js index 88c2f02972aa..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 {Object} [selection] - Copied content. + * @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 3eba2a2acd57..d14c819fdcce 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -16,10 +16,7 @@ const propTypes = { isVisible: PropTypes.bool, /** The copy selection. */ - selection: PropTypes.shape({ - text: PropTypes.string, - html: PropTypes.string, - }), + selection: PropTypes.string, /** Draft message - if this is set the comment is in 'edit' mode */ draftMessage: PropTypes.string, @@ -29,7 +26,7 @@ const defaultProps = { isMini: false, isVisible: false, draftMessage: '', - selection: null, + selection: '', }; export {propTypes, defaultProps}; diff --git a/src/pages/settings/AppDownloadLinks.js b/src/pages/settings/AppDownloadLinks.js index 7bd2e6dd5337..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 {Object} [selection] - Copied content. + * @param {String} [selection] - Copied content. */ const showPopover = (event, selection) => { ReportActionContextMenu.showContextMenu( @@ -85,7 +85,7 @@ const AppDownloadLinksPage = (props) => { key={item.translationKey} onPressIn={() => props.isSmallScreenWidth && canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={e => showPopover(e, {text: item.link})} + onSecondaryInteraction={e => showPopover(e, item.link)} ref={el => popoverAnchor = el} onKeyDown={(event) => { event.target.blur(); From 7499f3da0bf0112bc9dfe20bcc47731a22fb4378 Mon Sep 17 00:00:00 2001 From: Mark Toman Date: Fri, 29 Jul 2022 19:29:42 +0200 Subject: [PATCH 8/8] Fix comments & revert --- src/libs/SelectionScraper/index.js | 2 +- .../home/report/ContextMenu/PopoverReportActionContextMenu.js | 2 +- .../ContextMenu/genericReportActionContextMenuPropTypes.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index f59afbef3de6..99405259eaea 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -127,7 +127,7 @@ const replaceNodes = (dom) => { /** * Resolves the current selection to values and produces clean HTML. - * @returns {String} resolved HTML in the selection format + * @returns {String} resolved selection in the HTML format */ const getCurrentSelection = () => { const domRepresentation = parseDocument(getHTMLOfSelection()); diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 08be0300f8f9..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 {Object} [selection] - Copied content. + * @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 d14c819fdcce..cb6c759b17df 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -25,8 +25,8 @@ const propTypes = { const defaultProps = { isMini: false, isVisible: false, - draftMessage: '', selection: '', + draftMessage: '', }; export {propTypes, defaultProps};