Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for the paste and match style command #9880

Merged
merged 11 commits into from
Aug 1, 2022
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/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({
marktoman marked this conversation as resolved.
Show resolved Hide 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
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
}
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.html));
return;
}
Clipboard.setHtml(selection.html, parser.htmlToText(selection.html));
}

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: () => {},
};
148 changes: 71 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,20 @@ 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 {?Object} resolved HTML in the selection 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);
if (!newHtml) {
return null;
}
return {html: newHtml};
marktoman marked this conversation as resolved.
Show resolved Hide resolved
};

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: () => {},
marktoman marked this conversation as resolved.
Show resolved Hide resolved
};

export default SelectionParser;
Loading