diff --git a/lib/context-menu-interceptor.js b/lib/context-menu-interceptor.js
new file mode 100644
index 0000000000..44586668a0
--- /dev/null
+++ b/lib/context-menu-interceptor.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ContextMenuInterceptor extends React.Component {
+ static propTypes = {
+ onWillShowContextMenu: PropTypes.func.isRequired,
+ children: PropTypes.element.isRequired,
+ }
+
+ static registration = new Map()
+
+ static handle(event) {
+ for (const [element, callback] of ContextMenuInterceptor.registration) {
+ if (element.contains(event.target)) {
+ callback(event);
+ }
+ }
+ }
+
+ static dispose() {
+ document.removeEventListener('contextmenu', contextMenuHandler, {capture: true});
+ }
+
+ componentDidMount() {
+ // Helpfully, addEventListener dedupes listeners for us.
+ document.addEventListener('contextmenu', contextMenuHandler, {capture: true});
+ ContextMenuInterceptor.registration.set(this.element, this.props.onWillShowContextMenu);
+ }
+
+ render() {
+ return
this.props.mousedown(event, line)}
- onMouseMove={event => this.props.mousemove(event, line)}
- onContextMenu={event => this.props.contextMenuOnItem(event, line)}
- ref={e => this.props.registerLineElement(line, e)}>
-
{oldLineNumber}
-
{newLineNumber}
-
-
{line.getOrigin()}
-
{line.getText()}
+
this.props.contextMenuOnItem(event, line)}>
+ this.props.mousedown(event, line)}
+ onMouseMove={event => this.props.mousemove(event, line)}
+ ref={e => this.props.registerLineElement(line, e)}>
+
{oldLineNumber}
+
{newLineNumber}
+
+ {line.getOrigin()}
+ {line.getText()}
+
-
+
);
}
}
diff --git a/test/context-menu-interceptor.test.js b/test/context-menu-interceptor.test.js
new file mode 100644
index 0000000000..974eff6fa9
--- /dev/null
+++ b/test/context-menu-interceptor.test.js
@@ -0,0 +1,148 @@
+import React from 'react';
+import {mount} from 'enzyme';
+
+import ContextMenuInterceptor from '../lib/context-menu-interceptor';
+
+class SampleComponent extends React.Component {
+ render() {
+ return (
+
+
+ This element has content.
+
+
+ );
+ }
+}
+
+describe('ContextMenuInterceptor', function() {
+ let rootElement, rootHandler, rootHandlerCalled;
+
+ beforeEach(function() {
+ rootHandlerCalled = false;
+ rootHandler = event => {
+ rootHandlerCalled = true;
+ event.preventDefault();
+ return false;
+ };
+ document.addEventListener('contextmenu', rootHandler);
+
+ rootElement = document.createElement('div');
+ document.body.appendChild(rootElement);
+ });
+
+ afterEach(function() {
+ document.removeEventListener('contextmenu', rootHandler);
+ ContextMenuInterceptor.dispose();
+ rootElement.remove();
+ });
+
+ it('responds to native contextmenu events before they reach the document root', function() {
+ let interceptorHandlerCalled = false;
+ let rootHandlerCalledFirst = false;
+ const handler = () => {
+ interceptorHandlerCalled = true;
+ rootHandlerCalledFirst = rootHandlerCalled;
+ };
+
+ const wrapper = mount(
+
+
+ ,
+ {attachTo: rootElement},
+ );
+
+ const targetDOMNode = wrapper.find('.child').getDOMNode();
+ const event = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ });
+ targetDOMNode.dispatchEvent(event);
+
+ assert.isTrue(interceptorHandlerCalled);
+ assert.isFalse(rootHandlerCalledFirst);
+ assert.isTrue(rootHandlerCalled);
+ });
+
+ it('can prevent event propagation', function() {
+ let interceptorHandlerCalled = false;
+ const handler = () => {
+ interceptorHandlerCalled = true;
+ event.stopPropagation();
+ };
+
+ const wrapper = mount(
+
+
+ ,
+ {attachTo: rootElement},
+ );
+
+ const targetDOMNode = wrapper.find('.child').getDOMNode();
+ const event = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ });
+ targetDOMNode.dispatchEvent(event);
+
+ assert.isTrue(interceptorHandlerCalled);
+ assert.isFalse(rootHandlerCalled);
+ });
+
+ it('ignores contextmenu events from other children', function() {
+ let interceptorHandlerCalled = false;
+ const handler = () => {
+ interceptorHandlerCalled = true;
+ };
+
+ const wrapper = mount(
+
+
+
+
+
+
+ This is another div.
+
+
+
,
+ {attachTo: rootElement},
+ );
+
+ const targetDOMNode = wrapper.find('.otherNode').getDOMNode();
+ const event = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ });
+ targetDOMNode.dispatchEvent(event);
+
+ assert.isFalse(interceptorHandlerCalled);
+ assert.isTrue(rootHandlerCalled);
+ });
+
+ it('cleans itself up on .dispose()', function() {
+ let interceptorHandlerCalled = false;
+ const handler = () => {
+ interceptorHandlerCalled = true;
+ };
+
+ const wrapper = mount(
+
+
+ ,
+ {attachTo: rootElement},
+ );
+
+ ContextMenuInterceptor.dispose();
+
+ const targetDOMNode = wrapper.find('.child').getDOMNode();
+ const event = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ });
+ targetDOMNode.dispatchEvent(event);
+
+ assert.isFalse(interceptorHandlerCalled);
+ assert.isTrue(rootHandlerCalled);
+ });
+});
diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js
index 4148beeddf..d8b706bcb5 100644
--- a/test/controllers/file-patch-controller.test.js
+++ b/test/controllers/file-patch-controller.test.js
@@ -286,8 +286,8 @@ describe('FilePatchController', function() {
const opPromise0 = switchboard.getFinishStageOperationPromise();
const hunkView0 = wrapper.find('HunkView').at(0);
- hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1});
- hunkView0.find('LineView').at(3).simulate('mousemove', {});
+ hunkView0.find('LineView').at(1).find('.github-HunkView-line').simulate('mousedown', {button: 0, detail: 1});
+ hunkView0.find('LineView').at(3).find('.github-HunkView-line').simulate('mousemove', {});
window.dispatchEvent(new MouseEvent('mouseup'));
hunkView0.find('button.github-HunkView-stageButton').simulate('click');
await opPromise0;
@@ -329,9 +329,11 @@ describe('FilePatchController', function() {
await updatePromise2;
const hunkView2 = wrapper.find('HunkView').at(0);
- hunkView2.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1});
+ hunkView2.find('LineView').at(1).find('.github-HunkView-line')
+ .simulate('mousedown', {button: 0, detail: 1});
window.dispatchEvent(new MouseEvent('mouseup'));
- hunkView2.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1, metaKey: true});
+ hunkView2.find('LineView').at(2).find('.github-HunkView-line')
+ .simulate('mousedown', {button: 0, detail: 1, metaKey: true});
window.dispatchEvent(new MouseEvent('mouseup'));
const opPromise2 = switchboard.getFinishStageOperationPromise();
@@ -442,7 +444,8 @@ describe('FilePatchController', function() {
}));
const hunkView0 = wrapper.find('HunkView').at(0);
- hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1});
+ hunkView0.find('LineView').at(1).find('.github-HunkView-line')
+ .simulate('mousedown', {button: 0, detail: 1});
window.dispatchEvent(new MouseEvent('mouseup'));
// stage lines in rapid succession
@@ -467,7 +470,8 @@ describe('FilePatchController', function() {
await line1PatchPromise;
const hunkView1 = wrapper.find('HunkView').at(0);
- hunkView1.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1});
+ hunkView1.find('LineView').at(2).find('.github-HunkView-line')
+ .simulate('mousedown', {button: 0, detail: 1});
window.dispatchEvent(new MouseEvent('mouseup'));
const line2StagingPromise = switchboard.getFinishStageOperationPromise();
diff --git a/test/helpers.js b/test/helpers.js
index d0d3c14411..3a4fa8b398 100644
--- a/test/helpers.js
+++ b/test/helpers.js
@@ -9,6 +9,7 @@ import sinon from 'sinon';
import Repository from '../lib/models/repository';
import GitShellOutStrategy from '../lib/git-shell-out-strategy';
import WorkerManager from '../lib/worker-manager';
+import ContextMenuInterceptor from '../lib/context-menu-interceptor';
assert.autocrlfEqual = (actual, expected, ...args) => {
const newActual = actual.replace(/\r\n/g, '\n');
@@ -186,6 +187,9 @@ beforeEach(function() {
afterEach(function() {
activeRenderers.forEach(r => r.unmount());
activeRenderers = [];
+
+ ContextMenuInterceptor.dispose();
+
global.sinon.restore();
});