Skip to content
Merged
19 changes: 18 additions & 1 deletion desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Comment thread
marktoman marked this conversation as resolved.
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);
Expand Down
13 changes: 11 additions & 2 deletions src/components/CopySelectionHelper.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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() {
Expand Down
4 changes: 1 addition & 3 deletions src/components/PressableWithSecondaryInteraction/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down
32 changes: 31 additions & 1 deletion src/libs/Clipboard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
8 changes: 7 additions & 1 deletion src/libs/Clipboard/index.native.js
Original file line number Diff line number Diff line change
@@ -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: () => {},
};
145 changes: 68 additions & 77 deletions src/libs/SelectionScraper/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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:
// <span class="..." style="..." data-testid="strong">bold</span>
// to this:
// <strong>bold</strong>
//
// 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:
// <div className="..." style="..." data-testid="pre">
// <div dir="auto" class="..." style="...">
// this is block code
// </div>
// </div>
// commonAncestorContainer: #text "this is block code"
// commonAncestorContainer.parentNode:
// <div dir="auto" class="..." style="...">
// this is block code
// </div>
// 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:
// <span class="..." style="..." data-testid="strong">bold</span>
// to this:
// <strong>bold</strong>
//
// 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:
// <div className="..." style="..." data-testid="pre">
// <div dir="auto" class="..." style="...">
// this is block code
// </div>
// </div>
// commonAncestorContainer: #text "this is block code"
// commonAncestorContainer.parentNode:
// <div dir="auto" class="..." style="...">
// this is block code
// </div>
// 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;
};

/**
Expand All @@ -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', {}));
}
}
Expand All @@ -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;
11 changes: 3 additions & 8 deletions src/libs/SelectionScraper/index.native.js
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 12 additions & 9 deletions src/pages/home/report/ContextMenu/ContextMenuActions.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading